From 3423dd9c35f6a3af7d24db020677544e55d123c5 Mon Sep 17 00:00:00 2001 From: Eike Date: Fri, 22 May 2026 11:56:30 +0200 Subject: [PATCH 01/19] Submit jobs with a submission id --- Cargo.lock | 1 + Cargo.toml | 1 + src/cli/cmd/job/start.rs | 14 ++++++- src/data.rs | 1 + src/data/submission_id.rs | 82 +++++++++++++++++++++++++++++++++++++++ src/httpclient/data.rs | 7 +++- src/util.rs | 1 + src/util/strings.rs | 10 +++++ 8 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 src/data/submission_id.rs create mode 100644 src/util/strings.rs diff --git a/Cargo.lock b/Cargo.lock index 99d5b61..459a44e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2910,6 +2910,7 @@ dependencies = [ "openidconnect", "openssl", "predicates", + "rand 0.8.5", "regex", "reqwest 0.12.28", "self_update", diff --git a/Cargo.toml b/Cargo.toml index 040b420..d09e848 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ console = { version = "0.15.11" } env_logger = { version = "0.11.5" } log = { version = "0.4.22" } openssl = { version = "0.10.75", optional = true } +rand = { version = "0.8" } reqwest = { version = "0.12.28", default-features = false, features = [ "json", "multipart", diff --git a/src/cli/cmd/job/start.rs b/src/cli/cmd/job/start.rs index a0077d8..77aa7cd 100644 --- a/src/cli/cmd/job/start.rs +++ b/src/cli/cmd/job/start.rs @@ -1,4 +1,7 @@ -use crate::httpclient::{self, data::SessionStartRequest}; +use crate::{ + data::submission_id::SubmissionId, + httpclient::{self, data::SessionStartRequest}, +}; use super::Context; use crate::cli::sink::Error as SinkError; @@ -16,6 +19,10 @@ pub struct Input { /// The launcher to use for launching the job. #[arg(value_hint=ValueHint::Other)] pub launcher: Ulid, + + /// A submission id allows to deduplicate same job submissions. If missing, a random one is generated. + #[arg(long)] + pub submission_id: Option, } #[derive(Debug, Snafu)] @@ -29,9 +36,14 @@ pub enum Error { impl Input { pub async fn exec(&self, ctx: Context) -> Result<(), Error> { + let submission_id = self + .submission_id + .clone() + .unwrap_or_else(|| SubmissionId::random()); let req = SessionStartRequest { launcher_id: self.launcher.to_string(), session_type: "non-interactive".into(), + submission_id: Some(submission_id), }; let result = ctx .client diff --git a/src/data.rs b/src/data.rs index 5674cd0..d7cf8b1 100644 --- a/src/data.rs +++ b/src/data.rs @@ -7,3 +7,4 @@ Data types used across the cli pub mod project_id; pub mod renku_url; pub mod simple_message; +pub mod submission_id; diff --git a/src/data/submission_id.rs b/src/data/submission_id.rs new file mode 100644 index 0000000..3d0b9b1 --- /dev/null +++ b/src/data/submission_id.rs @@ -0,0 +1,82 @@ +use std::{fmt::Display, str::FromStr}; + +use serde::{Deserialize, Serialize}; + +use crate::util; + +#[derive(Debug, PartialEq, Clone)] +pub struct SubmissionId(String); + +impl SubmissionId { + pub fn parse>(input: S) -> Result { + let s = input.as_ref(); + if s.len() < 4 { + Err(SubmissionIdError::InvalidInput(s.to_string())) + } else { + Ok(SubmissionId(s.into())) + } + } + + pub fn as_str(&self) -> &str { + let SubmissionId(u) = self; + u.as_str() + } + + pub fn join(&self, seg: &str) -> Result { + let SubmissionId(u) = self; + SubmissionId::parse(format!("{}{}", u, seg)) + } + + pub fn random() -> SubmissionId { + let s = util::strings::random_alpha_num(8); + SubmissionId(s) + } +} + +impl<'de> Deserialize<'de> for SubmissionId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let u = SubmissionId::parse(&s).map_err(serde::de::Error::custom)?; + Ok(u) + } +} + +impl Serialize for SubmissionId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let SubmissionId(u) = self; + serializer.serialize_str(u.as_str()) + } +} +impl Display for SubmissionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl FromStr for SubmissionId { + type Err = SubmissionIdError; + + fn from_str(s: &str) -> Result { + SubmissionId::parse(s) + } +} + +#[derive(PartialEq, Eq, Clone, Debug)] +pub enum SubmissionIdError { + InvalidInput(String), +} + +impl Display for SubmissionIdError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SubmissionIdError::InvalidInput(msg) => write!(f, "Invalid submission id: {}", msg), + } + } +} +impl std::error::Error for SubmissionIdError {} diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index 373fc48..e41ea68 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize}; use std::{collections::HashMap, fmt}; use tabled::{Table, Tabled, settings::Style}; +use crate::data::submission_id::SubmissionId; + #[derive(Debug, Serialize, Deserialize)] pub struct SessionLogs(pub HashMap); @@ -44,13 +46,14 @@ impl SessionMode { pub struct SessionStartRequest { pub launcher_id: String, pub session_type: String, + pub submission_id: Option, } impl fmt::Display for SessionStartRequest { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "SessionStart(launcher={}, session_type={})", - self.launcher_id, self.session_type + "SessionStart(launcher={}, session_type={}, submission_id={:?})", + self.launcher_id, self.session_type, self.submission_id ) } } diff --git a/src/util.rs b/src/util.rs index 8ffd2ca..0d8b413 100644 --- a/src/util.rs +++ b/src/util.rs @@ -4,3 +4,4 @@ Utility functions. */ pub mod file; +pub mod strings; diff --git a/src/util/strings.rs b/src/util/strings.rs new file mode 100644 index 0000000..5eabefa --- /dev/null +++ b/src/util/strings.rs @@ -0,0 +1,10 @@ +use rand::Rng; + +pub fn random_alpha_num(length: usize) -> String { + const CHARSET: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + rand::thread_rng() + .sample_iter(&rand::distributions::Uniform::from(0..CHARSET.len())) + .take(length) + .map(|i| CHARSET.chars().nth(i).unwrap()) + .collect() +} From 91c993c202943810ab7ee1e7f26d21589155f0fc Mon Sep 17 00:00:00 2001 From: Eike Date: Fri, 22 May 2026 14:32:37 +0200 Subject: [PATCH 02/19] Improve output of job list --- src/httpclient/data.rs | 73 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 10 deletions(-) diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index e41ea68..c2ddc59 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -1,12 +1,15 @@ //! Defines data structures for requests and responses and their //! `De/Serialize` instances. +use crate::data::submission_id::SubmissionId; use iso8601_timestamp::Timestamp; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, fmt}; -use tabled::{Table, Tabled, settings::Style}; - -use crate::data::submission_id::SubmissionId; +use std::{borrow::Borrow, collections::HashMap, fmt}; +use tabled::{ + Table, + builder::Builder, + settings::{Settings, Style}, +}; #[derive(Debug, Serialize, Deserialize)] pub struct SessionLogs(pub HashMap); @@ -61,31 +64,81 @@ impl fmt::Display for SessionStartRequest { #[derive(Debug, Serialize, Deserialize)] pub struct SessionList(pub Vec); +fn create_session_table(data: I) -> Table +where + I: IntoIterator, + T: Borrow, +{ + let mut builder = Builder::default(); + for e in data { + let r = e.borrow(); + let sub_id = match &r.submission_id { + Some(n) => n, + None => "-", + }; + let started = r.started.format(); + let data = vec![ + &r.name, + sub_id, + &r.project_id, + &r.status.state, + &started, + &r.launcher_id, + &r.image, + ]; + builder.push_record(data); + } + builder.insert_record( + 0, + vec![ + "Job", + "Submission Id", + "Project Id", + "Status", + "Started", + "Launcher Id", + "Image", + ], + ); + + let mut table = builder.build(); + let settings = Settings::default().with(Style::sharp()); + + table.with(settings); + table +} + impl fmt::Display for SessionList { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if self.0.is_empty() { write!(f, "No jobs/sessions found.") } else { - let mut table = Table::new(&self.0); - table.with(Style::modern()); + let table = create_session_table(&self.0); write!(f, "{}", table) } } } -#[derive(Debug, Serialize, Deserialize, Tabled)] +#[derive(Debug, Serialize, Deserialize)] +pub struct SessionStatus { + message: Option, + state: String, +} + +#[derive(Debug, Serialize, Deserialize)] pub struct SessionStartResponse { image: String, name: String, project_id: String, launcher_id: String, + submission_id: Option, + status: SessionStatus, + started: Timestamp, } impl fmt::Display for SessionStartResponse { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut table = Table::new(vec![self]); - table.with(Style::modern()); - + let table = create_session_table(vec![self]); write!(f, "{}", table) } } From b944f8632be93810eb10271a2374d36ba31fa728 Mon Sep 17 00:00:00 2001 From: Eike Date: Wed, 27 May 2026 16:27:43 +0200 Subject: [PATCH 03/19] Specify arguments when submitting a job --- src/cli/cmd/job/start.rs | 5 +++++ src/httpclient/data.rs | 25 +++++-------------------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/src/cli/cmd/job/start.rs b/src/cli/cmd/job/start.rs index 77aa7cd..68f23a1 100644 --- a/src/cli/cmd/job/start.rs +++ b/src/cli/cmd/job/start.rs @@ -23,6 +23,10 @@ pub struct Input { /// A submission id allows to deduplicate same job submissions. If missing, a random one is generated. #[arg(long)] pub submission_id: Option, + + /// These arguments are passed to the renku job command. + #[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 0.., value_name = "ARGS")] + pub passthrough: Vec, } #[derive(Debug, Snafu)] @@ -44,6 +48,7 @@ impl Input { launcher_id: self.launcher.to_string(), session_type: "non-interactive".into(), submission_id: Some(submission_id), + job_args_override: self.passthrough.clone(), }; let result = ctx .client diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index c2ddc59..f3dc815 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -50,13 +50,14 @@ pub struct SessionStartRequest { pub launcher_id: String, pub session_type: String, pub submission_id: Option, + pub job_args_override: Vec, } impl fmt::Display for SessionStartRequest { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "SessionStart(launcher={}, session_type={}, submission_id={:?})", - self.launcher_id, self.session_type, self.submission_id + "SessionStart(launcher={}, session_type={}, submission_id={:?}, job_args_overrides={:?})", + self.launcher_id, self.session_type, self.submission_id, self.job_args_override ) } } @@ -77,28 +78,12 @@ where None => "-", }; let started = r.started.format(); - let data = vec![ - &r.name, - sub_id, - &r.project_id, - &r.status.state, - &started, - &r.launcher_id, - &r.image, - ]; + let data = vec![&r.name, sub_id, &r.project_id, &r.status.state, &started]; builder.push_record(data); } builder.insert_record( 0, - vec![ - "Job", - "Submission Id", - "Project Id", - "Status", - "Started", - "Launcher Id", - "Image", - ], + vec!["Job", "Submission Id", "Project Id", "Status", "Started"], ); let mut table = builder.build(); From fc408b18e8fd887aab88489881266d84b1801352 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 4 Jun 2026 20:42:09 +0200 Subject: [PATCH 04/19] Fix submission_id validation --- Cargo.lock | 10 ++++++++++ Cargo.toml | 1 + src/data/submission_id.rs | 22 ++++++++++++++++++---- src/util/strings.rs | 19 +++++++++++++++---- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 459a44e..67f74b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2766,6 +2766,15 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d306632607af6ec61c0b117971d57a96381b6317cf18ae419b5558048fe016e" +dependencies = [ + "regex", +] + [[package]] name = "regex-syntax" version = "0.8.10" @@ -2912,6 +2921,7 @@ dependencies = [ "predicates", "rand 0.8.5", "regex", + "regex-macro", "reqwest 0.12.28", "self_update", "serde", diff --git a/Cargo.toml b/Cargo.toml index d09e848..00e9435 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ tabled = "0.20.0" chrono = "0.4.43" futures = { version = "0.3" } regex = { version = "1.12.3" } +regex-macro = { version = "0.3.0" } iso8601-timestamp = { version = "0.2.17" } toml = { version = "0.8.19" } git2 = { version = "0.19.0", default-features = false, features = [ diff --git a/src/data/submission_id.rs b/src/data/submission_id.rs index 3d0b9b1..0b3a3c8 100644 --- a/src/data/submission_id.rs +++ b/src/data/submission_id.rs @@ -1,5 +1,6 @@ use std::{fmt::Display, str::FromStr}; +use regex_macro::regex; use serde::{Deserialize, Serialize}; use crate::util; @@ -10,10 +11,10 @@ pub struct SubmissionId(String); impl SubmissionId { pub fn parse>(input: S) -> Result { let s = input.as_ref(); - if s.len() < 4 { - Err(SubmissionIdError::InvalidInput(s.to_string())) - } else { + if regex!("^[a-z][-0-9a-z]{3,19}$").is_match(s) { Ok(SubmissionId(s.into())) + } else { + Err(SubmissionIdError::InvalidInput(s.to_string())) } } @@ -28,8 +29,9 @@ impl SubmissionId { } pub fn random() -> SubmissionId { + let first = util::strings::random(1, "abcdefghijklmnopqrstuvwxyz"); let s = util::strings::random_alpha_num(8); - SubmissionId(s) + SubmissionId(format!("{}{}", first, s)) } } @@ -80,3 +82,15 @@ impl Display for SubmissionIdError { } } impl std::error::Error for SubmissionIdError {} + +#[test] +fn submission_id_parse() { + assert!(SubmissionId::parse("__-").is_err()); + assert!(SubmissionId::parse("9abcd").is_err()); + assert!(SubmissionId::parse("abc-9ed").is_ok()); + assert_eq!( + SubmissionId::parse("ab-cd-de").unwrap().as_str(), + "ab-cd-de" + ); + assert!(SubmissionId::random().as_str().len() > 4); +} diff --git a/src/util/strings.rs b/src/util/strings.rs index 5eabefa..2de4e2d 100644 --- a/src/util/strings.rs +++ b/src/util/strings.rs @@ -1,10 +1,21 @@ use rand::Rng; -pub fn random_alpha_num(length: usize) -> String { - const CHARSET: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +const CHARSET_ALPHA_NUM: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + +const CHARSET_ALPHA: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +pub fn random(length: usize, charset: &str) -> String { rand::thread_rng() - .sample_iter(&rand::distributions::Uniform::from(0..CHARSET.len())) + .sample_iter(&rand::distributions::Uniform::from(0..charset.len())) .take(length) - .map(|i| CHARSET.chars().nth(i).unwrap()) + .map(|i| charset.chars().nth(i).unwrap()) .collect() } + +pub fn random_alpha_num(length: usize) -> String { + random(length, CHARSET_ALPHA_NUM) +} + +pub fn random_alpha(length: usize) -> String { + random(length, CHARSET_ALPHA) +} From 60017eb88c9ef878b42d9ee10b04a64db96f08c2 Mon Sep 17 00:00:00 2001 From: Eike Date: Fri, 5 Jun 2026 18:21:33 +0200 Subject: [PATCH 05/19] WIP: auto-complete session launcher ids --- src/cli.rs | 1 + src/cli/cmd.rs | 52 ++-------------------- src/cli/cmd/job/start.rs | 46 +++++++++++++++++++- src/cli/complete.rs | 94 ++++++++++++++++++++++++++++++++++++++++ src/cli/opts.rs | 57 +++++++++++++++++++++++- src/data/renku_url.rs | 12 +++++ src/httpclient.rs | 6 +++ src/httpclient/data.rs | 12 +++++ src/main.rs | 4 +- 9 files changed, 230 insertions(+), 54 deletions(-) create mode 100644 src/cli/complete.rs diff --git a/src/cli.rs b/src/cli.rs index 66e1bd2..3c8dad3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,5 @@ pub mod cmd; +pub mod complete; pub mod opts; pub mod sink; diff --git a/src/cli/cmd.rs b/src/cli/cmd.rs index 54bbb6b..8cb6a2a 100644 --- a/src/cli/cmd.rs +++ b/src/cli/cmd.rs @@ -8,15 +8,12 @@ pub mod userdoc; pub mod version; use super::sink::{Error as SinkError, Sink}; -use crate::cli::opts::{CommonOpts, ProxySetting}; +use crate::cli::opts::CommonOpts; use crate::data::renku_url::RenkuUrl; -use crate::httpclient::{self, Client, proxy}; +use crate::httpclient::{self, Client}; use serde::Serialize; use snafu::{ResultExt, Snafu}; -const RENKULAB_IO: &str = "https://renkulab.io"; -const ACCESS_TOKEN_ENV: &str = "RENKU_CLI_ACCESS_TOKEN"; - pub struct Context { pub opts: CommonOpts, pub client: Client, @@ -24,10 +21,7 @@ pub struct Context { impl Context { pub fn new(opts: &CommonOpts) -> Result { - let base_url = get_renku_url(opts)?; - let at = std::env::var(ACCESS_TOKEN_ENV).ok(); - let client = Client::new(base_url, proxy_settings(opts), None, false, at) - .context(ContextCreateSnafu)?; + let client = opts.create_client(None).context(ContextCreateSnafu)?; Ok(Context { opts: opts.clone(), client, @@ -51,46 +45,6 @@ impl Context { } } -fn get_renku_url(opts: &CommonOpts) -> Result { - match &opts.renku_url { - Some(u) => { - log::debug!("Use renku url from arguments: {}", u); - Ok(u.clone()) - } - None => match std::env::var("RENKU_CLI_RENKU_URL").ok() { - Some(u) => { - log::debug!("Use renku url from env RENKU_CLI_RENKU_URL: {}", u); - RenkuUrl::parse(&u).map_err(|e| CmdError::ContextCreate { - source: httpclient::Error::UrlParse { source: e }, - }) - } - None => { - log::debug!("Use renku url: https://renkulab.io"); - RenkuUrl::parse(RENKULAB_IO).map_err(|e| CmdError::ContextCreate { - source: httpclient::Error::UrlParse { source: e }, - }) - } - }, - } -} - -fn proxy_settings(opts: &CommonOpts) -> proxy::ProxySetting { - let user = opts.proxy_user.clone(); - let password = opts.proxy_password.clone(); - let prx = opts.proxy.clone(); - - log::debug!("Using proxy: {:?} @ {:?}", user, prx); - match prx { - None => proxy::ProxySetting::System, - Some(ProxySetting::None) => proxy::ProxySetting::None, - Some(ProxySetting::Custom { url }) => proxy::ProxySetting::Custom { - url: url.clone(), - user, - password, - }, - } -} - #[derive(Debug, Snafu)] pub enum CmdError { #[snafu(display("ContextCreate - {}", source))] diff --git a/src/cli/cmd/job/start.rs b/src/cli/cmd/job/start.rs index 68f23a1..6d0089b 100644 --- a/src/cli/cmd/job/start.rs +++ b/src/cli/cmd/job/start.rs @@ -1,4 +1,5 @@ use crate::{ + cli::complete::complete_launcher_id, data::submission_id::SubmissionId, httpclient::{self, data::SessionStartRequest}, }; @@ -7,6 +8,7 @@ use super::Context; use crate::cli::sink::Error as SinkError; use clap::{Parser, ValueHint}; +use clap_complete::ArgValueCompleter; use ulid::Ulid; use snafu::{ResultExt, Snafu}; @@ -17,10 +19,10 @@ use snafu::{ResultExt, Snafu}; #[derive(Parser, Debug)] pub struct Input { /// The launcher to use for launching the job. - #[arg(value_hint=ValueHint::Other)] + #[arg(long, value_hint=ValueHint::Other, add = ArgValueCompleter::new(complete_launcher_id))] pub launcher: Ulid, - /// A submission id allows to deduplicate same job submissions. If missing, a random one is generated. + /// A submission id allows to deduplicate same job submissions. If missing, a random one is generated. It must be at least 4 characters, starting with a lowercase letter, followed by alphanumeric characters (including the dash). #[arg(long)] pub submission_id: Option, @@ -29,6 +31,46 @@ pub struct Input { pub passthrough: Vec, } +// #[allow(dead_code, unused_mut, unused_variables, unreachable_code)] +// fn complete_launcher_id(current: &ffi::OsStr) -> Vec { +// let mut completions = vec![]; +// let Some(_current) = current.to_str() else { +// return completions; +// }; + +// let mut args = std::env::args() +// .take_while(|e| !e.eq_ignore_ascii_case("job")) +// .skip(2) +// .collect::>(); +// args.push("version".into()); +// let Ok(opts) = MainOpts::try_parse_from(args) else { +// return completions; +// }; + +// let Ok(client) = opts.common_opts.create_client(None) else { +// return completions; +// }; + +// // let args2 = args.take_while(|e| !e.eq_ignore_ascii_case("job")).skip(3); +// // let args3: Vec = args2.collect(); + +// // let mut def_opts = CommonOpts::empty(); +// // def_opts.try_update_from(args2).unwrap(); + +// panic!("opts: {:?}", opts); + +// let mut ulid = Ulid::from_string(&format!("test:{}", args.len())).unwrap(); +// // if copts.is_err() { +// // ulid = Ulid::from_string(&format!("help:{}", args.len())).unwrap() +// // } + +// // let matches = MainOpts::command().get_matches(); +// // let rurl = matches.get_one::("--renku-url"); +// // println!(">>>>> url: {:?}", rurl); +// completions.push(CompletionCandidate::new(ulid.to_string())); +// completions +// } + #[derive(Debug, Snafu)] pub enum Error { #[snafu(display("Error writing data: {}", source))] diff --git a/src/cli/complete.rs b/src/cli/complete.rs new file mode 100644 index 0000000..6c8329a --- /dev/null +++ b/src/cli/complete.rs @@ -0,0 +1,94 @@ +use std::ffi; + +use crate::{cli::opts::MainOpts, httpclient::Client}; + +use clap::Parser; +use clap_complete::CompletionCandidate; +use futures::executor::block_on; + +pub fn make_sync_completer(func: F) -> impl Fn(&ffi::OsStr) -> Vec +where + F: Fn(&ffi::OsStr, Client) -> Fut + Clone + 'static, + Fut: Future> + 'static, +{ + move |current: &ffi::OsStr| { + let Some(_current) = current.to_str() else { + return vec![]; + }; + + let args = truncate_to_common_opts(std::env::args()); + let Ok(opts) = MainOpts::try_parse_from(args) else { + return vec![]; + }; + + let Ok(client) = opts.common_opts.create_client(None) else { + return vec![]; + }; + + block_on(func(current, client)) + } +} + +pub fn make_sync_completer2(current: &ffi::OsStr, func: F) -> Vec +where + F: Fn(Client) -> Fut + Clone + 'static, + Fut: Future> + 'static, +{ + let Some(_current) = current.to_str() else { + return vec![]; + }; + + let args = truncate_to_common_opts(std::env::args()).collect::>(); + let Ok(opts) = MainOpts::try_parse_from(args) else { + return vec![]; + }; + + let Ok(client) = opts.common_opts.create_client(None) else { + return vec![]; + }; + + block_on(func(client)) +} + +/// Returns the part of the arguments that make up CommonOpts +fn truncate_to_common_opts(iter: I) -> impl Iterator +where + I: IntoIterator, +{ + let mut it = iter.into_iter(); + it.next(); + it.next(); + let first = it.next(); + let first_it = first.map(std::iter::once).into_iter().flatten(); + let remain = it.take_while(|e| e.starts_with('-')); + // the version command to make arg parsing successful + let version = std::iter::once("version".to_string()).into_iter(); + first_it.chain(remain).chain(version) +} + +/// Complete a session launcher id +#[allow(dead_code, unused_mut, unused_variables, unreachable_code)] +pub fn complete_launcher_id(current: &ffi::OsStr) -> Vec { + make_sync_completer2(current, async |client| { + let Ok(launchers) = client.list_launchers().await else { + panic!("error getting launchers"); + return vec![]; + }; + launchers + .iter() + .map(|l| CompletionCandidate::new(l.id.clone())) + .collect() + }) + // panic!("opts: {:?}", opts); + + // let mut ulid = Ulid::from_string(&format!("test:{}", args.len())).unwrap(); + // // if copts.is_err() { + // // ulid = Ulid::from_string(&format!("help:{}", args.len())).unwrap() + // // } + + // // let matches = MainOpts::command().get_matches(); + // // let rurl = matches.get_one::("--renku-url"); + // // println!(">>>>> url: {:?}", rurl); + // completions.push(CompletionCandidate::new(ulid.to_string())); + // completions +} diff --git a/src/cli/opts.rs b/src/cli/opts.rs index b898c9a..6f214c1 100644 --- a/src/cli/opts.rs +++ b/src/cli/opts.rs @@ -1,10 +1,13 @@ -use crate::data::renku_url::RenkuUrl; +use crate::{ + data::renku_url::RenkuUrl, + httpclient::{Client, Error as ClientError, proxy}, +}; use super::cmd::*; use clap::{Parser, ValueEnum, ValueHint}; use clap_verbosity_flag::{Verbosity, WarnLevel}; use serde::{Deserialize, Serialize}; -use std::str::FromStr; +use std::{path::PathBuf, str::FromStr}; /// Main options are available to all commands. They must appear /// before a sub-command. @@ -44,6 +47,56 @@ pub struct CommonOpts { pub proxy_password: Option, } +impl CommonOpts { + const ACCESS_TOKEN_ENV: &str = "RENKU_CLI_ACCESS_TOKEN"; + + pub fn create_client(&self, trusted_cert: Option) -> Result { + let at = std::env::var(Self::ACCESS_TOKEN_ENV).ok(); + let base_url = self + .get_renku_url() + .map_err(|e| ClientError::UrlParse { source: e })?; + Client::new(base_url, self.proxy_settings(), trusted_cert, false, at) + } + + fn proxy_settings(&self) -> proxy::ProxySetting { + let user = self.proxy_user.clone(); + let password = self.proxy_password.clone(); + let prx = self.proxy.clone(); + + log::debug!("Using proxy: {:?} @ {:?}", user, prx); + match prx { + None => proxy::ProxySetting::System, + Some(ProxySetting::None) => proxy::ProxySetting::None, + Some(ProxySetting::Custom { url }) => proxy::ProxySetting::Custom { + url: url.clone(), + user, + password, + }, + } + } + + fn get_renku_url(&self) -> Result { + match &self.renku_url { + Some(u) => { + log::debug!("Use renku url from arguments: {}", u); + Ok(u.clone()) + } + None => match RenkuUrl::from_env() { + Some(res) => { + if let Ok(u) = &res { + log::debug!("Use renku url from env RENKU_CLI_RENKU_URL: {}", u); + } + res + } + None => { + log::debug!("Use renku url: https://renkulab.io"); + Ok(RenkuUrl::renkulab_io()) + } + }, + } + } +} + #[derive(Parser, Debug)] pub enum SubCommand { #[command()] diff --git a/src/data/renku_url.rs b/src/data/renku_url.rs index 7a23bc8..0b7d61c 100644 --- a/src/data/renku_url.rs +++ b/src/data/renku_url.rs @@ -4,6 +4,8 @@ use reqwest::Url; use serde::{Deserialize, Serialize}; use url::ParseError; +const RENKULAB_IO: &str = "https://renkulab.io"; + // Need this newtype to be able to implement Serialize and Deserialize #[derive(Debug, PartialEq, Clone)] @@ -32,6 +34,16 @@ impl RenkuUrl { let RenkuUrl(u) = self; u.join(seg).map(RenkuUrl) } + + pub fn from_env() -> Option> { + std::env::var("RENKU_CLI_RENKU_URL") + .ok() + .map(|u| RenkuUrl::parse(&u)) + } + + pub fn renkulab_io() -> RenkuUrl { + RenkuUrl::parse(RENKULAB_IO).unwrap() + } } impl<'de> Deserialize<'de> for RenkuUrl { diff --git a/src/httpclient.rs b/src/httpclient.rs index 17d37d2..71d1572 100644 --- a/src/httpclient.rs +++ b/src/httpclient.rs @@ -406,4 +406,10 @@ impl Client { cache::write_auth_token(&r).await?; Ok(r) } + + pub async fn list_launchers(&self) -> Result, Error> { + let path = "/api/data/session_launchers"; + let result = self.json_get::>(path).await?; + Ok(result) + } } diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index f3dc815..d77c224 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -11,6 +11,18 @@ use tabled::{ settings::{Settings, Style}, }; +#[derive(Debug, Serialize, Deserialize)] +pub struct SessionLauncher { + pub id: String, + pub project_id: String, + pub name: String, +} +impl fmt::Display for SessionLauncher { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} ({})", self.name, self.id) + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct SessionLogs(pub HashMap); diff --git a/src/main.rs b/src/main.rs index 8f94c75..6adf03f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,9 @@ async fn main() -> EyreResult<()> { } async fn execute() -> Result<()> { - CompleteEnv::with_factory(MainOpts::command).complete(); + CompleteEnv::with_factory(MainOpts::command) + + .complete(); let opts = rnk::read_args(); env_logger::Builder::new() .filter_level(opts.common_opts.verbosity.log_level_filter()) From 88ba4bc4fe25ac249aeb94c2bc204cc12c14b306 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 9 Jun 2026 15:33:07 +0200 Subject: [PATCH 06/19] Complete launcher ids --- src/cli/complete.rs | 47 ++++++++++++++++++++++++--------------- src/data/submission_id.rs | 3 ++- src/util/strings.rs | 6 ++++- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/cli/complete.rs b/src/cli/complete.rs index 6c8329a..58c6a15 100644 --- a/src/cli/complete.rs +++ b/src/cli/complete.rs @@ -1,8 +1,11 @@ use std::ffi; -use crate::{cli::opts::MainOpts, httpclient::Client}; +use crate::{ + cli::opts::MainOpts, + httpclient::{Client, data::SessionLauncher}, +}; -use clap::Parser; +use clap::{Parser, builder::StyledStr}; use clap_complete::CompletionCandidate; use futures::executor::block_on; @@ -66,6 +69,24 @@ where first_it.chain(remain).chain(version) } +async fn make_launcher_completion_candidate( + client: &Client, + launcher: &SessionLauncher, +) -> CompletionCandidate { + let mut help = StyledStr::new(); + help.push_str(&launcher.name); + let cc = CompletionCandidate::new(launcher.id.clone()); + + let Ok(Some(project)) = client.get_project_by_id(&launcher.project_id).await else { + return cc.help(Some(help)); + }; + + help.push_str(" - "); + help.push_str(&project.name); + + cc.help(Some(help.into())) +} + /// Complete a session launcher id #[allow(dead_code, unused_mut, unused_variables, unreachable_code)] pub fn complete_launcher_id(current: &ffi::OsStr) -> Vec { @@ -74,21 +95,11 @@ pub fn complete_launcher_id(current: &ffi::OsStr) -> Vec { panic!("error getting launchers"); return vec![]; }; - launchers - .iter() - .map(|l| CompletionCandidate::new(l.id.clone())) - .collect() + let mut result: Vec = vec![]; + for launcher in launchers { + let cc = make_launcher_completion_candidate(&client, &launcher).await; + result.push(cc); + } + return result; }) - // panic!("opts: {:?}", opts); - - // let mut ulid = Ulid::from_string(&format!("test:{}", args.len())).unwrap(); - // // if copts.is_err() { - // // ulid = Ulid::from_string(&format!("help:{}", args.len())).unwrap() - // // } - - // // let matches = MainOpts::command().get_matches(); - // // let rurl = matches.get_one::("--renku-url"); - // // println!(">>>>> url: {:?}", rurl); - // completions.push(CompletionCandidate::new(ulid.to_string())); - // completions } diff --git a/src/data/submission_id.rs b/src/data/submission_id.rs index 0b3a3c8..fd7f9bc 100644 --- a/src/data/submission_id.rs +++ b/src/data/submission_id.rs @@ -30,7 +30,7 @@ impl SubmissionId { pub fn random() -> SubmissionId { let first = util::strings::random(1, "abcdefghijklmnopqrstuvwxyz"); - let s = util::strings::random_alpha_num(8); + let s = util::strings::random_lower_alpha_num(8); SubmissionId(format!("{}{}", first, s)) } } @@ -87,6 +87,7 @@ impl std::error::Error for SubmissionIdError {} fn submission_id_parse() { assert!(SubmissionId::parse("__-").is_err()); assert!(SubmissionId::parse("9abcd").is_err()); + assert!(SubmissionId::parse("aBCDEFg").is_err()); assert!(SubmissionId::parse("abc-9ed").is_ok()); assert_eq!( SubmissionId::parse("ab-cd-de").unwrap().as_str(), diff --git a/src/util/strings.rs b/src/util/strings.rs index 2de4e2d..d17ebab 100644 --- a/src/util/strings.rs +++ b/src/util/strings.rs @@ -1,7 +1,7 @@ use rand::Rng; const CHARSET_ALPHA_NUM: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - +const CHARSET_LOWER_ALPHA_NUM: &str = "abcdefghijklmnopqrstuvwxyz0123456789"; const CHARSET_ALPHA: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; pub fn random(length: usize, charset: &str) -> String { @@ -16,6 +16,10 @@ pub fn random_alpha_num(length: usize) -> String { random(length, CHARSET_ALPHA_NUM) } +pub fn random_lower_alpha_num(length: usize) -> String { + random(length, CHARSET_LOWER_ALPHA_NUM) +} + pub fn random_alpha(length: usize) -> String { random(length, CHARSET_ALPHA) } From bfda7f10f9cd3674d55ef344e9738abb996fac0d Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 9 Jun 2026 16:15:24 +0200 Subject: [PATCH 07/19] Allow to overwrite the job command --- src/cli/cmd/job/start.rs | 57 +++++++++++----------------------------- src/httpclient/data.rs | 11 +++++--- 2 files changed, 24 insertions(+), 44 deletions(-) diff --git a/src/cli/cmd/job/start.rs b/src/cli/cmd/job/start.rs index 6d0089b..9ec33c5 100644 --- a/src/cli/cmd/job/start.rs +++ b/src/cli/cmd/job/start.rs @@ -26,51 +26,15 @@ pub struct Input { #[arg(long)] pub submission_id: Option, + /// Overwrite the command that is set in the launcher. + #[arg(long)] + pub command: Vec, + /// These arguments are passed to the renku job command. #[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 0.., value_name = "ARGS")] pub passthrough: Vec, } -// #[allow(dead_code, unused_mut, unused_variables, unreachable_code)] -// fn complete_launcher_id(current: &ffi::OsStr) -> Vec { -// let mut completions = vec![]; -// let Some(_current) = current.to_str() else { -// return completions; -// }; - -// let mut args = std::env::args() -// .take_while(|e| !e.eq_ignore_ascii_case("job")) -// .skip(2) -// .collect::>(); -// args.push("version".into()); -// let Ok(opts) = MainOpts::try_parse_from(args) else { -// return completions; -// }; - -// let Ok(client) = opts.common_opts.create_client(None) else { -// return completions; -// }; - -// // let args2 = args.take_while(|e| !e.eq_ignore_ascii_case("job")).skip(3); -// // let args3: Vec = args2.collect(); - -// // let mut def_opts = CommonOpts::empty(); -// // def_opts.try_update_from(args2).unwrap(); - -// panic!("opts: {:?}", opts); - -// let mut ulid = Ulid::from_string(&format!("test:{}", args.len())).unwrap(); -// // if copts.is_err() { -// // ulid = Ulid::from_string(&format!("help:{}", args.len())).unwrap() -// // } - -// // let matches = MainOpts::command().get_matches(); -// // let rurl = matches.get_one::("--renku-url"); -// // println!(">>>>> url: {:?}", rurl); -// completions.push(CompletionCandidate::new(ulid.to_string())); -// completions -// } - #[derive(Debug, Snafu)] pub enum Error { #[snafu(display("Error writing data: {}", source))] @@ -86,11 +50,22 @@ impl Input { .submission_id .clone() .unwrap_or_else(|| SubmissionId::random()); + let cmd = if self.command.is_empty() { + None + } else { + Some(self.command.clone()) + }; + let args = if self.passthrough.is_empty() { + None + } else { + Some(self.passthrough.clone()) + }; let req = SessionStartRequest { launcher_id: self.launcher.to_string(), session_type: "non-interactive".into(), submission_id: Some(submission_id), - job_args_override: self.passthrough.clone(), + job_args_override: args, + job_command_override: cmd, }; let result = ctx .client diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index d77c224..530f9a4 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -62,14 +62,19 @@ pub struct SessionStartRequest { pub launcher_id: String, pub session_type: String, pub submission_id: Option, - pub job_args_override: Vec, + pub job_args_override: Option>, + pub job_command_override: Option>, } impl fmt::Display for SessionStartRequest { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "SessionStart(launcher={}, session_type={}, submission_id={:?}, job_args_overrides={:?})", - self.launcher_id, self.session_type, self.submission_id, self.job_args_override + "SessionStart(launcher={}, session_type={}, submission_id={:?}, job_args_overrides={:?}, command={:?})", + self.launcher_id, + self.session_type, + self.submission_id, + self.job_args_override, + self.job_command_override ) } } From 78b38a54ee0dbcab94b4eb4674303d9cc51624b7 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 9 Jun 2026 16:51:31 +0200 Subject: [PATCH 08/19] Filter only non-interactive launchers --- src/cli/cmd/job/start.rs | 4 ++-- src/cli/complete.rs | 12 +++++++++--- src/httpclient/data.rs | 5 ++++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/cli/cmd/job/start.rs b/src/cli/cmd/job/start.rs index 9ec33c5..38e2fdf 100644 --- a/src/cli/cmd/job/start.rs +++ b/src/cli/cmd/job/start.rs @@ -1,5 +1,5 @@ use crate::{ - cli::complete::complete_launcher_id, + cli::complete::complete_job_launcher_id, data::submission_id::SubmissionId, httpclient::{self, data::SessionStartRequest}, }; @@ -19,7 +19,7 @@ use snafu::{ResultExt, Snafu}; #[derive(Parser, Debug)] pub struct Input { /// The launcher to use for launching the job. - #[arg(long, value_hint=ValueHint::Other, add = ArgValueCompleter::new(complete_launcher_id))] + #[arg(long, value_hint=ValueHint::Other, add = ArgValueCompleter::new(complete_job_launcher_id))] pub launcher: Ulid, /// A submission id allows to deduplicate same job submissions. If missing, a random one is generated. It must be at least 4 characters, starting with a lowercase letter, followed by alphanumeric characters (including the dash). diff --git a/src/cli/complete.rs b/src/cli/complete.rs index 58c6a15..087601c 100644 --- a/src/cli/complete.rs +++ b/src/cli/complete.rs @@ -2,7 +2,10 @@ use std::ffi; use crate::{ cli::opts::MainOpts, - httpclient::{Client, data::SessionLauncher}, + httpclient::{ + Client, + data::{SessionLauncher, SessionMode}, + }, }; use clap::{Parser, builder::StyledStr}; @@ -89,14 +92,17 @@ async fn make_launcher_completion_candidate( /// Complete a session launcher id #[allow(dead_code, unused_mut, unused_variables, unreachable_code)] -pub fn complete_launcher_id(current: &ffi::OsStr) -> Vec { +pub fn complete_job_launcher_id(current: &ffi::OsStr) -> Vec { make_sync_completer2(current, async |client| { let Ok(launchers) = client.list_launchers().await else { panic!("error getting launchers"); return vec![]; }; let mut result: Vec = vec![]; - for launcher in launchers { + for launcher in launchers + .iter() + .filter(|e| e.launcher_type == SessionMode::NonInteractive) + { let cc = make_launcher_completion_candidate(&client, &launcher).await; result.push(cc); } diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index 530f9a4..515ac7e 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -16,6 +16,7 @@ pub struct SessionLauncher { pub id: String, pub project_id: String, pub name: String, + pub launcher_type: SessionMode, } impl fmt::Display for SessionLauncher { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -36,9 +37,11 @@ impl fmt::Display for SessionLogs { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, PartialEq)] pub enum SessionMode { + #[serde(rename = "interactive")] Interactive, + #[serde(rename = "non-interactive")] NonInteractive, } From 249257a63c2d2164b7c5c6d78243bd8234f4be7f Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 9 Jun 2026 17:33:08 +0200 Subject: [PATCH 09/19] Try more complicated async->sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the simple `block_on` worked for me, but not others… --- src/cli/complete.rs | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/cli/complete.rs b/src/cli/complete.rs index 087601c..e4bb75e 100644 --- a/src/cli/complete.rs +++ b/src/cli/complete.rs @@ -1,4 +1,4 @@ -use std::ffi; +use std::{ffi, thread}; use crate::{ cli::opts::MainOpts, @@ -11,6 +11,9 @@ use crate::{ use clap::{Parser, builder::StyledStr}; use clap_complete::CompletionCandidate; use futures::executor::block_on; +use tokio::sync::mpsc; + +use super::opts::CommonOpts; pub fn make_sync_completer(func: F) -> impl Fn(&ffi::OsStr) -> Vec where @@ -37,8 +40,8 @@ where pub fn make_sync_completer2(current: &ffi::OsStr, func: F) -> Vec where - F: Fn(Client) -> Fut + Clone + 'static, - Fut: Future> + 'static, + F: Fn(Client, CommonOpts) -> Fut + Clone + Send + 'static, + Fut: Future> + Send + 'static, { let Some(_current) = current.to_str() else { return vec![]; @@ -53,7 +56,20 @@ where return vec![]; }; - block_on(func(client)) + let (send, mut recv) = mpsc::unbounded_channel(); + tokio::spawn(async move { + let result = func(client, opts.common_opts).await; + send.send(result).unwrap(); + }); + + let sync_recv = thread::spawn(move || { + let mut completions = vec![]; + while let Some(candidate) = recv.blocking_recv() { + completions.extend(candidate); + } + completions + }); + sync_recv.join().unwrap() } /// Returns the part of the arguments that make up CommonOpts @@ -93,7 +109,7 @@ async fn make_launcher_completion_candidate( /// Complete a session launcher id #[allow(dead_code, unused_mut, unused_variables, unreachable_code)] pub fn complete_job_launcher_id(current: &ffi::OsStr) -> Vec { - make_sync_completer2(current, async |client| { + make_sync_completer2(current, async |client, _opts| { let Ok(launchers) = client.list_launchers().await else { panic!("error getting launchers"); return vec![]; From 4ecce746ecb4a8e7fdb10fd233aa9892a39a3be9 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 9 Jun 2026 17:37:27 +0200 Subject: [PATCH 10/19] Start with project context --- src/cli/opts.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/cli/opts.rs b/src/cli/opts.rs index 6f214c1..3ab9841 100644 --- a/src/cli/opts.rs +++ b/src/cli/opts.rs @@ -1,5 +1,8 @@ use crate::{ - data::renku_url::RenkuUrl, + data::{ + project_id::{ProjectId, ProjectIdParseError}, + renku_url::RenkuUrl, + }, httpclient::{Client, Error as ClientError, proxy}, }; @@ -31,6 +34,13 @@ pub struct CommonOpts { #[arg(long, value_hint = ValueHint::Url)] pub renku_url: Option, + /// Some commands may operate within a project. If this option is + /// set, or the environment variable RENKU_CLI_PROJECT_CONTEXT is + /// present and specifies a project id, some commands use it to + /// confine there functionality to this project. + #[arg(long, value_hint = ValueHint::Url)] + pub project_context: Option, + /// Set a proxy to use for doing http requests. By default, the /// system proxy will be used. Can be either `none` or . If /// `none`, the system proxy will be ignored; otherwise specify @@ -95,6 +105,18 @@ impl CommonOpts { }, } } + + #[allow(dead_code)] + fn get_project_context(&self) -> Result, ProjectIdParseError> { + if self.project_context.is_some() { + return Ok(self.project_context.clone()); + } else { + match std::env::var("RENKU_CLI_PROJECT_CONTEXT").ok() { + Some(id) => ProjectId::parse(&id).map(|e| Some(e)), + None => Ok(None), + } + } + } } #[derive(Parser, Debug)] From 5d6e1d1d0fdcaa91a1f9cdb88a34d336186acc64 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Tue, 9 Jun 2026 18:44:22 +0200 Subject: [PATCH 11/19] Allow to set a project context via env variables --- src/cli/complete.rs | 18 ++++++++++--- src/cli/opts.rs | 3 +-- src/httpclient.rs | 61 ++++++++++++++++++++++++++++-------------- src/httpclient/data.rs | 13 +++++++++ 4 files changed, 70 insertions(+), 25 deletions(-) diff --git a/src/cli/complete.rs b/src/cli/complete.rs index e4bb75e..27ddbf9 100644 --- a/src/cli/complete.rs +++ b/src/cli/complete.rs @@ -2,6 +2,7 @@ use std::{ffi, thread}; use crate::{ cli::opts::MainOpts, + data::project_id::ProjectId, httpclient::{ Client, data::{SessionLauncher, SessionMode}, @@ -106,18 +107,29 @@ async fn make_launcher_completion_candidate( cc.help(Some(help.into())) } +async fn resolve_project_id(client: &Client, id: ProjectId) -> Option { + client.get_project(&id).await.ok().flatten().map(|p| p.id) +} + /// Complete a session launcher id -#[allow(dead_code, unused_mut, unused_variables, unreachable_code)] pub fn complete_job_launcher_id(current: &ffi::OsStr) -> Vec { - make_sync_completer2(current, async |client, _opts| { + make_sync_completer2(current, async |client, opts| { let Ok(launchers) = client.list_launchers().await else { - panic!("error getting launchers"); return vec![]; }; let mut result: Vec = vec![]; + let project_ctx = opts.get_project_context().ok().flatten(); + let project_id = match project_ctx { + Some(id) => resolve_project_id(&client, id).await, + None => None, + }; for launcher in launchers .iter() .filter(|e| e.launcher_type == SessionMode::NonInteractive) + .filter(|e| match &project_id { + Some(id) => id == &e.project_id, + None => true, + }) { let cc = make_launcher_completion_candidate(&client, &launcher).await; result.push(cc); diff --git a/src/cli/opts.rs b/src/cli/opts.rs index 3ab9841..602b56a 100644 --- a/src/cli/opts.rs +++ b/src/cli/opts.rs @@ -106,8 +106,7 @@ impl CommonOpts { } } - #[allow(dead_code)] - fn get_project_context(&self) -> Result, ProjectIdParseError> { + pub fn get_project_context(&self) -> Result, ProjectIdParseError> { if self.project_context.is_some() { return Ok(self.project_context.clone()); } else { diff --git a/src/httpclient.rs b/src/httpclient.rs index 71d1572..4d4802d 100644 --- a/src/httpclient.rs +++ b/src/httpclient.rs @@ -301,12 +301,19 @@ impl Client { Ok(details) } + /// Get project details by a ui url. It supports two formats: + /// - /p/ + /// - /p// pub async fn get_project_by_url( &self, url: U, ) -> Result, Error> { let urlstr = url.as_str().to_string(); let url = url.into_url().context(HttpSnafu { url: urlstr })?; + let mut base = url.clone(); + base.set_path(""); + let base_url = RenkuUrl::new(base); + log::debug!("Get project by url: {}", &url); // there are different urls identifying the project // /v2/projects/ (ui) @@ -314,27 +321,12 @@ impl Client { // note the ui urls are currently not stable let project_path_regex = Regex::new( r"(?x) - (?/v2/projects/)(?[0-7][0-9A-HJKMNP-TV-Z]{25}) # /v2/projects/ (ui) + (?/p/)(?[0-7][0-9A-HJKMNP-TV-Z]{25}) # /p/ (ui) | - (?/v2/projects/)(?[^/]+)/(?.+) # /v2/projects// (ui)").unwrap(); + (?/p/)(?[^/]+)/(?.+) # /p// (ui)", + ) + .unwrap(); let captures = project_path_regex.captures(url.path()).unwrap(); - let path = if captures.name("uiproj").is_some() { - let proj_id = captures.name("uiid").unwrap().as_str(); - format!("/api/data/projects/{}", proj_id) - } else if captures.name("uinamespace").is_some() { - let namespace = captures.name("uins").unwrap().as_str(); - let proj_name = captures.name("uiname").unwrap().as_str(); - format!("/api/data/namespaces/{}/projects/{}", namespace, proj_name) - } else { - return Err(Error::ProjectUrlParse { - reason: format!("Url {} did not match project URL pattern", url.path()), - }); - }; - - log::debug!("Transformed path {} to: {}", url.path(), &path); - let mut base = url.clone(); - base.set_path(""); - let base_url = RenkuUrl::new(base); log::debug!("Create temporary client for {}", &base_url); let client = Client::new( @@ -344,8 +336,37 @@ impl Client { self.settings.accept_invalid_certs, self.access_token.clone(), )?; + if captures.name("uiproj").is_some() { + let proj_id = captures.name("uiid").unwrap().as_str(); + client.get_project_by_id(proj_id).await + } else if captures.name("uinamespace").is_some() { + let namespace = captures.name("uins").unwrap().as_str(); + let proj_name = captures.name("uiname").unwrap().as_str(); + client.get_project_by_slug(namespace, proj_name).await + } else { + Err(Error::ProjectUrlParse { + reason: format!("Url {} did not match project URL pattern", url.path()), + }) + } + } + + pub async fn get_namespace( + &self, + first_slug: &str, + second_slug: Option<&str>, + ) -> Result, Error> { + log::debug!( + "Get namespace by slug1/slug2: {}/{:?}", + first_slug, + second_slug + ); + let path = if let Some(second) = second_slug { + format!("/api/data/namespaces/{}/{}", first_slug, second) + } else { + format!("/api/data/namespaces/{}", first_slug) + }; - let details = client.json_get_option::(&path).await?; + let details = self.json_get_option::(&path).await?; Ok(details) } diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index 515ac7e..39adfb1 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -188,6 +188,19 @@ pub struct VersionInfo { pub data: SimpleVersion, } +#[derive(Debug, Serialize, Deserialize)] +pub struct NamespaceDetails { + pub id: String, + pub name: String, + pub slug: String, + pub path: String, +} +impl fmt::Display for NamespaceDetails { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Namespace: {} ({})", self.path, self.id) + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct ProjectDetails { pub id: String, From 1206edd85a6c28343523bb8585d71eb1e55eeab7 Mon Sep 17 00:00:00 2001 From: Eike Date: Wed, 10 Jun 2026 09:45:53 +0200 Subject: [PATCH 12/19] Try another variant to run async completion candidate calls --- src/cli/complete.rs | 68 +++++++++++++++++---------------------------- src/main.rs | 6 ++-- 2 files changed, 28 insertions(+), 46 deletions(-) diff --git a/src/cli/complete.rs b/src/cli/complete.rs index 27ddbf9..bba34ab 100644 --- a/src/cli/complete.rs +++ b/src/cli/complete.rs @@ -1,4 +1,4 @@ -use std::{ffi, thread}; +use std::ffi; use crate::{ cli::opts::MainOpts, @@ -11,35 +11,13 @@ use crate::{ use clap::{Parser, builder::StyledStr}; use clap_complete::CompletionCandidate; -use futures::executor::block_on; -use tokio::sync::mpsc; use super::opts::CommonOpts; -pub fn make_sync_completer(func: F) -> impl Fn(&ffi::OsStr) -> Vec -where - F: Fn(&ffi::OsStr, Client) -> Fut + Clone + 'static, - Fut: Future> + 'static, -{ - move |current: &ffi::OsStr| { - let Some(_current) = current.to_str() else { - return vec![]; - }; - - let args = truncate_to_common_opts(std::env::args()); - let Ok(opts) = MainOpts::try_parse_from(args) else { - return vec![]; - }; - - let Ok(client) = opts.common_opts.create_client(None) else { - return vec![]; - }; - - block_on(func(current, client)) - } -} - -pub fn make_sync_completer2(current: &ffi::OsStr, func: F) -> Vec +// Helper function to create completion-candidate functions that are +// async and use the client and common options for their +// implementation. +fn make_sync_completer(current: &ffi::OsStr, func: F) -> Vec where F: Fn(Client, CommonOpts) -> Fut + Clone + Send + 'static, Fut: Future> + Send + 'static, @@ -57,20 +35,26 @@ where return vec![]; }; - let (send, mut recv) = mpsc::unbounded_channel(); - tokio::spawn(async move { - let result = func(client, opts.common_opts).await; - send.send(result).unwrap(); - }); + tokio::task::block_in_place(move || { + tokio::runtime::Handle::current().block_on(func(client, opts.common_opts)) + }) - let sync_recv = thread::spawn(move || { - let mut completions = vec![]; - while let Some(candidate) = recv.blocking_recv() { - completions.extend(candidate); - } - completions - }); - sync_recv.join().unwrap() + // futures::executor::block_on(tokio::spawn(func(client, opts.common_opts))).unwrap() + + // let (send, mut recv) = mpsc::unbounded_channel(); + // tokio::spawn(async move { + // let result = func(client, opts.common_opts).await; + // send.send(result).unwrap(); + // }); + + // let sync_recv = thread::spawn(move || { + // let mut completions = vec![]; + // while let Some(candidate) = recv.blocking_recv() { + // completions.extend(candidate); + // } + // completions + // }); + // sync_recv.join().unwrap() } /// Returns the part of the arguments that make up CommonOpts @@ -111,9 +95,9 @@ async fn resolve_project_id(client: &Client, id: ProjectId) -> Option { client.get_project(&id).await.ok().flatten().map(|p| p.id) } -/// Complete a session launcher id +/// Complete a job session launcher id pub fn complete_job_launcher_id(current: &ffi::OsStr) -> Vec { - make_sync_completer2(current, async |client, opts| { + make_sync_completer(current, async |client, opts| { let Ok(launchers) = client.list_launchers().await else { return vec![]; }; diff --git a/src/main.rs b/src/main.rs index 6adf03f..a593c2b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use color_eyre::Result as EyreResult; use rnk::cli::opts::MainOpts; use rnk::error::Result; -#[tokio::main] +#[tokio::main(flavor = "multi_thread")] async fn main() -> EyreResult<()> { rnk::error::init()?; execute().await?; @@ -12,9 +12,7 @@ async fn main() -> EyreResult<()> { } async fn execute() -> Result<()> { - CompleteEnv::with_factory(MainOpts::command) - - .complete(); + CompleteEnv::with_factory(MainOpts::command).complete(); let opts = rnk::read_args(); env_logger::Builder::new() .filter_level(opts.common_opts.verbosity.log_level_filter()) From 7f2d48be029ae917f40f170632bcd71879c34a4b Mon Sep 17 00:00:00 2001 From: Eike Date: Wed, 10 Jun 2026 13:47:09 +0200 Subject: [PATCH 13/19] some debugging --- src/cli/complete.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/cli/complete.rs b/src/cli/complete.rs index bba34ab..b48629c 100644 --- a/src/cli/complete.rs +++ b/src/cli/complete.rs @@ -23,16 +23,16 @@ where Fut: Future> + Send + 'static, { let Some(_current) = current.to_str() else { - return vec![]; + return vec![CompletionCandidate::new("invalid current str")]; }; let args = truncate_to_common_opts(std::env::args()).collect::>(); let Ok(opts) = MainOpts::try_parse_from(args) else { - return vec![]; + return vec![CompletionCandidate::new("option parsing failed")]; }; let Ok(client) = opts.common_opts.create_client(None) else { - return vec![]; + return vec![CompletionCandidate::new("client creation failed")]; }; tokio::task::block_in_place(move || { @@ -99,8 +99,11 @@ async fn resolve_project_id(client: &Client, id: ProjectId) -> Option { pub fn complete_job_launcher_id(current: &ffi::OsStr) -> Vec { make_sync_completer(current, async |client, opts| { let Ok(launchers) = client.list_launchers().await else { - return vec![]; + return vec![CompletionCandidate::new("Getting list of launchers failed")]; }; + if launchers.is_empty() { + return vec![CompletionCandidate::new("No launchers found")]; + } let mut result: Vec = vec![]; let project_ctx = opts.get_project_context().ok().flatten(); let project_id = match project_ctx { @@ -118,6 +121,10 @@ pub fn complete_job_launcher_id(current: &ffi::OsStr) -> Vec Date: Wed, 10 Jun 2026 14:51:24 +0200 Subject: [PATCH 14/19] Cleanup for completing launcher ids --- src/cli/cmd/job/start.rs | 5 ++- src/cli/complete.rs | 74 +++++++++++++++++++--------------------- 2 files changed, 39 insertions(+), 40 deletions(-) diff --git a/src/cli/cmd/job/start.rs b/src/cli/cmd/job/start.rs index 38e2fdf..6ecb8d5 100644 --- a/src/cli/cmd/job/start.rs +++ b/src/cli/cmd/job/start.rs @@ -22,7 +22,10 @@ pub struct Input { #[arg(long, value_hint=ValueHint::Other, add = ArgValueCompleter::new(complete_job_launcher_id))] pub launcher: Ulid, - /// A submission id allows to deduplicate same job submissions. If missing, a random one is generated. It must be at least 4 characters, starting with a lowercase letter, followed by alphanumeric characters (including the dash). + /// A submission id allows to deduplicate same job submissions. If + /// missing, a random one is generated. It must be at least 4 + /// characters, starting with a lowercase letter, followed by + /// alphanumeric characters (including the dash). #[arg(long)] pub submission_id: Option, diff --git a/src/cli/complete.rs b/src/cli/complete.rs index b48629c..21c968b 100644 --- a/src/cli/complete.rs +++ b/src/cli/complete.rs @@ -9,7 +9,7 @@ use crate::{ }, }; -use clap::{Parser, builder::StyledStr}; +use clap::{Parser, builder::StyledStr, error::Error as ClapError}; use clap_complete::CompletionCandidate; use super::opts::CommonOpts; @@ -26,43 +26,30 @@ where return vec![CompletionCandidate::new("invalid current str")]; }; - let args = truncate_to_common_opts(std::env::args()).collect::>(); - let Ok(opts) = MainOpts::try_parse_from(args) else { - return vec![CompletionCandidate::new("option parsing failed")]; + let Ok(opts) = parse_common_opts() else { + eprintln!("Completions failed: Error parsing common options"); + return vec![]; }; - let Ok(client) = opts.common_opts.create_client(None) else { - return vec![CompletionCandidate::new("client creation failed")]; + let Ok(client) = opts.create_client(None) else { + eprintln!("Completions failed: Error creating http renku client"); + return vec![]; }; tokio::task::block_in_place(move || { - tokio::runtime::Handle::current().block_on(func(client, opts.common_opts)) + tokio::runtime::Handle::current().block_on(func(client, opts)) }) - - // futures::executor::block_on(tokio::spawn(func(client, opts.common_opts))).unwrap() - - // let (send, mut recv) = mpsc::unbounded_channel(); - // tokio::spawn(async move { - // let result = func(client, opts.common_opts).await; - // send.send(result).unwrap(); - // }); - - // let sync_recv = thread::spawn(move || { - // let mut completions = vec![]; - // while let Some(candidate) = recv.blocking_recv() { - // completions.extend(candidate); - // } - // completions - // }); - // sync_recv.join().unwrap() } -/// Returns the part of the arguments that make up CommonOpts -fn truncate_to_common_opts(iter: I) -> impl Iterator -where - I: IntoIterator, -{ - let mut it = iter.into_iter(); +/// Parses the part of the arguments that make up CommonOpts. +fn parse_common_opts() -> Result { + // this is a bit nasty, due to lack of a better option: manually + // massage the arguments to remove everything after the first + // non-option argument appears, which is the subcommand passed to + // the binary. Then the standard command 'version' is appended, so + // that parsing succeeds. Only common-options are of interest + // here. + let mut it = std::env::args(); it.next(); it.next(); let first = it.next(); @@ -70,7 +57,8 @@ where let remain = it.take_while(|e| e.starts_with('-')); // the version command to make arg parsing successful let version = std::iter::once("version".to_string()).into_iter(); - first_it.chain(remain).chain(version) + let args = first_it.chain(remain).chain(version); + MainOpts::try_parse_from(args).map(|e| e.common_opts) } async fn make_launcher_completion_candidate( @@ -82,6 +70,7 @@ async fn make_launcher_completion_candidate( let cc = CompletionCandidate::new(launcher.id.clone()); let Ok(Some(project)) = client.get_project_by_id(&launcher.project_id).await else { + eprintln!("Cannot get project details for: {}", launcher.project_id); return cc.help(Some(help)); }; @@ -92,18 +81,26 @@ async fn make_launcher_completion_candidate( } async fn resolve_project_id(client: &Client, id: ProjectId) -> Option { - client.get_project(&id).await.ok().flatten().map(|p| p.id) + match client.get_project(&id).await { + Ok(Some(p)) => Some(p.id), + Ok(None) => { + eprintln!("Project context not found: {}", id); + None + } + Err(msg) => { + eprintln!("Error getting project for id '{}': {}", id, msg); + None + } + } } /// Complete a job session launcher id pub fn complete_job_launcher_id(current: &ffi::OsStr) -> Vec { make_sync_completer(current, async |client, opts| { let Ok(launchers) = client.list_launchers().await else { - return vec![CompletionCandidate::new("Getting list of launchers failed")]; + eprintln!("Completions failed: Error getting list of launchers"); + return vec![]; }; - if launchers.is_empty() { - return vec![CompletionCandidate::new("No launchers found")]; - } let mut result: Vec = vec![]; let project_ctx = opts.get_project_context().ok().flatten(); let project_id = match project_ctx { @@ -122,9 +119,8 @@ pub fn complete_job_launcher_id(current: &ffi::OsStr) -> Vec Date: Wed, 10 Jun 2026 14:56:59 +0200 Subject: [PATCH 15/19] Fix doc strings --- src/cli/opts.rs | 5 +++-- src/httpclient/data.rs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cli/opts.rs b/src/cli/opts.rs index 602b56a..83c43b5 100644 --- a/src/cli/opts.rs +++ b/src/cli/opts.rs @@ -36,8 +36,9 @@ pub struct CommonOpts { /// Some commands may operate within a project. If this option is /// set, or the environment variable RENKU_CLI_PROJECT_CONTEXT is - /// present and specifies a project id, some commands use it to - /// confine there functionality to this project. + /// present commands can use it to confine there functionality to + /// this project. The value may be the project id (ulid) or the + /// path like /. #[arg(long, value_hint = ValueHint::Url)] pub project_context: Option, diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index 39adfb1..10b3b47 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -116,7 +116,7 @@ where impl fmt::Display for SessionList { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if self.0.is_empty() { - write!(f, "No jobs/sessions found.") + write!(f, "No jobs found.") } else { let table = create_session_table(&self.0); write!(f, "{}", table) From a2c28884362fbab71d75dece8ebda5e84957d15c Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 11 Jun 2026 10:40:50 +0200 Subject: [PATCH 16/19] Fix version command, make it client-only by default --- src/cli.rs | 2 +- src/cli/cmd/version.rs | 50 ++++++++++++++++++------------------------ src/cli/sink.rs | 1 + src/httpclient.rs | 10 +++------ src/httpclient/data.rs | 16 +++++++++++--- 5 files changed, 39 insertions(+), 40 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 3c8dad3..8974d40 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -61,7 +61,7 @@ impl fmt::Display for BuildInfo { let cc = &self.git_commit[..8]; write!( f, - " Built at: {}\n Version: {}\n Sha: {}", + "Renku CLI:\nBuilt at: {}\nVersion: {}\nSha: {}", self.build_date, self.build_version, cc ) } diff --git a/src/cli/cmd/version.rs b/src/cli/cmd/version.rs index c5d5d13..c7876ce 100644 --- a/src/cli/cmd/version.rs +++ b/src/cli/cmd/version.rs @@ -11,14 +11,12 @@ use std::fmt; /// Prints version about server and client. /// -/// Queries the server for its version information and prints more -/// version details about this client. +/// Prints version details about this client and can also query the renku platform for its verion. #[derive(Parser, Debug, PartialEq)] pub struct Input { - /// Only show the client version and don't request server side - /// version information. + /// Also request the version on the renku platform. #[arg(long, default_value_t = false)] - pub client_only: bool, + pub with_server: bool, } #[derive(Debug, Snafu)] @@ -32,44 +30,38 @@ pub enum Error { impl Input { pub async fn exec(&self, ctx: &Context) -> Result<(), Error> { - if self.client_only { - let vinfo = BuildInfo::default(); - ctx.write_result(&vinfo).await.context(WriteResultSnafu)?; - } else { + if self.with_server { let result = ctx.client.version().await.context(HttpClientSnafu)?; - let urlstr = ctx.renku_url().as_str(); - let vinfo = Versions::create(result, urlstr); - ctx.write_result(&vinfo).await.context(WriteResultSnafu)?; + let info = Versions::create(result); + ctx.write_result(&info).await.context(WriteResultSnafu)?; + } else { + let build_info = BuildInfo::default(); + ctx.write_result(&build_info) + .await + .context(WriteResultSnafu)?; } Ok(()) } } #[derive(Debug, Serialize)] -pub struct Versions<'a> { - pub client: BuildInfo, - pub server: VersionInfo, - pub renku_url: &'a str, +pub struct Versions { + pub renku_cli: BuildInfo, + pub renku_platform: VersionInfo, } -impl Versions<'_> { - pub fn create(server: VersionInfo, renku_url: &'_ str) -> Versions<'_> { +impl Versions { + pub fn create(renku_platform: VersionInfo) -> Versions { Versions { - client: BuildInfo::default(), - server, - renku_url, + renku_cli: BuildInfo::default(), + renku_platform, } } } -impl fmt::Display for Versions<'_> { +impl fmt::Display for Versions { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let hc = &self.server.search.head_commit[..8]; - write!( - f, - "Client:\n{}\n\nRenku @ {}\n Data Services: {}\n Search Services: {} ({})", - self.client, self.renku_url, self.server.data.version, self.server.search.version, hc - ) + write!(f, "{}\n\n{}", self.renku_cli, self.renku_platform) } } -impl Sink for Versions<'_> {} +impl Sink for Versions {} diff --git a/src/cli/sink.rs b/src/cli/sink.rs index ab295f1..fcb94e1 100644 --- a/src/cli/sink.rs +++ b/src/cli/sink.rs @@ -64,3 +64,4 @@ impl Sink for Response {} impl Sink for SessionStartResponse {} impl Sink for SessionList {} impl Sink for SessionLogs {} +impl Sink for VersionInfo {} diff --git a/src/httpclient.rs b/src/httpclient.rs index 4d4802d..90dbdcd 100644 --- a/src/httpclient.rs +++ b/src/httpclient.rs @@ -261,13 +261,9 @@ impl Client { /// Queries Renku for its version pub async fn version(&self) -> Result { - let data = self - .json_get::("/ui-server/api/data/version") - .await?; - let search = self - .json_get::("/ui-server/api/search/version") - .await?; - Ok(VersionInfo { search, data }) + let renku = self.json_get::("/api/data/version").await?; + let renku_url = self.base_url().clone(); + Ok(VersionInfo { renku, renku_url }) } pub async fn get_project(&self, id: &ProjectId) -> Result, Error> { diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index 10b3b47..2a9e147 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -1,7 +1,7 @@ //! Defines data structures for requests and responses and their //! `De/Serialize` instances. -use crate::data::submission_id::SubmissionId; +use crate::data::{renku_url::RenkuUrl, submission_id::SubmissionId}; use iso8601_timestamp::Timestamp; use serde::{Deserialize, Serialize}; use std::{borrow::Borrow, collections::HashMap, fmt}; @@ -184,8 +184,18 @@ pub struct SimpleVersion { /// Describes the version information provided by the renku platform. #[derive(Debug, Serialize, Deserialize)] pub struct VersionInfo { - pub search: SearchServiceVersion, - pub data: SimpleVersion, + pub renku_url: RenkuUrl, + pub renku: SimpleVersion, +} + +impl fmt::Display for VersionInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Renku Platform:\nUrl: {}\nVersion: {}", + self.renku_url, self.renku.version + ) + } } #[derive(Debug, Serialize, Deserialize)] From 0f5a5eabf3addb552e4d6985ccde8ddbfcf8ee83 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 11 Jun 2026 14:45:03 +0200 Subject: [PATCH 17/19] clippy cleanup --- src/cli/cmd/job/start.rs | 2 +- src/cli/complete.rs | 6 +++--- src/cli/opts.rs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cli/cmd/job/start.rs b/src/cli/cmd/job/start.rs index 6ecb8d5..09d7896 100644 --- a/src/cli/cmd/job/start.rs +++ b/src/cli/cmd/job/start.rs @@ -52,7 +52,7 @@ impl Input { let submission_id = self .submission_id .clone() - .unwrap_or_else(|| SubmissionId::random()); + .unwrap_or_else(SubmissionId::random); let cmd = if self.command.is_empty() { None } else { diff --git a/src/cli/complete.rs b/src/cli/complete.rs index 21c968b..f2bc438 100644 --- a/src/cli/complete.rs +++ b/src/cli/complete.rs @@ -56,7 +56,7 @@ fn parse_common_opts() -> Result { let first_it = first.map(std::iter::once).into_iter().flatten(); let remain = it.take_while(|e| e.starts_with('-')); // the version command to make arg parsing successful - let version = std::iter::once("version".to_string()).into_iter(); + let version = std::iter::once("version".to_string()); let args = first_it.chain(remain).chain(version); MainOpts::try_parse_from(args).map(|e| e.common_opts) } @@ -77,7 +77,7 @@ async fn make_launcher_completion_candidate( help.push_str(" - "); help.push_str(&project.name); - cc.help(Some(help.into())) + cc.help(Some(help)) } async fn resolve_project_id(client: &Client, id: ProjectId) -> Option { @@ -115,7 +115,7 @@ pub fn complete_job_launcher_id(current: &ffi::OsStr) -> Vec true, }) { - let cc = make_launcher_completion_candidate(&client, &launcher).await; + let cc = make_launcher_completion_candidate(&client, launcher).await; result.push(cc); } if result.is_empty() { diff --git a/src/cli/opts.rs b/src/cli/opts.rs index 83c43b5..c573cf9 100644 --- a/src/cli/opts.rs +++ b/src/cli/opts.rs @@ -109,10 +109,10 @@ impl CommonOpts { pub fn get_project_context(&self) -> Result, ProjectIdParseError> { if self.project_context.is_some() { - return Ok(self.project_context.clone()); + Ok(self.project_context.clone()) } else { match std::env::var("RENKU_CLI_PROJECT_CONTEXT").ok() { - Some(id) => ProjectId::parse(&id).map(|e| Some(e)), + Some(id) => ProjectId::parse(&id).map(Some), None => Ok(None), } } From 689cc639c1f00e5d00574d69c94f329670a624d3 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 11 Jun 2026 17:31:14 +0200 Subject: [PATCH 18/19] Improvements from review --- src/cli/complete.rs | 6 ++---- src/httpclient/data.rs | 5 +---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/cli/complete.rs b/src/cli/complete.rs index f2bc438..0d87d96 100644 --- a/src/cli/complete.rs +++ b/src/cli/complete.rs @@ -23,7 +23,7 @@ where Fut: Future> + Send + 'static, { let Some(_current) = current.to_str() else { - return vec![CompletionCandidate::new("invalid current str")]; + return vec![]; }; let Ok(opts) = parse_common_opts() else { @@ -49,9 +49,7 @@ fn parse_common_opts() -> Result { // the binary. Then the standard command 'version' is appended, so // that parsing succeeds. Only common-options are of interest // here. - let mut it = std::env::args(); - it.next(); - it.next(); + let mut it = std::env::args().skip(2); let first = it.next(); let first_it = first.map(std::iter::once).into_iter().flatten(); let remain = it.take_while(|e| e.starts_with('-')); diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index 2a9e147..248481e 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -93,10 +93,7 @@ where let mut builder = Builder::default(); for e in data { let r = e.borrow(); - let sub_id = match &r.submission_id { - Some(n) => n, - None => "-", - }; + let sub_id = r.submission_id.as_deref().unwrap_or("-"); let started = r.started.format(); let data = vec![&r.name, sub_id, &r.project_id, &r.status.state, &started]; builder.push_record(data); From 0046044d225189e69ecdb0002f50b960f6fc4774 Mon Sep 17 00:00:00 2001 From: Eike Date: Fri, 12 Jun 2026 13:26:27 +0200 Subject: [PATCH 19/19] Use concrete iterator element type --- src/httpclient/data.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index 248481e..22fdbfa 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -4,7 +4,7 @@ use crate::data::{renku_url::RenkuUrl, submission_id::SubmissionId}; use iso8601_timestamp::Timestamp; use serde::{Deserialize, Serialize}; -use std::{borrow::Borrow, collections::HashMap, fmt}; +use std::{collections::HashMap, fmt}; use tabled::{ Table, builder::Builder, @@ -85,14 +85,12 @@ impl fmt::Display for SessionStartRequest { #[derive(Debug, Serialize, Deserialize)] pub struct SessionList(pub Vec); -fn create_session_table(data: I) -> Table +fn create_session_table<'a, I>(data: I) -> Table where - I: IntoIterator, - T: Borrow, + I: IntoIterator, { let mut builder = Builder::default(); - for e in data { - let r = e.borrow(); + for r in data { let sub_id = r.submission_id.as_deref().unwrap_or("-"); let started = r.started.format(); let data = vec![&r.name, sub_id, &r.project_id, &r.status.state, &started];