From 0907dec03696c9d483e0f7a7f8b11943cb3b2ca2 Mon Sep 17 00:00:00 2001 From: Eike Date: Wed, 15 Apr 2026 14:50:07 +0200 Subject: [PATCH 1/7] skeleton command for job start --- Cargo.lock | 11 +++++++ Cargo.toml | 1 + src/cli.rs | 1 + src/cli/cmd.rs | 10 +++++++ src/cli/cmd/job.rs | 32 ++++++++++++++++++++ src/cli/cmd/job/start.rs | 64 ++++++++++++++++++++++++++++++++++++++++ src/cli/opts.rs | 3 ++ 7 files changed, 122 insertions(+) create mode 100644 src/cli/cmd/job.rs create mode 100644 src/cli/cmd/job/start.rs diff --git a/Cargo.lock b/Cargo.lock index 599a4d9..99d5b61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2920,6 +2920,7 @@ dependencies = [ "tokio", "tokio-util", "toml 0.8.23", + "ulid", "url", "vergen", "walkdir", @@ -4017,6 +4018,16 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.2", + "web-time", +] + [[package]] name = "unicase" version = "2.9.0" diff --git a/Cargo.toml b/Cargo.toml index ac47d42..040b420 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ self_update = { version = "0.43.1", features = [ "compression-flate2", ] } md5 = "0.8.0" +ulid = "1.2.1" [features] default = ["reqwest/default-tls"] # link against system library diff --git a/src/cli.rs b/src/cli.rs index 17b8f12..66e1bd2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -27,6 +27,7 @@ pub async fn execute_cmd(opts: MainOpts) -> Result<(), CmdError> { SubCommand::UserDoc(input) => input.exec(ctx).await?, SubCommand::Dataset(input) => input.exec(ctx).await?, + SubCommand::Job(input) => input.exec(ctx).await?, }; Ok(()) } diff --git a/src/cli/cmd.rs b/src/cli/cmd.rs index 0750340..54bbb6b 100644 --- a/src/cli/cmd.rs +++ b/src/cli/cmd.rs @@ -1,4 +1,5 @@ pub mod dataset; +pub mod job; pub mod login; pub mod project; pub mod update; @@ -113,6 +114,15 @@ pub enum CmdError { #[snafu(display("Dataset - {}", source))] Dataset { source: dataset::Error }, + + #[snafu(display("Job - {}", source))] + Job { source: job::Error }, +} + +impl From for CmdError { + fn from(source: job::Error) -> Self { + CmdError::Job { source } + } } impl From for CmdError { diff --git a/src/cli/cmd/job.rs b/src/cli/cmd/job.rs new file mode 100644 index 0000000..cecc2ec --- /dev/null +++ b/src/cli/cmd/job.rs @@ -0,0 +1,32 @@ +pub mod start; + +use super::Context; +use clap::Parser; +use snafu::{ResultExt, Snafu}; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Error starting job: {}", source))] + Start { source: start::Error }, +} + +/// Sub command for managing projects +#[derive(Parser, Debug)] +pub struct Input { + #[command(subcommand)] + pub subcmd: JobCommand, +} + +impl Input { + pub async fn exec(&self, ctx: Context) -> Result<(), Error> { + match &self.subcmd { + JobCommand::Start(input) => input.exec(ctx).await.context(StartSnafu), + } + } +} + +#[derive(Parser, Debug)] +pub enum JobCommand { + #[command()] + Start(start::Input), +} diff --git a/src/cli/cmd/job/start.rs b/src/cli/cmd/job/start.rs new file mode 100644 index 0000000..21abc4a --- /dev/null +++ b/src/cli/cmd/job/start.rs @@ -0,0 +1,64 @@ +use crate::{data::simple_message::SimpleMessage, project_config::ProjectConfigError}; + +use super::Context; +use crate::cli::sink::Error as SinkError; +use crate::data::project_id::ProjectIdParseError; +use crate::httpclient::Error as HttpError; + +use clap::{Parser, ValueHint}; +use git2::Error as GitError; +use tokio::task::JoinError; +use ulid::Ulid; + +use snafu::{ResultExt, Snafu}; + +/// Start a job. +/// +/// Starts a non-interactive session using a pre-configured session launcher. +#[derive(Parser, Debug)] +pub struct Input { + /// The launcher to use for launching the job. + #[arg(value_hint=ValueHint::Other)] + pub launcher: Ulid, +} + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("An http error occurred: {}", source))] + HttpClient { source: HttpError }, + + #[snafu(display("Error writing data: {}", source))] + WriteResult { source: SinkError }, + + #[snafu(display("Error reading project id: {}", source))] + ProjectIdParse { source: ProjectIdParseError }, + + #[snafu(display("Error getting current directory: {}", source))] + CurrentDir { source: std::io::Error }, + + #[snafu(display("Error creating directory: {}", source))] + CreateDir { source: std::io::Error }, + + #[snafu(display("Error cloning project: {}", source))] + GitClone { source: GitError }, + + #[snafu(display("Error in task: {}", source))] + TaskJoin { source: JoinError }, + + #[snafu(display("Error creating config file: {}", source))] + RenkuConfig { source: ProjectConfigError }, + + #[snafu(display("The project name is missing: {}", repo_url))] + MissingProjectName { repo_url: String }, +} + +impl Input { + pub async fn exec(&self, ctx: Context) -> Result<(), Error> { + ctx.write_result(&SimpleMessage { + message: "Hello world".into(), + }) + .await + .context(WriteResultSnafu)?; + Ok(()) + } +} diff --git a/src/cli/opts.rs b/src/cli/opts.rs index 9b87f16..b898c9a 100644 --- a/src/cli/opts.rs +++ b/src/cli/opts.rs @@ -66,6 +66,9 @@ pub enum SubCommand { #[command()] Dataset(dataset::Input), + + #[command()] + Job(job::Input), } /// This is the command line interface to the Renku platform. Main From 79495833b25b8058b65d897fdfaab4f7ded9ec5a Mon Sep 17 00:00:00 2001 From: Eike Date: Wed, 15 Apr 2026 16:14:16 +0200 Subject: [PATCH 2/7] start a non-interactive session --- src/cli/cmd/job/start.rs | 42 ++++++++++++---------------------------- src/cli/sink.rs | 1 + src/httpclient.rs | 40 +++++++++++++++++++++++++++++++++++++- src/httpclient/data.rs | 33 +++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 31 deletions(-) diff --git a/src/cli/cmd/job/start.rs b/src/cli/cmd/job/start.rs index 21abc4a..9fa7917 100644 --- a/src/cli/cmd/job/start.rs +++ b/src/cli/cmd/job/start.rs @@ -1,13 +1,10 @@ -use crate::{data::simple_message::SimpleMessage, project_config::ProjectConfigError}; +use crate::httpclient::data::SessionStartRequest; use super::Context; use crate::cli::sink::Error as SinkError; -use crate::data::project_id::ProjectIdParseError; use crate::httpclient::Error as HttpError; use clap::{Parser, ValueHint}; -use git2::Error as GitError; -use tokio::task::JoinError; use ulid::Ulid; use snafu::{ResultExt, Snafu}; @@ -29,36 +26,21 @@ pub enum Error { #[snafu(display("Error writing data: {}", source))] WriteResult { source: SinkError }, - - #[snafu(display("Error reading project id: {}", source))] - ProjectIdParse { source: ProjectIdParseError }, - - #[snafu(display("Error getting current directory: {}", source))] - CurrentDir { source: std::io::Error }, - - #[snafu(display("Error creating directory: {}", source))] - CreateDir { source: std::io::Error }, - - #[snafu(display("Error cloning project: {}", source))] - GitClone { source: GitError }, - - #[snafu(display("Error in task: {}", source))] - TaskJoin { source: JoinError }, - - #[snafu(display("Error creating config file: {}", source))] - RenkuConfig { source: ProjectConfigError }, - - #[snafu(display("The project name is missing: {}", repo_url))] - MissingProjectName { repo_url: String }, } impl Input { pub async fn exec(&self, ctx: Context) -> Result<(), Error> { - ctx.write_result(&SimpleMessage { - message: "Hello world".into(), - }) - .await - .context(WriteResultSnafu)?; + let req = SessionStartRequest { + launcher_id: self.launcher.to_string(), + session_type: "non-interactive".into(), + }; + let result = ctx + .client + .start_session(req, true) + .await + .context(HttpClientSnafu)?; + + ctx.write_result(&result).await.context(WriteResultSnafu)?; Ok(()) } } diff --git a/src/cli/sink.rs b/src/cli/sink.rs index 5bd9066..0361f91 100644 --- a/src/cli/sink.rs +++ b/src/cli/sink.rs @@ -61,3 +61,4 @@ impl Sink for BuildInfo {} impl Sink for PathEntry {} impl Sink for UserCode {} impl Sink for Response {} +impl Sink for SessionStartResponse {} diff --git a/src/httpclient.rs b/src/httpclient.rs index cf23c87..30a2a5d 100644 --- a/src/httpclient.rs +++ b/src/httpclient.rs @@ -37,7 +37,7 @@ use auth::{Response, UserCode}; use openidconnect::OAuth2TokenResponse; use regex::Regex; use reqwest::{Certificate, ClientBuilder, IntoUrl, RequestBuilder, Url}; -use serde::de::DeserializeOwned; +use serde::{Serialize, de::DeserializeOwned}; use snafu::{ResultExt, Snafu}; use std::path::PathBuf; @@ -184,6 +184,30 @@ impl Client { } } + async fn json_post( + &self, + path: &str, + body: &I, + debug: bool, + ) -> Result { + let url = self.make_url(path)?; + log::debug!("JSON POST: {}", url); + + let resp = self + .set_bearer_token(self.client.post(url.clone())) + .json::(&body) + .send() + .await + .context(HttpSnafu { url: url.clone() })?; + if debug { + let body = resp.text().await.context(DeserializeRespSnafu)?; + log::debug!("POST {} -> {}", url, body); + serde_json::from_str::(&body).context(DeserializeJsonSnafu) + } else { + resp.json::().await.context(DeserializeRespSnafu) + } + } + /// Runs a GET request to the given url. When `debug` is true, the /// response is first decoded into utf8 chars and logged at debug /// level. Otherwise bytes are directly decoded from JSON into the @@ -320,6 +344,20 @@ impl Client { Ok(details) } + pub async fn start_session( + &self, + req: SessionStartRequest, + debug: bool, + ) -> Result { + log::debug!("Starting session: {}", req); + + let path = "/api/data/sessions"; + let details = self + .json_post::(&path, &req, debug) + .await?; + Ok(details) + } + pub async fn start_login_flow(&self) -> Result { let c = auth::get_user_code(self.settings.base_url.clone()).await?; Ok(c) diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index febf42a..99736bb 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -5,6 +5,39 @@ use iso8601_timestamp::Timestamp; use serde::{Deserialize, Serialize}; use std::fmt; +#[derive(Debug, Serialize, Deserialize)] +pub struct SessionStartRequest { + pub launcher_id: String, + pub session_type: String, +} +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 + ) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SessionStartResponse { + image: String, + name: String, + project_id: String, + launcher_id: String, +} + +impl fmt::Display for SessionStartResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "SessionStartResponse({}, image={})", + self.name, self.image + ) + } +} + #[derive(Debug, Serialize, Deserialize)] pub enum Visibility { #[serde(alias = "public")] From 49e0305e2a223485b0b08031ec3efe9d239abba8 Mon Sep 17 00:00:00 2001 From: Eike Date: Wed, 15 Apr 2026 16:34:48 +0200 Subject: [PATCH 3/7] stop a session --- src/cli/cmd/job.rs | 8 ++++++++ src/cli/cmd/job/stop.rs | 44 +++++++++++++++++++++++++++++++++++++++++ src/httpclient.rs | 12 +++++++++++ 3 files changed, 64 insertions(+) create mode 100644 src/cli/cmd/job/stop.rs diff --git a/src/cli/cmd/job.rs b/src/cli/cmd/job.rs index cecc2ec..1d629a9 100644 --- a/src/cli/cmd/job.rs +++ b/src/cli/cmd/job.rs @@ -1,4 +1,5 @@ pub mod start; +pub mod stop; use super::Context; use clap::Parser; @@ -8,6 +9,9 @@ use snafu::{ResultExt, Snafu}; pub enum Error { #[snafu(display("Error starting job: {}", source))] Start { source: start::Error }, + + #[snafu(display("Error stopping job: {}", source))] + Stop { source: stop::Error }, } /// Sub command for managing projects @@ -21,6 +25,7 @@ impl Input { pub async fn exec(&self, ctx: Context) -> Result<(), Error> { match &self.subcmd { JobCommand::Start(input) => input.exec(ctx).await.context(StartSnafu), + JobCommand::Stop(input) => input.exec(ctx).await.context(StopSnafu), } } } @@ -29,4 +34,7 @@ impl Input { pub enum JobCommand { #[command()] Start(start::Input), + + #[command()] + Stop(stop::Input), } diff --git a/src/cli/cmd/job/stop.rs b/src/cli/cmd/job/stop.rs new file mode 100644 index 0000000..7d075a4 --- /dev/null +++ b/src/cli/cmd/job/stop.rs @@ -0,0 +1,44 @@ +use crate::data::simple_message::SimpleMessage; + +use super::Context; +use crate::cli::sink::Error as SinkError; +use crate::httpclient::Error as HttpError; + +use clap::{Parser, ValueHint}; + +use snafu::{ResultExt, Snafu}; + +/// Stop a job. +/// +/// Stop a running non-interactive session. +#[derive(Parser, Debug)] +pub struct Input { + /// The launcher to use for launching the job. + #[arg(value_hint=ValueHint::Other)] + pub job_id: String, +} + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("An http error occurred: {}", source))] + HttpClient { source: HttpError }, + + #[snafu(display("Error writing data: {}", source))] + WriteResult { source: SinkError }, +} + +impl Input { + pub async fn exec(&self, ctx: Context) -> Result<(), Error> { + ctx.client + .stop_session(&self.job_id) + .await + .context(HttpClientSnafu)?; + + ctx.write_result(&SimpleMessage { + message: "Job is being removed.".into(), + }) + .await + .context(WriteResultSnafu)?; + Ok(()) + } +} diff --git a/src/httpclient.rs b/src/httpclient.rs index 30a2a5d..156ef82 100644 --- a/src/httpclient.rs +++ b/src/httpclient.rs @@ -184,6 +184,7 @@ impl Client { } } + /// Runs a POST request to the given url. async fn json_post( &self, path: &str, @@ -358,6 +359,17 @@ impl Client { Ok(details) } + pub async fn stop_session(&self, session_id: &str) -> Result<(), Error> { + log::debug!("Stop session: {}", session_id); + let path = format!("/api/data/sessions/{}", session_id); + let url = self.make_url(&path)?; + self.set_bearer_token(self.client.delete(url.clone())) + .send() + .await + .context(HttpSnafu { url: url })?; + Ok(()) + } + pub async fn start_login_flow(&self) -> Result { let c = auth::get_user_code(self.settings.base_url.clone()).await?; Ok(c) From 463c81f283e12e457eefea668a8c7f22dfb6a4ab Mon Sep 17 00:00:00 2001 From: Eike Date: Wed, 15 Apr 2026 17:30:34 +0200 Subject: [PATCH 4/7] start listing jobs --- src/cli/cmd/job.rs | 8 ++++++++ src/cli/cmd/job/list.rs | 31 +++++++++++++++++++++++++++++++ src/cli/sink.rs | 1 + src/httpclient.rs | 7 +++++++ src/httpclient/data.rs | 18 ++++++++++++++++++ 5 files changed, 65 insertions(+) create mode 100644 src/cli/cmd/job/list.rs diff --git a/src/cli/cmd/job.rs b/src/cli/cmd/job.rs index 1d629a9..03ffce1 100644 --- a/src/cli/cmd/job.rs +++ b/src/cli/cmd/job.rs @@ -1,3 +1,4 @@ +pub mod list; pub mod start; pub mod stop; @@ -12,6 +13,9 @@ pub enum Error { #[snafu(display("Error stopping job: {}", source))] Stop { source: stop::Error }, + + #[snafu(display("Error listing jobs: {}", source))] + List { source: list::Error }, } /// Sub command for managing projects @@ -26,6 +30,7 @@ impl Input { match &self.subcmd { JobCommand::Start(input) => input.exec(ctx).await.context(StartSnafu), JobCommand::Stop(input) => input.exec(ctx).await.context(StopSnafu), + JobCommand::List(input) => input.exec(ctx).await.context(ListSnafu), } } } @@ -37,4 +42,7 @@ pub enum JobCommand { #[command()] Stop(stop::Input), + + #[command()] + List(list::Input), } diff --git a/src/cli/cmd/job/list.rs b/src/cli/cmd/job/list.rs new file mode 100644 index 0000000..07b5c57 --- /dev/null +++ b/src/cli/cmd/job/list.rs @@ -0,0 +1,31 @@ +use super::Context; +use crate::cli::sink::Error as SinkError; +use crate::httpclient::Error as HttpError; + +use clap::Parser; + +use snafu::{ResultExt, Snafu}; + +/// Listing jobs. +/// +/// List currently running jobs. +#[derive(Parser, Debug)] +pub struct Input {} + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("An http error occurred: {}", source))] + HttpClient { source: HttpError }, + + #[snafu(display("Error writing data: {}", source))] + WriteResult { source: SinkError }, +} + +impl Input { + pub async fn exec(&self, ctx: Context) -> Result<(), Error> { + let result = ctx.client.list_sessions().await.context(HttpClientSnafu)?; + + ctx.write_result(&result).await.context(WriteResultSnafu)?; + Ok(()) + } +} diff --git a/src/cli/sink.rs b/src/cli/sink.rs index 0361f91..fa242c0 100644 --- a/src/cli/sink.rs +++ b/src/cli/sink.rs @@ -62,3 +62,4 @@ impl Sink for PathEntry {} impl Sink for UserCode {} impl Sink for Response {} impl Sink for SessionStartResponse {} +impl Sink for SessionList {} diff --git a/src/httpclient.rs b/src/httpclient.rs index 156ef82..c0384b1 100644 --- a/src/httpclient.rs +++ b/src/httpclient.rs @@ -370,6 +370,13 @@ impl Client { Ok(()) } + pub async fn list_sessions(&self) -> Result { + let result = self + .json_get::>("/api/data/sessions", true) + .await?; + Ok(SessionList(result)) + } + pub async fn start_login_flow(&self) -> Result { let c = auth::get_user_code(self.settings.base_url.clone()).await?; Ok(c) diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index 99736bb..ed433cb 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -20,6 +20,24 @@ impl fmt::Display for SessionStartRequest { } } +#[derive(Debug, Serialize, Deserialize)] +pub struct SessionList(pub Vec); + +impl fmt::Display for SessionList { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let lines = self + .0 + .iter() + .fold(String::new(), |a, b| format!("{}\n - {}", a, b)); + + if self.0.is_empty() { + write!(f, "No sessions found.") + } else { + write!(f, "Sessions\n{}", lines) + } + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct SessionStartResponse { image: String, From a8207ea30a5c86c314112a0278f24224dc269bd4 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 16 Apr 2026 17:15:24 +0200 Subject: [PATCH 5/7] list logs of jobs --- flake.nix | 6 ++++- src/cli/cmd/job.rs | 8 +++++++ src/cli/cmd/job/list.rs | 8 +++++-- src/cli/cmd/job/logs.rs | 50 +++++++++++++++++++++++++++++++++++++++++ src/cli/sink.rs | 1 + src/httpclient.rs | 27 ++++++++++++++++++---- src/httpclient/data.rs | 45 ++++++++++++++++++++++++++++++++----- 7 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 src/cli/cmd/job/logs.rs diff --git a/flake.nix b/flake.nix index e7990e0..de861de 100644 --- a/flake.nix +++ b/flake.nix @@ -6,7 +6,6 @@ crane = { url = "github:ipetkov/crane"; - inputs.nixpkgs.follows = "nixpkgs"; }; fenix = { @@ -42,12 +41,14 @@ craneLib = crane.mkLib pkgs; src = craneLib.cleanCargoSource ./.; docSrc = ./docs; + version = "0.0.0"; fenixToolChain = fenix.packages.${system}.complete; # Common arguments can be set here to avoid repeating them later commonArgs = { inherit src; + inherit version; strictDeps = true; nativeBuildInputs = [ @@ -128,17 +129,20 @@ # Check formatting my-crate-fmt = craneLib.cargoFmt { inherit src; + inherit version; }; # Audit dependencies my-crate-audit = craneLib.cargoAudit { inherit src advisory-db; cargoAuditExtraArgs = "--ignore RUSTSEC-2023-0071"; + inherit version; }; # Audit licenses my-crate-deny = craneLib.cargoDeny { inherit src; + inherit version; }; # Run tests with cargo-nextest diff --git a/src/cli/cmd/job.rs b/src/cli/cmd/job.rs index 03ffce1..3b40ec5 100644 --- a/src/cli/cmd/job.rs +++ b/src/cli/cmd/job.rs @@ -1,4 +1,5 @@ pub mod list; +pub mod logs; pub mod start; pub mod stop; @@ -16,6 +17,9 @@ pub enum Error { #[snafu(display("Error listing jobs: {}", source))] List { source: list::Error }, + + #[snafu(display("Error getting logs: {}", source))] + Logs { source: logs::Error }, } /// Sub command for managing projects @@ -31,6 +35,7 @@ impl Input { JobCommand::Start(input) => input.exec(ctx).await.context(StartSnafu), JobCommand::Stop(input) => input.exec(ctx).await.context(StopSnafu), JobCommand::List(input) => input.exec(ctx).await.context(ListSnafu), + JobCommand::Logs(input) => input.exec(ctx).await.context(LogsSnafu), } } } @@ -45,4 +50,7 @@ pub enum JobCommand { #[command()] List(list::Input), + + #[command()] + Logs(logs::Input), } diff --git a/src/cli/cmd/job/list.rs b/src/cli/cmd/job/list.rs index 07b5c57..4c727b5 100644 --- a/src/cli/cmd/job/list.rs +++ b/src/cli/cmd/job/list.rs @@ -1,6 +1,6 @@ use super::Context; -use crate::cli::sink::Error as SinkError; use crate::httpclient::Error as HttpError; +use crate::{cli::sink::Error as SinkError, httpclient::data::SessionMode}; use clap::Parser; @@ -23,7 +23,11 @@ pub enum Error { impl Input { pub async fn exec(&self, ctx: Context) -> Result<(), Error> { - let result = ctx.client.list_sessions().await.context(HttpClientSnafu)?; + let result = ctx + .client + .list_sessions(Some(SessionMode::NonInteractive)) + .await + .context(HttpClientSnafu)?; ctx.write_result(&result).await.context(WriteResultSnafu)?; Ok(()) diff --git a/src/cli/cmd/job/logs.rs b/src/cli/cmd/job/logs.rs new file mode 100644 index 0000000..850005a --- /dev/null +++ b/src/cli/cmd/job/logs.rs @@ -0,0 +1,50 @@ +use super::Context; +use crate::httpclient::Error as HttpError; +use crate::{cli::sink::Error as SinkError, data::simple_message::SimpleMessage}; + +use clap::{Parser, ValueHint}; + +use snafu::{ResultExt, Snafu}; + +/// Listing logs of a jobs. +/// +/// List the logs of a job. +#[derive(Parser, Debug)] +pub struct Input { + #[arg(value_hint=ValueHint::Other)] + pub job_id: String, +} + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("An http error occurred: {}", source))] + HttpClient { source: HttpError }, + + #[snafu(display("Error writing data: {}", source))] + WriteResult { source: SinkError }, +} + +impl Input { + pub async fn exec(&self, ctx: Context) -> Result<(), Error> { + let result = ctx + .client + .session_logs(&self.job_id) + .await + .context(HttpClientSnafu)?; + + if let Some(lines) = result.0.get("amalthea-session") { + ctx.write_result(&SimpleMessage { + message: lines.to_string(), + }) + .await + .context(WriteResultSnafu)?; + } else { + ctx.write_result(&SimpleMessage { + message: "No logs available.".to_string(), + }) + .await + .context(WriteResultSnafu)?; + } + Ok(()) + } +} diff --git a/src/cli/sink.rs b/src/cli/sink.rs index fa242c0..ab295f1 100644 --- a/src/cli/sink.rs +++ b/src/cli/sink.rs @@ -63,3 +63,4 @@ impl Sink for UserCode {} impl Sink for Response {} impl Sink for SessionStartResponse {} impl Sink for SessionList {} +impl Sink for SessionLogs {} diff --git a/src/httpclient.rs b/src/httpclient.rs index c0384b1..9a27722 100644 --- a/src/httpclient.rs +++ b/src/httpclient.rs @@ -370,13 +370,32 @@ impl Client { Ok(()) } - pub async fn list_sessions(&self) -> Result { - let result = self - .json_get::>("/api/data/sessions", true) - .await?; + pub async fn list_sessions(&self, mode: Option) -> Result { + let url = self.make_url("/api/data/sessions")?; + log::debug!( + "List sessions: {}?session_mode={}", + url, + mode.as_ref().map_or("", |e| e.to_query_param()) + ); + let mut req = self.set_bearer_token(self.client.get(url.clone())); + if let Some(m) = mode { + req = req.query(&[("session_type", m.to_query_param())]) + } + + let resp = req.send().await.context(HttpSnafu { url: url.clone() })?; + let result = resp + .json::>() + .await + .context(DeserializeRespSnafu)?; Ok(SessionList(result)) } + pub async fn session_logs(&self, session_id: &str) -> Result { + let path = format!("/api/data/sessions/{}/logs", session_id); + let result = self.json_get::(&path, true).await?; + Ok(result) + } + pub async fn start_login_flow(&self) -> Result { let c = auth::get_user_code(self.settings.base_url.clone()).await?; Ok(c) diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index ed433cb..65361d1 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -3,7 +3,41 @@ use iso8601_timestamp::Timestamp; use serde::{Deserialize, Serialize}; -use std::fmt; +use std::{collections::HashMap, fmt}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct SessionLogs(pub HashMap); + +impl fmt::Display for SessionLogs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (k, v) in &self.0 { + write!(f, "- {}\n", k)?; + write!(f, "{}", v)?; + } + write!(f, "") + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum SessionMode { + Interactive, + NonInteractive, +} + +impl fmt::Display for SessionMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_query_param()) + } +} + +impl SessionMode { + pub fn to_query_param(&self) -> &str { + match self { + SessionMode::Interactive => "interactive", + SessionMode::NonInteractive => "non-interactive", + } + } +} #[derive(Debug, Serialize, Deserialize)] pub struct SessionStartRequest { @@ -25,15 +59,14 @@ pub struct SessionList(pub Vec); impl fmt::Display for SessionList { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let lines = self - .0 - .iter() - .fold(String::new(), |a, b| format!("{}\n - {}", a, b)); + let lines = self.0.iter().fold(String::new(), |a, b| { + format!("{}\n - {} (image={})", a, b.name, b.image) + }); if self.0.is_empty() { write!(f, "No sessions found.") } else { - write!(f, "Sessions\n{}", lines) + write!(f, "Sessions:{}", lines) } } } From 00638daf5addf623504338abde2206f48f2db648 Mon Sep 17 00:00:00 2001 From: Eike Date: Fri, 17 Apr 2026 11:43:54 +0200 Subject: [PATCH 6/7] react better on failure responses, remove debug flag in httpclient --- src/cli/cmd/job/list.rs | 15 ++-- src/cli/cmd/job/logs.rs | 14 ++- src/cli/cmd/job/start.rs | 14 ++- src/cli/cmd/job/stop.rs | 13 ++- src/cli/cmd/project/clone.rs | 5 +- src/cli/cmd/version.rs | 8 +- src/httpclient.rs | 168 +++++++++++++++++------------------ src/httpclient/data.rs | 44 +++++++++ 8 files changed, 155 insertions(+), 126 deletions(-) diff --git a/src/cli/cmd/job/list.rs b/src/cli/cmd/job/list.rs index 4c727b5..c3a8cb8 100644 --- a/src/cli/cmd/job/list.rs +++ b/src/cli/cmd/job/list.rs @@ -1,6 +1,8 @@ use super::Context; -use crate::httpclient::Error as HttpError; -use crate::{cli::sink::Error as SinkError, httpclient::data::SessionMode}; +use crate::{ + cli::sink::Error as SinkError, + httpclient::{self, data::SessionMode}, +}; use clap::Parser; @@ -14,11 +16,11 @@ pub struct Input {} #[derive(Debug, Snafu)] pub enum Error { - #[snafu(display("An http error occurred: {}", source))] - HttpClient { source: HttpError }, - #[snafu(display("Error writing data: {}", source))] WriteResult { source: SinkError }, + + #[snafu(display("Http error: {}", source))] + HttpClient { source: httpclient::Error }, } impl Input { @@ -29,7 +31,6 @@ impl Input { .await .context(HttpClientSnafu)?; - ctx.write_result(&result).await.context(WriteResultSnafu)?; - Ok(()) + ctx.write_result(&result).await.context(WriteResultSnafu) } } diff --git a/src/cli/cmd/job/logs.rs b/src/cli/cmd/job/logs.rs index 850005a..6cc4bb4 100644 --- a/src/cli/cmd/job/logs.rs +++ b/src/cli/cmd/job/logs.rs @@ -1,6 +1,5 @@ use super::Context; -use crate::httpclient::Error as HttpError; -use crate::{cli::sink::Error as SinkError, data::simple_message::SimpleMessage}; +use crate::{cli::sink::Error as SinkError, data::simple_message::SimpleMessage, httpclient}; use clap::{Parser, ValueHint}; @@ -17,11 +16,11 @@ pub struct Input { #[derive(Debug, Snafu)] pub enum Error { - #[snafu(display("An http error occurred: {}", source))] - HttpClient { source: HttpError }, - #[snafu(display("Error writing data: {}", source))] WriteResult { source: SinkError }, + + #[snafu(display("Http error: {}", source))] + HttpClient { source: httpclient::Error }, } impl Input { @@ -37,14 +36,13 @@ impl Input { message: lines.to_string(), }) .await - .context(WriteResultSnafu)?; + .context(WriteResultSnafu) } else { ctx.write_result(&SimpleMessage { message: "No logs available.".to_string(), }) .await - .context(WriteResultSnafu)?; + .context(WriteResultSnafu) } - Ok(()) } } diff --git a/src/cli/cmd/job/start.rs b/src/cli/cmd/job/start.rs index 9fa7917..a0077d8 100644 --- a/src/cli/cmd/job/start.rs +++ b/src/cli/cmd/job/start.rs @@ -1,8 +1,7 @@ -use crate::httpclient::data::SessionStartRequest; +use crate::httpclient::{self, data::SessionStartRequest}; use super::Context; use crate::cli::sink::Error as SinkError; -use crate::httpclient::Error as HttpError; use clap::{Parser, ValueHint}; use ulid::Ulid; @@ -21,11 +20,11 @@ pub struct Input { #[derive(Debug, Snafu)] pub enum Error { - #[snafu(display("An http error occurred: {}", source))] - HttpClient { source: HttpError }, - #[snafu(display("Error writing data: {}", source))] WriteResult { source: SinkError }, + + #[snafu(display("Http error: {}", source))] + HttpClient { source: httpclient::Error }, } impl Input { @@ -36,11 +35,10 @@ impl Input { }; let result = ctx .client - .start_session(req, true) + .start_session(req) .await .context(HttpClientSnafu)?; - ctx.write_result(&result).await.context(WriteResultSnafu)?; - Ok(()) + ctx.write_result(&result).await.context(WriteResultSnafu) } } diff --git a/src/cli/cmd/job/stop.rs b/src/cli/cmd/job/stop.rs index 7d075a4..de94a4d 100644 --- a/src/cli/cmd/job/stop.rs +++ b/src/cli/cmd/job/stop.rs @@ -1,8 +1,7 @@ -use crate::data::simple_message::SimpleMessage; +use crate::{data::simple_message::SimpleMessage, httpclient}; use super::Context; use crate::cli::sink::Error as SinkError; -use crate::httpclient::Error as HttpError; use clap::{Parser, ValueHint}; @@ -20,11 +19,11 @@ pub struct Input { #[derive(Debug, Snafu)] pub enum Error { - #[snafu(display("An http error occurred: {}", source))] - HttpClient { source: HttpError }, - #[snafu(display("Error writing data: {}", source))] WriteResult { source: SinkError }, + + #[snafu(display("Http error: {}", source))] + HttpClient { source: httpclient::Error }, } impl Input { @@ -33,12 +32,10 @@ impl Input { .stop_session(&self.job_id) .await .context(HttpClientSnafu)?; - ctx.write_result(&SimpleMessage { message: "Job is being removed.".into(), }) .await - .context(WriteResultSnafu)?; - Ok(()) + .context(WriteResultSnafu) } } diff --git a/src/cli/cmd/project/clone.rs b/src/cli/cmd/project/clone.rs index 2aaa8f4..b847f60 100644 --- a/src/cli/cmd/project/clone.rs +++ b/src/cli/cmd/project/clone.rs @@ -67,10 +67,7 @@ impl Input { pub async fn exec(&self, ctx: Context) -> Result<(), Error> { let opt_details = ctx .client - .get_project( - &self.project_ref, - ctx.opts.verbosity.log_level().unwrap_or(log::Level::Warn) > log::Level::Info, - ) + .get_project(&self.project_ref) .await .context(HttpClientSnafu)?; if let Some(details) = opt_details { diff --git a/src/cli/cmd/version.rs b/src/cli/cmd/version.rs index 917df2d..c5d5d13 100644 --- a/src/cli/cmd/version.rs +++ b/src/cli/cmd/version.rs @@ -36,13 +36,7 @@ impl Input { let vinfo = BuildInfo::default(); ctx.write_result(&vinfo).await.context(WriteResultSnafu)?; } else { - let result = ctx - .client - .version( - ctx.opts.verbosity.log_level().unwrap_or(log::Level::Warn) > log::Level::Info, - ) - .await - .context(HttpClientSnafu)?; + 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)?; diff --git a/src/httpclient.rs b/src/httpclient.rs index 9a27722..c676ed1 100644 --- a/src/httpclient.rs +++ b/src/httpclient.rs @@ -43,12 +43,34 @@ use std::path::PathBuf; const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); +fn display_bad_response(em: &Option, body: &String) -> String { + match em { + Some(s) => match &s.error { + Some(em) => em.message.to_owned(), + None => s.message.to_owned().unwrap_or(body.to_owned()), + }, + None => body.to_owned(), + } +} + #[derive(Debug, Snafu)] #[snafu(visibility(pub(crate)))] pub enum Error { #[snafu(display("An error was received from {}: {}", url, source))] Http { source: reqwest::Error, url: String }, + #[snafu(display( + "Response not successful: {} - {}", + status, + display_bad_response(err_message, body) + ))] + BadResponse { + status: reqwest::StatusCode, + body: String, + url: String, + err_message: Option, + }, + #[snafu(display("An error occurred creating the http client: {}", source))] ClientCreate { source: reqwest::Error }, @@ -163,25 +185,39 @@ impl Client { } } + async fn run_request( + &self, + req: RequestBuilder, + url: Url, + ) -> Result { + log::debug!("Run request: {}", url); + let resp = req.send().await.context(HttpSnafu { url: url.clone() })?; + + let status = resp.status(); + let body = resp.text().await.context(DeserializeRespSnafu)?; + log::debug!("Response: {} -> {}", url, body); + if status.is_success() { + serde_json::from_str::(&body).context(DeserializeJsonSnafu) + } else { + let err_resp = serde_json::from_str::(&body).ok(); + Err(Error::BadResponse { + status: status, + body: body, + url: url.to_string(), + err_message: err_resp, + }) + } + } + /// Runs a GET request to the given url. When `debug` is true, the /// response is first decoded into utf8 chars and logged at debug /// level. Otherwise bytes are directly decoded from JSON into the /// expected structure. - async fn json_get(&self, path: &str, debug: bool) -> Result { + async fn json_get(&self, path: &str) -> Result { let url = self.make_url(path)?; log::debug!("JSON GET: {}", url); - let resp = self - .set_bearer_token(self.client.get(url.clone())) - .send() - .await - .context(HttpSnafu { url: url.clone() })?; - if debug { - let body = resp.text().await.context(DeserializeRespSnafu)?; - log::debug!("GET {} -> {}", url, body); - serde_json::from_str::(&body).context(DeserializeJsonSnafu) - } else { - resp.json::().await.context(DeserializeRespSnafu) - } + let req = self.set_bearer_token(self.client.get(url.clone())); + self.run_request(req, url).await } /// Runs a POST request to the given url. @@ -189,83 +225,59 @@ impl Client { &self, path: &str, body: &I, - debug: bool, ) -> Result { let url = self.make_url(path)?; - log::debug!("JSON POST: {}", url); - - let resp = self + let req = self .set_bearer_token(self.client.post(url.clone())) - .json::(&body) - .send() - .await - .context(HttpSnafu { url: url.clone() })?; - if debug { - let body = resp.text().await.context(DeserializeRespSnafu)?; - log::debug!("POST {} -> {}", url, body); - serde_json::from_str::(&body).context(DeserializeJsonSnafu) - } else { - resp.json::().await.context(DeserializeRespSnafu) - } + .json::(&body); + self.run_request(req, url).await } /// Runs a GET request to the given url. When `debug` is true, the /// response is first decoded into utf8 chars and logged at debug /// level. Otherwise bytes are directly decoded from JSON into the /// expected structure. - async fn json_get_option( - &self, - path: &str, - debug: bool, - ) -> Result, Error> { + async fn json_get_option(&self, path: &str) -> Result, Error> { let url = self.make_url(path)?; - let resp = self - .set_bearer_token(self.client.get(url.clone())) - .send() - .await - .context(HttpSnafu { url: url.clone() })?; - - if debug { - if resp.status() == reqwest::StatusCode::NOT_FOUND { - log::debug!("GET {} -> NotFound", &url); - Ok(None) - } else { - let body = &resp.text().await.context(DeserializeRespSnafu)?; - log::debug!("GET {} -> {}", &url, body); - let r = serde_json::from_str::(body).context(DeserializeJsonSnafu)?; - Ok(Some(r)) + let req = self.set_bearer_token(self.client.get(url.clone())); + + let result = self.run_request(req, url).await; + match result { + Err(Error::BadResponse { + status, + body: _, + url: _, + err_message: _, + }) => { + if status == reqwest::StatusCode::NOT_FOUND { + Ok(None) + } else { + result + } } - } else if resp.status() == reqwest::StatusCode::NOT_FOUND { - Ok(None) - } else { - let r = resp.json::().await.context(DeserializeRespSnafu)?; - Ok(Some(r)) + _ => result, } } /// Queries Renku for its version - pub async fn version(&self, debug: bool) -> Result { + pub async fn version(&self) -> Result { let data = self - .json_get::("/ui-server/api/data/version", debug) + .json_get::("/ui-server/api/data/version") .await?; let search = self - .json_get::("/ui-server/api/search/version", debug) + .json_get::("/ui-server/api/search/version") .await?; Ok(VersionInfo { search, data }) } - pub async fn get_project( - &self, - id: &ProjectId, - debug: bool, - ) -> Result, Error> { + pub async fn get_project(&self, id: &ProjectId) -> Result, Error> { match id { ProjectId::NamespaceSlug { namespace, slug } => { - self.get_project_by_slug(namespace, slug, debug).await + self.get_project_by_slug(namespace, slug).await } - ProjectId::Id(pid) => self.get_project_by_id(pid, debug).await, + ProjectId::Id(pid) => self.get_project_by_id(pid).await, - ProjectId::FullUrl(url) => self.get_project_by_url(url.as_url().clone(), debug).await, + ProjectId::FullUrl(url) => self.get_project_by_url(url.as_url().clone()).await, } } @@ -274,30 +286,24 @@ impl Client { &self, namespace: &str, slug: &str, - debug: bool, ) -> Result, Error> { log::debug!("Get project by namespace/slug: {}/{}", namespace, slug); let path = format!("/api/data/namespaces/{}/projects/{}", namespace, slug); - let details = self.json_get_option::(&path, debug).await?; + let details = self.json_get_option::(&path).await?; Ok(details) } /// Get project details by project id. - pub async fn get_project_by_id( - &self, - id: &str, - debug: bool, - ) -> Result, Error> { + pub async fn get_project_by_id(&self, id: &str) -> Result, Error> { log::debug!("Get project by id: {}", id); let path = format!("/api/data/projects/{}", id); - let details = self.json_get_option::(&path, debug).await?; + let details = self.json_get_option::(&path).await?; Ok(details) } pub async fn get_project_by_url( &self, url: U, - debug: bool, ) -> Result, Error> { let urlstr = url.as_str().to_string(); let url = url.into_url().context(HttpSnafu { url: urlstr })?; @@ -339,22 +345,19 @@ impl Client { self.access_token.clone(), )?; - let details = client - .json_get_option::(&path, debug) - .await?; + let details = client.json_get_option::(&path).await?; Ok(details) } pub async fn start_session( &self, req: SessionStartRequest, - debug: bool, ) -> Result { log::debug!("Starting session: {}", req); let path = "/api/data/sessions"; let details = self - .json_post::(&path, &req, debug) + .json_post::(&path, &req) .await?; Ok(details) } @@ -382,17 +385,14 @@ impl Client { req = req.query(&[("session_type", m.to_query_param())]) } - let resp = req.send().await.context(HttpSnafu { url: url.clone() })?; - let result = resp - .json::>() + self.run_request::>(req, url) .await - .context(DeserializeRespSnafu)?; - Ok(SessionList(result)) + .map(|e| SessionList(e)) } pub async fn session_logs(&self, session_id: &str) -> Result { let path = format!("/api/data/sessions/{}/logs", session_id); - let result = self.json_get::(&path, true).await?; + let result = self.json_get::(&path).await?; Ok(result) } diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index 65361d1..02b859b 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -155,3 +155,47 @@ impl fmt::Display for ProjectDetails { ) } } + +#[derive(Debug, Serialize, Deserialize)] +pub struct RenkuError { + pub code: i32, + pub message: String, +} + +impl fmt::Display for RenkuError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Error: {} - {}", self.code, self.message) + } +} + +/// Error response can be either a concrete renku error, or an error +/// from the proxy/gateway then there is only a message field. +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorResponse { + pub error: Option, + pub message: Option, +} + +impl ErrorResponse { + pub fn code(&self) -> Option { + match &self.error { + Some(em) => Some(em.code), + None => None, + } + } +} + +impl fmt::Display for ErrorResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.error { + Some(re) => write!(f, "{}", re), + None => { + if let Some(m) = &self.message { + write!(f, "{}", m) + } else { + write!(f, "No error message.") + } + } + } + } +} From 88634d4c1435bf38ad2e3e17ecb0651fb1864acd Mon Sep 17 00:00:00 2001 From: Eike Date: Wed, 22 Apr 2026 15:49:43 +0200 Subject: [PATCH 7/7] clippy --- src/httpclient.rs | 14 +++++++------- src/httpclient/data.rs | 29 ++++++++++++----------------- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/httpclient.rs b/src/httpclient.rs index c676ed1..17d37d2 100644 --- a/src/httpclient.rs +++ b/src/httpclient.rs @@ -16,7 +16,7 @@ //! None, //! ).unwrap(); //! async { -//! println!("{:?}", client.version(false).await); +//! println!("{:?}", client.version().await); //! }; //! ``` //! @@ -201,8 +201,8 @@ impl Client { } else { let err_resp = serde_json::from_str::(&body).ok(); Err(Error::BadResponse { - status: status, - body: body, + status, + body, url: url.to_string(), err_message: err_resp, }) @@ -229,7 +229,7 @@ impl Client { let url = self.make_url(path)?; let req = self .set_bearer_token(self.client.post(url.clone())) - .json::(&body); + .json::(body); self.run_request(req, url).await } @@ -357,7 +357,7 @@ impl Client { let path = "/api/data/sessions"; let details = self - .json_post::(&path, &req) + .json_post::(path, &req) .await?; Ok(details) } @@ -369,7 +369,7 @@ impl Client { self.set_bearer_token(self.client.delete(url.clone())) .send() .await - .context(HttpSnafu { url: url })?; + .context(HttpSnafu { url })?; Ok(()) } @@ -387,7 +387,7 @@ impl Client { self.run_request::>(req, url) .await - .map(|e| SessionList(e)) + .map(SessionList) } pub async fn session_logs(&self, session_id: &str) -> Result { diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index 02b859b..373fc48 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -4,6 +4,7 @@ use iso8601_timestamp::Timestamp; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, fmt}; +use tabled::{Table, Tabled, settings::Style}; #[derive(Debug, Serialize, Deserialize)] pub struct SessionLogs(pub HashMap); @@ -11,7 +12,7 @@ pub struct SessionLogs(pub HashMap); impl fmt::Display for SessionLogs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { for (k, v) in &self.0 { - write!(f, "- {}\n", k)?; + writeln!(f, "- {}", k)?; write!(f, "{}", v)?; } write!(f, "") @@ -59,19 +60,17 @@ pub struct SessionList(pub Vec); impl fmt::Display for SessionList { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let lines = self.0.iter().fold(String::new(), |a, b| { - format!("{}\n - {} (image={})", a, b.name, b.image) - }); - if self.0.is_empty() { - write!(f, "No sessions found.") + write!(f, "No jobs/sessions found.") } else { - write!(f, "Sessions:{}", lines) + let mut table = Table::new(&self.0); + table.with(Style::modern()); + write!(f, "{}", table) } } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Tabled)] pub struct SessionStartResponse { image: String, name: String, @@ -81,11 +80,10 @@ pub struct SessionStartResponse { impl fmt::Display for SessionStartResponse { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "SessionStartResponse({}, image={})", - self.name, self.image - ) + let mut table = Table::new(vec![self]); + table.with(Style::modern()); + + write!(f, "{}", table) } } @@ -178,10 +176,7 @@ pub struct ErrorResponse { impl ErrorResponse { pub fn code(&self) -> Option { - match &self.error { - Some(em) => Some(em.code), - None => None, - } + self.error.as_ref().map(|em| em.code) } }