From ac99d2b19749dbb99a53c1eaeeca7dd81f8ee00c Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 6 Oct 2026 16:18:26 -0400 Subject: [PATCH 01/32] fix: report failures and stop side effects of the CLI - Failing commands exit non-zero: `project` and `init` no longer exit 0 on errors, and an error prints once, with its full cause. - `unlink` no longer panics without a terminal and accepts `--yes`. - Bare `tracel` prints help instead of logging in, initializing a project or running it. - `train` replaces itself with `cargo run`, so signals sent to `tracel` reach the program instead of orphaning it. - `--dev` and `--staging` also work after the subcommand, `--version` prints `tracel`, prompts check stderr for a terminal, and URLs are plain when stderr is not a terminal. --- README.md | 3 +- crates/tracel-cli/src/cli.rs | 35 ++++--- crates/tracel-cli/src/commands/init.rs | 2 +- crates/tracel-cli/src/commands/me.rs | 14 +-- crates/tracel-cli/src/commands/mod.rs | 25 ----- crates/tracel-cli/src/commands/model.rs | 2 +- crates/tracel-cli/src/commands/package.rs | 2 +- crates/tracel-cli/src/commands/project.rs | 61 +++++------- crates/tracel-cli/src/commands/training.rs | 20 +++- crates/tracel-cli/src/commands/unlink.rs | 44 +++++---- crates/tracel-cli/src/helpers/project.rs | 106 ++++++--------------- crates/tracel-cli/src/tools/terminal.rs | 6 +- 12 files changed, 132 insertions(+), 188 deletions(-) diff --git a/README.md b/README.md index 5c6b029..b141c32 100644 --- a/README.md +++ b/README.md @@ -114,10 +114,11 @@ tracel init ### `tracel unlink` -Unlink the current directory from Tracel project. +Unlink the current directory from Tracel project. `--yes` skips the confirmation. ```bash tracel unlink +tracel unlink --yes ``` ### `tracel me` diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index 341dab7..83ee294 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -1,23 +1,28 @@ -use clap::{Parser, Subcommand}; +use clap::{CommandFactory, Parser, Subcommand}; use tracel_client::console::Env; use crate::commands; -use crate::commands::default_command; use crate::context::CliContext; use crate::tools::terminal::Terminal; #[derive(Parser, Debug)] -#[clap(author, version, about, long_about = None)] +#[clap(name = "tracel", author, version, about, long_about = None)] pub struct CliArgs { #[command(subcommand)] pub command: Option, /// Use development environment (localhost:9001) with separate dev credentials - #[arg(long, action = clap::ArgAction::SetTrue, hide = true, conflicts_with = "staging")] + #[arg(long, global = true, action = clap::ArgAction::SetTrue, hide = true, conflicts_with = "staging")] pub dev: bool, /// Use staging environment (specify version: 1, 2, etc.) - #[arg(long, value_name = "VERSION", hide = true, conflicts_with = "dev")] + #[arg( + long, + global = true, + value_name = "VERSION", + hide = true, + conflicts_with = "dev" + )] pub staging: Option, } @@ -37,7 +42,7 @@ pub enum Commands { /// Initialize a new project or reinitialize an existing one. Init(commands::init::InitArgs), /// Unlink the Tracel Console project from this repository. - Unlink, + Unlink(commands::unlink::UnlinkArgs), /// Display current user information. Me, /// Display current project information. @@ -64,15 +69,17 @@ pub fn cli_main() { .print_warning("Running in development mode - using local server and dev credentials"); } - let context = CliContext::new(terminal.clone(), environment); - - let cli_res = match args.command { - Some(command) => handle_command(command, context), - None => default_command(context), + let Some(command) = args.command else { + // Bare `tracel` only explains itself: it never logs in, writes files or runs anything. + // A reader that closes early (`tracel | head`) is not an error. + let _ = CliArgs::command().print_help(); + return; }; - if let Err(e) = cli_res { - terminal.cancel_finalize(&format!("{e}")); + let context = CliContext::new(terminal.clone(), environment); + + if let Err(e) = handle_command(command, context) { + terminal.cancel_finalize(&format!("{e:#}")); std::process::exit(1); } } @@ -85,7 +92,7 @@ fn handle_command(command: Commands, context: CliContext) -> anyhow::Result<()> Commands::Logout => commands::logout::handle_command(context), Commands::Auth(auth_args) => commands::auth::handle_command(auth_args, context), Commands::Init(init_args) => commands::init::handle_command(init_args, context), - Commands::Unlink => commands::unlink::handle_command(context), + Commands::Unlink(unlink_args) => commands::unlink::handle_command(unlink_args, context), Commands::Me => commands::me::handle_command(context), Commands::Project => commands::project::handle_command(context), Commands::Model(model_args) => commands::model::handle_command(model_args, context), diff --git a/crates/tracel-cli/src/commands/init.rs b/crates/tracel-cli/src/commands/init.rs index 7ffa709..6746d9e 100644 --- a/crates/tracel-cli/src/commands/init.rs +++ b/crates/tracel-cli/src/commands/init.rs @@ -28,7 +28,7 @@ pub fn handle_command(args: InitArgs, context: CliContext) -> anyhow::Result<()> pub fn prompt_init(context: &CliContext, client: &Client) -> anyhow::Result<()> { let user = client.get_current_user()?; - let workspace_info = require_cargo_workspace(context)?; + let workspace_info = require_cargo_workspace()?; context.terminal().command_title("Project Initialization"); diff --git a/crates/tracel-cli/src/commands/me.rs b/crates/tracel-cli/src/commands/me.rs index f63e565..8e69ce9 100644 --- a/crates/tracel-cli/src/commands/me.rs +++ b/crates/tracel-cli/src/commands/me.rs @@ -1,3 +1,5 @@ +use anyhow::Context; + use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use tracel_client::console::Env; @@ -7,15 +9,9 @@ pub fn handle_command(context: CliContext) -> anyhow::Result<()> { let client = get_client_and_login_if_needed(&context)?; - let user = match client.get_current_user() { - Ok(user) => user, - Err(e) => { - context - .terminal() - .cancel_finalize(&format!("Failed to retrieve user information: {}", e)); - anyhow::bail!(e); - } - }; + let user = client + .get_current_user() + .context("Failed to retrieve user information")?; context .terminal() diff --git a/crates/tracel-cli/src/commands/mod.rs b/crates/tracel-cli/src/commands/mod.rs index 01819e6..51fb18e 100644 --- a/crates/tracel-cli/src/commands/mod.rs +++ b/crates/tracel-cli/src/commands/mod.rs @@ -1,8 +1,3 @@ -use crate::commands::init::prompt_init; -use crate::commands::login::get_client_and_login_if_needed; -use crate::context::CliContext; -use crate::helpers::{is_tracel_project_linked, require_cargo_workspace}; - pub mod auth; pub mod init; pub mod login; @@ -13,23 +8,3 @@ pub mod package; pub mod project; pub mod training; pub mod unlink; - -/// `tracel` with no subcommand runs the project via `cargo run` (like `tracel train` -/// with no forwarded arguments), but first ensures the repository is linked to a -/// Tracel Console project, prompting for initialization if it is not. -pub fn default_command(context: CliContext) -> anyhow::Result<()> { - let client = get_client_and_login_if_needed(&context)?; - - // Check if we have a linked Tracel Console project - if !is_tracel_project_linked() { - // Make sure we're at least in a Rust project before initializing - let _crate_info = require_cargo_workspace(&context)?; - context - .terminal() - .print("No Tracel Console project linked, prompting for initialization."); - prompt_init(&context, &client)?; - return Ok(()); - } - - training::run_cargo(&[], context) -} diff --git a/crates/tracel-cli/src/commands/model.rs b/crates/tracel-cli/src/commands/model.rs index 9955bf9..26a60ac 100644 --- a/crates/tracel-cli/src/commands/model.rs +++ b/crates/tracel-cli/src/commands/model.rs @@ -59,7 +59,7 @@ fn upload_model_version(args: UploadModelArgs, context: CliContext) -> anyhow::R context.terminal().command_title("Model upload"); let client = crate::commands::login::get_client_and_login_if_needed(&context)?; - let (namespace, project) = resolve_namespace_project(&context, args.namespace, args.project)?; + let (namespace, project) = resolve_namespace_project(args.namespace, args.project)?; context .terminal() diff --git a/crates/tracel-cli/src/commands/package.rs b/crates/tracel-cli/src/commands/package.rs index 7a43049..5f73cb5 100644 --- a/crates/tracel-cli/src/commands/package.rs +++ b/crates/tracel-cli/src/commands/package.rs @@ -46,7 +46,7 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< // 0. Ensure we have auth and a linked project that exists on the server. let client = get_client_and_login_if_needed(&context)?; - let project = require_linked_project(&context)?; + let project = require_linked_project()?; validate_project_exists_on_server(&context, &project, &client)?; // 1. Dirty check — warn and offer to commit, but allow proceeding. diff --git a/crates/tracel-cli/src/commands/project.rs b/crates/tracel-cli/src/commands/project.rs index 4888d45..829bfd9 100644 --- a/crates/tracel-cli/src/commands/project.rs +++ b/crates/tracel-cli/src/commands/project.rs @@ -1,3 +1,5 @@ +use anyhow::Context; + use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::helpers::require_linked_project; @@ -5,48 +7,33 @@ use crate::helpers::require_linked_project; pub fn handle_command(context: CliContext) -> anyhow::Result<()> { context.terminal().command_title("Project Information"); - let project = require_linked_project(&context)?; - - let client = match get_client_and_login_if_needed(&context) { - Ok(client) => client, - Err(e) => { - context.terminal().cancel_finalize(&format!("{e}")); - return Ok(()); - } - }; + let project = require_linked_project()?; + let client = get_client_and_login_if_needed(&context)?; let bc_project = project.get_project(); - // Fetch project information from the server - match client.get_project(&bc_project.owner, &bc_project.name) { - Ok(project) => { - context - .terminal() - .print(&format!("Project: {}", project.project_name)); - context - .terminal() - .print(&format!("Namespace: {}", project.namespace_name)); - context - .terminal() - .print(&format!("Description: {}", project.description)); - context - .terminal() - .print(&format!("Created By: {}", project.created_by)); - context - .terminal() - .finalize("Project information retrieved successfully."); - } - Err(e) if e.is_not_found() => { - context.terminal().cancel_finalize(&format!( - "Project {}/{} not found on the server.", - bc_project.owner, bc_project.name - )); - } + let project = match client.get_project(&bc_project.owner, &bc_project.name) { + Ok(project) => project, + Err(e) if e.is_not_found() => anyhow::bail!( + "Project {}/{} not found on Tracel Console. Run 'tracel init --force' to link another project.", + bc_project.owner, + bc_project.name + ), Err(e) => { - context - .terminal() - .cancel_finalize(&format!("Failed to retrieve project information: {}", e)); + return Err(e).with_context(|| { + format!( + "Failed to retrieve project {}/{}", + bc_project.owner, bc_project.name + ) + }); } }; + let terminal = context.terminal(); + terminal.print(&format!("Project: {}", project.project_name)); + terminal.print(&format!("Namespace: {}", project.namespace_name)); + terminal.print(&format!("Description: {}", project.description)); + terminal.print(&format!("Created By: {}", project.created_by)); + terminal.finalize("Project information retrieved successfully."); + Ok(()) } diff --git a/crates/tracel-cli/src/commands/training.rs b/crates/tracel-cli/src/commands/training.rs index bda3f89..f3596e9 100644 --- a/crates/tracel-cli/src/commands/training.rs +++ b/crates/tracel-cli/src/commands/training.rs @@ -18,9 +18,8 @@ pub fn handle_command(args: TrainingArgs, context: CliContext) -> anyhow::Result /// Run `cargo run` in the current directory, forwarding `forwarded` after `--`. /// -/// stdin/stdout/stderr are inherited so the run is interactive, and the child's -/// exit code is mirrored. `tracel train -- entrypoint` is therefore equivalent to -/// `cargo run -- entrypoint`. +/// `tracel train -- entrypoint` is equivalent to `cargo run -- entrypoint`: stdio is +/// inherited and the program's exit code is the command's. pub fn run_cargo(forwarded: &[String], context: CliContext) -> anyhow::Result<()> { let mut cmd = cargo::command(); cmd.arg("run"); @@ -32,6 +31,21 @@ pub fn run_cargo(forwarded: &[String], context: CliContext) -> anyhow::Result<() cmd.args(forwarded); } + hand_over(cmd) +} + +/// Replace this process with `cmd`. `cargo run` does the same with the program it +/// builds, so a signal sent to `tracel` reaches the program instead of orphaning it. +#[cfg(unix)] +fn hand_over(mut cmd: std::process::Command) -> anyhow::Result<()> { + use std::os::unix::process::CommandExt; + + Err(cmd.exec()).context("Failed to run `cargo run`") +} + +/// Windows cannot replace a process, so wait for the child and mirror its exit code. +#[cfg(not(unix))] +fn hand_over(mut cmd: std::process::Command) -> anyhow::Result<()> { let status = cmd.status().context("Failed to run `cargo run`")?; if !status.success() { std::process::exit(status.code().unwrap_or(1)); diff --git a/crates/tracel-cli/src/commands/unlink.rs b/crates/tracel-cli/src/commands/unlink.rs index 87521a6..b462bb2 100644 --- a/crates/tracel-cli/src/commands/unlink.rs +++ b/crates/tracel-cli/src/commands/unlink.rs @@ -1,30 +1,38 @@ +use anyhow::Context; +use clap::Args; + use crate::{ context::CliContext, helpers::require_linked_project, tools::project_context::ProjectContext, }; -pub fn handle_command(context: CliContext) -> anyhow::Result<()> { - let project = require_linked_project(&context)?; +#[derive(Args, Debug)] +pub struct UnlinkArgs { + /// Unlink without asking for confirmation + #[arg(long, short = 'y')] + pub yes: bool, +} + +pub fn handle_command(args: UnlinkArgs, context: CliContext) -> anyhow::Result<()> { + let project = require_linked_project()?; context.terminal().command_title("Unlink"); - let confirm_value = context - .terminal() - .confirm("Are you sure you want to unlink the Tracel Console project from this repository?") - .unwrap(); - - if confirm_value { - match ProjectContext::unlink(&project.get_manifest_path()) { - Ok(_) => context.terminal().finalize("Project unlinked successfully"), - Err(e) => { - context - .terminal() - .cancel_finalize(&format!("Failed to unlink project: {}", e)); - anyhow::bail!(e); - } + if !args.yes { + if !context.terminal().is_interactive() { + anyhow::bail!("Unlinking needs confirmation: pass --yes to unlink without a prompt."); + } + + let confirmed = context.terminal().confirm( + "Are you sure you want to unlink the Tracel Console project from this repository?", + )?; + if !confirmed { + context.terminal().cancel_finalize("Cancelled"); + return Ok(()); } - } else { - context.terminal().cancel_finalize("Cancelled"); } + ProjectContext::unlink(&project.get_manifest_path()).context("Failed to unlink project")?; + context.terminal().finalize("Project unlinked successfully"); + Ok(()) } diff --git a/crates/tracel-cli/src/helpers/project.rs b/crates/tracel-cli/src/helpers/project.rs index a39b5a3..b2f659c 100644 --- a/crates/tracel-cli/src/helpers/project.rs +++ b/crates/tracel-cli/src/helpers/project.rs @@ -10,11 +10,6 @@ use crate::{ }; use tracel_client::console::Client; -/// Check if current directory contains a Rust project (has Cargo.toml) -pub fn is_cargo_workspace() -> bool { - find_manifest().is_ok() -} - pub fn find_manifest() -> anyhow::Result { try_locate_manifest().ok_or_else(|| { anyhow::anyhow!( @@ -32,59 +27,35 @@ pub fn is_tracel_project_linked() -> bool { } } -pub fn handle_project_context_error(context: &CliContext, e: &ProjectContextError) { - match e.kind() { - ErrorKind::ManifestNotFound => { - context - .terminal() - .print_err("No Cargo.toml found in current directory."); - context - .terminal() - .print("Navigate to a Rust project directory first."); - } - ErrorKind::ProjectNotLinked => { - context - .terminal() - .print_err("This Rust project is not linked to Tracel Console."); - context - .terminal() - .print("Run 'tracel init' to initialize a Tracel Console project."); - } - ErrorKind::Parsing => { - context.terminal().print_err(&e.to_string()); - context.terminal().print("Ensure your Cargo.toml is valid."); - } - ErrorKind::ProjectInitialization => { - context.terminal().print_err(&e.to_string()); - context.terminal().print( - "Try re-initializing the Tracel Console project with 'tracel init --force'.", - ); - } - ErrorKind::Unexpected => { - context.terminal().print_err(&e.to_string()); - context - .terminal() - .print("An unexpected error occurred. Please check your project setup."); - } +/// One error that says what went wrong, why, and what to do next. +fn explain_project_context_error(e: ProjectContextError) -> anyhow::Error { + let hint = match e.kind() { + ErrorKind::ManifestNotFound => "Run this command from a Rust project directory.", + ErrorKind::ProjectNotLinked => "Run 'tracel init' to link it.", + ErrorKind::Parsing => "Check that Cargo.toml and tracel.toml are valid.", + ErrorKind::ProjectInitialization => "Re-link the project with 'tracel init --force'.", + ErrorKind::Unexpected => "Check your project setup.", + }; + + let mut message = e.to_string(); + let mut cause = std::error::Error::source(&e); + while let Some(err) = cause { + message.push_str(&format!(": {err}")); + cause = err.source(); } + + anyhow::anyhow!("{message}. {hint}") } -/// Require a linked Tracel Console project, showing helpful errors if not found -pub fn require_linked_project(context: &CliContext) -> anyhow::Result { +/// Require a linked Tracel Console project. +pub fn require_linked_project() -> anyhow::Result { let manifest_path = find_manifest()?; - match ProjectContext::load(&manifest_path) { - Ok(project) => Ok(project), - Err(e) => { - handle_project_context_error(context, &e); - anyhow::bail!("Failed to load linked Tracel Console project") - } - } + ProjectContext::load(&manifest_path).map_err(explain_project_context_error) } /// Resolve a namespace/project pair, using explicit overrides where given and /// falling back to the linked project's namespace/name for whichever is omitted. pub fn resolve_namespace_project( - context: &CliContext, namespace: Option, project: Option, ) -> anyhow::Result<(String, String)> { @@ -92,7 +63,7 @@ pub fn resolve_namespace_project( return Ok((ns.clone(), proj.clone())); } - let linked = require_linked_project(context)?; + let linked = require_linked_project()?; let bc_project = linked.get_project(); Ok(( @@ -102,43 +73,26 @@ pub fn resolve_namespace_project( } /// Require a Cargo workspace (with or without Tracel Console linkage) -pub fn require_cargo_workspace(context: &CliContext) -> anyhow::Result { +pub fn require_cargo_workspace() -> anyhow::Result { let manifest_path = find_manifest()?; - match ProjectContext::load_workspace_info(&manifest_path) { - Ok(workspace_info) => Ok(workspace_info), - Err(e) => { - handle_project_context_error(context, &e); - anyhow::bail!("Failed to load Cargo workspace info") - } - } + ProjectContext::load_workspace_info(&manifest_path).map_err(explain_project_context_error) } -/// Check if we're in a valid state for initialization +/// Whether `tracel init` should go ahead. Fails outside a Rust project, and stops +/// without error when the project is already linked and `force` is off. pub fn can_initialize_project(context: &CliContext, force: bool) -> anyhow::Result { - if !is_cargo_workspace() { + find_manifest()?; + + if is_tracel_project_linked() && !force { context .terminal() - .print_err("No Rust project found in current directory."); + .print("Project is already linked to Tracel Console."); context .terminal() - .print("Run this command from a Rust project directory with a Cargo.toml file."); + .print("Use --force flag to reinitialize."); return Ok(false); } - if is_tracel_project_linked() { - if force { - return Ok(true); - } else { - context - .terminal() - .print("Project is already linked to Tracel Console."); - context - .terminal() - .print("Use --force flag to reinitialize."); - return Ok(false); - } - } - Ok(true) } diff --git a/crates/tracel-cli/src/tools/terminal.rs b/crates/tracel-cli/src/tools/terminal.rs index 7bf5ce6..7d1141f 100644 --- a/crates/tracel-cli/src/tools/terminal.rs +++ b/crates/tracel-cli/src/tools/terminal.rs @@ -40,8 +40,9 @@ impl Terminal { clear_screen().expect("Failed to clear screen"); } + /// A URL styled for stderr, plain when stderr is not a terminal or `NO_COLOR` is set. pub fn format_url(&self, url: &url::Url) -> String { - format!("\x1b[1;34m{url}\x1b[0m") + console::style(url).for_stderr().blue().bold().to_string() } pub fn confirm(&self, message: &str) -> anyhow::Result { @@ -64,7 +65,8 @@ impl Terminal { .expect("To be able to print message"); } + /// Whether a person can answer prompts: they read from stdin and draw on stderr. pub fn is_interactive(&self) -> bool { - std::io::stdin().is_terminal() && std::io::stdout().is_terminal() + std::io::stdin().is_terminal() && std::io::stderr().is_terminal() } } From 7c18bedd99b6bbfbfcfd3525d3383e1c8c6824e6 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 6 Oct 2026 16:18:26 -0400 Subject: [PATCH 02/32] feat: add JSON output and typed exit codes - Global `--json` and `-o, --output `, plus `TRACEL_OUTPUT`. `auto` is human on a terminal and JSON otherwise. - JSON mode prints one line on stdout: `{"ok":true,"data":...}`, or `{"ok":false,"error":{"code","message","hint","exit_code"}}`. - Errors map to fixed exit codes: 1 internal, 2 usage, 3 not authenticated, 4 forbidden, 5 not found, 6 conflict, 7 confirmation required, 8 limit reached, 11 unavailable. Client errors keep their kind through added context. - Human output is plain lines when stderr is not a terminal. - `me`, `project`, `auth status`, `auth token`, `unlink` and `logout` return structured data. `auth token` prints the bare token unless JSON is requested. --- Cargo.lock | 3 + README.md | 25 ++ crates/tracel-cli/Cargo.toml | 1 + crates/tracel-cli/src/cli.rs | 198 ++++++++++++-- crates/tracel-cli/src/commands/auth.rs | 56 ++-- crates/tracel-cli/src/commands/init.rs | 41 +-- crates/tracel-cli/src/commands/login.rs | 38 ++- crates/tracel-cli/src/commands/logout.rs | 5 +- crates/tracel-cli/src/commands/me.rs | 11 +- crates/tracel-cli/src/commands/model.rs | 2 +- crates/tracel-cli/src/commands/package.rs | 4 +- crates/tracel-cli/src/commands/project.rs | 28 +- crates/tracel-cli/src/commands/unlink.rs | 36 ++- crates/tracel-cli/src/context.rs | 17 +- crates/tracel-cli/src/error.rs | 251 ++++++++++++++++++ .../src/helpers/model_upload/existence.rs | 3 +- .../src/helpers/model_upload/upload.rs | 51 ++-- crates/tracel-cli/src/helpers/project.rs | 17 +- crates/tracel-cli/src/lib.rs | 2 + crates/tracel-cli/src/output.rs | 123 +++++++++ crates/tracel-cli/src/tools/target.rs | 1 + crates/tracel-cli/src/tools/terminal.rs | 157 +++++++++-- 22 files changed, 947 insertions(+), 123 deletions(-) create mode 100644 crates/tracel-cli/src/error.rs create mode 100644 crates/tracel-cli/src/output.rs diff --git a/Cargo.lock b/Cargo.lock index 5535f75..ce41247 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,8 +295,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -3617,6 +3619,7 @@ version = "0.5.1" dependencies = [ "anyhow", "cargo_metadata", + "chrono", "clap", "cliclack", "colored", diff --git a/README.md b/README.md index b141c32..f01a90a 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,31 @@ After installation, the `tracel` command will be available in your terminal. ## Commands +All commands accept `--json` or `-o, --output `. The default +`auto` format uses human output on a terminal and JSON when stdout is redirected. +`TRACEL_OUTPUT=human` or `TRACEL_OUTPUT=json` sets the format when no output flag +is given. `--json` and `--output` cannot be combined. + +JSON results are one line on stdout: `{"ok":true,"data":{...}}` on success, or +`{"ok":false,"error":{"code":"NOT_FOUND","message":"...","hint":null,"exit_code":5}}` +on failure. Diagnostics go to stderr. Help and version output retain their normal +format, and `train` inherits the executed program's output and exit code. + +| Error code | Exit code | +| --- | --- | +| Success | 0 | +| `INTERNAL` | 1 | +| `USAGE` | 2 | +| `NOT_AUTHENTICATED` | 3 | +| `FORBIDDEN` | 4 | +| `NOT_FOUND` | 5 | +| `CONFLICT` | 6 | +| `CONFIRMATION_REQUIRED` | 7 | +| `LIMIT_REACHED` | 8 | +| `UNAVAILABLE` | 11 | + +Exit codes 9 and 10 are reserved. + ### `tracel train` Run your project locally. This is a thin alias for `cargo run`: every argument diff --git a/crates/tracel-cli/Cargo.toml b/crates/tracel-cli/Cargo.toml index ca83126..f4281b6 100644 --- a/crates/tracel-cli/Cargo.toml +++ b/crates/tracel-cli/Cargo.toml @@ -25,6 +25,7 @@ cliclack.workspace = true directories.workspace = true serde.workspace = true serde_json.workspace = true +chrono.workspace = true sha2.workspace = true thiserror.workspace = true toml_edit.workspace = true diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index 83ee294..84596fa 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -1,8 +1,13 @@ +use std::io::IsTerminal; + use clap::{CommandFactory, Parser, Subcommand}; +use serde_json::{Value, json}; use tracel_client::console::Env; use crate::commands; use crate::context::CliContext; +use crate::error::{CliError, ErrorKind, ErrorReport}; +use crate::output::{self, OutputMode}; use crate::tools::terminal::Terminal; #[derive(Parser, Debug)] @@ -11,6 +16,14 @@ pub struct CliArgs { #[command(subcommand)] pub command: Option, + /// Output format (default: auto) + #[arg(short, long, global = true, value_enum, conflicts_with = "json")] + pub output: Option, + + /// Print machine-readable JSON + #[arg(long, global = true, conflicts_with = "output")] + pub json: bool, + /// Use development environment (localhost:9001) with separate dev credentials #[arg(long, global = true, action = clap::ArgAction::SetTrue, hide = true, conflicts_with = "staging")] pub dev: bool, @@ -52,7 +65,61 @@ pub enum Commands { } pub fn cli_main() { - let args = CliArgs::parse(); + let args = match CliArgs::try_parse() { + Ok(args) => args, + Err(error) if !error.use_stderr() => error.exit(), + Err(error) => { + let mode = usage_output_mode(std::env::args_os().skip(1)); + if mode == OutputMode::Json { + let message = error.to_string(); + let message = message + .trim() + .strip_prefix("error: ") + .unwrap_or(message.trim()); + let error = anyhow::Error::new(CliError::new(ErrorKind::Usage, message)); + report_error(&error, mode, &Terminal::new(mode)); + } else { + error.exit(); + } + return; + } + }; + + let Some(command) = args.command else { + // A reader that closes early (`tracel | head`) is not an error. + let _ = CliArgs::command().print_help(); + return; + }; + + let mode = match OutputMode::resolve( + args.output, + args.json, + std::env::var("TRACEL_OUTPUT").ok().as_deref(), + std::io::stdout().is_terminal(), + ) { + Ok(mode) => mode, + Err(error) => { + let mode = OutputMode::resolve(None, false, None, std::io::stdout().is_terminal()) + .expect("Auto output is valid"); + report_error(&error.into(), mode, &Terminal::new(mode)); + return; + } + }; + // `auth token` prints the bare token for `$(tracel auth token)` unless JSON is asked for. + let json_requested = args.json + || args.output == Some(OutputMode::Json) + || std::env::var("TRACEL_OUTPUT").as_deref() == Ok("json"); + let prints_raw_token = matches!( + &command, + Commands::Auth(commands::auth::AuthArgs { + command: commands::auth::AuthCommands::Token + }) + ) && !json_requested; + let mode = if matches!(command, Commands::Train(_)) || prints_raw_token { + OutputMode::Human + } else { + mode + }; let environment = if args.dev { Env::Development @@ -62,39 +129,134 @@ pub fn cli_main() { Env::Production }; - let terminal = Terminal::default(); + let terminal = Terminal::new(mode); if args.dev { terminal .print_warning("Running in development mode - using local server and dev credentials"); } - let Some(command) = args.command else { - // Bare `tracel` only explains itself: it never logs in, writes files or runs anything. - // A reader that closes early (`tracel | head`) is not an error. - let _ = CliArgs::command().print_help(); - return; - }; + let context = CliContext::new(terminal.clone(), environment, mode); + + let result = handle_command(command, context).and_then(|data| { + if mode == OutputMode::Json { + output::write_success(&data)?; + } + Ok(()) + }); + if let Err(error) = result { + report_error(&error, mode, &terminal); + } +} - let context = CliContext::new(terminal.clone(), environment); +fn report_error(error: &anyhow::Error, mode: OutputMode, terminal: &Terminal) { + let report = ErrorReport::new(error); + if mode == OutputMode::Json { + let _ = output::write_failure(&report); + eprintln!( + "error: {}", + console::strip_ansi_codes(&report.message) + .split_whitespace() + .collect::>() + .join(" ") + ); + } else { + terminal.cancel_finalize(&format!("{error:#}")); + if let Some(hint) = report.hint { + terminal.print(hint); + } + } + std::process::exit(report.exit_code); +} - if let Err(e) = handle_command(command, context) { - terminal.cancel_finalize(&format!("{e:#}")); - std::process::exit(1); +fn usage_output_mode(arguments: impl IntoIterator) -> OutputMode { + let mut arguments = arguments.into_iter(); + let mut flag = None; + let mut json = false; + while let Some(argument) = arguments.next() { + let argument = argument.to_string_lossy(); + if argument == "--" { + break; + } + if argument == "--json" { + json = true; + } else { + let value = if argument == "--output" || argument == "-o" { + arguments + .next() + .map(|value| value.to_string_lossy().into_owned()) + } else { + argument + .strip_prefix("--output=") + .or_else(|| argument.strip_prefix("-o")) + .map(str::to_owned) + }; + if let Some(value) = value { + flag = Some(match value.as_str() { + "human" => OutputMode::Human, + "json" => OutputMode::Json, + _ => OutputMode::Auto, + }); + } + } } + OutputMode::resolve( + flag, + json, + std::env::var("TRACEL_OUTPUT").ok().as_deref(), + std::io::stdout().is_terminal(), + ) + .unwrap_or_else(|_| { + if std::io::stdout().is_terminal() { + OutputMode::Human + } else { + OutputMode::Json + } + }) } -fn handle_command(command: Commands, context: CliContext) -> anyhow::Result<()> { +fn handle_command(command: Commands, context: CliContext) -> anyhow::Result { match command { - Commands::Train(run_args) => commands::training::handle_command(run_args, context), - Commands::Package(package_args) => commands::package::handle_command(package_args, context), - Commands::Login => commands::login::handle_command(context), + Commands::Train(run_args) => { + commands::training::handle_command(run_args, context).map(|()| json!({})) + } + Commands::Package(package_args) => { + commands::package::handle_command(package_args, context).map(|()| json!({})) + } + Commands::Login => commands::login::handle_command(context).map(|()| json!({})), Commands::Logout => commands::logout::handle_command(context), Commands::Auth(auth_args) => commands::auth::handle_command(auth_args, context), - Commands::Init(init_args) => commands::init::handle_command(init_args, context), + Commands::Init(init_args) => { + commands::init::handle_command(init_args, context).map(|()| json!({})) + } Commands::Unlink(unlink_args) => commands::unlink::handle_command(unlink_args, context), Commands::Me => commands::me::handle_command(context), Commands::Project => commands::project::handle_command(context), - Commands::Model(model_args) => commands::model::handle_command(model_args, context), + Commands::Model(model_args) => { + commands::model::handle_command(model_args, context).map(|()| json!({})) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn output_flags_are_global_and_conflict() { + for arguments in [ + vec!["tracel", "me", "--json"], + vec!["tracel", "--json", "me"], + ] { + assert!(CliArgs::try_parse_from(arguments).unwrap().json); + } + assert_eq!( + CliArgs::try_parse_from(["tracel", "auth", "status", "-o", "human"]) + .unwrap() + .output, + Some(OutputMode::Human) + ); + let error = CliArgs::try_parse_from(["tracel", "me", "--json", "-o", "json"]).unwrap_err(); + assert_eq!(error.exit_code(), 2); } } diff --git a/crates/tracel-cli/src/commands/auth.rs b/crates/tracel-cli/src/commands/auth.rs index 06b559c..b303b23 100644 --- a/crates/tracel-cli/src/commands/auth.rs +++ b/crates/tracel-cli/src/commands/auth.rs @@ -1,11 +1,15 @@ use std::time::SystemTime; +use chrono::{DateTime, Utc}; use clap::{Args, Subcommand}; +use serde_json::{Value, json}; use tracel_client::ClientError; use tracel_client::console::TracelCredentials; use crate::commands::login::environment_suffix; use crate::context::{CliContext, ClientCreationError}; +use crate::error::{CliError, ErrorKind}; +use crate::output::OutputMode; #[derive(Args, Debug)] pub struct AuthArgs { @@ -22,28 +26,39 @@ pub enum AuthCommands { Status, } -pub fn handle_command(args: AuthArgs, context: CliContext) -> anyhow::Result<()> { +pub fn handle_command(args: AuthArgs, context: CliContext) -> anyhow::Result { match args.command { AuthCommands::Token => print_token(&context), AuthCommands::Status => show_status(&context), } } -fn print_token(context: &CliContext) -> anyhow::Result<()> { - let access_token = context.app_session()?.access_token().map_err(|e| match e { - ClientError::AppSessionEnded => anyhow::anyhow!( - "Not logged in{}. Run 'tracel login' first.", - environment_suffix(&context.environment()) - ), - e => e.into(), - })?; +fn print_token(context: &CliContext) -> anyhow::Result { + let access_token = context + .app_session()? + .access_token() + .map_err(|e| -> anyhow::Error { + match e { + ClientError::AppSessionEnded => CliError::new( + ErrorKind::NotAuthenticated, + format!( + "Not logged in{}. Run 'tracel login' first.", + environment_suffix(&context.environment()) + ), + ) + .into(), + e => e.into(), + } + })?; - println!("{}", access_token.as_str()); + if context.output() == OutputMode::Human { + println!("{}", access_token.as_str()); + } - Ok(()) + Ok(json!({"access_token": access_token.as_str()})) } -fn show_status(context: &CliContext) -> anyhow::Result<()> { +fn show_status(context: &CliContext) -> anyhow::Result { context.terminal().command_title("Authentication Status"); let client = context.create_client(); @@ -76,13 +91,22 @@ fn show_status(context: &CliContext) -> anyhow::Result<()> { client.user().username, env_msg )); - Ok(()) + Ok(json!({ + "credential": if credential == "TRACEL_API_KEY" { "api_key" } else { credential }, + "login_ends_at": login.as_ref().map(|login| DateTime::::from(login.refresh_token_expires_at).to_rfc3339()), + "user": {"username": client.user().username, "namespace": client.user().namespace}, + "environment": context.environment_name(), + })) } Err(ClientCreationError::NoCredentials | ClientCreationError::InvalidCredentials) => { - anyhow::bail!( - "Not logged in{}. Run 'tracel login' or set TRACEL_API_KEY.", - env_msg + Err(CliError::new( + ErrorKind::NotAuthenticated, + format!( + "Not logged in{}. Run 'tracel login' or set TRACEL_API_KEY.", + env_msg + ), ) + .into()) } Err(e) => Err(e.into()), } diff --git a/crates/tracel-cli/src/commands/init.rs b/crates/tracel-cli/src/commands/init.rs index 6746d9e..172f77e 100644 --- a/crates/tracel-cli/src/commands/init.rs +++ b/crates/tracel-cli/src/commands/init.rs @@ -34,7 +34,7 @@ pub fn prompt_init(context: &CliContext, client: &Client) -> anyhow::Result<()> let terminal = context.terminal(); - ensure_git_repo_initialized(&workspace_info.get_ws_root())?; + ensure_git_repo_initialized(&workspace_info.get_ws_root(), terminal)?; ensure_git_repo_clean(terminal)?; let first_commit_hash = git::get_first_commit_hash(); @@ -54,9 +54,9 @@ pub fn prompt_init(context: &CliContext, client: &Client) -> anyhow::Result<()> ProjectKind::Organization(org_name) => org_name.as_str(), }; let project_info = match client.get_project(owner_name, &project_name) { - Ok(project) => handle_existing_project(&project)?, + Ok(project) => handle_existing_project(&project, terminal)?, Err(e) if e.is_not_found() => { - create_new_project(client, project_owner.clone(), &project_name)? + create_new_project(client, project_owner.clone(), &project_name, terminal)? } Err(e) => { terminal.cancel_finalize(&format!("Failed to check for existing project: {e}")); @@ -135,7 +135,10 @@ pub fn prompt_project_name(workspace_name: &str) -> anyhow::Result { Ok(input) } -fn handle_existing_project(project: &ProjectResponse) -> anyhow::Result { +fn handle_existing_project( + project: &ProjectResponse, + terminal: &Terminal, +) -> anyhow::Result { let confirmed = cliclack::confirm(format!( "Project \"{}\" already exists under owner \"{}\". Do you want to link it?", project.project_name, project.namespace_name @@ -148,7 +151,7 @@ fn handle_existing_project(project: &ProjectResponse) -> anyhow::Result anyhow::Result { let description = cliclack::input("Enter the project description (default empty)") .required(false) @@ -189,19 +193,22 @@ fn create_new_project( name: project.project_name, }), Err(e) => { - cliclack::outro_cancel(format!("Failed to create project: {e}"))?; - Err(anyhow::anyhow!("Failed to create project: {}", e)) + terminal.outro_cancel(&format!("Failed to create project: {e}")); + Err(e).context("Failed to create project") } } } -pub fn ensure_git_repo_initialized(ws_root: &std::path::Path) -> anyhow::Result<()> { +pub fn ensure_git_repo_initialized( + ws_root: &std::path::Path, + terminal: &Terminal, +) -> anyhow::Result<()> { if !git::is_repo_initialized() { let repo = git::init_repo(ws_root)?; - cliclack::log::step(format!( + terminal.step(&format!( "No git repository found. Initialized new git repository at: {}", repo.path().display() - ))?; + )); } Ok(()) } @@ -213,13 +220,13 @@ pub fn ensure_git_repo_clean(terminal: &Terminal) -> anyhow::Result<()> { terminal.print( "Repository is dirty. Tracel Console needs a valid commit hash to associated your code with your repository.", ); - commit_sequence().map_err(|e| anyhow::anyhow!("Failed to make initial commit: {}", e)) + commit_sequence(terminal).context("Failed to make initial commit") } Err(e) if e.to_string().contains("does not have any commits") => { terminal.print( "Repository is dirty. Please commit or stash your changes before proceeding.", ); - commit_sequence().map_err(|e| anyhow::anyhow!("Failed to make initial commit: {}", e)) + commit_sequence(terminal).context("Failed to make initial commit") } Err(_) => Err(anyhow::anyhow!( "Failed to check if the repository is dirty." @@ -227,7 +234,7 @@ pub fn ensure_git_repo_clean(terminal: &Terminal) -> anyhow::Result<()> { } } -pub fn commit_sequence() -> anyhow::Result<()> { +pub fn commit_sequence(terminal: &Terminal) -> anyhow::Result<()> { let do_commit = cliclack::confirm("Do you want to automatically commit all files?").interact()?; if do_commit { @@ -250,9 +257,9 @@ pub fn commit_sequence() -> anyhow::Result<()> { if !status.success() { return Err(anyhow::anyhow!("Failed to commit files to git")); } - cliclack::log::success("Committed all files to git.")?; + terminal.print_success("Committed all files to git."); } else { - let spinner = cliclack::spinner(); + let spinner = terminal.spinner(); let message = format!( "{}\n{}\n\n{}", console::style("Waiting for manual commit").bold(), @@ -269,12 +276,12 @@ pub fn commit_sequence() -> anyhow::Result<()> { match term.read_key() { Ok(console::Key::Escape) => { spinner.cancel("Manual commit"); - cliclack::outro_cancel("Cancelled")?; + terminal.outro_cancel("Cancelled"); return Err(anyhow::anyhow!("Manual commit cancelled")); } Err(e) if e.kind() == std::io::ErrorKind::Interrupted => { spinner.error("Manual commit"); - cliclack::outro_cancel("Interrupted")?; + terminal.outro_cancel("Interrupted"); return Err(anyhow::anyhow!("Manual commit interrupted")); } _ => { diff --git a/crates/tracel-cli/src/commands/login.rs b/crates/tracel-cli/src/commands/login.rs index 919db88..56434c5 100644 --- a/crates/tracel-cli/src/commands/login.rs +++ b/crates/tracel-cli/src/commands/login.rs @@ -2,6 +2,8 @@ use tracel_client::console::{Client, Env, TracelCredentials}; use url::Url; use crate::context::{CliContext, ClientCreationError}; +use crate::error::{CliError, ErrorKind}; +use crate::output::OutputMode; pub fn get_client_and_login_if_needed(context: &CliContext) -> anyhow::Result { const MAX_RETRIES: u32 = 3; @@ -21,14 +23,22 @@ pub fn get_client_and_login_if_needed(context: &CliContext) -> anyhow::Result { if attempts > MAX_RETRIES { - return Err(anyhow::anyhow!("Maximum login attempts exceeded")); + return Err(CliError::new( + ErrorKind::NotAuthenticated, + "Maximum login attempts exceeded", + ) + .into()); } let env_msg = environment_suffix(&context.environment()); if !context.terminal().is_interactive() { - anyhow::bail!( - "Not logged in{}. Run 'tracel login' or set TRACEL_API_KEY.", - env_msg - ); + return Err(CliError::new( + ErrorKind::NotAuthenticated, + format!( + "Not logged in{}. Run 'tracel login' or set TRACEL_API_KEY.", + env_msg + ), + ) + .into()); } context.terminal().print_err(&format!( "Not logged in{}. Log in below, or press Ctrl+C to exit.", @@ -39,10 +49,11 @@ pub fn get_client_and_login_if_needed(context: &CliContext) -> anyhow::Result { if attempts > MAX_RETRIES { - return Err(anyhow::anyhow!( - "Server connection failed after maximum retries: {}", - msg - )); + return Err(CliError::new( + ErrorKind::Unavailable, + format!("Server connection failed after maximum retries: {}", msg), + ) + .into()); } context.terminal().print_err(&format!( "Failed to connect to the server: {msg}. Retrying..." @@ -68,11 +79,16 @@ fn log_in(context: &CliContext) -> anyhow::Result<()> { let device_auth = context.device_auth(); let authorization = device_auth.start()?; - terminal.print(&format!( + let instructions = format!( "Open {} and check that it shows the code {}.", terminal.format_url(&Url::parse(&authorization.verification_uri_complete)?), console::style(&authorization.user_code).bold() - )); + ); + if context.output() == OutputMode::Json { + eprintln!("{}", console::strip_ansi_codes(&instructions)); + } else { + terminal.print(&instructions); + } let spinner = terminal.spinner(); spinner.start("Waiting for approval... Press Ctrl+C to cancel."); diff --git a/crates/tracel-cli/src/commands/logout.rs b/crates/tracel-cli/src/commands/logout.rs index 9b97041..e2798ad 100644 --- a/crates/tracel-cli/src/commands/logout.rs +++ b/crates/tracel-cli/src/commands/logout.rs @@ -1,7 +1,7 @@ use crate::commands::login::environment_suffix; use crate::context::CliContext; -pub fn handle_command(context: CliContext) -> anyhow::Result<()> { +pub fn handle_command(context: CliContext) -> anyhow::Result { context.terminal().command_title("Logout"); let app_session = context.app_session()?; @@ -19,5 +19,6 @@ pub fn handle_command(context: CliContext) -> anyhow::Result<()> { .finalize(&format!("Nobody was logged in{}.", env_msg)); } - Ok(()) + Ok(json!({"logged_out": was_logged_in})) } +use serde_json::{Value, json}; diff --git a/crates/tracel-cli/src/commands/me.rs b/crates/tracel-cli/src/commands/me.rs index 8e69ce9..59d279e 100644 --- a/crates/tracel-cli/src/commands/me.rs +++ b/crates/tracel-cli/src/commands/me.rs @@ -1,10 +1,11 @@ use anyhow::Context; +use serde_json::{Value, json}; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use tracel_client::console::Env; -pub fn handle_command(context: CliContext) -> anyhow::Result<()> { +pub fn handle_command(context: CliContext) -> anyhow::Result { context.terminal().command_title("User Information"); let client = get_client_and_login_if_needed(&context)?; @@ -36,5 +37,11 @@ pub fn handle_command(context: CliContext) -> anyhow::Result<()> { .terminal() .finalize("User information retrieved successfully."); - Ok(()) + Ok(json!({ + "username": user.username, + "email": user.email, + "namespace": user.namespace, + "environment": context.environment_name(), + "api_url": context.get_api_endpoint(), + })) } diff --git a/crates/tracel-cli/src/commands/model.rs b/crates/tracel-cli/src/commands/model.rs index 26a60ac..389d6e4 100644 --- a/crates/tracel-cli/src/commands/model.rs +++ b/crates/tracel-cli/src/commands/model.rs @@ -116,7 +116,7 @@ fn upload_model_version(args: UploadModelArgs, context: CliContext) -> anyhow::R spinner.stop(format!("Allocated model version {}.", upload.version)); let tasks = build_part_tasks(&files, &file_sizes, &upload.files)?; - upload_parts(&client, tasks)?; + upload_parts(&client, tasks, context.terminal())?; client.complete_model_version_upload(&namespace, &project, &args.model_name, upload.version)?; diff --git a/crates/tracel-cli/src/commands/package.rs b/crates/tracel-cli/src/commands/package.rs index 5f73cb5..811a627 100644 --- a/crates/tracel-cli/src/commands/package.rs +++ b/crates/tracel-cli/src/commands/package.rs @@ -58,7 +58,7 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< .initial_value(true) .interact()? { - commit_sequence()?; + commit_sequence(context.terminal())?; } } @@ -317,7 +317,7 @@ fn upload( std::fs::read(&path).with_context(|| format!("Failed to read {}", path.display()))?; client.upload_bytes_to_url(url, bytes).map_err(|e| { spinner.error("Upload failed."); - anyhow::anyhow!("Failed to upload `{key}`: {e}") + anyhow::Error::new(e).context(format!("Failed to upload `{key}`")) })?; } spinner.stop("Artifacts uploaded."); diff --git a/crates/tracel-cli/src/commands/project.rs b/crates/tracel-cli/src/commands/project.rs index 829bfd9..3df4a22 100644 --- a/crates/tracel-cli/src/commands/project.rs +++ b/crates/tracel-cli/src/commands/project.rs @@ -1,10 +1,12 @@ use anyhow::Context; +use serde_json::{Value, json}; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; +use crate::error::{CliError, ErrorKind}; use crate::helpers::require_linked_project; -pub fn handle_command(context: CliContext) -> anyhow::Result<()> { +pub fn handle_command(context: CliContext) -> anyhow::Result { context.terminal().command_title("Project Information"); let project = require_linked_project()?; @@ -13,11 +15,17 @@ pub fn handle_command(context: CliContext) -> anyhow::Result<()> { let bc_project = project.get_project(); let project = match client.get_project(&bc_project.owner, &bc_project.name) { Ok(project) => project, - Err(e) if e.is_not_found() => anyhow::bail!( - "Project {}/{} not found on Tracel Console. Run 'tracel init --force' to link another project.", - bc_project.owner, - bc_project.name - ), + Err(e) if e.is_not_found() => { + return Err(CliError::new( + ErrorKind::NotFound, + format!( + "Project {}/{} not found on Tracel Console.", + bc_project.owner, bc_project.name + ), + ) + .with_hint("Run 'tracel init --force' to link another project.") + .into()); + } Err(e) => { return Err(e).with_context(|| { format!( @@ -35,5 +43,11 @@ pub fn handle_command(context: CliContext) -> anyhow::Result<()> { terminal.print(&format!("Created By: {}", project.created_by)); terminal.finalize("Project information retrieved successfully."); - Ok(()) + Ok(json!({ + "namespace": project.namespace_name, + "name": project.project_name, + "description": project.description, + "created_by": project.created_by, + "visibility": project.visibility, + })) } diff --git a/crates/tracel-cli/src/commands/unlink.rs b/crates/tracel-cli/src/commands/unlink.rs index b462bb2..0040567 100644 --- a/crates/tracel-cli/src/commands/unlink.rs +++ b/crates/tracel-cli/src/commands/unlink.rs @@ -1,8 +1,12 @@ use anyhow::Context; use clap::Args; +use serde_json::{Value, json}; use crate::{ - context::CliContext, helpers::require_linked_project, tools::project_context::ProjectContext, + context::CliContext, + error::{CliError, ErrorKind}, + helpers::require_linked_project, + tools::project_context::ProjectContext, }; #[derive(Args, Debug)] @@ -12,27 +16,45 @@ pub struct UnlinkArgs { pub yes: bool, } -pub fn handle_command(args: UnlinkArgs, context: CliContext) -> anyhow::Result<()> { +pub fn handle_command(args: UnlinkArgs, context: CliContext) -> anyhow::Result { let project = require_linked_project()?; context.terminal().command_title("Unlink"); if !args.yes { if !context.terminal().is_interactive() { - anyhow::bail!("Unlinking needs confirmation: pass --yes to unlink without a prompt."); + return Err(CliError::new( + ErrorKind::Usage, + "Unlinking needs confirmation: pass --yes to unlink without a prompt.", + ) + .into()); } - let confirmed = context.terminal().confirm( + let confirmed = match context.terminal().confirm( "Are you sure you want to unlink the Tracel Console project from this repository?", - )?; + ) { + Ok(confirmed) => confirmed, + Err(error) + if error + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::Interrupted) => + { + false + } + Err(error) => return Err(error), + }; if !confirmed { context.terminal().cancel_finalize("Cancelled"); - return Ok(()); + return Ok(json!({"unlinked": false})); } } ProjectContext::unlink(&project.get_manifest_path()).context("Failed to unlink project")?; context.terminal().finalize("Project unlinked successfully"); - Ok(()) + Ok(json!({ + "unlinked": true, + "namespace": project.get_project().owner, + "name": project.get_project().name, + })) } diff --git a/crates/tracel-cli/src/context.rs b/crates/tracel-cli/src/context.rs index 5ffd1af..99ccd75 100644 --- a/crates/tracel-cli/src/context.rs +++ b/crates/tracel-cli/src/context.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use crate::output::OutputMode; use crate::tools::terminal::Terminal; use tracel_client::ClientError; use tracel_client::console::auth::DeviceAuthClient; @@ -26,13 +27,15 @@ pub enum ClientCreationError { pub struct CliContext { terminal: Terminal, environment: Env, + output: OutputMode, } impl CliContext { - pub fn new(terminal: Terminal, environment: Env) -> Self { + pub fn new(terminal: Terminal, environment: Env, output: OutputMode) -> Self { Self { terminal, environment, + output, } } @@ -90,6 +93,18 @@ impl CliContext { self.environment.clone() } + pub fn output(&self) -> OutputMode { + self.output + } + + pub fn environment_name(&self) -> String { + match self.environment { + Env::Production => "production".to_string(), + Env::Development => "development".to_string(), + Env::Staging(version) => format!("staging-{version}"), + } + } + pub fn get_api_endpoint(&self) -> String { self.environment.get_url().to_string() } diff --git a/crates/tracel-cli/src/error.rs b/crates/tracel-cli/src/error.rs new file mode 100644 index 0000000..a9d77b5 --- /dev/null +++ b/crates/tracel-cli/src/error.rs @@ -0,0 +1,251 @@ +use serde::Serialize; +use tracel_client::ClientError; + +use crate::context::ClientCreationError; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ErrorKind { + Internal, + Usage, + NotAuthenticated, + Forbidden, + NotFound, + Conflict, + ConfirmationRequired, + LimitReached, + Unavailable, +} + +impl ErrorKind { + pub const fn exit_code(self) -> i32 { + match self { + Self::Internal => 1, + Self::Usage => 2, + Self::NotAuthenticated => 3, + Self::Forbidden => 4, + Self::NotFound => 5, + Self::Conflict => 6, + Self::ConfirmationRequired => 7, + Self::LimitReached => 8, + Self::Unavailable => 11, + } + } +} + +#[derive(Debug, thiserror::Error)] +#[error("{message}")] +pub struct CliError { + pub kind: ErrorKind, + message: String, + hint: Option, +} + +impl CliError { + pub fn new(kind: ErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + hint: None, + } + } + + pub fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } +} + +#[derive(Serialize)] +pub struct ErrorReport<'a> { + code: ErrorKind, + pub message: String, + pub hint: Option<&'a str>, + pub exit_code: i32, +} + +impl<'a> ErrorReport<'a> { + pub fn new(error: &'a anyhow::Error) -> Self { + let code = classify(error); + let hint = explicit_error(error).and_then(|error| error.hint.as_deref()); + Self { + code, + message: format!("{error:#}"), + hint, + exit_code: code.exit_code(), + } + } +} + +pub fn classify(error: &anyhow::Error) -> ErrorKind { + if let Some(error) = explicit_error(error) { + return error.kind; + } + + for cause in error.chain() { + if let Some(error) = cause.downcast_ref::() { + return match error { + ClientCreationError::NoCredentials + | ClientCreationError::InvalidCredentials + | ClientCreationError::ApiKeyRefused(_) => ErrorKind::NotAuthenticated, + ClientCreationError::ServerConnectionError(_) => ErrorKind::Unavailable, + ClientCreationError::SessionStore(error) => classify_client(error), + }; + } + if let Some(error) = cause.downcast_ref::() { + return classify_client(error); + } + } + ErrorKind::Internal +} + +fn explicit_error(error: &anyhow::Error) -> Option<&CliError> { + error.downcast_ref::().or_else(|| { + error + .chain() + .find_map(|cause| cause.downcast_ref::()) + }) +} + +fn classify_client(error: &ClientError) -> ErrorKind { + match error { + ClientError::Unauthenticated | ClientError::AppSessionEnded => ErrorKind::NotAuthenticated, + ClientError::CredentialNotAllowed => ErrorKind::Forbidden, + ClientError::NotFound | ClientError::NotFoundWithCode(_) => ErrorKind::NotFound, + ClientError::InternalServerError => ErrorKind::Unavailable, + ClientError::ApiError { status, .. } => match status.as_u16() { + 401 => ErrorKind::NotAuthenticated, + 403 => ErrorKind::Forbidden, + 404 => ErrorKind::NotFound, + 409 => ErrorKind::Conflict, + 429 | 500..=599 => ErrorKind::Unavailable, + _ => ErrorKind::Internal, + }, + _ => ErrorKind::Internal, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exit_codes_and_code_strings_are_stable() { + for (kind, code, exit) in [ + (ErrorKind::Internal, "INTERNAL", 1), + (ErrorKind::Usage, "USAGE", 2), + (ErrorKind::NotAuthenticated, "NOT_AUTHENTICATED", 3), + (ErrorKind::Forbidden, "FORBIDDEN", 4), + (ErrorKind::NotFound, "NOT_FOUND", 5), + (ErrorKind::Conflict, "CONFLICT", 6), + (ErrorKind::ConfirmationRequired, "CONFIRMATION_REQUIRED", 7), + (ErrorKind::LimitReached, "LIMIT_REACHED", 8), + (ErrorKind::Unavailable, "UNAVAILABLE", 11), + ] { + assert_eq!(kind.exit_code(), exit); + assert_eq!(serde_json::to_value(kind).unwrap(), code); + } + } + + #[test] + fn client_errors_survive_context() { + for (error, kind) in [ + (ClientError::Unauthenticated, ErrorKind::NotAuthenticated), + (ClientError::AppSessionEnded, ErrorKind::NotAuthenticated), + (ClientError::CredentialNotAllowed, ErrorKind::Forbidden), + (ClientError::NotFound, ErrorKind::NotFound), + ( + ClientError::NotFoundWithCode(tracel_client::ApiErrorCode::Unknown), + ErrorKind::NotFound, + ), + (ClientError::InternalServerError, ErrorKind::Unavailable), + ( + ClientError::UnknownError("unknown".into()), + ErrorKind::Internal, + ), + ( + ClientError::SessionStore("unreadable".into()), + ErrorKind::Internal, + ), + ] { + assert_eq!( + classify(&anyhow::Error::new(error).context("request failed")), + kind + ); + } + } + + #[test] + fn api_status_determines_kind() { + for (status, kind) in [ + (400, ErrorKind::Internal), + (401, ErrorKind::NotAuthenticated), + (403, ErrorKind::Forbidden), + (404, ErrorKind::NotFound), + (409, ErrorKind::Conflict), + (429, ErrorKind::Unavailable), + (500, ErrorKind::Unavailable), + (503, ErrorKind::Unavailable), + (599, ErrorKind::Unavailable), + (408, ErrorKind::Internal), + ] { + let error = ClientError::ApiError { + status: status.try_into().unwrap(), + body: Default::default(), + }; + assert_eq!( + classify(&anyhow::Error::new(error).context("request failed")), + kind + ); + } + } + + #[test] + fn creation_errors_keep_their_kind() { + for (error, kind) in [ + ( + ClientCreationError::NoCredentials, + ErrorKind::NotAuthenticated, + ), + ( + ClientCreationError::InvalidCredentials, + ErrorKind::NotAuthenticated, + ), + ( + ClientCreationError::ApiKeyRefused(ClientError::CredentialNotAllowed), + ErrorKind::NotAuthenticated, + ), + ( + ClientCreationError::ServerConnectionError("offline".into()), + ErrorKind::Unavailable, + ), + ( + ClientCreationError::SessionStore(ClientError::SessionStore("unreadable".into())), + ErrorKind::Internal, + ), + ] { + assert_eq!( + classify(&anyhow::Error::new(error).context("client failed")), + kind + ); + } + } + + #[test] + fn explicit_kind_and_hint_survive_context() { + let error = anyhow::Error::new(ClientError::NotFound) + .context( + CliError::new(ErrorKind::LimitReached, "limit reached") + .with_hint("Try again later."), + ) + .context("operation failed"); + let report = ErrorReport::new(&error); + assert_eq!(report.code, ErrorKind::LimitReached); + assert_eq!(report.hint, Some("Try again later.")); + assert_eq!(report.exit_code, 8); + assert_eq!( + classify(&anyhow::anyhow!("unclassified")), + ErrorKind::Internal + ); + } +} diff --git a/crates/tracel-cli/src/helpers/model_upload/existence.rs b/crates/tracel-cli/src/helpers/model_upload/existence.rs index 0614788..23658c9 100644 --- a/crates/tracel-cli/src/helpers/model_upload/existence.rs +++ b/crates/tracel-cli/src/helpers/model_upload/existence.rs @@ -15,7 +15,7 @@ pub fn ensure_model_exists( match client.get_model(namespace, project, model_name) { Ok(_) => return Ok(()), Err(e) if e.is_not_found() => {} - Err(e) => anyhow::bail!("Failed to check model '{model_name}': {e}"), + Err(e) => return Err(e).with_context(|| format!("Failed to check model '{model_name}'")), } let create = match auto_create { @@ -55,3 +55,4 @@ pub fn ensure_model_exists( Ok(()) } +use anyhow::Context; diff --git a/crates/tracel-cli/src/helpers/model_upload/upload.rs b/crates/tracel-cli/src/helpers/model_upload/upload.rs index 2a630c9..3034e19 100644 --- a/crates/tracel-cli/src/helpers/model_upload/upload.rs +++ b/crates/tracel-cli/src/helpers/model_upload/upload.rs @@ -6,6 +6,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::Duration; use tracel_client::ClientError; +use crate::tools::terminal::Terminal; + use super::parts::PartUploadTask; const UPLOAD_WORKER_COUNT: usize = 8; @@ -81,7 +83,7 @@ fn upload_chunk( } Err(e) => { cancelled.store(true, Ordering::Relaxed); - anyhow::bail!("{} (part {}): {e}", task.rel_path, task.part); + return Err(e.context(format!("{} (part {})", task.rel_path, task.part))); } } } @@ -92,6 +94,7 @@ fn upload_chunk( pub fn upload_parts( uploader: &impl PartUploader, tasks: Vec, + terminal: &Terminal, ) -> anyhow::Result<()> { let total_parts = tasks.len(); let worker_count = UPLOAD_WORKER_COUNT.min(total_parts.max(1)); @@ -100,11 +103,17 @@ pub fn upload_parts( let cancelled = AtomicBool::new(false); let completed = AtomicU64::new(0); - let multi = cliclack::multi_progress(format!( - "Uploading {total_parts} part(s) with {worker_count} worker(s)" - )); - let bar = multi.add(cliclack::ProgressBar::new(total_parts as u64).with_download_template()); - bar.start(format!("Uploading (0/{total_parts})")); + let message = format!("Uploading {total_parts} part(s) with {worker_count} worker(s)"); + let progress = if terminal.is_styled() { + let multi = cliclack::multi_progress(&message); + let bar = + multi.add(cliclack::ProgressBar::new(total_parts as u64).with_download_template()); + bar.start(format!("Uploading (0/{total_parts})")); + Some((multi, bar)) + } else { + terminal.print(&message); + None + }; let failure = std::thread::scope(|scope| { let handles: Vec<_> = chunks @@ -120,8 +129,10 @@ pub fn upload_parts( while handles.iter().any(|handle| !handle.is_finished()) { let done = completed.load(Ordering::Relaxed); if done > reported { - bar.inc(done - reported); - bar.set_message(format!("Uploading ({done}/{total_parts})")); + if let Some((_, bar)) = &progress { + bar.inc(done - reported); + bar.set_message(format!("Uploading ({done}/{total_parts})")); + } reported = done; } std::thread::sleep(Duration::from_millis(50)); @@ -141,17 +152,27 @@ pub fn upload_parts( }); let done = completed.load(Ordering::Relaxed); - bar.set_message(format!("Uploading ({done}/{total_parts})")); + if let Some((_, bar)) = &progress { + bar.set_message(format!("Uploading ({done}/{total_parts})")); + } match failure { Some(e) => { - bar.error(format!("Upload failed: {e}")); - multi.error("Model upload failed"); + if let Some((multi, bar)) = &progress { + bar.error(format!("Upload failed: {e}")); + multi.error("Model upload failed"); + } else { + terminal.print_err(&format!("Upload failed: {e}")); + } Err(e.context("Failed to upload model file part")) } None => { - bar.stop(format!("Uploaded {total_parts}/{total_parts}")); - multi.stop(); + if let Some((multi, bar)) = &progress { + bar.stop(format!("Uploaded {total_parts}/{total_parts}")); + multi.stop(); + } else { + terminal.print_success(&format!("Uploaded {total_parts}/{total_parts}")); + } Ok(()) } } @@ -224,7 +245,7 @@ mod tests { ]; let uploader = FakeUploader::new(None); - let result = upload_parts(&uploader, tasks); + let result = upload_parts(&uploader, tasks, &Terminal::default()); assert!(result.is_ok()); assert_eq!(uploader.calls.lock().unwrap().len(), 2); @@ -241,7 +262,7 @@ mod tests { ]; let uploader = FakeUploader::new(Some("https://example.com/b")); - let result = upload_parts(&uploader, tasks); + let result = upload_parts(&uploader, tasks, &Terminal::default()); assert!(result.is_err()); diff --git a/crates/tracel-cli/src/helpers/project.rs b/crates/tracel-cli/src/helpers/project.rs index b2f659c..2cc4f82 100644 --- a/crates/tracel-cli/src/helpers/project.rs +++ b/crates/tracel-cli/src/helpers/project.rs @@ -1,7 +1,10 @@ //! Project helpers for CLI operations +use anyhow::Context; + use crate::{ context::CliContext, + error::{CliError, ErrorKind as CliErrorKind}, tools::{ cargo::try_locate_manifest, project_context::{ErrorKind, ProjectContext, ProjectContextError}, @@ -117,18 +120,22 @@ pub fn validate_project_exists_on_server( context.terminal().print( "Run 'tracel init --force' to reinitialize and link to a different project.", ); - anyhow::bail!( - "Project {}/{} not found on Tracel Console", - bc_project.owner, - bc_project.name + Err(CliError::new( + CliErrorKind::NotFound, + format!( + "Project {}/{} not found on Tracel Console", + bc_project.owner, bc_project.name + ), ) + .with_hint("Run 'tracel init --force' to link another project.") + .into()) } Err(e) => { context.terminal().print_err(&format!( "Failed to verify project on Tracel Console: {}", e )); - anyhow::bail!("Failed to verify project exists on server: {}", e) + Err(e).context("Failed to verify project exists on server") } } } diff --git a/crates/tracel-cli/src/lib.rs b/crates/tracel-cli/src/lib.rs index fe40544..879ce38 100644 --- a/crates/tracel-cli/src/lib.rs +++ b/crates/tracel-cli/src/lib.rs @@ -1,4 +1,6 @@ pub mod cli; +pub mod error; +pub mod output; pub mod tools; mod commands; diff --git a/crates/tracel-cli/src/output.rs b/crates/tracel-cli/src/output.rs new file mode 100644 index 0000000..195c9eb --- /dev/null +++ b/crates/tracel-cli/src/output.rs @@ -0,0 +1,123 @@ +use std::io::Write; + +use clap::ValueEnum; +use serde::Serialize; + +use crate::error::{CliError, ErrorKind, ErrorReport}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] +pub enum OutputMode { + #[default] + Auto, + Human, + Json, +} + +impl OutputMode { + pub fn resolve( + flag: Option, + json: bool, + environment: Option<&str>, + stdout_is_terminal: bool, + ) -> Result { + let mode = if json { + Self::Json + } else if let Some(mode) = flag { + mode + } else { + match environment { + Some("human") => Self::Human, + Some("json") => Self::Json, + None => Self::Auto, + Some(value) => { + return Err(CliError::new( + ErrorKind::Usage, + format!("Invalid TRACEL_OUTPUT value '{value}': expected human or json."), + )); + } + } + }; + + Ok(match mode { + Self::Auto if stdout_is_terminal => Self::Human, + Self::Auto => Self::Json, + mode => mode, + }) + } +} + +#[derive(Serialize)] +struct Success<'a, T> { + ok: bool, + data: &'a T, +} + +#[derive(Serialize)] +struct Failure<'a> { + ok: bool, + error: &'a ErrorReport<'a>, +} + +pub fn write_success(data: &impl Serialize) -> anyhow::Result<()> { + write_line(&Success { ok: true, data }) +} + +pub fn write_failure(error: &ErrorReport<'_>) -> anyhow::Result<()> { + write_line(&Failure { ok: false, error }) +} + +fn write_line(value: &impl Serialize) -> anyhow::Result<()> { + let line = serde_json::to_string(value)?; + let mut stdout = std::io::stdout().lock(); + writeln!(stdout, "{line}")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auto_uses_stdout_terminal_status() { + assert_eq!( + OutputMode::resolve(None, false, None, true).unwrap(), + OutputMode::Human + ); + assert_eq!( + OutputMode::resolve(None, false, None, false).unwrap(), + OutputMode::Json + ); + } + + #[test] + fn flags_override_environment() { + for environment in [Some("human"), Some("json"), Some("invalid")] { + assert_eq!( + OutputMode::resolve(Some(OutputMode::Human), false, environment, false).unwrap(), + OutputMode::Human + ); + assert_eq!( + OutputMode::resolve(None, true, environment, true).unwrap(), + OutputMode::Json + ); + assert_eq!( + OutputMode::resolve(Some(OutputMode::Auto), false, environment, false).unwrap(), + OutputMode::Json + ); + } + } + + #[test] + fn environment_selects_mode_or_reports_usage() { + assert_eq!( + OutputMode::resolve(None, false, Some("human"), false).unwrap(), + OutputMode::Human + ); + assert_eq!( + OutputMode::resolve(None, false, Some("json"), true).unwrap(), + OutputMode::Json + ); + let error = OutputMode::resolve(None, false, Some("auto"), true).unwrap_err(); + assert_eq!(error.kind, ErrorKind::Usage); + } +} diff --git a/crates/tracel-cli/src/tools/target.rs b/crates/tracel-cli/src/tools/target.rs index ad9d761..74ee38e 100644 --- a/crates/tracel-cli/src/tools/target.rs +++ b/crates/tracel-cli/src/tools/target.rs @@ -92,6 +92,7 @@ pub fn add_target(triple: &str) -> anyhow::Result<()> { .arg("target") .arg("add") .arg(triple) + .stdout(Stdio::from(std::io::stderr())) .status() .with_context(|| { format!("Failed to run `rustup target add {triple}` (is rustup installed?)") diff --git a/crates/tracel-cli/src/tools/terminal.rs b/crates/tracel-cli/src/tools/terminal.rs index 7d1141f..ab3215c 100644 --- a/crates/tracel-cli/src/tools/terminal.rs +++ b/crates/tracel-cli/src/tools/terminal.rs @@ -1,9 +1,11 @@ -use std::io::IsTerminal; +use std::{fmt::Display, io::IsTerminal}; use cliclack::{ProgressBar, clear_screen, confirm}; use colored::CustomColor; +use crate::output::OutputMode; + #[allow(dead_code)] pub const BURN_ORANGE: CustomColor = CustomColor { r: 254, @@ -11,38 +13,99 @@ pub const BURN_ORANGE: CustomColor = CustomColor { b: 0, }; -#[derive(Clone, Default)] -pub struct Terminal {} +#[derive(Clone)] +pub struct Terminal { + output: OutputMode, +} + +impl Default for Terminal { + fn default() -> Self { + Self::new(OutputMode::Human) + } +} impl Terminal { + pub fn new(output: OutputMode) -> Self { + Self { output } + } + + fn is_human(&self) -> bool { + self.output != OutputMode::Json + } + + pub(crate) fn is_styled(&self) -> bool { + self.is_human() && std::io::stderr().is_terminal() + } + pub fn print_warning(&self, message: &str) { - cliclack::log::warning(message).expect("To be able to print remark"); + if self.is_styled() { + cliclack::log::warning(message).expect("To be able to print remark"); + } else if self.is_human() { + eprintln!("warning: {}", console::strip_ansi_codes(message)); + } } pub fn print(&self, message: &str) { - cliclack::log::info(message).expect("To be able to print message"); + if self.is_styled() { + cliclack::log::info(message).expect("To be able to print message"); + } else if self.is_human() { + eprintln!("{}", console::strip_ansi_codes(message)); + } } pub fn print_err(&self, message: &str) { - cliclack::log::error(message).expect("To be able to print message"); + if self.is_styled() { + cliclack::log::error(message).expect("To be able to print message"); + } else if self.is_human() { + eprintln!("error: {}", console::strip_ansi_codes(message)); + } } pub fn print_success(&self, message: &str) { - cliclack::log::success(message).expect("To be able to print success message"); + if self.is_styled() { + cliclack::log::success(message).expect("To be able to print success message"); + } else if self.is_human() { + eprintln!("{}", console::strip_ansi_codes(message)); + } + } + + pub fn step(&self, message: &str) { + if self.is_styled() { + cliclack::log::step(message).expect("To be able to print message"); + } else { + self.print(message); + } + } + + pub fn outro_cancel(&self, message: &str) { + if self.is_styled() { + cliclack::outro_cancel(message).expect("To be able to print message"); + } else { + self.print_err(message); + } } - pub fn spinner(&self) -> ProgressBar { - cliclack::spinner() + pub fn spinner(&self) -> TerminalSpinner { + TerminalSpinner { + bar: self.is_styled().then(cliclack::spinner), + terminal: self.clone(), + } } #[allow(dead_code)] pub fn clear(&self) { - clear_screen().expect("Failed to clear screen"); + if self.is_styled() { + clear_screen().expect("Failed to clear screen"); + } } /// A URL styled for stderr, plain when stderr is not a terminal or `NO_COLOR` is set. pub fn format_url(&self, url: &url::Url) -> String { - console::style(url).for_stderr().blue().bold().to_string() + if self.is_styled() { + console::style(url).for_stderr().blue().bold().to_string() + } else { + url.to_string() + } } pub fn confirm(&self, message: &str) -> anyhow::Result { @@ -50,19 +113,29 @@ impl Terminal { } pub fn command_title(&self, title: &str) { - let title = format!(" {} {} ", "▶", title); - cliclack::intro(console::style(title).black().on_green()) - .expect("To be able to print title"); + if self.is_styled() { + let title = format!(" {} {} ", "▶", title); + cliclack::intro(console::style(title).black().on_green()) + .expect("To be able to print title"); + } } pub fn finalize(&self, msg: &str) { - cliclack::outro(console::style(format!(" {} ", msg)).black().on_green()) - .expect("To be able to print message"); + if self.is_styled() { + cliclack::outro(console::style(format!(" {} ", msg)).black().on_green()) + .expect("To be able to print message"); + } else { + self.print_success(msg); + } } pub fn cancel_finalize(&self, msg: &str) { - cliclack::outro_cancel(console::style(format!(" {} ", msg)).black().on_red()) - .expect("To be able to print message"); + if self.is_styled() { + cliclack::outro_cancel(console::style(format!(" {} ", msg)).black().on_red()) + .expect("To be able to print message"); + } else { + self.print_err(msg); + } } /// Whether a person can answer prompts: they read from stdin and draw on stderr. @@ -70,3 +143,51 @@ impl Terminal { std::io::stdin().is_terminal() && std::io::stderr().is_terminal() } } + +#[derive(Clone)] +pub struct TerminalSpinner { + bar: Option, + terminal: Terminal, +} + +impl TerminalSpinner { + pub fn start(&self, message: impl Display) { + if let Some(bar) = &self.bar { + bar.start(message); + } else { + self.terminal.print(&message.to_string()); + } + } + + pub fn set_message(&self, message: impl Display) { + if let Some(bar) = &self.bar { + bar.set_message(message); + } else { + self.terminal.print(&message.to_string()); + } + } + + pub fn stop(&self, message: impl Display) { + if let Some(bar) = &self.bar { + bar.stop(message); + } else { + self.terminal.print_success(&message.to_string()); + } + } + + pub fn error(&self, message: impl Display) { + if let Some(bar) = &self.bar { + bar.error(message); + } else { + self.terminal.print_err(&message.to_string()); + } + } + + pub fn cancel(&self, message: impl Display) { + if let Some(bar) = &self.bar { + bar.cancel(message); + } else { + self.terminal.print_err(&message.to_string()); + } + } +} From f37879360f64ae3b3d42624fbb5fbe71f09d0518 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 6 Oct 2026 16:18:26 -0400 Subject: [PATCH 03/32] feat: never prompt without a terminal - Prompts only appear when stdin and stderr are terminals, the output is not JSON, `CI` is unset and `--no-input` is not passed. Otherwise a missing answer is a usage error naming the flag to pass. - Every prompt has a flag: `init --owner --name --description --yes --commit --allow-dirty`, `package --mode --target --bin --install-targets --commit`, and `model upload --auto-create`. - `init` no longer waits for a manual commit, makes no request when the repository is already linked, and fails with a conflict when asked to link a different project without `--force`. - `init`, `package`, `model upload` and `login` return structured data. - Project setup errors carry their kind: no Cargo project is a usage error and an unlinked repository is not found. --- README.md | 45 ++ crates/tracel-cli/src/cli.rs | 30 +- crates/tracel-cli/src/commands/init.rs | 405 +++++++++++++----- crates/tracel-cli/src/commands/login.rs | 8 +- crates/tracel-cli/src/commands/model.rs | 25 +- crates/tracel-cli/src/commands/package.rs | 204 +++++++-- crates/tracel-cli/src/commands/unlink.rs | 30 +- .../src/helpers/model_upload/existence.rs | 35 +- crates/tracel-cli/src/helpers/project.rs | 32 +- crates/tracel-cli/src/tools/target.rs | 112 ++++- crates/tracel-cli/src/tools/terminal.rs | 142 +++++- 11 files changed, 825 insertions(+), 243 deletions(-) diff --git a/README.md b/README.md index f01a90a..1f3d9db 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,10 @@ All commands accept `--json` or `-o, --output `. The default `TRACEL_OUTPUT=human` or `TRACEL_OUTPUT=json` sets the format when no output flag is given. `--json` and `--output` cannot be combined. +All commands also accept `--no-input` to disable prompts. Prompts require stdin +and stderr to be terminals and human output. Missing input in scripts produces +`USAGE` with a hint naming the required flag. + JSON results are one line on stdout: `{"ok":true,"data":{...}}` on success, or `{"ok":false,"error":{"code":"NOT_FOUND","message":"...","hint":null,"exit_code":5}}` on failure. Diagnostics go to stderr. Help and version output retain their normal @@ -95,10 +99,23 @@ Package your project for deployment on remote compute providers. ```bash tracel package +# Source packaging without prompts +tracel package --mode source --allow-dirty --json +# Build selected binaries and install missing targets without prompts +tracel package --mode binary --target x86_64-unknown-linux-gnu --bin trainer --install-targets --commit --json ``` This creates a deployable artifact containing your code, dependencies, and configurations. +`--mode ` is required without prompts. In binary mode, repeat +`--target ` to choose targets; without prompts, omitting it builds for the +host. Use `--bin ` when several binaries are built. `--install-targets` +installs missing Rust targets without asking. `--commit` commits all current +changes before packaging; `--allow-dirty` continues with uncommitted changes. +These two flags cannot be combined. The code version digest remains the current +commit hash. JSON data contains `namespace`, `project`, `digest`, `version_id`, +`mode`, `targets`, and `uploaded` (false when the commit was already packaged). + ### `tracel login` Authenticate with the Console platform. The CLI prints a link and a code to @@ -135,8 +152,36 @@ Initialize or reinitialize a Tracel project in the current directory. ```bash # Interactive initialization tracel init +# Initialize without prompts, accepting a project that already exists +tracel init --owner my-namespace --name my-project --description "" --yes --allow-dirty --json ``` +`--owner ` must name your own namespace or one of your organizations. +`--name ` accepts alphanumeric characters, underscores, and hyphens. +`--description ` supplies the new project's description; without a terminal +it defaults to empty. These flags replace the corresponding prompts. `--yes` +links an existing project without asking. `--commit` commits all current changes, +including the first commit if needed; `--allow-dirty` continues without committing +when a commit already exists. These two flags cannot be combined. A dirty +repository needs one of them without prompts. `--force` reinitializes an already +linked project; without it, asking for a different project fails with `CONFLICT`. +JSON data contains `namespace`, `name`, `created`, and `url`; `created` is false +when an existing project is linked, and `url` is null when nothing changed. + +### `tracel model upload` + +Upload a directory as a new version of a model. + +```bash +tracel model upload my-model --directory ./weights --auto-create true --description "Model weights" --json +``` + +`--namespace` and `--project` override the linked project. `--auto-create true` +creates a missing model without asking; without prompts, a missing model needs +this flag. `--auto-create false` requires an existing model. `--description ` +sets the new model's description and requires `--auto-create true`. JSON data +contains `namespace`, `project`, `model`, `version`, `files`, and `bytes`. + ### `tracel unlink` Unlink the current directory from Tracel project. `--yes` skips the confirmation. diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index 84596fa..105e0c4 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -24,6 +24,10 @@ pub struct CliArgs { #[arg(long, global = true, conflicts_with = "output")] pub json: bool, + /// Never prompt; fail when input is needed + #[arg(long, global = true)] + pub no_input: bool, + /// Use development environment (localhost:9001) with separate dev credentials #[arg(long, global = true, action = clap::ArgAction::SetTrue, hide = true, conflicts_with = "staging")] pub dev: bool, @@ -129,7 +133,7 @@ pub fn cli_main() { Env::Production }; - let terminal = Terminal::new(mode); + let terminal = Terminal::new(mode).with_no_input(args.no_input); if args.dev { terminal @@ -220,21 +224,15 @@ fn handle_command(command: Commands, context: CliContext) -> anyhow::Result { commands::training::handle_command(run_args, context).map(|()| json!({})) } - Commands::Package(package_args) => { - commands::package::handle_command(package_args, context).map(|()| json!({})) - } - Commands::Login => commands::login::handle_command(context).map(|()| json!({})), + Commands::Package(package_args) => commands::package::handle_command(package_args, context), + Commands::Login => commands::login::handle_command(context), Commands::Logout => commands::logout::handle_command(context), Commands::Auth(auth_args) => commands::auth::handle_command(auth_args, context), - Commands::Init(init_args) => { - commands::init::handle_command(init_args, context).map(|()| json!({})) - } + Commands::Init(init_args) => commands::init::handle_command(init_args, context), Commands::Unlink(unlink_args) => commands::unlink::handle_command(unlink_args, context), Commands::Me => commands::me::handle_command(context), Commands::Project => commands::project::handle_command(context), - Commands::Model(model_args) => { - commands::model::handle_command(model_args, context).map(|()| json!({})) - } + Commands::Model(model_args) => commands::model::handle_command(model_args, context), } } @@ -259,4 +257,14 @@ mod tests { let error = CliArgs::try_parse_from(["tracel", "me", "--json", "-o", "json"]).unwrap_err(); assert_eq!(error.exit_code(), 2); } + + #[test] + fn no_input_is_global() { + for arguments in [ + ["tracel", "--no-input", "unlink"], + ["tracel", "unlink", "--no-input"], + ] { + assert!(CliArgs::try_parse_from(arguments).unwrap().no_input); + } + } } diff --git a/crates/tracel-cli/src/commands/init.rs b/crates/tracel-cli/src/commands/init.rs index 172f77e..5f6449d 100644 --- a/crates/tracel-cli/src/commands/init.rs +++ b/crates/tracel-cli/src/commands/init.rs @@ -1,11 +1,13 @@ use crate::context::CliContext; -use crate::helpers::{can_initialize_project, require_cargo_workspace}; +use crate::error::{CliError, ErrorKind}; +use crate::helpers::{can_initialize_project, require_cargo_workspace, require_linked_project}; use crate::tools::git; use crate::tools::project_context::ProjectContext; use crate::tools::terminal::Terminal; use crate::tools::tracel_config::TracelProject; use anyhow::Context; use clap::Args; +use serde_json::{Value, json}; use tracel_client::console::Client; use tracel_client::console::project::request::Visibility; use tracel_client::console::project::response::ProjectResponse; @@ -15,18 +17,61 @@ pub struct InitArgs { /// Force reinitialization of the project #[arg(long, short = 'f')] pub force: bool, + /// Namespace of your user or one of your organizations + #[arg(long, value_name = "NAMESPACE")] + pub owner: Option, + /// Project name (alphanumeric characters, underscores, and hyphens) + #[arg(long, value_name = "PROJECT")] + pub name: Option, + /// Description for a new project (use an empty string for none) + #[arg(long, value_name = "TEXT")] + pub description: Option, + /// Link an existing project without asking for confirmation + #[arg(long, short = 'y')] + pub yes: bool, + /// Commit all current changes, creating the first commit if needed + #[arg(long, conflicts_with = "allow_dirty")] + pub commit: bool, + /// Continue with uncommitted changes + #[arg(long)] + pub allow_dirty: bool, } -pub fn handle_command(args: InitArgs, context: CliContext) -> anyhow::Result<()> { +pub fn handle_command(args: InitArgs, context: CliContext) -> anyhow::Result { + if let Some(name) = &args.name { + validate_project_name(name).map_err(|message| { + CliError::new(ErrorKind::Usage, message).with_hint("Pass --name .") + })?; + } if !can_initialize_project(&context, args.force)? { - return Ok(()); + let project = require_linked_project()?; + let linked = project.get_project(); + let other_owner = args.owner.as_deref().is_some_and(|o| o != linked.owner); + let other_name = args.name.as_deref().is_some_and(|n| n != linked.name); + if other_owner || other_name { + return Err(CliError::new( + ErrorKind::Conflict, + format!( + "This repository is already linked to {}/{}.", + linked.owner, linked.name + ), + ) + .with_hint("Pass --force to link it to another project.") + .into()); + } + return Ok(json!({ + "namespace": linked.owner, + "name": linked.name, + "created": false, + "url": null, + })); } let client = super::login::get_client_and_login_if_needed(&context)?; - prompt_init(&context, &client).context("Failed to initialize the project") + prompt_init(args, &context, &client).context("Failed to initialize the project") } -pub fn prompt_init(context: &CliContext, client: &Client) -> anyhow::Result<()> { +pub fn prompt_init(args: InitArgs, context: &CliContext, client: &Client) -> anyhow::Result { let user = client.get_current_user()?; let workspace_info = require_cargo_workspace()?; @@ -35,7 +80,7 @@ pub fn prompt_init(context: &CliContext, client: &Client) -> anyhow::Result<()> let terminal = context.terminal(); ensure_git_repo_initialized(&workspace_info.get_ws_root(), terminal)?; - ensure_git_repo_clean(terminal)?; + ensure_git_repo_clean(terminal, args.commit, args.allow_dirty)?; let first_commit_hash = git::get_first_commit_hash(); if let Err(e) = first_commit_hash { @@ -46,85 +91,153 @@ pub fn prompt_init(context: &CliContext, client: &Client) -> anyhow::Result<()> } let _first_commit_hash = first_commit_hash?; - let project_owner = prompt_owner_name(&user.username, client)?; - let project_name = prompt_project_name(&workspace_info.workspace_name)?; + let project_owner = prompt_owner_name( + &user.username, + &user.namespace, + args.owner.as_deref(), + client, + terminal, + )?; + let project_name = match args.name { + Some(name) => name, + None => prompt_project_name(&workspace_info.workspace_name, terminal)?, + }; let owner_name = match &project_owner { ProjectKind::User => user.namespace.as_str(), ProjectKind::Organization(org_name) => org_name.as_str(), }; - let project_info = match client.get_project(owner_name, &project_name) { - Ok(project) => handle_existing_project(&project, terminal)?, - Err(e) if e.is_not_found() => { - create_new_project(client, project_owner.clone(), &project_name, terminal)? - } + let (project_info, created) = match client.get_project(owner_name, &project_name) { + Ok(project) => ( + handle_existing_project(&project, terminal, args.yes)?, + false, + ), + Err(e) if e.is_not_found() => ( + create_new_project( + client, + project_owner.clone(), + &project_name, + args.description, + terminal, + )?, + true, + ), Err(e) => { terminal.cancel_finalize(&format!("Failed to check for existing project: {e}")); return Err(anyhow::anyhow!(e)); } }; - ProjectContext::init(project_info, &workspace_info.get_manifest_path()).map_err(|e| { - terminal.cancel_finalize(&format!("Failed to initialize project metadata: {}", e)); - e - })?; + ProjectContext::init(project_info.clone(), &workspace_info.get_manifest_path()).map_err( + |e| { + terminal.cancel_finalize(&format!("Failed to initialize project metadata: {}", e)); + e + }, + )?; terminal.print("Created project metadata"); - let url_path = match &project_owner { - ProjectKind::User => format!("/users/{}/projects/{}", user.username, project_name), - ProjectKind::Organization(org_name) => { - format!("/orgs/{}/projects/{}", org_name, project_name) - } - }; - let frontend_url = context - .get_frontend_endpoint() - .join(&url_path) - .expect("Should be able to construct frontend URL"); + let frontend_url = project_url(context, &user.username, &project_owner, &project_name)?; terminal.finalize(&format!( "Project initialized successfully! You can check out your project at {}", context.terminal().format_url(&frontend_url) )); - Ok(()) + Ok(json!({ + "namespace": project_info.owner, + "name": project_info.name, + "created": created, + "url": frontend_url.as_str(), + })) } -fn prompt_owner_name(user_name: &str, client: &Client) -> anyhow::Result { +fn prompt_owner_name( + user_name: &str, + user_namespace: &str, + owner: Option<&str>, + client: &Client, + terminal: &Terminal, +) -> anyhow::Result { let organizations = client.get_user_organizations()?; + let mut valid_values = vec![user_namespace]; + valid_values.extend( + organizations + .organizations + .iter() + .map(|org| org.namespace.as_str()), + ); + if let Some(owner) = owner { + validate_owner(owner, &valid_values)?; + return Ok(if owner == user_namespace { + ProjectKind::User + } else { + ProjectKind::Organization(owner.to_string()) + }); + } let mut namespaces = vec![(ProjectKind::User, format!("[user] {user_name}"), "")]; - namespaces.extend(organizations.organizations.into_iter().map(|org| { + namespaces.extend(organizations.organizations.iter().map(|org| { ( ProjectKind::Organization(org.namespace.clone()), format!("[org] {}", org.name), "", ) })); - cliclack::select("Select the owner of the project") - .items(&namespaces) - .initial_value(ProjectKind::User) - .interact() - .map_err(anyhow::Error::from) + terminal.select( + "Select the owner of the project", + "owner", + &namespaces, + Some(ProjectKind::User), + &valid_values, + ) } -pub fn prompt_project_name(workspace_name: &str) -> anyhow::Result { - let input = cliclack::input(format!( - "Enter the project name (default: {}) ", - console::style(workspace_name).bold() - )) - .placeholder(workspace_name) - .required(false) - .validate(|input: &String| { - if input.is_empty() - || input - .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '-') - { - Ok(()) - } else { - Err("Project name must be alphanumeric or contain underscores only.".to_string()) - } - }) - .interact::()?; +fn validate_owner(owner: &str, valid_values: &[&str]) -> Result<(), CliError> { + if valid_values.contains(&owner) { + Ok(()) + } else { + Err(CliError::new( + ErrorKind::Usage, + format!( + "Invalid --owner '{owner}'. Valid values: {}.", + valid_values.join(", ") + ), + ) + .with_hint("Pass --owner using one of the valid values.")) + } +} + +fn validate_project_name(name: &str) -> Result<(), String> { + if name.is_empty() { + Err("Project name cannot be empty.".to_string()) + } else if name + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') + { + Ok(()) + } else { + Err( + "Project name must contain only alphanumeric characters, underscores, or hyphens." + .to_string(), + ) + } +} + +pub fn prompt_project_name(workspace_name: &str, terminal: &Terminal) -> anyhow::Result { + let input = terminal.input_validated( + &format!( + "Enter the project name (default: {}) ", + console::style(workspace_name).bold() + ), + "name", + workspace_name, + |input| { + if input.is_empty() { + Ok(()) + } else { + validate_project_name(input) + } + }, + )?; let input = if input.is_empty() { workspace_name.to_string() @@ -132,18 +245,24 @@ pub fn prompt_project_name(workspace_name: &str) -> anyhow::Result { input }; + validate_project_name(&input).map_err(|message| CliError::new(ErrorKind::Usage, message))?; Ok(input) } fn handle_existing_project( project: &ProjectResponse, terminal: &Terminal, + yes: bool, ) -> anyhow::Result { - let confirmed = cliclack::confirm(format!( - "Project \"{}\" already exists under owner \"{}\". Do you want to link it?", - project.project_name, project.namespace_name - )) - .interact()?; + let confirmed = yes + || terminal.confirm( + &format!( + "Project \"{}\" already exists under owner \"{}\". Do you want to link it?", + project.project_name, project.namespace_name + ), + "yes", + false, + )?; if confirmed { Ok(TracelProject { @@ -162,15 +281,34 @@ enum ProjectKind { Organization(String), } +fn project_url( + context: &CliContext, + username: &str, + owner: &ProjectKind, + name: &str, +) -> anyhow::Result { + let path = match owner { + ProjectKind::User => format!("/users/{username}/projects/{name}"), + ProjectKind::Organization(namespace) => format!("/orgs/{namespace}/projects/{name}"), + }; + Ok(context.get_frontend_endpoint().join(&path)?) +} + fn create_new_project( client: &Client, project_kind: ProjectKind, name: &str, + description: Option, terminal: &Terminal, ) -> anyhow::Result { - let description = cliclack::input("Enter the project description (default empty)") - .required(false) - .interact::()?; + let description = match description { + Some(description) => description, + None if terminal.is_interactive() => terminal.input( + "Enter the project description (default empty)", + "description", + )?, + None => String::new(), + }; let desc = if description.is_empty() { None } else { @@ -213,34 +351,59 @@ pub fn ensure_git_repo_initialized( Ok(()) } -pub fn ensure_git_repo_clean(terminal: &Terminal) -> anyhow::Result<()> { - match git::is_repo_dirty() { - Ok(false) => Ok(()), - Ok(true) => { - terminal.print( - "Repository is dirty. Tracel Console needs a valid commit hash to associated your code with your repository.", - ); - commit_sequence(terminal).context("Failed to make initial commit") - } - Err(e) if e.to_string().contains("does not have any commits") => { - terminal.print( - "Repository is dirty. Please commit or stash your changes before proceeding.", - ); - commit_sequence(terminal).context("Failed to make initial commit") - } - Err(_) => Err(anyhow::anyhow!( - "Failed to check if the repository is dirty." - )), +pub fn ensure_git_repo_clean( + terminal: &Terminal, + commit: bool, + allow_dirty: bool, +) -> anyhow::Result<()> { + let has_commit = git::get_last_commit_hash().is_ok(); + let dirty = match git::is_repo_dirty() { + Ok(dirty) => dirty, + Err(_) if !has_commit => true, + Err(error) => return Err(error).context("Failed to check if the repository is dirty"), + }; + if !dirty && has_commit { + return Ok(()); } + if commit { + return commit_sequence(terminal, true); + } + if allow_dirty && has_commit { + return Ok(()); + } + if allow_dirty { + return Err( + CliError::new(ErrorKind::Usage, "The repository needs an initial commit.") + .with_hint("Pass --commit to create the first commit.") + .into(), + ); + } + if !terminal.is_interactive() { + return Err(commit_required().into()); + } + terminal.print( + "Repository is dirty. Tracel Console needs a valid commit hash to associate your code with your repository.", + ); + commit_sequence(terminal, false).context("Failed to make initial commit") +} + +fn commit_required() -> CliError { + CliError::new(ErrorKind::Usage, "The repository has uncommitted changes.") + .with_hint("Commit your changes, or pass --commit or --allow-dirty") } -pub fn commit_sequence(terminal: &Terminal) -> anyhow::Result<()> { - let do_commit = - cliclack::confirm("Do you want to automatically commit all files?").interact()?; - if do_commit { +pub fn commit_sequence(terminal: &Terminal, commit: bool) -> anyhow::Result<()> { + if commit + || terminal.confirm( + "Do you want to automatically commit all files?", + "commit", + false, + )? + { let commit_message = "Automatic commit by Tracel Console CLI"; let status = std::process::Command::new("git") .args(["add", "--all"]) + .stdin(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .status() @@ -248,8 +411,13 @@ pub fn commit_sequence(terminal: &Terminal) -> anyhow::Result<()> { if !status.success() { return Err(anyhow::anyhow!("Failed to add files to git")); } - let status = std::process::Command::new("git") - .args(["commit", "-m", commit_message]) + let mut command = std::process::Command::new("git"); + command.args(["commit", "-m", commit_message]); + if git::get_last_commit_hash().is_err() { + command.arg("--allow-empty"); + } + let status = command + .stdin(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .status() @@ -259,40 +427,39 @@ pub fn commit_sequence(terminal: &Terminal) -> anyhow::Result<()> { } terminal.print_success("Committed all files to git."); } else { - let spinner = terminal.spinner(); - let message = format!( - "{}\n{}\n\n{}", - console::style("Waiting for manual commit").bold(), - console::style("Press Esc, Enter, or Ctrl-C").dim(), - console::style( - "Please make a commit before proceeding. Press Enter to continue or Esc to cancel." - ) - .magenta() - .italic() - ); - spinner.start(message); - let term = console::Term::stderr(); - loop { - match term.read_key() { - Ok(console::Key::Escape) => { - spinner.cancel("Manual commit"); - terminal.outro_cancel("Cancelled"); - return Err(anyhow::anyhow!("Manual commit cancelled")); - } - Err(e) if e.kind() == std::io::ErrorKind::Interrupted => { - spinner.error("Manual commit"); - terminal.outro_cancel("Interrupted"); - return Err(anyhow::anyhow!("Manual commit interrupted")); - } - _ => { - if let Ok(false) | Err(_) = git::is_repo_dirty() { - spinner.stop("Manual commit"); - break; - } - } - } - } + return Err(commit_required().into()); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn owner_must_match_a_namespace() { + let valid = ["user-namespace", "org-namespace"]; + for owner in valid { + assert!(validate_owner(owner, &valid).is_ok()); + } + let error = validate_owner("display-name", &valid).unwrap_err(); + assert_eq!(error.kind, ErrorKind::Usage); + assert!(error.to_string().contains("user-namespace, org-namespace")); + } + + #[test] + fn project_name_allows_alphanumeric_underscores_and_hyphens() { + for name in ["project", "Project123", "my_project-1", "modèle"] { + assert!(validate_project_name(name).is_ok()); + } + for name in ["", "my project", "project.name", "project/name"] { + assert!(validate_project_name(name).is_err()); + } + assert!( + validate_project_name("project.name") + .unwrap_err() + .contains("hyphens") + ); + } +} diff --git a/crates/tracel-cli/src/commands/login.rs b/crates/tracel-cli/src/commands/login.rs index 56434c5..e6f6ab9 100644 --- a/crates/tracel-cli/src/commands/login.rs +++ b/crates/tracel-cli/src/commands/login.rs @@ -1,3 +1,4 @@ +use serde_json::{Value, json}; use tracel_client::console::{Client, Env, TracelCredentials}; use url::Url; @@ -102,7 +103,7 @@ fn log_in(context: &CliContext) -> anyhow::Result<()> { Ok(()) } -pub fn handle_command(context: CliContext) -> anyhow::Result<()> { +pub fn handle_command(context: CliContext) -> anyhow::Result { context.terminal().command_title("Login"); log_in(&context)?; @@ -121,5 +122,8 @@ pub fn handle_command(context: CliContext) -> anyhow::Result<()> { environment_suffix(&context.environment()) )); - Ok(()) + Ok(json!({ + "username": client.user().username, + "environment": context.environment_name(), + })) } diff --git a/crates/tracel-cli/src/commands/model.rs b/crates/tracel-cli/src/commands/model.rs index 389d6e4..ffe5d90 100644 --- a/crates/tracel-cli/src/commands/model.rs +++ b/crates/tracel-cli/src/commands/model.rs @@ -1,11 +1,13 @@ use std::path::PathBuf; use clap::{Args, Subcommand}; +use serde_json::{Value, json}; use tracel_client::console::model::request::{ ModelFileSpecRequest, RequestModelVersionUploadRequest, }; use crate::context::CliContext; +use crate::error::{CliError, ErrorKind}; use crate::helpers::{ build_part_tasks, ensure_model_exists, resolve_namespace_project, upload_parts, }; @@ -36,8 +38,7 @@ pub struct UploadModelArgs { /// Tracel Console project name. Defaults to the linked project's name. #[arg(long, short)] pub project: Option, - /// Automatically create the model if it doesn't exist yet (true/false). - /// If omitted, you'll be prompted interactively. + /// Create a missing model (true/false); otherwise ask when interactive. #[arg(long, short)] pub auto_create: Option, /// Description to use when auto-creating the model. Requires --auto-create true. @@ -45,15 +46,19 @@ pub struct UploadModelArgs { pub description: Option, } -pub fn handle_command(args: ModelArgs, context: CliContext) -> anyhow::Result<()> { +pub fn handle_command(args: ModelArgs, context: CliContext) -> anyhow::Result { match args.command { ModelCommands::Upload(upload_args) => upload_model_version(upload_args, context), } } -fn upload_model_version(args: UploadModelArgs, context: CliContext) -> anyhow::Result<()> { +fn upload_model_version(args: UploadModelArgs, context: CliContext) -> anyhow::Result { if args.description.is_some() && args.auto_create != Some(true) { - anyhow::bail!("--description can only be used together with --auto-create true."); + return Err(CliError::new( + ErrorKind::Usage, + "--description can only be used together with --auto-create true.", + ) + .into()); } context.terminal().command_title("Model upload"); @@ -93,6 +98,7 @@ fn upload_model_version(args: UploadModelArgs, context: CliContext) -> anyhow::R .iter() .map(|f| (f.rel_path.clone(), f.size_bytes)) .collect(); + let bytes: u64 = file_sizes.values().sum(); let spinner = context.terminal().spinner(); spinner.start("Requesting upload URLs..."); @@ -128,5 +134,12 @@ fn upload_model_version(args: UploadModelArgs, context: CliContext) -> anyhow::R .terminal() .finalize("Model version uploaded successfully."); - Ok(()) + Ok(json!({ + "namespace": namespace, + "project": project, + "model": args.model_name, + "version": upload.version, + "files": files.len(), + "bytes": bytes, + })) } diff --git a/crates/tracel-cli/src/commands/package.rs b/crates/tracel-cli/src/commands/package.rs index 811a627..e7a4c73 100644 --- a/crates/tracel-cli/src/commands/package.rs +++ b/crates/tracel-cli/src/commands/package.rs @@ -3,7 +3,8 @@ use std::process::Stdio; use std::sync::Arc; use anyhow::Context; -use clap::Args; +use clap::{Args, ValueEnum}; +use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use tracel_client::console::Client; use tracel_client::console::project::request::{ @@ -14,6 +15,7 @@ use tracel_client::console::project::request::{ use crate::commands::init::commit_sequence; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; +use crate::error::{CliError, ErrorKind}; use crate::helpers::{require_linked_project, validate_project_exists_on_server}; use crate::tools::build_driver::{self, BuildDriver}; use crate::tools::packager::{PackageEvent, package_workspace}; @@ -25,10 +27,25 @@ pub struct PackageArgs { /// Package even if the git repository has uncommitted changes (skips the commit prompt). #[arg(long, action)] pub allow_dirty: bool, + /// Package a compiled binary or source (required without prompts) + #[arg(long, value_enum)] + pub mode: Option, + /// Rust target triple to build (repeatable; binary mode only) + #[arg(long = "target", value_name = "TRIPLE")] + pub targets: Vec, + /// Name of the binary to upload when several are built + #[arg(long, value_name = "NAME")] + pub bin: Option, + /// Install missing Rust targets without asking + #[arg(long)] + pub install_targets: bool, + /// Commit all current changes before packaging + #[arg(long, conflicts_with = "allow_dirty")] + pub commit: bool, } -#[derive(Clone, Copy, PartialEq, Eq)] -enum Mode { +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum Mode { Binary, Source, } @@ -39,9 +56,28 @@ enum Mode { struct PreparedArtifact { request: PublishArtifactRequest, uploads: Vec<(String, PathBuf)>, + targets: Vec, } -pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result<()> { +pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result { + if args.mode.is_none() && !context.terminal().is_interactive() { + return Err(CliError::new( + ErrorKind::Usage, + "Missing --mode. Valid values: binary, source.", + ) + .with_hint("Pass --mode binary or --mode source.") + .into()); + } + if args.mode == Some(Mode::Source) && !args.targets.is_empty() { + return Err(CliError::new( + ErrorKind::Usage, + "--target is only valid with --mode binary.", + ) + .into()); + } + for triple in &args.targets { + target::parse_target(triple)?; + } context.terminal().command_title("Package project"); // 0. Ensure we have auth and a linked project that exists on the server. @@ -50,15 +86,35 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< validate_project_exists_on_server(&context, &project, &client)?; // 1. Dirty check — warn and offer to commit, but allow proceeding. - if git::is_repo_dirty()? && !args.allow_dirty { + let has_commit = git::get_last_commit_hash().is_ok(); + if !has_commit && !args.commit && !context.terminal().is_interactive() { + return Err(CliError::new( + ErrorKind::Usage, + "The repository needs at least one commit to package.", + ) + .with_hint("Commit your changes, or pass --commit to create the first commit.") + .into()); + } + if args.commit { + if !has_commit || git::is_repo_dirty()? { + commit_sequence(context.terminal(), true)?; + } + } else if git::is_repo_dirty()? && !args.allow_dirty { context .terminal() .print_warning("Your repository has uncommitted changes."); - if cliclack::confirm("Commit changes before packaging?") - .initial_value(true) - .interact()? + if !context.terminal().is_interactive() { + return Err( + CliError::new(ErrorKind::Usage, "The repository has uncommitted changes.") + .with_hint("Commit your changes, or pass --commit or --allow-dirty") + .into(), + ); + } + if context + .terminal() + .confirm("Commit changes before packaging?", "commit", true)? { - commit_sequence(context.terminal())?; + commit_sequence(context.terminal(), false)?; } } @@ -73,28 +129,42 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< } // 3. Choose how to package. - let mode = cliclack::select("How would you like to package your code?") - .items(&[ - ( - Mode::Binary, - "Binary (more secure)", - "ship a compiled binary; your source is not uploaded", - ), - ( - Mode::Source, - "Source (more portable)", - "upload source; it is built on the compute provider", - ), - ]) - .interact()?; + let mode = match args.mode { + Some(mode) => mode, + None => context.terminal().select( + "How would you like to package your code?", + "mode", + &[ + ( + Mode::Binary, + "Binary (more secure)", + "ship a compiled binary; your source is not uploaded", + ), + ( + Mode::Source, + "Source (more portable)", + "upload source; it is built on the compute provider", + ), + ], + None, + &["binary", "source"], + )?, + }; + if mode == Mode::Source && !args.targets.is_empty() { + return Err(CliError::new( + ErrorKind::Usage, + "--target is only valid with --mode binary.", + ) + .into()); + } let artifact = match mode { Mode::Source => build_source_artifact(&context, &project)?, - Mode::Binary => build_binary_artifact(&context, &project)?, + Mode::Binary => build_binary_artifact(&context, &project, &args)?, }; // 4. Upload. - upload(&context, &client, &project, &digest, artifact) + upload(&context, &client, &project, &digest, mode, artifact) } fn build_source_artifact( @@ -124,17 +194,19 @@ fn build_source_artifact( }, }, uploads: vec![("source.zip".to_string(), result.path)], + targets: Vec::new(), }) } fn build_binary_artifact( context: &CliContext, project: &ProjectContext, + args: &PackageArgs, ) -> anyhow::Result { let host = target::host_target()?; let installed = target::installed_targets(); - let selected = target::prompt_targets(host, &installed)?; + let selected = target::select_targets(context.terminal(), &args.targets, host, &installed)?; // rustup preflight: offer to install any selected cross target whose std is missing. let missing: Vec<&str> = selected @@ -143,14 +215,14 @@ fn build_binary_artifact( .map(|&(os, arch)| target::target_triple(os, arch)) .filter(|triple| !installed.contains(*triple)) .collect(); - target::install_missing_target(missing)?; + target::install_missing_target(context.terminal(), missing, args.install_targets)?; let root = project.get_workspace_root(); let drivers = build_driver::detect(); let mut binaries = Vec::new(); let mut uploads = Vec::new(); - for (os, arch) in selected { + for &(os, arch) in &selected { let triple = target::target_triple(os, arch); let is_host = (os, arch) == host; let driver = if is_host { @@ -168,7 +240,13 @@ fn build_binary_artifact( build_driver::cross_preflight(context.terminal(), root, host, (os, arch), driver)? }; - let path = build_release_binary(context, (!is_host).then_some(triple), driver, linker)?; + let path = build_release_binary( + context, + (!is_host).then_some(triple), + driver, + linker, + args.bin.as_deref(), + )?; let (checksum, size) = sha256_and_size(&path)?; binaries.push(PublishBinaryRequest { os, @@ -182,6 +260,10 @@ fn build_binary_artifact( Ok(PreparedArtifact { request: PublishArtifactRequest::Binaries { binaries }, uploads, + targets: selected + .iter() + .map(|&(os, arch)| target::target_triple(os, arch).to_string()) + .collect(), }) } @@ -192,6 +274,7 @@ fn build_release_binary( target: Option<&str>, driver: BuildDriver, linker: Option<&str>, + bin: Option<&str>, ) -> anyhow::Result { let mut cmd_label = match target { Some(triple) => format!("{} --release --target {triple}", driver.label()), @@ -227,7 +310,7 @@ fn build_release_binary( anyhow::bail!("`{cmd_label}` failed"); } - let mut executables: Vec = Vec::new(); + let mut executables: Vec<(String, PathBuf)> = Vec::new(); for line in output.stdout.split(|&b| b == b'\n') { if line.is_empty() { continue; @@ -235,32 +318,53 @@ fn build_release_binary( if let Ok(msg) = serde_json::from_slice::(line) { if msg.get("reason").and_then(|r| r.as_str()) == Some("compiler-artifact") { if let Some(exe) = msg.get("executable").and_then(|e| e.as_str()) { - executables.push(PathBuf::from(exe)); + let name = msg["target"]["name"].as_str().ok_or_else(|| { + anyhow::anyhow!("Cargo did not return a name for binary {exe}") + })?; + executables.push((name.to_string(), PathBuf::from(exe))); } } } } + let names: Vec<&str> = executables.iter().map(|(name, _)| name.as_str()).collect(); + if let Some(bin) = bin { + return executables + .iter() + .find(|(name, _)| name == bin) + .map(|(_, path)| path.clone()) + .ok_or_else(|| { + CliError::new( + ErrorKind::Usage, + format!("Invalid --bin '{bin}'. Valid values: {}.", names.join(", ")), + ) + .with_hint("Pass --bin using one of the valid values.") + .into() + }); + } match executables.len() { 0 => anyhow::bail!("The build did not produce any binary target."), - 1 => Ok(executables.into_iter().next().unwrap()), + 1 => Ok(executables[0].1.clone()), _ => { let items: Vec<(PathBuf, String, &str)> = executables .iter() - .map(|p| { + .map(|(_, path)| { ( - p.clone(), - p.file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| p.display().to_string()), + path.clone(), + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()), "", ) }) .collect(); - cliclack::select("Multiple binaries were built. Select which to upload") - .items(&items) - .interact() - .map_err(anyhow::Error::from) + context.terminal().select( + "Multiple binaries were built. Select which to upload", + "bin", + &items, + None, + &names, + ) } } } @@ -277,8 +381,9 @@ fn upload( client: &Client, project: &ProjectContext, digest: &str, + mode: Mode, prepared: PreparedArtifact, -) -> anyhow::Result<()> { +) -> anyhow::Result { let bc_project = project.get_project(); let response = client @@ -297,13 +402,24 @@ fn upload( ) })?; + let data = |uploaded| { + json!({ + "namespace": bc_project.owner, + "project": bc_project.name, + "digest": digest, + "version_id": response.id, + "mode": match mode { Mode::Binary => "binary", Mode::Source => "source" }, + "targets": prepared.targets, + "uploaded": uploaded, + }) + }; let Some(urls) = response.urls else { context.terminal().print_success(&format!( "This commit ({digest}) is already packaged (version {}).", response.id )); context.terminal().finalize("Nothing to upload."); - return Ok(()); + return Ok(data(false)); }; let spinner = context.terminal().spinner(); @@ -337,5 +453,5 @@ fn upload( context .terminal() .finalize("Project packaged successfully."); - Ok(()) + Ok(data(true)) } diff --git a/crates/tracel-cli/src/commands/unlink.rs b/crates/tracel-cli/src/commands/unlink.rs index 0040567..463d280 100644 --- a/crates/tracel-cli/src/commands/unlink.rs +++ b/crates/tracel-cli/src/commands/unlink.rs @@ -3,10 +3,7 @@ use clap::Args; use serde_json::{Value, json}; use crate::{ - context::CliContext, - error::{CliError, ErrorKind}, - helpers::require_linked_project, - tools::project_context::ProjectContext, + context::CliContext, helpers::require_linked_project, tools::project_context::ProjectContext, }; #[derive(Args, Debug)] @@ -18,31 +15,14 @@ pub struct UnlinkArgs { pub fn handle_command(args: UnlinkArgs, context: CliContext) -> anyhow::Result { let project = require_linked_project()?; - context.terminal().command_title("Unlink"); if !args.yes { - if !context.terminal().is_interactive() { - return Err(CliError::new( - ErrorKind::Usage, - "Unlinking needs confirmation: pass --yes to unlink without a prompt.", - ) - .into()); - } - - let confirmed = match context.terminal().confirm( + let confirmed = context.terminal().confirm( "Are you sure you want to unlink the Tracel Console project from this repository?", - ) { - Ok(confirmed) => confirmed, - Err(error) - if error - .downcast_ref::() - .is_some_and(|error| error.kind() == std::io::ErrorKind::Interrupted) => - { - false - } - Err(error) => return Err(error), - }; + "yes", + false, + )?; if !confirmed { context.terminal().cancel_finalize("Cancelled"); return Ok(json!({"unlinked": false})); diff --git a/crates/tracel-cli/src/helpers/model_upload/existence.rs b/crates/tracel-cli/src/helpers/model_upload/existence.rs index 23658c9..66ef679 100644 --- a/crates/tracel-cli/src/helpers/model_upload/existence.rs +++ b/crates/tracel-cli/src/helpers/model_upload/existence.rs @@ -1,7 +1,9 @@ +use anyhow::Context; use tracel_client::console::Client; use tracel_client::console::model::request::CreateModelRequest; use crate::context::CliContext; +use crate::error::{CliError, ErrorKind}; pub fn ensure_model_exists( context: &CliContext, @@ -18,11 +20,24 @@ pub fn ensure_model_exists( Err(e) => return Err(e).with_context(|| format!("Failed to check model '{model_name}'")), } + if !context.terminal().is_interactive() && auto_create != Some(true) { + return Err(CliError::new( + ErrorKind::Usage, + format!("Model '{model_name}' does not exist in {namespace}/{project}."), + ) + .with_hint("Pass --auto-create true to create it") + .into()); + } + let create = match auto_create { Some(create) => create, - None => context.terminal().confirm(&format!( - "Model '{model_name}' does not exist in {namespace}/{project}. Create it now?" - ))?, + None => context.terminal().confirm( + &format!( + "Model '{model_name}' does not exist in {namespace}/{project}. Create it now?" + ), + "auto-create", + false, + )?, }; if !create { @@ -31,12 +46,13 @@ pub fn ensure_model_exists( let description = match description { Some(description) => Some(description), - None if auto_create.is_none() => cliclack::input("Enter model description (optional)") - .required(false) - .interact::() - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()), + None if auto_create.is_none() => { + let description = context + .terminal() + .input("Enter model description (optional)", "description")?; + let description = description.trim().to_string(); + (!description.is_empty()).then_some(description) + } None => None, }; @@ -55,4 +71,3 @@ pub fn ensure_model_exists( Ok(()) } -use anyhow::Context; diff --git a/crates/tracel-cli/src/helpers/project.rs b/crates/tracel-cli/src/helpers/project.rs index 2cc4f82..dc3dda9 100644 --- a/crates/tracel-cli/src/helpers/project.rs +++ b/crates/tracel-cli/src/helpers/project.rs @@ -15,9 +15,12 @@ use tracel_client::console::Client; pub fn find_manifest() -> anyhow::Result { try_locate_manifest().ok_or_else(|| { - anyhow::anyhow!( - "Could not locate Cargo.toml manifest. Please run this command inside a Burn project directory." + CliError::new( + CliErrorKind::Usage, + "Could not locate a Cargo.toml manifest.", ) + .with_hint("Run this command inside a Burn project directory.") + .into() }) } @@ -32,12 +35,21 @@ pub fn is_tracel_project_linked() -> bool { /// One error that says what went wrong, why, and what to do next. fn explain_project_context_error(e: ProjectContextError) -> anyhow::Error { - let hint = match e.kind() { - ErrorKind::ManifestNotFound => "Run this command from a Rust project directory.", - ErrorKind::ProjectNotLinked => "Run 'tracel init' to link it.", - ErrorKind::Parsing => "Check that Cargo.toml and tracel.toml are valid.", - ErrorKind::ProjectInitialization => "Re-link the project with 'tracel init --force'.", - ErrorKind::Unexpected => "Check your project setup.", + let (kind, hint) = match e.kind() { + ErrorKind::ManifestNotFound => ( + CliErrorKind::Usage, + "Run this command from a Rust project directory.", + ), + ErrorKind::ProjectNotLinked => (CliErrorKind::NotFound, "Run 'tracel init' to link it."), + ErrorKind::Parsing => ( + CliErrorKind::Usage, + "Check that Cargo.toml and tracel.toml are valid.", + ), + ErrorKind::ProjectInitialization => ( + CliErrorKind::Internal, + "Re-link the project with 'tracel init --force'.", + ), + ErrorKind::Unexpected => (CliErrorKind::Internal, "Check your project setup."), }; let mut message = e.to_string(); @@ -47,7 +59,9 @@ fn explain_project_context_error(e: ProjectContextError) -> anyhow::Error { cause = err.source(); } - anyhow::anyhow!("{message}. {hint}") + CliError::new(kind, format!("{message}.")) + .with_hint(hint) + .into() } /// Require a linked Tracel Console project. diff --git a/crates/tracel-cli/src/tools/target.rs b/crates/tracel-cli/src/tools/target.rs index 74ee38e..299eead 100644 --- a/crates/tracel-cli/src/tools/target.rs +++ b/crates/tracel-cli/src/tools/target.rs @@ -7,6 +7,9 @@ use anyhow::Context; use colored::Colorize; use tracel_client::console::project::request::{Arch, Os}; +use crate::error::{CliError, ErrorKind}; +use crate::tools::terminal::Terminal; + /// Every (os, arch) target we offer to build for, in canonical display order. /// The host is surfaced separately and pulled to the front by `prompt_targets`. /// macOS x86_64 (Intel) is intentionally omitted — we don't support it. @@ -31,6 +34,51 @@ pub fn target_triple(os: Os, arch: Arch) -> &'static str { } } +pub fn parse_target(triple: &str) -> Result<(Os, Arch), CliError> { + ALL_TARGETS + .iter() + .copied() + .find(|&(os, arch)| target_triple(os, arch) == triple) + .ok_or_else(|| { + let valid = ALL_TARGETS + .iter() + .map(|&(os, arch)| target_triple(os, arch)) + .collect::>() + .join(", "); + CliError::new( + ErrorKind::Usage, + format!("Invalid --target '{triple}'. Valid values: {valid}."), + ) + .with_hint("Pass --target using one of the valid values.") + }) +} + +fn resolve_targets(host: (Os, Arch), requested: &[String]) -> Result, CliError> { + if requested.is_empty() { + return Ok(vec![host]); + } + let mut targets = Vec::new(); + for triple in requested { + let target = parse_target(triple)?; + if !targets.contains(&target) { + targets.push(target); + } + } + Ok(targets) +} + +pub fn select_targets( + terminal: &Terminal, + requested: &[String], + host: (Os, Arch), + installed: &HashSet, +) -> anyhow::Result> { + if !requested.is_empty() || !terminal.is_interactive() { + return resolve_targets(host, requested).map_err(anyhow::Error::from); + } + prompt_targets(terminal, host, installed) +} + /// Human-friendly name for an (os, arch) pair, e.g. "Linux x86_64". fn pretty_name(os: Os, arch: Arch) -> &'static str { match (os, arch) { @@ -92,6 +140,7 @@ pub fn add_target(triple: &str) -> anyhow::Result<()> { .arg("target") .arg("add") .arg(triple) + .stdin(Stdio::null()) .stdout(Stdio::from(std::io::stderr())) .status() .with_context(|| { @@ -107,6 +156,7 @@ pub fn add_target(triple: &str) -> anyhow::Result<()> { /// first and labelled "(this machine)"; cross targets not installed via rustup are /// dimmed and annotated with the `rustup target add` command to install them. pub fn prompt_targets( + terminal: &Terminal, host: (Os, Arch), installed: &HashSet, ) -> anyhow::Result> { @@ -143,22 +193,29 @@ pub fn prompt_targets( }) .collect(); - cliclack::multiselect("Select the target(s) to build for (space to toggle, enter to confirm)") - .items(&items) - .initial_values(vec![host]) - .required(true) - .interact() - .map_err(anyhow::Error::from) + terminal.multiselect( + "Select the target(s) to build for (space to toggle, enter to confirm)", + "target", + &items, + vec![host], + ) } -pub fn install_missing_target(missing: Vec<&str>) -> anyhow::Result<()> { +pub fn install_missing_target( + terminal: &Terminal, + missing: Vec<&str>, + install_targets: bool, +) -> anyhow::Result<()> { if !missing.is_empty() { let list = missing.join(", "); - if cliclack::confirm(format!( - "These targets are not installed: {list}. Run `rustup target add` for them now?" - )) - .initial_value(true) - .interact()? + if install_targets + || terminal.confirm( + &format!( + "These targets are not installed: {list}. Run `rustup target add` for them now?" + ), + "install-targets", + true, + )? { for triple in &missing { add_target(triple)?; @@ -176,3 +233,34 @@ pub fn install_missing_target(missing: Vec<&str>) -> anyhow::Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_explicit_targets_defaults_to_host() { + for host in ALL_TARGETS { + assert_eq!(resolve_targets(host, &[]).unwrap(), vec![host]); + } + } + + #[test] + fn explicit_targets_are_validated_and_deduplicated_in_order() { + let host = (Os::Linux, Arch::X86_64); + let requested = [ + "aarch64-unknown-linux-gnu".to_string(), + "x86_64-pc-windows-msvc".to_string(), + "aarch64-unknown-linux-gnu".to_string(), + ]; + assert_eq!( + resolve_targets(host, &requested).unwrap(), + vec![(Os::Linux, Arch::Arm64), (Os::Windows, Arch::X86_64),] + ); + let error = parse_target("unknown-triple").unwrap_err(); + assert_eq!(error.kind, ErrorKind::Usage); + for (os, arch) in ALL_TARGETS { + assert!(error.to_string().contains(target_triple(os, arch))); + } + } +} diff --git a/crates/tracel-cli/src/tools/terminal.rs b/crates/tracel-cli/src/tools/terminal.rs index ab3215c..b33abd1 100644 --- a/crates/tracel-cli/src/tools/terminal.rs +++ b/crates/tracel-cli/src/tools/terminal.rs @@ -1,9 +1,10 @@ use std::{fmt::Display, io::IsTerminal}; -use cliclack::{ProgressBar, clear_screen, confirm}; +use cliclack::{ProgressBar, clear_screen}; use colored::CustomColor; +use crate::error::{CliError, ErrorKind}; use crate::output::OutputMode; #[allow(dead_code)] @@ -16,6 +17,7 @@ pub const BURN_ORANGE: CustomColor = CustomColor { #[derive(Clone)] pub struct Terminal { output: OutputMode, + no_input: bool, } impl Default for Terminal { @@ -26,7 +28,15 @@ impl Default for Terminal { impl Terminal { pub fn new(output: OutputMode) -> Self { - Self { output } + Self { + output, + no_input: false, + } + } + + pub fn with_no_input(mut self, no_input: bool) -> Self { + self.no_input = no_input; + self } fn is_human(&self) -> bool { @@ -108,8 +118,77 @@ impl Terminal { } } - pub fn confirm(&self, message: &str) -> anyhow::Result { - confirm(message).interact().map_err(anyhow::Error::from) + fn require_input(&self, message: &str, flag: &str, values: &[&str]) -> anyhow::Result<()> { + if !self.is_interactive() { + let mut message = format!("Input needed: {}", message.trim()); + if !values.is_empty() { + message.push_str(&format!(" Valid values: {}.", values.join(", "))); + } + return Err(CliError::new(ErrorKind::Usage, message) + .with_hint(format!("Pass --{flag} to answer without a prompt.")) + .into()); + } + Ok(()) + } + + pub fn confirm(&self, message: &str, flag: &str, initial: bool) -> anyhow::Result { + self.require_input(message, flag, &[])?; + cliclack::confirm(message) + .initial_value(initial) + .interact() + .map_err(anyhow::Error::from) + } + + pub fn input(&self, message: &str, flag: &str) -> anyhow::Result { + self.input_validated(message, flag, "", |_| Ok(())) + } + + pub fn input_validated( + &self, + message: &str, + flag: &str, + placeholder: &str, + validate: impl Fn(&String) -> Result<(), String> + 'static, + ) -> anyhow::Result { + self.require_input(message, flag, &[])?; + cliclack::input(message) + .placeholder(placeholder) + .required(false) + .validate(validate) + .interact() + .map_err(anyhow::Error::from) + } + + pub fn select( + &self, + message: &str, + flag: &str, + items: &[(T, impl Display, impl Display)], + initial: Option, + values: &[&str], + ) -> anyhow::Result { + self.require_input(message, flag, values)?; + let mut prompt = cliclack::select(message).items(items); + if let Some(initial) = initial { + prompt = prompt.initial_value(initial); + } + prompt.interact().map_err(anyhow::Error::from) + } + + pub fn multiselect( + &self, + message: &str, + flag: &str, + items: &[(T, impl Display, impl Display)], + initial: Vec, + ) -> anyhow::Result> { + self.require_input(message, flag, &[])?; + cliclack::multiselect(message) + .items(items) + .initial_values(initial) + .required(true) + .interact() + .map_err(anyhow::Error::from) } pub fn command_title(&self, title: &str) { @@ -140,7 +219,60 @@ impl Terminal { /// Whether a person can answer prompts: they read from stdin and draw on stderr. pub fn is_interactive(&self) -> bool { - std::io::stdin().is_terminal() && std::io::stderr().is_terminal() + let ci = std::env::var_os("CI"); + allows_input( + std::io::stdin().is_terminal(), + std::io::stderr().is_terminal(), + self.output, + ci.as_ref().map(|value| value.to_str().unwrap_or("true")), + self.no_input, + ) + } +} + +fn allows_input( + stdin_is_terminal: bool, + stderr_is_terminal: bool, + output: OutputMode, + ci: Option<&str>, + no_input: bool, +) -> bool { + stdin_is_terminal + && stderr_is_terminal + && output != OutputMode::Json + && matches!(ci, None | Some("" | "0" | "false")) + && !no_input +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interactivity_requires_terminals_human_output_and_allowed_environment() { + for ci in [None, Some(""), Some("0"), Some("false")] { + for output in [OutputMode::Auto, OutputMode::Human, OutputMode::Json] { + for stdin in [false, true] { + for stderr in [false, true] { + for no_input in [false, true] { + assert_eq!( + allows_input(stdin, stderr, output, ci, no_input), + stdin && stderr && output != OutputMode::Json && !no_input + ); + } + } + } + } + } + for ci in ["1", "true", "yes", "FALSE", " "] { + assert!(!allows_input( + true, + true, + OutputMode::Human, + Some(ci), + false + )); + } } } From 681e7d29d6896177eb13e9383d99d657a577af60 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 6 Oct 2026 16:19:29 -0400 Subject: [PATCH 04/32] feat: resolve the project and environment like the SDK - `--project /` overrides `TRACEL_NAMESPACE` and `TRACEL_PROJECT`, which override tracel.toml, each part falling back on its own. `project` and `model upload` work outside a Cargo project, and `project` reports where the identity came from. - `model upload` drops its own `-n` and `-p` flags for `--project`. - `TRACEL_ENV` selects the environment when no flag does. - `train` passes the resolved project to the program. - `-C ` runs as if started in that directory. --- README.md | 16 +- crates/tracel-cli/src/cli.rs | 200 +++++++++++++++- crates/tracel-cli/src/commands/model.rs | 10 +- crates/tracel-cli/src/commands/package.rs | 14 +- crates/tracel-cli/src/commands/project.rs | 7 +- crates/tracel-cli/src/commands/training.rs | 4 + crates/tracel-cli/src/context.rs | 14 +- crates/tracel-cli/src/helpers/project.rs | 255 ++++++++++++++++++++- 8 files changed, 483 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 1f3d9db..d069ade 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,18 @@ All commands also accept `--no-input` to disable prompts. Prompts require stdin and stderr to be terminals and human output. Missing input in scripts produces `USAGE` with a hint naming the required flag. +Select a project with global `--project /`. It takes precedence +over `TRACEL_NAMESPACE` and `TRACEL_PROJECT`, which each independently fall back +to `tracel.toml` at the Cargo workspace root. `project` and `model upload` work +from any directory with a flag or both variables; `package` still requires a +Cargo workspace. Global `-C ` runs as if started in that directory. `init` +and `unlink` operate on `tracel.toml` and ignore project overrides. + +```bash +tracel --project alice/demo project --json +TRACEL_NAMESPACE=alice TRACEL_PROJECT=demo tracel -C ./trainer project --json +``` + JSON results are one line on stdout: `{"ok":true,"data":{...}}` on success, or `{"ok":false,"error":{"code":"NOT_FOUND","message":"...","hint":null,"exit_code":5}}` on failure. Diagnostics go to stderr. Help and version output retain their normal @@ -173,10 +185,10 @@ when an existing project is linked, and `url` is null when nothing changed. Upload a directory as a new version of a model. ```bash -tracel model upload my-model --directory ./weights --auto-create true --description "Model weights" --json +tracel --project alice/demo model upload my-model --directory ./weights --auto-create true --description "Model weights" --json ``` -`--namespace` and `--project` override the linked project. `--auto-create true` +Global `--project /` selects the destination. `--auto-create true` creates a missing model without asking; without prompts, a missing model needs this flag. `--auto-create false` requires an existing model. `--description ` sets the new model's description and requires `--auto-create true`. JSON data diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index 105e0c4..bbf03a7 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -7,8 +7,10 @@ use tracel_client::console::Env; use crate::commands; use crate::context::CliContext; use crate::error::{CliError, ErrorKind, ErrorReport}; +use crate::helpers::project::parse_project; use crate::output::{self, OutputMode}; use crate::tools::terminal::Terminal; +use crate::tools::tracel_config::TracelProject; #[derive(Parser, Debug)] #[clap(name = "tracel", author, version, about, long_about = None)] @@ -28,6 +30,14 @@ pub struct CliArgs { #[arg(long, global = true)] pub no_input: bool, + /// Tracel Console project to use + #[arg(long, global = true, value_name = "NAMESPACE/NAME", value_parser = parse_project)] + pub project: Option, + + /// Run as if started in this directory + #[arg(short = 'C', global = true, value_name = "DIR")] + pub working_directory: Option, + /// Use development environment (localhost:9001) with separate dev credentials #[arg(long, global = true, action = clap::ArgAction::SetTrue, hide = true, conflicts_with = "staging")] pub dev: bool, @@ -89,6 +99,21 @@ pub fn cli_main() { } }; + if let Some(directory) = &args.working_directory { + if let Err(error) = std::env::set_current_dir(directory) { + let mode = usage_output_mode(std::env::args_os().skip(1)); + let error = CliError::new( + ErrorKind::Usage, + format!( + "Cannot change directory to '{}': {error}", + directory.display() + ), + ); + report_error(&error.into(), mode, &Terminal::new(mode)); + return; + } + } + let Some(command) = args.command else { // A reader that closes early (`tracel | head`) is not an error. let _ = CliArgs::command().print_help(); @@ -125,22 +150,24 @@ pub fn cli_main() { mode }; - let environment = if args.dev { - Env::Development - } else if let Some(version) = args.staging { - Env::Staging(version) - } else { - Env::Production - }; - let terminal = Terminal::new(mode).with_no_input(args.no_input); + let environment_value = + std::env::var_os("TRACEL_ENV").map(|value| value.to_string_lossy().into_owned()); + let environment = + match resolve_environment(args.dev, args.staging, environment_value.as_deref()) { + Ok(environment) => environment, + Err(error) => { + report_error(&error.into(), mode, &terminal); + return; + } + }; if args.dev { terminal .print_warning("Running in development mode - using local server and dev credentials"); } - let context = CliContext::new(terminal.clone(), environment, mode); + let context = CliContext::new(terminal.clone(), environment, mode, args.project); let result = handle_command(command, context).and_then(|data| { if mode == OutputMode::Json { @@ -153,6 +180,35 @@ pub fn cli_main() { } } +fn resolve_environment( + dev: bool, + version: Option, + value: Option<&str>, +) -> Result { + if dev { + return Ok(Env::Development); + } + if let Some(version) = version { + return Ok(Env::Staging(version)); + } + match value { + None | Some("Production" | "production") => Ok(Env::Production), + Some("Development" | "development") => Ok(Env::Development), + Some(value) => value + .strip_prefix("Staging(") + .and_then(|number| number.strip_suffix(')')) + .or_else(|| value.strip_prefix("staging-")) + .and_then(|number| number.parse::().ok()) + .map(Env::Staging) + .ok_or_else(|| { + CliError::new( + ErrorKind::Usage, + format!("Invalid TRACEL_ENV value '{value}'."), + ) + }), + } +} + fn report_error(error: &anyhow::Error, mode: OutputMode, terminal: &Terminal) { let report = ErrorReport::new(error); if mode == OutputMode::Json { @@ -240,6 +296,132 @@ fn handle_command(command: Commands, context: CliContext) -> anyhow::Result, - /// Tracel Console project name. Defaults to the linked project's name. - #[arg(long, short)] - pub project: Option, /// Create a missing model (true/false); otherwise ask when interactive. #[arg(long, short)] pub auto_create: Option, @@ -63,8 +57,10 @@ fn upload_model_version(args: UploadModelArgs, context: CliContext) -> anyhow::R context.terminal().command_title("Model upload"); + let resolved = resolve_namespace_project(&context)?; + let namespace = resolved.project.owner; + let project = resolved.project.name; let client = crate::commands::login::get_client_and_login_if_needed(&context)?; - let (namespace, project) = resolve_namespace_project(args.namespace, args.project)?; context .terminal() diff --git a/crates/tracel-cli/src/commands/package.rs b/crates/tracel-cli/src/commands/package.rs index e7a4c73..46a36c2 100644 --- a/crates/tracel-cli/src/commands/package.rs +++ b/crates/tracel-cli/src/commands/package.rs @@ -16,7 +16,9 @@ use crate::commands::init::commit_sequence; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; -use crate::helpers::{require_linked_project, validate_project_exists_on_server}; +use crate::helpers::{ + require_cargo_workspace, resolve_namespace_project, validate_project_exists_on_server, +}; use crate::tools::build_driver::{self, BuildDriver}; use crate::tools::packager::{PackageEvent, package_workspace}; use crate::tools::project_context::ProjectContext; @@ -80,9 +82,15 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< } context.terminal().command_title("Package project"); - // 0. Ensure we have auth and a linked project that exists on the server. + // 0. Require a workspace and a project that exists on the server. + let workspace_info = require_cargo_workspace()?; + let resolved = resolve_namespace_project(&context)?; + let project = ProjectContext { + workspace_info, + build_profile: "release".to_string(), + project: resolved.project, + }; let client = get_client_and_login_if_needed(&context)?; - let project = require_linked_project()?; validate_project_exists_on_server(&context, &project, &client)?; // 1. Dirty check — warn and offer to commit, but allow proceeding. diff --git a/crates/tracel-cli/src/commands/project.rs b/crates/tracel-cli/src/commands/project.rs index 3df4a22..2e120e0 100644 --- a/crates/tracel-cli/src/commands/project.rs +++ b/crates/tracel-cli/src/commands/project.rs @@ -4,15 +4,15 @@ use serde_json::{Value, json}; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; -use crate::helpers::require_linked_project; +use crate::helpers::resolve_namespace_project; pub fn handle_command(context: CliContext) -> anyhow::Result { context.terminal().command_title("Project Information"); - let project = require_linked_project()?; + let resolved = resolve_namespace_project(&context)?; let client = get_client_and_login_if_needed(&context)?; - let bc_project = project.get_project(); + let bc_project = &resolved.project; let project = match client.get_project(&bc_project.owner, &bc_project.name) { Ok(project) => project, Err(e) if e.is_not_found() => { @@ -49,5 +49,6 @@ pub fn handle_command(context: CliContext) -> anyhow::Result { "description": project.description, "created_by": project.created_by, "visibility": project.visibility, + "source": resolved.source, })) } diff --git a/crates/tracel-cli/src/commands/training.rs b/crates/tracel-cli/src/commands/training.rs index f3596e9..00747e5 100644 --- a/crates/tracel-cli/src/commands/training.rs +++ b/crates/tracel-cli/src/commands/training.rs @@ -25,6 +25,10 @@ pub fn run_cargo(forwarded: &[String], context: CliContext) -> anyhow::Result<() cmd.arg("run"); cmd.env("TRACEL_ENV", tracel_env_value(&context.environment())); + if let Some(project) = context.project() { + cmd.env("TRACEL_NAMESPACE", &project.owner); + cmd.env("TRACEL_PROJECT", &project.name); + } if !forwarded.is_empty() { cmd.arg("--"); diff --git a/crates/tracel-cli/src/context.rs b/crates/tracel-cli/src/context.rs index 99ccd75..7858b21 100644 --- a/crates/tracel-cli/src/context.rs +++ b/crates/tracel-cli/src/context.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use crate::output::OutputMode; use crate::tools::terminal::Terminal; +use crate::tools::tracel_config::TracelProject; use tracel_client::ClientError; use tracel_client::console::auth::DeviceAuthClient; use tracel_client::console::{AppSession, Client, Env, FileSessionStore, TracelCredentials}; @@ -28,14 +29,21 @@ pub struct CliContext { terminal: Terminal, environment: Env, output: OutputMode, + project: Option, } impl CliContext { - pub fn new(terminal: Terminal, environment: Env, output: OutputMode) -> Self { + pub fn new( + terminal: Terminal, + environment: Env, + output: OutputMode, + project: Option, + ) -> Self { Self { terminal, environment, output, + project, } } @@ -97,6 +105,10 @@ impl CliContext { self.output } + pub fn project(&self) -> Option<&TracelProject> { + self.project.as_ref() + } + pub fn environment_name(&self) -> String { match self.environment { Env::Production => "production".to_string(), diff --git a/crates/tracel-cli/src/helpers/project.rs b/crates/tracel-cli/src/helpers/project.rs index dc3dda9..e2661d0 100644 --- a/crates/tracel-cli/src/helpers/project.rs +++ b/crates/tracel-cli/src/helpers/project.rs @@ -1,6 +1,7 @@ //! Project helpers for CLI operations use anyhow::Context; +use serde::{Deserialize, Serialize}; use crate::{ context::CliContext, @@ -8,6 +9,7 @@ use crate::{ tools::{ cargo::try_locate_manifest, project_context::{ErrorKind, ProjectContext, ProjectContextError}, + tracel_config::TracelProject, workspace::WorkspaceInfo, }, }; @@ -70,23 +72,121 @@ pub fn require_linked_project() -> anyhow::Result { ProjectContext::load(&manifest_path).map_err(explain_project_context_error) } -/// Resolve a namespace/project pair, using explicit overrides where given and -/// falling back to the linked project's namespace/name for whichever is omitted. -pub fn resolve_namespace_project( +pub fn parse_project(value: &str) -> Result { + if let Some((namespace, project)) = value.split_once('/') { + if !namespace.is_empty() && !project.is_empty() && !project.contains('/') { + return Ok(TracelProject { + owner: namespace.to_owned(), + name: project.to_owned(), + }); + } + } + + Err(CliError::new( + CliErrorKind::Usage, + format!("Invalid --project value '{value}': expected /."), + )) +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +pub enum ProjectSource { + #[serde(rename = "flag")] + Flag, + #[serde(rename = "env")] + Env, + #[serde(rename = "tracel.toml")] + TracelToml, +} + +#[derive(Debug)] +pub struct ResolvedProject { + pub project: TracelProject, + pub source: ProjectSource, +} + +#[derive(Deserialize, Default)] +struct TracelTomlConfig { + #[serde(alias = "owner")] namespace: Option, + #[serde(alias = "name")] project: Option, -) -> anyhow::Result<(String, String)> { - if let (Some(ns), Some(proj)) = (&namespace, &project) { - return Ok((ns.clone(), proj.clone())); +} + +fn project_not_found() -> CliError { + CliError::new( + CliErrorKind::NotFound, + "No Tracel Console project resolved.", + ) + .with_hint("Run 'tracel init' or pass --project /.") +} + +/// Resolve project identity from flags, environment variables, or tracel.toml. +pub fn resolve_namespace_project(context: &CliContext) -> anyhow::Result { + resolve_project( + context.project(), + std::env::var("TRACEL_NAMESPACE").ok(), + std::env::var("TRACEL_PROJECT").ok(), + || { + let manifest_path = try_locate_manifest().ok_or_else(project_not_found)?; + let workspace_root = manifest_path.parent().ok_or_else(project_not_found)?; + let path = TracelProject::path(workspace_root); + let contents = match std::fs::read_to_string(&path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(TracelTomlConfig::default()); + } + Err(error) => { + return Err(CliError::new( + CliErrorKind::Usage, + format!("Failed to read '{}': {error}", path.display()), + ) + .into()); + } + }; + toml::from_str(&contents).map_err(|error| { + CliError::new( + CliErrorKind::Usage, + format!("Failed to parse '{}': {error}", path.display()), + ) + .into() + }) + }, + ) +} + +fn resolve_project( + flag: Option<&TracelProject>, + namespace: Option, + project: Option, + load_config: impl FnOnce() -> anyhow::Result, +) -> anyhow::Result { + if let Some(project) = flag { + return Ok(ResolvedProject { + project: project.clone(), + source: ProjectSource::Flag, + }); } - let linked = require_linked_project()?; - let bc_project = linked.get_project(); + let source = if namespace.is_some() || project.is_some() { + ProjectSource::Env + } else { + ProjectSource::TracelToml + }; + let config = if namespace.is_some() && project.is_some() { + TracelTomlConfig::default() + } else { + load_config()? + }; - Ok(( - namespace.unwrap_or_else(|| bc_project.owner.clone()), - project.unwrap_or_else(|| bc_project.name.clone()), - )) + Ok(ResolvedProject { + project: TracelProject { + owner: namespace + .or(config.namespace) + .ok_or_else(project_not_found)?, + name: project.or(config.project).ok_or_else(project_not_found)?, + }, + source, + }) } /// Require a Cargo workspace (with or without Tracel Console linkage) @@ -153,3 +253,134 @@ pub fn validate_project_exists_on_server( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn project_flag_requires_two_nonempty_parts() { + let project = parse_project("alice/demo").unwrap(); + assert_eq!(project.owner, "alice"); + assert_eq!(project.name, "demo"); + for value in [ + "", + "alice", + "/demo", + "alice/", + "/", + "alice/demo/extra", + "alice//demo", + ] { + let error = parse_project(value).unwrap_err(); + assert_eq!(error.kind, CliErrorKind::Usage); + assert!(error.to_string().contains("--project")); + } + } + + #[test] + fn complete_overrides_skip_file_discovery() { + let flag = parse_project("alice/demo").unwrap(); + let resolved = resolve_project( + Some(&flag), + Some("other".into()), + Some("project".into()), + || panic!("File discovery must be skipped"), + ) + .unwrap(); + assert_eq!(resolved.project.owner, "alice"); + assert_eq!(resolved.project.name, "demo"); + assert_eq!(resolved.source, ProjectSource::Flag); + + let resolved = resolve_project(None, Some("alice".into()), Some("demo".into()), || { + panic!("File discovery must be skipped") + }) + .unwrap(); + assert_eq!(resolved.project.owner, "alice"); + assert_eq!(resolved.project.name, "demo"); + assert_eq!(resolved.source, ProjectSource::Env); + } + + #[test] + fn environment_parts_independently_override_file() { + for (namespace, project, expected_namespace, expected_project, source) in [ + ( + None, + None, + "file-owner", + "file-project", + ProjectSource::TracelToml, + ), + ( + Some("alice"), + None, + "alice", + "file-project", + ProjectSource::Env, + ), + (None, Some("demo"), "file-owner", "demo", ProjectSource::Env), + ] { + let resolved = resolve_project( + None, + namespace.map(str::to_owned), + project.map(str::to_owned), + || { + Ok(TracelTomlConfig { + namespace: Some("file-owner".into()), + project: Some("file-project".into()), + }) + }, + ) + .unwrap(); + assert_eq!(resolved.project.owner, expected_namespace); + assert_eq!(resolved.project.name, expected_project); + assert_eq!(resolved.source, source); + } + } + + #[test] + fn partial_config_and_legacy_keys_match_sdk() { + for (contents, namespace, project) in [ + ("project = 'demo'", Some("alice"), None), + ("namespace = 'alice'", None, Some("demo")), + ("owner = 'alice'\nname = 'demo'", None, None), + ] { + let resolved = resolve_project( + None, + namespace.map(str::to_owned), + project.map(str::to_owned), + || Ok(toml::from_str(contents).unwrap()), + ) + .unwrap(); + assert_eq!(resolved.project.owner, "alice"); + assert_eq!(resolved.project.name, "demo"); + } + } + + #[test] + fn incomplete_identity_reports_not_found_and_hint() { + for (namespace, project) in [(None, None), (Some("alice"), None), (None, Some("demo"))] { + let error = resolve_project( + None, + namespace.map(str::to_owned), + project.map(str::to_owned), + || Ok(TracelTomlConfig::default()), + ) + .unwrap_err(); + let report = crate::error::ErrorReport::new(&error); + assert_eq!(crate::error::classify(&error), CliErrorKind::NotFound); + assert_eq!(report.exit_code, 5); + assert_eq!( + report.hint, + Some("Run 'tracel init' or pass --project /.") + ); + } + for (source, value) in [ + (ProjectSource::Flag, "flag"), + (ProjectSource::Env, "env"), + (ProjectSource::TracelToml, "tracel.toml"), + ] { + assert_eq!(serde_json::to_value(source).unwrap(), value); + } + } +} From 5f2eb8c1df58a92fe33d0168353331eafee33637 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 6 Oct 2026 16:30:10 -0400 Subject: [PATCH 05/32] feat: add a two-step login for scripts - `tracel login --no-wait` starts the device flow, keeps the pending login in an owner-only file next to the session, prints the approval link and code, and exits. - `tracel login --complete [--timeout ]` finishes it. A timeout exits 10 and keeps the pending login. A denied or expired login has to be started again. An unreachable server exits 11 and keeps it. - Requests that never get a response are classified as unavailable. - The not-logged-in error suggests the two-step login. --- README.md | 26 +- crates/tracel-cli/src/cli.rs | 4 +- crates/tracel-cli/src/commands/login.rs | 249 +++++++++++++++++- .../tracel-cli/src/commands/login/pending.rs | 245 +++++++++++++++++ crates/tracel-cli/src/error.rs | 33 ++- 5 files changed, 549 insertions(+), 8 deletions(-) create mode 100644 crates/tracel-cli/src/commands/login/pending.rs diff --git a/README.md b/README.md index d069ade..d011b33 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,10 @@ format, and `train` inherits the executed program's output and exit code. | `CONFLICT` | 6 | | `CONFIRMATION_REQUIRED` | 7 | | `LIMIT_REACHED` | 8 | +| `TIMEOUT` | 10 | | `UNAVAILABLE` | 11 | -Exit codes 9 and 10 are reserved. +Exit code 9 is reserved. ### `tracel train` @@ -137,6 +138,29 @@ approve in your browser, and the login then lasts seven days. tracel login ``` +For scripts, start a login with `--no-wait`, hand the approval link and user code +to a person, then finish with `--complete`: + +```bash +tracel login --no-wait --json +tracel login --complete --json +# Wait up to 30 seconds, keeping the pending login if approval takes longer +tracel login --complete --timeout 30 --json +``` + +`--no-wait` saves the pending login locally and exits successfully without +waiting. Starting again replaces the previous pending login. JSON data contains +`status` (`"pending"`), `verification_uri_complete`, `user_code`, and `expires_at` +(RFC 3339). The device code is kept in an owner-only file beside the session +file and is never printed. + +`--complete` waits for approval until the code expires, or until `--timeout +` elapses. A timeout returns `TIMEOUT` (exit code 10); run `tracel login +--complete` again to continue. Expired or denied logins must be started again. +Successful completion returns the same JSON data as plain login: `username` and +`environment`. `--no-wait` and `--complete` conflict; `--timeout` requires +`--complete`. Plain `tracel login` still waits for approval. + When `TRACEL_API_KEY` is set, commands use that API key instead of the login. ### `tracel logout` diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index bbf03a7..efbba79 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -61,7 +61,7 @@ pub enum Commands { /// Package your project for running on a remote machine. Package(commands::package::PackageArgs), /// Log in to the Tracel server. - Login, + Login(commands::login::LoginArgs), /// Log out of the Tracel server. Logout, /// Show which credential commands use, or print an access token for scripts. @@ -281,7 +281,7 @@ fn handle_command(command: Commands, context: CliContext) -> anyhow::Result commands::package::handle_command(package_args, context), - Commands::Login => commands::login::handle_command(context), + Commands::Login(login_args) => commands::login::handle_command(login_args, context), Commands::Logout => commands::logout::handle_command(context), Commands::Auth(auth_args) => commands::auth::handle_command(auth_args, context), Commands::Init(init_args) => commands::init::handle_command(init_args, context), diff --git a/crates/tracel-cli/src/commands/login.rs b/crates/tracel-cli/src/commands/login.rs index e6f6ab9..9bef9e8 100644 --- a/crates/tracel-cli/src/commands/login.rs +++ b/crates/tracel-cli/src/commands/login.rs @@ -1,11 +1,33 @@ +mod pending; + +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::time::{Duration, Instant, SystemTime}; + +use chrono::{DateTime, Utc}; +use clap::Args; use serde_json::{Value, json}; +use tracel_client::console::auth::{DeviceAuthClient, DeviceFlowError, DevicePollOutcome}; use tracel_client::console::{Client, Env, TracelCredentials}; use url::Url; +use self::pending::{PendingLogin, PendingLoginStore}; use crate::context::{CliContext, ClientCreationError}; -use crate::error::{CliError, ErrorKind}; +use crate::error::{CliError, ErrorKind, classify}; use crate::output::OutputMode; +#[derive(Args, Debug)] +pub struct LoginArgs { + /// Start a login and save it for later completion. + #[arg(long, conflicts_with = "complete")] + pub no_wait: bool, + /// Finish the pending login. + #[arg(long)] + pub complete: bool, + /// Wait at most this many seconds, keeping the pending login on timeout. + #[arg(long, requires = "complete", value_name = "SECONDS")] + pub timeout: Option, +} + pub fn get_client_and_login_if_needed(context: &CliContext) -> anyhow::Result { const MAX_RETRIES: u32 = 3; let mut attempts = 0; @@ -39,6 +61,9 @@ pub fn get_client_and_login_if_needed(context: &CliContext) -> anyhow::Result anyhow::Result<()> { Ok(()) } -pub fn handle_command(context: CliContext) -> anyhow::Result { +fn start_pending_login(context: &CliContext) -> anyhow::Result { + let authorization = context.device_auth().start()?; + let pending = PendingLogin::new(authorization, SystemTime::now())?; + let store = PendingLoginStore::for_server(&context.environment().get_url())?; + store.save(&pending)?; + + let terminal = context.terminal(); + terminal.print(&format!( + "Open {} and check that it shows the code {}.", + terminal.format_url(&Url::parse( + &pending.authorization.verification_uri_complete + )?), + console::style(&pending.authorization.user_code).bold() + )); + terminal.print("Then run 'tracel login --complete'."); + + Ok(json!({ + "status": "pending", + "verification_uri_complete": pending.authorization.verification_uri_complete, + "user_code": pending.authorization.user_code, + "expires_at": DateTime::::from(pending.expires_at).to_rfc3339(), + })) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PollDeadline { + Expiry, + Timeout, +} + +impl PollDeadline { + fn error(self) -> CliError { + match self { + Self::Expiry => CliError::new( + ErrorKind::NotAuthenticated, + "The pending login expired before it was approved.", + ) + .with_hint("Start again with 'tracel login --no-wait'."), + Self::Timeout => CliError::new(ErrorKind::Timeout, "The login is still pending.") + .with_hint("Run 'tracel login --complete' again."), + } + } +} + +fn poll_window( + expires_at: SystemTime, + now: SystemTime, + timeout: Option, + elapsed: Duration, +) -> (Duration, PollDeadline) { + let remaining = expires_at.duration_since(now).unwrap_or_default(); + if let Some(timeout) = timeout { + let timeout_remaining = timeout.saturating_sub(elapsed); + if timeout_remaining < remaining { + return (timeout_remaining, PollDeadline::Timeout); + } + } + (remaining, PollDeadline::Expiry) +} + +fn poll_backoff(interval: Duration) -> Duration { + interval.saturating_add(Duration::from_secs(5)) +} + +fn poll_with_deadline( + device_auth: &DeviceAuthClient, + device_code: &str, + remaining: Duration, + deadline: PollDeadline, +) -> anyhow::Result { + let device_auth = device_auth.clone(); + let device_code = device_code.to_string(); + let (sender, receiver) = mpsc::channel(); + // The client's blocking HTTP timeout can exceed the remaining login time. + std::thread::spawn(move || { + let _ = sender.send(device_auth.poll(&device_code)); + }); + match receiver.recv_timeout(remaining) { + Ok(Ok(outcome)) => Ok(outcome), + Err(RecvTimeoutError::Timeout) => Err(deadline.error().into()), + Ok(Err(DeviceFlowError::Client(error))) => { + Err(anyhow::Error::new(error).context("Failed to check the pending login")) + } + Ok(Err(error)) => Err( + CliError::new(ErrorKind::NotAuthenticated, error.to_string()) + .with_hint("Start again with 'tracel login --no-wait'.") + .into(), + ), + Err(RecvTimeoutError::Disconnected) => Err(anyhow::anyhow!( + "The pending login check stopped unexpectedly" + )), + } +} + +fn complete_pending_login(context: &CliContext, timeout: Option) -> anyhow::Result<()> { + let started = Instant::now(); + let store = PendingLoginStore::for_server(&context.environment().get_url())?; + let mut pending = store.load()?.ok_or_else(|| { + CliError::new(ErrorKind::NotFound, "No pending login found.") + .with_hint("Run 'tracel login --no-wait' first.") + })?; + let timeout = timeout.map(Duration::from_secs); + let expires_at = pending.expires_at; + let window = || poll_window(expires_at, SystemTime::now(), timeout, started.elapsed()); + + let result = (|| { + let (remaining, deadline) = window(); + if remaining.is_zero() { + return Err(deadline.error().into()); + } + let device_auth = context.device_auth(); + let mut interval = pending.authorization.interval().max(Duration::from_secs(1)); + loop { + let (remaining, deadline) = window(); + if remaining.is_zero() { + return Err(deadline.error().into()); + } + std::thread::sleep(interval.min(remaining)); + let (remaining, deadline) = window(); + if remaining.is_zero() { + return Err(deadline.error().into()); + } + + let outcome = poll_with_deadline( + &device_auth, + &pending.authorization.device_code, + remaining, + deadline, + )?; + let (remaining, deadline) = window(); + if remaining.is_zero() { + return Err(deadline.error().into()); + } + match outcome { + DevicePollOutcome::Pending => {} + DevicePollOutcome::SlowDown => { + interval = poll_backoff(interval); + pending.authorization.interval = + interval.as_secs().try_into().unwrap_or(i64::MAX); + store.save(&pending)?; + } + DevicePollOutcome::Approved(issued) => { + context.app_session()?.sign_in(issued)?; + return Ok(()); + } + } + } + })(); + + // Keep the pending login for timeouts and outages, so the command can run again. + let finished = match &result { + Ok(()) => true, + Err(error) => classify(error) == ErrorKind::NotAuthenticated, + }; + if finished { + store.clear()?; + } + result +} + +pub fn handle_command(args: LoginArgs, context: CliContext) -> anyhow::Result { context.terminal().command_title("Login"); - log_in(&context)?; + if args.no_wait { + return start_pending_login(&context); + } + if args.complete { + complete_pending_login(&context, args.timeout)?; + } else { + log_in(&context)?; + } let credentials = TracelCredentials::app_session(context.app_session()?); let client = Client::connect(context.environment(), &credentials)?; @@ -127,3 +319,54 @@ pub fn handle_command(context: CliContext) -> anyhow::Result { "environment": context.environment_name(), })) } + +#[cfg(test)] +mod tests { + use std::time::UNIX_EPOCH; + + use super::*; + + #[test] + fn polling_stops_at_the_earlier_deadline() { + let now = UNIX_EPOCH + Duration::from_secs(1_900_000_000); + let expires_at = now + Duration::from_secs(60); + for (timeout, elapsed, remaining, deadline) in [ + (None, 0, 60, PollDeadline::Expiry), + (Some(90), 0, 60, PollDeadline::Expiry), + (Some(60), 0, 60, PollDeadline::Expiry), + (Some(30), 10, 20, PollDeadline::Timeout), + (Some(30), 30, 0, PollDeadline::Timeout), + (Some(30), 31, 0, PollDeadline::Timeout), + (Some(0), 0, 0, PollDeadline::Timeout), + ] { + assert_eq!( + poll_window( + expires_at, + now, + timeout.map(Duration::from_secs), + Duration::from_secs(elapsed) + ), + (Duration::from_secs(remaining), deadline) + ); + } + for timeout in [None, Some(Duration::ZERO), Some(Duration::MAX)] { + for current in [expires_at, expires_at + Duration::from_secs(1)] { + assert_eq!( + poll_window(expires_at, current, timeout, Duration::ZERO), + (Duration::ZERO, PollDeadline::Expiry) + ); + } + } + } + + #[test] + fn slow_down_adds_five_seconds_without_overflow() { + let interval = Duration::from_secs(7); + assert_eq!(poll_backoff(interval), Duration::from_secs(12)); + assert_eq!( + poll_backoff(poll_backoff(interval)), + Duration::from_secs(17) + ); + assert_eq!(poll_backoff(Duration::MAX), Duration::MAX); + } +} diff --git a/crates/tracel-cli/src/commands/login/pending.rs b/crates/tracel-cli/src/commands/login/pending.rs new file mode 100644 index 0000000..2b23ef0 --- /dev/null +++ b/crates/tracel-cli/src/commands/login/pending.rs @@ -0,0 +1,245 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::SystemTime; + +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use tracel_client::console::FileSessionStore; +use tracel_client::console::auth::DeviceCodeResponse; +use url::Url; + +static TEMPORARY_ID: AtomicUsize = AtomicUsize::new(0); + +#[derive(Serialize, Deserialize)] +pub(super) struct PendingLogin { + pub authorization: DeviceCodeResponse, + pub expires_at: SystemTime, +} + +impl PendingLogin { + pub fn new(authorization: DeviceCodeResponse, now: SystemTime) -> anyhow::Result { + let expires_at = now + .checked_add(authorization.expires_in()) + .context("Invalid device authorization expiry")?; + Ok(Self { + authorization, + expires_at, + }) + } +} + +pub(super) struct PendingLoginStore { + path: PathBuf, +} + +impl PendingLoginStore { + pub fn for_server(base_url: &Url) -> anyhow::Result { + let session = FileSessionStore::for_server(base_url)?; + Ok(Self { + path: session.path().with_extension("pending.json"), + }) + } + + pub fn load(&self) -> anyhow::Result> { + let bytes = match fs::read(&self.path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("Could not read pending login"), + }; + let pending = serde_json::from_slice(&bytes).context("Could not decode pending login")?; + Ok(Some(pending)) + } + + pub fn save(&self, pending: &PendingLogin) -> anyhow::Result<()> { + let bytes = serde_json::to_vec(pending)?; + let directory = self + .path + .parent() + .context("Missing pending login directory")?; + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder + .create(directory) + .context("Could not create pending login directory")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(directory, fs::Permissions::from_mode(0o700)) + .context("Could not protect pending login directory")?; + } + + let (temporary, mut file) = self.create_temporary()?; + let result = (|| { + file.write_all(&bytes)?; + file.sync_all()?; + drop(file); + fs::rename(&temporary, &self.path) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result.context("Could not save pending login") + } + + pub fn clear(&self) -> anyhow::Result<()> { + match fs::remove_file(&self.path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).context("Could not remove pending login"), + } + } + + fn create_temporary(&self) -> anyhow::Result<(PathBuf, File)> { + loop { + let mut name = self.path.file_name().unwrap_or_default().to_os_string(); + name.push(format!( + ".{}.{}.tmp", + std::process::id(), + TEMPORARY_ID.fetch_add(1, Ordering::Relaxed) + )); + let temporary = self.path.with_file_name(name); + match create_owner_only(&temporary) { + Ok(file) => return Ok((temporary, file)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error).context("Could not create pending login file"), + } + } + } +} + +fn create_owner_only(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path) +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, UNIX_EPOCH}; + + use super::*; + + struct ScratchDirectory(PathBuf); + + impl ScratchDirectory { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "tracel-pending-login-{}-{}", + std::process::id(), + TEMPORARY_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + + fn store(&self) -> PendingLoginStore { + PendingLoginStore { + path: self.0.join("sessions").join("localhost_9001.pending.json"), + } + } + } + + impl Drop for ScratchDirectory { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } + } + + fn authorization(device_code: &str) -> DeviceCodeResponse { + DeviceCodeResponse { + device_code: device_code.to_string(), + user_code: "BCDF-GHJK".to_string(), + verification_uri: "http://localhost:9001/verify".to_string(), + verification_uri_complete: "http://localhost:9001/verify?code=BCDF-GHJK".to_string(), + expires_in: 600, + interval: 5, + } + } + + #[test] + fn pending_login_round_trip_replace_and_clear() { + let directory = ScratchDirectory::new(); + let store = directory.store(); + assert!(store.load().unwrap().is_none()); + + let now = UNIX_EPOCH + Duration::from_secs(1_900_000_000); + for device_code in ["first", "second"] { + let pending = PendingLogin::new(authorization(device_code), now).unwrap(); + store.save(&pending).unwrap(); + let loaded = store.load().unwrap().unwrap(); + assert_eq!( + serde_json::to_value(&loaded).unwrap(), + serde_json::to_value(&pending).unwrap() + ); + assert_eq!( + fs::read_dir(store.path.parent().unwrap()).unwrap().count(), + 1 + ); + } + + store.clear().unwrap(); + store.clear().unwrap(); + assert!(store.load().unwrap().is_none()); + } + + #[cfg(unix)] + #[test] + fn pending_login_and_its_directory_are_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let directory = ScratchDirectory::new(); + let store = directory.store(); + let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777; + let pending = PendingLogin::new(authorization("first"), SystemTime::now()).unwrap(); + store.save(&pending).unwrap(); + assert_eq!(mode(&store.path), 0o600); + assert_eq!(mode(store.path.parent().unwrap()), 0o700); + + fs::set_permissions(&store.path, fs::Permissions::from_mode(0o644)).unwrap(); + fs::set_permissions( + store.path.parent().unwrap(), + fs::Permissions::from_mode(0o755), + ) + .unwrap(); + store.save(&pending).unwrap(); + assert_eq!(mode(&store.path), 0o600); + assert_eq!(mode(store.path.parent().unwrap()), 0o700); + } + + #[test] + fn expiry_uses_the_start_time_and_clamps_negative_lifetimes() { + let now = UNIX_EPOCH + Duration::from_secs(1_900_000_000); + for seconds in [-1, 0, 1, 600] { + let mut authorization = authorization("device-code"); + authorization.expires_in = seconds; + let pending = PendingLogin::new(authorization, now).unwrap(); + assert_eq!( + pending.expires_at, + now + Duration::from_secs(seconds.max(0) as u64) + ); + } + } + + #[test] + fn pending_path_is_a_sibling_of_the_session() { + for url in ["https://console.tracel.ai/api/", "http://localhost:9001/"] { + let url = Url::parse(url).unwrap(); + let session = FileSessionStore::for_server(&url).unwrap(); + let pending = PendingLoginStore::for_server(&url).unwrap(); + assert_eq!(pending.path.parent(), session.path().parent()); + assert_eq!(pending.path, session.path().with_extension("pending.json")); + } + } +} diff --git a/crates/tracel-cli/src/error.rs b/crates/tracel-cli/src/error.rs index a9d77b5..dd731ed 100644 --- a/crates/tracel-cli/src/error.rs +++ b/crates/tracel-cli/src/error.rs @@ -1,5 +1,6 @@ use serde::Serialize; use tracel_client::ClientError; +use tracel_client::console::auth::DeviceFlowError; use crate::context::ClientCreationError; @@ -14,6 +15,7 @@ pub enum ErrorKind { Conflict, ConfirmationRequired, LimitReached, + Timeout, Unavailable, } @@ -28,6 +30,7 @@ impl ErrorKind { Self::Conflict => 6, Self::ConfirmationRequired => 7, Self::LimitReached => 8, + Self::Timeout => 10, Self::Unavailable => 11, } } @@ -95,6 +98,12 @@ pub fn classify(error: &anyhow::Error) -> ErrorKind { if let Some(error) = cause.downcast_ref::() { return classify_client(error); } + if let Some(error) = cause.downcast_ref::() { + return match error { + DeviceFlowError::Client(error) => classify_client(error), + _ => ErrorKind::NotAuthenticated, + }; + } } ErrorKind::Internal } @@ -121,6 +130,8 @@ fn classify_client(error: &ClientError) -> ErrorKind { 429 | 500..=599 => ErrorKind::Unavailable, _ => ErrorKind::Internal, }, + // A request that never got a response, such as a refused connection. + ClientError::UnknownError(_) => ErrorKind::Unavailable, _ => ErrorKind::Internal, } } @@ -140,6 +151,7 @@ mod tests { (ErrorKind::Conflict, "CONFLICT", 6), (ErrorKind::ConfirmationRequired, "CONFIRMATION_REQUIRED", 7), (ErrorKind::LimitReached, "LIMIT_REACHED", 8), + (ErrorKind::Timeout, "TIMEOUT", 10), (ErrorKind::Unavailable, "UNAVAILABLE", 11), ] { assert_eq!(kind.exit_code(), exit); @@ -160,8 +172,8 @@ mod tests { ), (ClientError::InternalServerError, ErrorKind::Unavailable), ( - ClientError::UnknownError("unknown".into()), - ErrorKind::Internal, + ClientError::UnknownError("connection refused".into()), + ErrorKind::Unavailable, ), ( ClientError::SessionStore("unreadable".into()), @@ -200,6 +212,23 @@ mod tests { } } + #[test] + fn device_flow_errors_separate_outcomes_from_outages() { + for (error, kind) in [ + (DeviceFlowError::AccessDenied, ErrorKind::NotAuthenticated), + (DeviceFlowError::ExpiredToken, ErrorKind::NotAuthenticated), + ( + DeviceFlowError::Client(ClientError::UnknownError("connection refused".into())), + ErrorKind::Unavailable, + ), + ] { + assert_eq!( + classify(&anyhow::Error::new(error).context("login failed")), + kind + ); + } + } + #[test] fn creation_errors_keep_their_kind() { for (error, kind) in [ From bf8acf5a915b7dd5888078d5c55f799a0227e9c7 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 6 Oct 2026 16:51:08 -0400 Subject: [PATCH 06/32] chore: build against the local tracel-client The next commands need client APIs that are not released yet. The workspace points at a sibling burn-central-client checkout until that release is published. --- Cargo.lock | 4 +--- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce41247..3b9789c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3643,9 +3643,7 @@ dependencies = [ [[package]] name = "tracel-client" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16ab04b5ffd6422e4923e2e11c8fa157a1550dd25e03a768f84ed5d21b809ba5" +version = "0.21.0" dependencies = [ "directories 6.0.0", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index e609328..912dfad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ strum = { version = "0.27.2", features = ["derive"] } ## External Crate -tracel-client = "0.20.0" +tracel-client = { path = "../burn-central-client/tracel-client" } ## Crate tracel-cli = { version = "0.5.0", path = "crates/tracel-cli" } From f091c6a2fc1433ac76f4ed2912dc9a173320ac6f Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 6 Oct 2026 17:05:21 -0400 Subject: [PATCH 07/32] feat: exit 7 for missing confirmations and 8 for server limits - A yes/no prompt that cannot be shown fails with CONFIRMATION_REQUIRED (exit 7) instead of USAGE, with a hint naming the flag, such as `unlink --yes`. - A server error carrying the LimitReached code maps to LIMIT_REACHED (exit 8). --- README.md | 3 ++- crates/tracel-cli/src/error.rs | 20 +++++++++++++++++++- crates/tracel-cli/src/tools/terminal.rs | 14 ++++++++++++-- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d011b33..d7103b0 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,8 @@ is given. `--json` and `--output` cannot be combined. All commands also accept `--no-input` to disable prompts. Prompts require stdin and stderr to be terminals and human output. Missing input in scripts produces -`USAGE` with a hint naming the required flag. +`USAGE`, and a missing yes/no confirmation produces `CONFIRMATION_REQUIRED`, each +with a hint naming the flag to pass. Select a project with global `--project /`. It takes precedence over `TRACEL_NAMESPACE` and `TRACEL_PROJECT`, which each independently fall back diff --git a/crates/tracel-cli/src/error.rs b/crates/tracel-cli/src/error.rs index dd731ed..3ecc792 100644 --- a/crates/tracel-cli/src/error.rs +++ b/crates/tracel-cli/src/error.rs @@ -1,6 +1,6 @@ use serde::Serialize; -use tracel_client::ClientError; use tracel_client::console::auth::DeviceFlowError; +use tracel_client::{ApiErrorCode, ClientError}; use crate::context::ClientCreationError; @@ -122,6 +122,9 @@ fn classify_client(error: &ClientError) -> ErrorKind { ClientError::CredentialNotAllowed => ErrorKind::Forbidden, ClientError::NotFound | ClientError::NotFoundWithCode(_) => ErrorKind::NotFound, ClientError::InternalServerError => ErrorKind::Unavailable, + ClientError::ApiError { body, .. } if matches!(body.code, ApiErrorCode::LimitReached) => { + ErrorKind::LimitReached + } ClientError::ApiError { status, .. } => match status.as_u16() { 401 => ErrorKind::NotAuthenticated, 403 => ErrorKind::Forbidden, @@ -212,6 +215,21 @@ mod tests { } } + #[test] + fn server_limit_code_is_limit_reached() { + let error = ClientError::ApiError { + status: 403.try_into().unwrap(), + body: tracel_client::error::ApiErrorBody { + code: ApiErrorCode::LimitReached, + message: "limit".into(), + }, + }; + assert_eq!( + classify(&anyhow::Error::new(error).context("request failed")), + ErrorKind::LimitReached + ); + } + #[test] fn device_flow_errors_separate_outcomes_from_outages() { for (error, kind) in [ diff --git a/crates/tracel-cli/src/tools/terminal.rs b/crates/tracel-cli/src/tools/terminal.rs index b33abd1..317ffb1 100644 --- a/crates/tracel-cli/src/tools/terminal.rs +++ b/crates/tracel-cli/src/tools/terminal.rs @@ -119,12 +119,22 @@ impl Terminal { } fn require_input(&self, message: &str, flag: &str, values: &[&str]) -> anyhow::Result<()> { + self.require_answer(ErrorKind::Usage, message, flag, values) + } + + fn require_answer( + &self, + kind: ErrorKind, + message: &str, + flag: &str, + values: &[&str], + ) -> anyhow::Result<()> { if !self.is_interactive() { let mut message = format!("Input needed: {}", message.trim()); if !values.is_empty() { message.push_str(&format!(" Valid values: {}.", values.join(", "))); } - return Err(CliError::new(ErrorKind::Usage, message) + return Err(CliError::new(kind, message) .with_hint(format!("Pass --{flag} to answer without a prompt.")) .into()); } @@ -132,7 +142,7 @@ impl Terminal { } pub fn confirm(&self, message: &str, flag: &str, initial: bool) -> anyhow::Result { - self.require_input(message, flag, &[])?; + self.require_answer(ErrorKind::ConfirmationRequired, message, flag, &[])?; cliclack::confirm(message) .initial_value(initial) .interact() From 390b7fcc7557c92114878d41163ed3b7c5193d4f Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 6 Oct 2026 17:05:21 -0400 Subject: [PATCH 08/32] refactor: expose only cli_main from the library - Every module is private and the library re-exports `cli_main`, the only item the binary uses. Items use plain `pub` instead of `pub(crate)` or `pub(super)`. - Private modules surfaced unused code, now removed: the exported logging macros (callers use `tracing` directly), an unused spinner method, an unread archive name and build profile, and an unused error helper. --- crates/tracel-cli/src/bin/tracel.rs | 2 +- .../tracel-cli/src/commands/login/pending.rs | 4 +-- crates/tracel-cli/src/commands/package.rs | 1 - crates/tracel-cli/src/lib.rs | 12 +++---- crates/tracel-cli/src/logging.rs | 36 ------------------- crates/tracel-cli/src/tools/packager.rs | 9 ++--- .../tracel-cli/src/tools/project_context.rs | 7 ---- crates/tracel-cli/src/tools/terminal.rs | 10 +----- 8 files changed, 13 insertions(+), 68 deletions(-) delete mode 100644 crates/tracel-cli/src/logging.rs diff --git a/crates/tracel-cli/src/bin/tracel.rs b/crates/tracel-cli/src/bin/tracel.rs index 51d3830..e38fc5f 100644 --- a/crates/tracel-cli/src/bin/tracel.rs +++ b/crates/tracel-cli/src/bin/tracel.rs @@ -1,3 +1,3 @@ pub fn main() { - tracel_cli::cli::cli_main(); + tracel_cli::cli_main(); } diff --git a/crates/tracel-cli/src/commands/login/pending.rs b/crates/tracel-cli/src/commands/login/pending.rs index 2b23ef0..91ba5b6 100644 --- a/crates/tracel-cli/src/commands/login/pending.rs +++ b/crates/tracel-cli/src/commands/login/pending.rs @@ -13,7 +13,7 @@ use url::Url; static TEMPORARY_ID: AtomicUsize = AtomicUsize::new(0); #[derive(Serialize, Deserialize)] -pub(super) struct PendingLogin { +pub struct PendingLogin { pub authorization: DeviceCodeResponse, pub expires_at: SystemTime, } @@ -30,7 +30,7 @@ impl PendingLogin { } } -pub(super) struct PendingLoginStore { +pub struct PendingLoginStore { path: PathBuf, } diff --git a/crates/tracel-cli/src/commands/package.rs b/crates/tracel-cli/src/commands/package.rs index 46a36c2..7cdb054 100644 --- a/crates/tracel-cli/src/commands/package.rs +++ b/crates/tracel-cli/src/commands/package.rs @@ -87,7 +87,6 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< let resolved = resolve_namespace_project(&context)?; let project = ProjectContext { workspace_info, - build_profile: "release".to_string(), project: resolved.project, }; let client = get_client_and_login_if_needed(&context)?; diff --git a/crates/tracel-cli/src/lib.rs b/crates/tracel-cli/src/lib.rs index 879ce38..58d9f86 100644 --- a/crates/tracel-cli/src/lib.rs +++ b/crates/tracel-cli/src/lib.rs @@ -1,9 +1,9 @@ -pub mod cli; -pub mod error; -pub mod output; -pub mod tools; - +mod cli; mod commands; mod context; +mod error; mod helpers; -mod logging; +mod output; +mod tools; + +pub use cli::cli_main; diff --git a/crates/tracel-cli/src/logging.rs b/crates/tracel-cli/src/logging.rs deleted file mode 100644 index 863f5a5..0000000 --- a/crates/tracel-cli/src/logging.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Logging utilities for the Tracel Console library -//! -//! This module provides logging macros that can be used throughout the library. -//! Unlike CLI-specific printing, these use the standard `log` crate for output. - -/// Print an informational message -#[macro_export] -macro_rules! print_info { - ($($arg:tt)*) => { - tracing::info!($($arg)*); - }; -} - -/// Print a warning message -#[macro_export] -macro_rules! print_warn { - ($($arg:tt)*) => { - tracing::warn!($($arg)*); - }; -} - -/// Print an error message -#[macro_export] -macro_rules! print_err { - ($($arg:tt)*) => { - tracing::error!($($arg)*); - }; -} - -/// Print a debug message -#[macro_export] -macro_rules! print_debug { - ($($arg:tt)*) => { - tracing::debug!($($arg)*); - }; -} diff --git a/crates/tracel-cli/src/tools/packager.rs b/crates/tracel-cli/src/tools/packager.rs index e37dcd3..72e7fef 100644 --- a/crates/tracel-cli/src/tools/packager.rs +++ b/crates/tracel-cli/src/tools/packager.rs @@ -14,12 +14,10 @@ use colored::Colorize; use sha2::Digest as _; use sha2::Sha256; -use crate::print_info; use crate::tools::event::Reporter; #[derive(Debug)] pub struct ArchiveMetadata { - pub name: String, pub path: PathBuf, pub checksum: String, pub size: u64, @@ -51,7 +49,7 @@ pub fn package_workspace( .canonicalize() .map_err(|e| anyhow::anyhow!("Failed to canonicalize workspace root: {}", e))?; - print_info!( + tracing::info!( "Packaging workspace at: {}", workspace_root.display().to_string().bold() ); @@ -63,7 +61,7 @@ pub fn package_workspace( let files = list_workspace_files(&workspace_root)?; - print_info!("Found {} files to package", files.len()); + tracing::info!("Found {} files to package", files.len()); event_reporter.report_event(PackageEvent { message: format!("Discovered {} files", files.len()), @@ -114,7 +112,6 @@ pub fn package_workspace( }); let archive_data = ArchiveMetadata { - name: workspace_name.to_string(), path: archive_path, checksum: checksum.clone(), size, @@ -132,7 +129,7 @@ fn list_workspace_files(workspace_root: &Path) -> anyhow::Result> { let git_repo = discover_gix_repo(workspace_root)?; if let Some(ref repo) = git_repo { - print_info!( + tracing::info!( "Git repository found at {}", repo.path().display().to_string().bold() ); diff --git a/crates/tracel-cli/src/tools/project_context.rs b/crates/tracel-cli/src/tools/project_context.rs index 845b94c..450c907 100644 --- a/crates/tracel-cli/src/tools/project_context.rs +++ b/crates/tracel-cli/src/tools/project_context.rs @@ -31,10 +31,6 @@ impl ProjectContextError { pub fn kind(&self) -> &ErrorKind { &self.kind } - - pub fn is_project_not_linked(&self) -> bool { - matches!(self.kind, ErrorKind::ProjectNotLinked) - } } impl std::fmt::Display for ProjectContextError { @@ -45,7 +41,6 @@ impl std::fmt::Display for ProjectContextError { pub struct ProjectContext { pub workspace_info: WorkspaceInfo, - pub build_profile: String, pub project: TracelProject, } @@ -75,7 +70,6 @@ impl ProjectContext { Ok(Self { workspace_info, - build_profile: "release".to_string(), project, }) } @@ -93,7 +87,6 @@ impl ProjectContext { Ok(Self { workspace_info, - build_profile: "release".to_string(), project, }) } diff --git a/crates/tracel-cli/src/tools/terminal.rs b/crates/tracel-cli/src/tools/terminal.rs index 317ffb1..4f238c0 100644 --- a/crates/tracel-cli/src/tools/terminal.rs +++ b/crates/tracel-cli/src/tools/terminal.rs @@ -43,7 +43,7 @@ impl Terminal { self.output != OutputMode::Json } - pub(crate) fn is_styled(&self) -> bool { + pub fn is_styled(&self) -> bool { self.is_human() && std::io::stderr().is_terminal() } @@ -324,12 +324,4 @@ impl TerminalSpinner { self.terminal.print_err(&message.to_string()); } } - - pub fn cancel(&self, message: impl Display) { - if let Some(bar) = &self.bar { - bar.cancel(message); - } else { - self.terminal.print_err(&message.to_string()); - } - } } From e6f5f85758bbbb89033d22a79bdced6c52a521f4 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 6 Oct 2026 17:06:14 -0400 Subject: [PATCH 09/32] feat: add experiment commands - `tracel experiments` (alias `exp`) covers `list`, `get `, `metrics` (definitions, a series, or a summary) and `logs`. `logs --follow` tails until the experiment ends and streams NDJSON in JSON mode. --- README.md | 29 + crates/tracel-cli/src/cli.rs | 9 +- crates/tracel-cli/src/commands/experiments.rs | 702 ++++++++++++++++++ crates/tracel-cli/src/commands/mod.rs | 1 + crates/tracel-cli/src/output.rs | 6 + 5 files changed, 746 insertions(+), 1 deletion(-) create mode 100644 crates/tracel-cli/src/commands/experiments.rs diff --git a/README.md b/README.md index d7103b0..c19e18a 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,35 @@ Display information about the current project. tracel project ``` +### `tracel experiments` + +Browse experiments in the selected project; `exp` is a visible alias. `list` +accepts zero-based `--page`, `--limit`, and repeated `--sort` values in the server +format: `field`, `field,asc`, or `field,desc`. `get ` shows details and +config. Numbers are project-scoped experiment numbers. + +```bash +tracel --project alice/demo experiments list --sort created_at,desc --limit 10 --json +tracel exp get latest +tracel exp metrics 42 --metric loss --max-points 100 --downsampling 1 +tracel exp logs 42 --level info --level error --follow --json +``` + +`metrics ` lists definitions without `--metric`; adding `--summary` returns +the named metric's summary. Series default to 100 maximum points and a +downsampling factor of 1. `logs ` reads one page (100 entries by default), +with `--level`, `--search`, `--from`, `--to`, `--offset`, `--after`, and metadata +filters (`--metadata key=value`, `--metadata-not key=value`, `--metadata-exists +key`). `--after` is a log sequence cursor and cannot be combined with time ranges +or offsets. `--follow` uses that cursor and polls every two seconds until the +experiment finishes, including any remaining pages. + +JSON data is the server response, with `null` for an unavailable metric series +or summary. Following logs emits NDJSON instead of a success envelope: each log +item has `"type":"log"` added, followed by `{"type":"end","running":false}`. +Errors still use the standard error envelope. Human output uses tables, experiment +key/value lines, or one timestamp, level, and message line per log entry. + ## Project Structure The Tracel CLI is organized as a Cargo workspace: diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index efbba79..b44d297 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -66,6 +66,9 @@ pub enum Commands { Logout, /// Show which credential commands use, or print an access token for scripts. Auth(commands::auth::AuthArgs), + /// Browse project experiments, metrics, and logs. + #[command(visible_alias = "exp")] + Experiments(commands::experiments::ExperimentsArgs), /// Initialize a new project or reinitialize an existing one. Init(commands::init::InitArgs), /// Unlink the Tracel Console project from this repository. @@ -169,8 +172,9 @@ pub fn cli_main() { let context = CliContext::new(terminal.clone(), environment, mode, args.project); + let streams_output = matches!(&command, Commands::Experiments(args) if args.streams_output()); let result = handle_command(command, context).and_then(|data| { - if mode == OutputMode::Json { + if mode == OutputMode::Json && !streams_output { output::write_success(&data)?; } Ok(()) @@ -284,6 +288,9 @@ fn handle_command(command: Commands, context: CliContext) -> anyhow::Result commands::login::handle_command(login_args, context), Commands::Logout => commands::logout::handle_command(context), Commands::Auth(auth_args) => commands::auth::handle_command(auth_args, context), + Commands::Experiments(experiments_args) => { + commands::experiments::handle_command(experiments_args, context) + } Commands::Init(init_args) => commands::init::handle_command(init_args, context), Commands::Unlink(unlink_args) => commands::unlink::handle_command(unlink_args, context), Commands::Me => commands::me::handle_command(context), diff --git a/crates/tracel-cli/src/commands/experiments.rs b/crates/tracel-cli/src/commands/experiments.rs new file mode 100644 index 0000000..e5ed3a8 --- /dev/null +++ b/crates/tracel-cli/src/commands/experiments.rs @@ -0,0 +1,702 @@ +use std::io::Write; +use std::time::Duration; + +use clap::{Args, Subcommand, ValueEnum}; +use serde::Serialize; +use serde_json::{Value, json}; +use tracel_client::console::Client; +use tracel_client::console::experiment::request::{ + ExperimentLogQueryRequest, ListExperimentsQuery, LogLevelRequest, MetadataFilterRequest, + MetricAggregatedQuery, MetricSummaryQuery, +}; +use tracel_client::console::experiment::response::{ + ExperimentDetailsResponse, ExperimentLogItemResponse, ListExperimentsResponse, + LogLevelResponse, MetricMetadataResponse, MetricResponse, MetricSummaryResponse, +}; + +use crate::commands::login::get_client_and_login_if_needed; +use crate::context::CliContext; +use crate::error::{CliError, ErrorKind}; +use crate::helpers::project::resolve_namespace_project; +use crate::output::{self, OutputMode}; +use crate::tools::tracel_config::TracelProject; + +#[derive(Args, Debug)] +pub struct ExperimentsArgs { + #[command(subcommand)] + pub command: ExperimentsCommands, +} + +impl ExperimentsArgs { + pub fn streams_output(&self) -> bool { + matches!( + &self.command, + ExperimentsCommands::Logs(LogsArgs { follow: true, .. }) + ) + } +} + +#[derive(Subcommand, Debug)] +pub enum ExperimentsCommands { + /// List experiments in the selected project. + List(ListArgs), + /// Show an experiment by its project-scoped number, or latest. + Get(GetArgs), + /// List metric definitions, or read a metric series or summary. + Metrics(MetricsArgs), + /// Read experiment logs, optionally following until the experiment finishes. + Logs(LogsArgs), +} + +#[derive(Args, Debug)] +pub struct ListArgs { + /// Page number (zero-based). + #[arg(long)] + pub page: Option, + /// Maximum experiments per page. + #[arg(long, value_parser = clap::value_parser!(u32).range(1..))] + pub limit: Option, + /// Server sort: field, field,asc, or field,desc; repeat for multiple fields. + #[arg(long, value_name = "FIELD[,asc|desc]")] + pub sort: Vec, +} + +#[derive(Debug, Clone)] +pub enum ExperimentSelector { + Number(i32), + Latest, +} + +fn parse_experiment(value: &str) -> Result { + if value == "latest" { + return Ok(ExperimentSelector::Latest); + } + match value.parse::() { + Ok(number) if number > 0 => Ok(ExperimentSelector::Number(number)), + _ => Err("Expected a positive project-scoped experiment number or 'latest'.".into()), + } +} + +#[derive(Args, Debug)] +pub struct GetArgs { + /// Project-scoped experiment number, or latest. + #[arg(value_name = "NUM|latest", value_parser = parse_experiment)] + pub experiment: ExperimentSelector, +} + +#[derive(Args, Debug)] +pub struct MetricsArgs { + /// Project-scoped experiment number. + #[arg(value_parser = clap::value_parser!(i32).range(1..))] + pub num: i32, + /// Metric name; omit to list metric definitions. + #[arg(long)] + pub metric: Option, + /// Return a summary instead of the series. + #[arg(long, requires = "metric")] + pub summary: bool, + /// Maximum number of series points. + #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(i64).range(1..))] + pub max_points: i64, + /// Series downsampling factor. + #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(i64).range(1..))] + pub downsampling: i64, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl From for LogLevelRequest { + fn from(level: LogLevel) -> Self { + match level { + LogLevel::Trace => Self::Trace, + LogLevel::Debug => Self::Debug, + LogLevel::Info => Self::Info, + LogLevel::Warn => Self::Warn, + LogLevel::Error => Self::Error, + } + } +} + +fn parse_metadata_pair(value: &str) -> Result<(String, String), String> { + match value.split_once('=') { + Some((key, value)) if !key.is_empty() => Ok((key.into(), value.into())), + _ => Err("Expected KEY=VALUE with a nonempty key.".into()), + } +} + +#[derive(Args, Debug)] +pub struct LogsArgs { + /// Project-scoped experiment number. + #[arg(value_parser = clap::value_parser!(i32).range(1..))] + pub num: i32, + /// Maximum entries per request. + #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u32).range(1..))] + pub limit: u32, + /// Include this log level; repeat to include multiple levels. + #[arg(long = "level", value_enum, value_name = "LEVEL")] + pub levels: Vec, + /// Return entries after this sequence, oldest first. + #[arg(long, conflicts_with_all = ["from", "to", "offset"])] + pub after: Option, + /// Start of the time range, as a timestamp string. + #[arg(long)] + pub from: Option, + /// End of the time range, as a timestamp string. + #[arg(long)] + pub to: Option, + /// Offset within the time range. + #[arg(long)] + pub offset: Option, + /// Filter messages by search text. + #[arg(long)] + pub search: Option, + /// Require a metadata value; repeat for multiple filters. + #[arg(long, value_name = "KEY=VALUE", value_parser = parse_metadata_pair)] + pub metadata: Vec<(String, String)>, + /// Exclude a metadata value; repeat for multiple filters. + #[arg(long, value_name = "KEY=VALUE", value_parser = parse_metadata_pair)] + pub metadata_not: Vec<(String, String)>, + /// Require a metadata key; repeat for multiple filters. + #[arg(long, value_name = "KEY", value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub metadata_exists: Vec, + /// Poll every 2 seconds until finished; JSON output is NDJSON log and end events. + #[arg(long, conflicts_with_all = ["from", "to", "offset"])] + pub follow: bool, +} + +impl LogsArgs { + fn into_request(self) -> ExperimentLogQueryRequest { + let metadata_filters = self + .metadata + .into_iter() + .map(|(key, value)| MetadataFilterRequest::Equals { key, value }) + .chain( + self.metadata_not + .into_iter() + .map(|(key, value)| MetadataFilterRequest::NotEquals { key, value }), + ) + .chain( + self.metadata_exists + .into_iter() + .map(|key| MetadataFilterRequest::Exists { key }), + ) + .collect(); + ExperimentLogQueryRequest { + after: if self.follow { + Some(self.after.unwrap_or(0)) + } else { + self.after + }, + from: self.from, + to: self.to, + limit: Some(self.limit), + offset: self.offset, + levels: if self.levels.is_empty() { + None + } else { + Some(self.levels.into_iter().map(Into::into).collect()) + }, + search: self.search, + metadata_filters, + } + } +} + +fn write_table( + stdout: &mut impl Write, + headers: &[&str], + rows: Vec>, +) -> std::io::Result<()> { + let rows: Vec> = + std::iter::once(headers.iter().map(|header| (*header).into()).collect()) + .chain(rows) + .map(|row: Vec| { + row.into_iter() + .map(|cell| cell.split_whitespace().collect::>().join(" ")) + .collect() + }) + .collect(); + let widths: Vec<_> = (0..headers.len()) + .map(|column| { + rows.iter() + .map(|row| console::measure_text_width(&row[column])) + .max() + .unwrap_or(0) + }) + .collect(); + for row in rows { + for (column, cell) in row.iter().enumerate() { + write!(stdout, "{cell}")?; + if column + 1 < headers.len() { + write!( + stdout, + "{} ", + " ".repeat(widths[column].saturating_sub(console::measure_text_width(cell))) + )?; + } + } + writeln!(stdout)?; + } + Ok(()) +} + +fn print_list(response: &ListExperimentsResponse) -> anyhow::Result<()> { + let rows = response + .items + .iter() + .map(|experiment| { + vec![ + experiment.experiment_num.to_string(), + experiment.name.clone().unwrap_or_default(), + experiment.status.clone(), + experiment.created_at.clone(), + experiment.created_by.username.clone(), + ] + }) + .collect(); + let mut stdout = std::io::stdout().lock(); + write_table( + &mut stdout, + &["NUMBER", "NAME", "STATUS", "CREATED AT", "CREATED BY"], + rows, + )?; + let shown = response.items.len() as u64; + let end = u64::from(response.page) + .saturating_mul(u64::from(response.per_page)) + .saturating_add(shown); + if end < response.total { + writeln!(stdout, "Showing {shown} of {}", response.total)?; + } + Ok(()) +} + +fn print_experiment(experiment: &ExperimentDetailsResponse) -> anyhow::Result<()> { + let mut stdout = std::io::stdout().lock(); + writeln!(stdout, "Number: {}", experiment.experiment_num)?; + writeln!(stdout, "ID: {}", experiment.id)?; + writeln!(stdout, "Project ID: {}", experiment.project_id)?; + writeln!(stdout, "Name: {}", experiment.name.as_deref().unwrap_or(""))?; + writeln!(stdout, "Status: {}", experiment.status)?; + writeln!(stdout, "Description: {}", experiment.description)?; + writeln!(stdout, "Created at: {}", experiment.created_at)?; + writeln!(stdout, "Created by: {}", experiment.created_by.username)?; + writeln!(stdout, "Config:")?; + serde_json::to_writer_pretty(&mut stdout, &experiment.config)?; + writeln!(stdout, "\nConfigurations:")?; + serde_json::to_writer_pretty(&mut stdout, &experiment.configurations)?; + writeln!(stdout, "\nAttributes:")?; + serde_json::to_writer_pretty(&mut stdout, &experiment.attributes)?; + writeln!(stdout)?; + Ok(()) +} + +fn print_metric_metadata(metadata: &MetricMetadataResponse) -> anyhow::Result<()> { + let mut stdout = std::io::stdout().lock(); + write_table( + &mut stdout, + &["METRIC"], + metadata + .metric_types + .iter() + .map(|name| vec![name.clone()]) + .collect(), + )?; + writeln!(stdout)?; + write_table( + &mut stdout, + &["GROUP"], + metadata + .groups + .iter() + .map(|name| vec![name.clone()]) + .collect(), + )?; + Ok(()) +} + +fn print_metrics(response: &MetricResponse) -> anyhow::Result<()> { + let rows = response + .groups + .iter() + .flat_map(|group| { + group.entries.iter().map(|entry| { + vec![ + group.name.clone(), + entry.epoch.to_string(), + entry.iteration.to_string(), + entry.value.to_string(), + entry.low.to_string(), + entry.high.to_string(), + ] + }) + }) + .collect(); + write_table( + &mut std::io::stdout().lock(), + &["GROUP", "EPOCH", "ITERATION", "VALUE", "LOW", "HIGH"], + rows, + )?; + Ok(()) +} + +fn print_metric_summary(response: &MetricSummaryResponse) -> anyhow::Result<()> { + let rows = response + .groups + .iter() + .map(|group| { + vec![ + group.group.clone(), + group.optimal_value.to_string(), + group.epoch.to_string(), + ] + }) + .collect(); + write_table( + &mut std::io::stdout().lock(), + &["GROUP", "OPTIMAL VALUE", "EPOCH"], + rows, + )?; + Ok(()) +} + +fn print_logs(items: &[ExperimentLogItemResponse]) -> anyhow::Result<()> { + let mut stdout = std::io::stdout().lock(); + for item in items { + let level = match item.log_level { + LogLevelResponse::Trace => "trace", + LogLevelResponse::Debug => "debug", + LogLevelResponse::Info => "info", + LogLevelResponse::Warn => "warn", + LogLevelResponse::Error => "error", + }; + let message = item.message.replace(['\r', '\n'], " "); + writeln!(stdout, "{} {level} {message}", item.timestamp)?; + } + stdout.flush()?; + Ok(()) +} + +#[derive(Serialize)] +struct LogEvent<'a> { + #[serde(rename = "type")] + event_type: &'static str, + #[serde(flatten)] + item: &'a ExperimentLogItemResponse, +} + +fn follow_logs( + client: &Client, + project: &TracelProject, + num: i32, + mut request: ExperimentLogQueryRequest, + mode: OutputMode, +) -> anyhow::Result { + loop { + let response = + client.query_experiment_logs(&project.owner, &project.name, num, request.clone())?; + if mode == OutputMode::Json { + for item in &response.items { + output::write_stream_item(&LogEvent { + event_type: "log", + item, + })?; + } + } else { + print_logs(&response.items)?; + } + let previous = request.after.unwrap_or(0); + let cursor = response + .items + .iter() + .map(|item| item.seq) + .max() + .unwrap_or(previous) + .max(previous); + request.after = Some(cursor); + if !response.running && !response.has_more { + let end = json!({"type": "end", "running": false}); + if mode == OutputMode::Json { + output::write_stream_item(&end)?; + } + return Ok(end); + } + if response.has_more && cursor == previous { + return Err(CliError::new( + ErrorKind::Internal, + "Log response has more entries but no advancing sequence.", + ) + .into()); + } + // Drain a backlog right away; wait only once caught up. + if !response.has_more { + std::thread::sleep(Duration::from_secs(2)); + } + } +} + +pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Result { + context.terminal().command_title("Experiments"); + let client = get_client_and_login_if_needed(&context)?; + let project = resolve_namespace_project(&context)?.project; + let human = context.output() == OutputMode::Human; + match args.command { + ExperimentsCommands::List(args) => { + let response = client.get_project_experiments( + &project.owner, + &project.name, + ListExperimentsQuery { + page: args.page, + limit: args.limit, + sort: args.sort, + }, + )?; + if human { + print_list(&response)?; + } + Ok(serde_json::to_value(response)?) + } + ExperimentsCommands::Get(args) => { + let experiment = match args.experiment { + ExperimentSelector::Number(num) => { + client.get_experiment(&project.owner, &project.name, num)? + } + ExperimentSelector::Latest => client + .get_project_latest_experiment(&project.owner, &project.name)? + .ok_or_else(|| { + CliError::new(ErrorKind::NotFound, "No experiments found in this project.") + })?, + }; + if human { + print_experiment(&experiment)?; + } + Ok(serde_json::to_value(experiment)?) + } + ExperimentsCommands::Metrics(args) => { + let Some(metric) = args.metric else { + let metadata = + client.get_metric_metadata(&project.owner, &project.name, args.num)?; + if human { + print_metric_metadata(&metadata)?; + } + return Ok(serde_json::to_value(metadata)?); + }; + if args.summary { + let response = client.get_metric_summary( + &project.owner, + &project.name, + args.num, + MetricSummaryQuery { metric }, + )?; + if human { + if let Some(response) = &response { + print_metric_summary(response)?; + } else { + context.terminal().print("No metric summary available."); + } + } + Ok(serde_json::to_value(response)?) + } else { + let response = client.get_metrics( + &project.owner, + &project.name, + args.num, + MetricAggregatedQuery { + metric, + max_points: args.max_points, + downsampling_factor: args.downsampling, + }, + )?; + if human { + if let Some(response) = &response { + print_metrics(response)?; + } else { + context.terminal().print("No metric series available."); + } + } + Ok(serde_json::to_value(response)?) + } + } + ExperimentsCommands::Logs(args) => { + let num = args.num; + let follow = args.follow; + let request = args.into_request(); + if follow { + return follow_logs(&client, &project, num, request, context.output()); + } + let response = + client.query_experiment_logs(&project.owner, &project.name, num, request)?; + if human { + print_logs(&response.items)?; + } + Ok(serde_json::to_value(response)?) + } + } +} + +#[cfg(test)] +mod tests { + use clap::{CommandFactory, Parser}; + + use super::*; + use crate::cli::{CliArgs, Commands}; + + fn parse_command(arguments: &[&str]) -> ExperimentsArgs { + let args = CliArgs::try_parse_from(arguments).unwrap(); + let Some(Commands::Experiments(args)) = args.command else { + panic!("Expected experiments command"); + }; + args + } + + #[test] + fn command_definitions_are_valid_and_alias_preserves_sort() { + CliArgs::command().debug_assert(); + let args = parse_command(&[ + "tracel", + "exp", + "list", + "--page", + "0", + "--limit", + "25", + "--sort", + "created_at,desc", + "--sort", + "name,asc", + ]); + let ExperimentsCommands::List(args) = args.command else { + panic!("Expected list command"); + }; + assert_eq!(args.page, Some(0)); + assert_eq!(args.limit, Some(25)); + assert_eq!(args.sort, ["created_at,desc", "name,asc"]); + } + + #[test] + fn experiment_selectors_and_metric_defaults_are_validated() { + for (selector, number) in [("latest", None), ("42", Some(42))] { + let args = parse_command(&["tracel", "experiments", "get", selector]); + let ExperimentsCommands::Get(args) = args.command else { + panic!("Expected get command"); + }; + match args.experiment { + ExperimentSelector::Latest => assert!(number.is_none()), + ExperimentSelector::Number(value) => assert_eq!(Some(value), number), + } + } + let args = parse_command(&["tracel", "exp", "metrics", "42", "--metric", "loss"]); + let ExperimentsCommands::Metrics(args) = args.command else { + panic!("Expected metrics command"); + }; + assert_eq!(args.num, 42); + assert_eq!(args.metric.as_deref(), Some("loss")); + assert_eq!(args.max_points, 100); + assert_eq!(args.downsampling, 1); + for arguments in [ + vec!["tracel", "exp", "get", "invalid"], + vec!["tracel", "exp", "get", "0"], + vec!["tracel", "exp", "metrics", "42", "--summary"], + vec!["tracel", "exp", "metrics", "42", "--max-points", "0"], + vec!["tracel", "exp", "metrics", "42", "--downsampling", "0"], + vec!["tracel", "exp", "logs", "42", "--limit", "0"], + vec!["tracel", "exp", "list", "--limit", "0"], + ] { + assert!(CliArgs::try_parse_from(arguments).is_err()); + } + } + + #[test] + fn log_filters_map_to_the_client_request() { + let args = parse_command(&[ + "tracel", + "exp", + "logs", + "42", + "--limit", + "25", + "--level", + "info", + "--level", + "error", + "--from", + "2026-10-06T00:00:00Z", + "--to", + "2026-10-07T00:00:00Z", + "--offset", + "5", + "--search", + "loss", + "--metadata", + "worker=gpu=0", + "--metadata-not", + "stage=init", + "--metadata-exists", + "epoch", + ]); + assert!(!args.streams_output()); + let ExperimentsCommands::Logs(args) = args.command else { + panic!("Expected logs command"); + }; + let request = args.into_request(); + assert_eq!(request.limit, Some(25)); + assert_eq!( + request.levels, + Some(vec![LogLevelRequest::Info, LogLevelRequest::Error]) + ); + assert_eq!(request.from.as_deref(), Some("2026-10-06T00:00:00Z")); + assert_eq!(request.to.as_deref(), Some("2026-10-07T00:00:00Z")); + assert_eq!(request.offset, Some(5)); + assert_eq!(request.search.as_deref(), Some("loss")); + assert_eq!(request.after, None); + assert_eq!( + serde_json::to_value(request.metadata_filters).unwrap(), + json!([ + {"type":"equals", "key":"worker", "value":"gpu=0"}, + {"type":"not_equals", "key":"stage", "value":"init"}, + {"type":"exists", "key":"epoch"}, + ]) + ); + } + + #[test] + fn follow_uses_a_sequence_cursor_and_streams_output() { + for after in [None, Some("12")] { + let mut arguments = vec!["tracel", "exp", "logs", "42", "--follow", "--json"]; + if let Some(after) = after { + arguments.extend(["--after", after]); + } + let args = parse_command(&arguments); + assert!(args.streams_output()); + let ExperimentsCommands::Logs(args) = args.command else { + panic!("Expected logs command"); + }; + let request = args.into_request(); + assert_eq!( + request.after, + Some(after.map_or(0, |value| value.parse().unwrap())) + ); + assert_eq!(request.limit, Some(100)); + assert!(request.levels.is_none()); + } + for flag in ["--follow", "--after"] { + for (filter, value) in [("--from", "start"), ("--to", "end"), ("--offset", "5")] { + let mut arguments = vec!["tracel", "exp", "logs", "42", flag]; + if flag == "--after" { + arguments.push("12"); + } + arguments.extend([filter, value]); + assert_eq!( + CliArgs::try_parse_from(arguments).unwrap_err().kind(), + clap::error::ErrorKind::ArgumentConflict + ); + } + } + } +} diff --git a/crates/tracel-cli/src/commands/mod.rs b/crates/tracel-cli/src/commands/mod.rs index 51fb18e..23dcfb3 100644 --- a/crates/tracel-cli/src/commands/mod.rs +++ b/crates/tracel-cli/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod experiments; pub mod init; pub mod login; pub mod logout; diff --git a/crates/tracel-cli/src/output.rs b/crates/tracel-cli/src/output.rs index 195c9eb..df73bf5 100644 --- a/crates/tracel-cli/src/output.rs +++ b/crates/tracel-cli/src/output.rs @@ -66,6 +66,12 @@ pub fn write_failure(error: &ErrorReport<'_>) -> anyhow::Result<()> { write_line(&Failure { ok: false, error }) } +pub fn write_stream_item(value: &impl Serialize) -> anyhow::Result<()> { + write_line(value)?; + std::io::stdout().flush()?; + Ok(()) +} + fn write_line(value: &impl Serialize) -> anyhow::Result<()> { let line = serde_json::to_string(value)?; let mut stdout = std::io::stdout().lock(); From a58315723f94e1bc3637f23cf6d0336b90c4ec5e Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 6 Oct 2026 17:45:38 -0400 Subject: [PATCH 10/32] fix: input the server rejects exits 2 - A 400 or 422 response maps to USAGE instead of INTERNAL, so a request the server rejects as invalid reads as a usage error. - The LIMIT_REACHED code still wins over the status. --- crates/tracel-cli/src/error.rs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/crates/tracel-cli/src/error.rs b/crates/tracel-cli/src/error.rs index 3ecc792..3ea7bc7 100644 --- a/crates/tracel-cli/src/error.rs +++ b/crates/tracel-cli/src/error.rs @@ -126,6 +126,7 @@ fn classify_client(error: &ClientError) -> ErrorKind { ErrorKind::LimitReached } ClientError::ApiError { status, .. } => match status.as_u16() { + 400 | 422 => ErrorKind::Usage, 401 => ErrorKind::NotAuthenticated, 403 => ErrorKind::Forbidden, 404 => ErrorKind::NotFound, @@ -193,7 +194,8 @@ mod tests { #[test] fn api_status_determines_kind() { for (status, kind) in [ - (400, ErrorKind::Internal), + (400, ErrorKind::Usage), + (422, ErrorKind::Usage), (401, ErrorKind::NotAuthenticated), (403, ErrorKind::Forbidden), (404, ErrorKind::NotFound), @@ -217,17 +219,19 @@ mod tests { #[test] fn server_limit_code_is_limit_reached() { - let error = ClientError::ApiError { - status: 403.try_into().unwrap(), - body: tracel_client::error::ApiErrorBody { - code: ApiErrorCode::LimitReached, - message: "limit".into(), - }, - }; - assert_eq!( - classify(&anyhow::Error::new(error).context("request failed")), - ErrorKind::LimitReached - ); + for status in [400, 403] { + let error = ClientError::ApiError { + status: status.try_into().unwrap(), + body: tracel_client::error::ApiErrorBody { + code: ApiErrorCode::LimitReached, + message: "limit".into(), + }, + }; + assert_eq!( + classify(&anyhow::Error::new(error).context("request failed")), + ErrorKind::LimitReached + ); + } } #[test] From eb6ec9134ddf7cd8a8f5a02deeff5e473ad72d32 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 6 Oct 2026 17:45:38 -0400 Subject: [PATCH 11/32] feat: add models, artifacts, datasets and project list - `tracel models` replaces `tracel model`: `list`, `get [--version REF]`, `versions [--all]`, `pull`, `push` (today's upload, plus `--metadata`), `promote --experiment --artifact [--alias]`, and `alias list|set|remove`. - `tracel artifacts list|download` for an experiment. - `tracel datasets list|get|versions`. - `tracel project list [NAMESPACE]` covers your own and your organizations' projects; bare `tracel project` is unchanged. - Downloads check every path and conflict before fetching, verify declared sizes and SHA-256 checksums, and need `--force` to overwrite. - JSON data is the server response; human output uses compact tables. --- README.md | 113 ++- crates/tracel-cli/src/cli.rs | 46 +- crates/tracel-cli/src/commands/artifacts.rs | 264 +++++ crates/tracel-cli/src/commands/datasets.rs | 224 +++++ crates/tracel-cli/src/commands/experiments.rs | 40 +- crates/tracel-cli/src/commands/mod.rs | 4 +- crates/tracel-cli/src/commands/model.rs | 141 --- crates/tracel-cli/src/commands/models.rs | 912 ++++++++++++++++++ crates/tracel-cli/src/commands/project.rs | 139 ++- crates/tracel-cli/src/helpers/download.rs | 279 ++++++ crates/tracel-cli/src/helpers/mod.rs | 13 +- .../src/helpers/model_upload/mod.rs | 2 +- crates/tracel-cli/src/helpers/resources.rs | 119 +++ crates/tracel-cli/src/output.rs | 38 + 14 files changed, 2122 insertions(+), 212 deletions(-) create mode 100644 crates/tracel-cli/src/commands/artifacts.rs create mode 100644 crates/tracel-cli/src/commands/datasets.rs delete mode 100644 crates/tracel-cli/src/commands/model.rs create mode 100644 crates/tracel-cli/src/commands/models.rs create mode 100644 crates/tracel-cli/src/helpers/download.rs create mode 100644 crates/tracel-cli/src/helpers/resources.rs diff --git a/README.md b/README.md index c19e18a..443d029 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,10 @@ with a hint naming the flag to pass. Select a project with global `--project /`. It takes precedence over `TRACEL_NAMESPACE` and `TRACEL_PROJECT`, which each independently fall back -to `tracel.toml` at the Cargo workspace root. `project` and `model upload` work -from any directory with a flag or both variables; `package` still requires a -Cargo workspace. Global `-C ` runs as if started in that directory. `init` -and `unlink` operate on `tracel.toml` and ignore project overrides. +to `tracel.toml` at the Cargo workspace root. `project`, `models`, `artifacts`, +and `datasets` work from any directory with a flag or both variables; `package` +still requires a Cargo workspace. Global `-C ` runs as if started in that +directory. `init` and `unlink` operate on `tracel.toml` and ignore project overrides. ```bash tracel --project alice/demo project --json @@ -205,19 +205,98 @@ linked project; without it, asking for a different project fails with `CONFLICT` JSON data contains `namespace`, `name`, `created`, and `url`; `created` is false when an existing project is linked, and `url` is null when nothing changed. -### `tracel model upload` +### `tracel models` -Upload a directory as a new version of a model. +Browse and manage models in the selected project's model registry. ```bash -tracel --project alice/demo model upload my-model --directory ./weights --auto-create true --description "Model weights" --json +tracel models list +tracel models get my-model +tracel models get my-model --version production --json +tracel models versions my-model --all +tracel models pull my-model --version v7 --directory ./weights --force +tracel --project alice/demo models push my-model --directory ./weights --auto-create true --description "Model weights" --metadata '{"format":"safetensors"}' --json +tracel models promote my-model --experiment 42 --artifact weights --alias production --metadata '{"accuracy":0.98}' --json +tracel models alias list my-model +tracel models alias set my-model production 7 --expect 6 +tracel models alias remove my-model production ``` -Global `--project /` selects the destination. `--auto-create true` -creates a missing model without asking; without prompts, a missing model needs -this flag. `--auto-create false` requires an existing model. `--description ` -sets the new model's description and requires `--auto-create true`. JSON data -contains `namespace`, `project`, `model`, `version`, `files`, and `bytes`. +- `list` shows names, version counts, latest versions, aliases, and creation times. +- `get ` shows model details. `--version ` shows version details, + files, and metadata. References accept `latest`, a number such as `7` or `v7`, + or an alias, and are resolved by the server. +- `versions ` lists ready versions. `--all` includes pending, failed, + and deleted versions. +- `pull ` defaults to `--version latest` and downloads into + `./-v`. `-d, --directory ` selects another + destination. `--force` overwrites existing files. Paths and overwrite conflicts + are checked before downloading; declared sizes and SHA-256 checksums are + verified before each file is saved. +- `push -d, --directory ` uploads local files as a new version. + `-a, --auto-create ` creates a missing model when true and requires + an existing model when false. Omit it to ask when interactive; a missing model + without prompts requires `--auto-create true`. `--description ` sets + the new model's description and requires `--auto-create true`. `--metadata + ` sets version metadata and must be a JSON object. +- `promote --experiment --artifact ` copies an experiment + artifact into a ready model version. Artifact ids take precedence over names; + an ambiguous name requires the id. `--alias ` points an alias at the + new version. `--metadata `, `--auto-create `, and + `--description ` have the same meaning as for `push`. +- `alias list ` lists aliases and their versions. `alias set + ` creates or moves an alias to a ready version; `--expect + ` requires that the alias currently points at that version. + Version numbers must be at least 1. `alias remove ` removes it. + +JSON data is the server response: the model list (`items` and `total`), a +model, a version, the version list, the alias list, or an alias. `push` and `promote` +return the new version, including its state, source, manifest, metadata, and +aliases. `pull` data contains `model`, the resolved version object as `version`, +`directory`, `files` (each with `rel_path`, `path`, and downloaded `bytes`), +and total `bytes`. `alias remove` returns `model`, `alias`, and `removed: true`. + +### `tracel artifacts` + +Browse and download artifacts using positive project-scoped experiment numbers. + +```bash +tracel artifacts list 42 --name weights --json +tracel artifacts download 42 weights --directory ./weights --force --json +``` + +`list ` shows name, kind, id, manifest file count, and creation time. +`--name ` keeps artifacts whose name contains `NAME`. `download + ` selects an artifact by id first, otherwise by an exact, +unique name. Ambiguous names require an id. +The destination defaults to `./` when the name is a single normal +path component; otherwise pass `-d, --directory `. `--force` overwrites +existing files. Declared manifest sizes and SHA-256 checksums are verified. + +JSON data for `list` is the server response (`items` and `total`). `download` +data contains `experiment`, the server's artifact object as `artifact`, +`directory`, `files` (each with `rel_path`, `path`, and downloaded `bytes`), +and total `bytes`. + +### `tracel datasets` + +Browse datasets in the selected project. + +```bash +tracel datasets list --page 0 --per-page 25 --json +tracel datasets get images +tracel datasets versions images --page 0 --per-page 10 --json +``` + +`list` shows name, description, and id, followed by the number shown and total +when more datasets exist. `get ` shows details and pretty metadata. +`versions ` lists version, item count, source, and creation time. +`list` and `versions` accept zero-based `--page ` and positive +`--per-page `; omitted values use the server's defaults. + +JSON data is the server response. The list responses contain `items` and +`total_count`; dataset objects contain `id`, `name`, `description`, and +`metadata`. ### `tracel unlink` @@ -242,8 +321,18 @@ Display information about the current project. ```bash tracel project +tracel project list --json +tracel project list alice ``` +`list [NAMESPACE]` lists your own projects followed by each of your organizations' +projects. Supply your namespace or an organization namespace to limit the list. +It works without a linked project or `tracel.toml`. Human output shows +`namespace/name`, visibility, description, and creation time. JSON data is an +array of the server's project objects, including `namespace_name`. +Bare `project` returns `namespace`, `name`, `description`, `created_by`, +`visibility`, and project resolution `source`. + ### `tracel experiments` Browse experiments in the selected project; `exp` is a visible alias. `list` diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index b44d297..85f5004 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -75,10 +75,14 @@ pub enum Commands { Unlink(commands::unlink::UnlinkArgs), /// Display current user information. Me, - /// Display current project information. - Project, - /// Upload local files as a model version in the model registry. - Model(commands::model::ModelArgs), + /// Display the current project, or list projects. + Project(commands::project::ProjectArgs), + /// Browse and manage models in the model registry. + Models(commands::models::ModelsArgs), + /// Browse and download experiment artifacts. + Artifacts(commands::artifacts::ArtifactsArgs), + /// Browse datasets in the selected project. + Datasets(commands::datasets::DatasetsArgs), } pub fn cli_main() { @@ -294,8 +298,10 @@ fn handle_command(command: Commands, context: CliContext) -> anyhow::Result commands::init::handle_command(init_args, context), Commands::Unlink(unlink_args) => commands::unlink::handle_command(unlink_args, context), Commands::Me => commands::me::handle_command(context), - Commands::Project => commands::project::handle_command(context), - Commands::Model(model_args) => commands::model::handle_command(model_args, context), + Commands::Project(args) => commands::project::handle_command(args, context), + Commands::Models(args) => commands::models::handle_command(args, context), + Commands::Artifacts(args) => commands::artifacts::handle_command(args, context), + Commands::Datasets(args) => commands::datasets::handle_command(args, context), } } @@ -303,6 +309,14 @@ fn handle_command(command: Commands, context: CliContext) -> anyhow::Result, +} + +#[derive(Args, Debug)] +pub struct DownloadArgs { + /// Project-scoped experiment number. + #[arg(value_parser = clap::value_parser!(i32).range(1..))] + pub experiment: i32, + /// Artifact name or id in the experiment. + #[arg(value_name = "NAME|ID", value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub artifact: String, + /// Destination directory (default: ./). + #[arg(short, long, value_name = "DIR")] + pub directory: Option, + /// Overwrite existing destination files. + #[arg(long)] + pub force: bool, +} + +fn default_directory(name: &str) -> Result { + if validate_rel_path(name).is_err() || Path::new(name).components().count() != 1 { + return Err(CliError::new( + ErrorKind::Usage, + format!("Artifact name '{name}' needs an explicit destination directory."), + ) + .with_hint("Pass --directory .")); + } + Ok(PathBuf::from(format!("./{name}"))) +} + +fn manifest_files(manifest: &Value) -> Option<&Vec> { + manifest.get("files").and_then(Value::as_array) +} + +fn file_integrity(manifest: &Value, rel_path: &str) -> (Option, Option) { + let file = manifest_files(manifest).and_then(|files| { + files + .iter() + .find(|file| file.get("rel_path").and_then(Value::as_str) == Some(rel_path)) + }); + match file { + Some(file) => ( + file.get("size_bytes").and_then(Value::as_u64), + file.get("checksum") + .and_then(Value::as_str) + .map(str::to_owned), + ), + None => (None, None), + } +} + +pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Result { + context.terminal().command_title("Artifacts"); + let project = resolve_namespace_project(&context)?.project; + if let ArtifactsCommands::Download(args) = &args.command { + if let Some(directory) = &args.directory { + if directory.as_os_str().is_empty() || (directory.exists() && !directory.is_dir()) { + return Err(CliError::new( + ErrorKind::Usage, + format!("Invalid destination directory '{}'.", directory.display()), + ) + .into()); + } + } + } + let client = get_client_and_login_if_needed(&context)?; + let human = context.output() == OutputMode::Human; + let namespace = &project.owner; + let name = &project.name; + match args.command { + ArtifactsCommands::List(args) => { + let response = match args.name { + Some(filter) => { + client.list_artifacts_by_name(namespace, name, args.experiment, &filter)? + } + None => client.list_artifacts(namespace, name, args.experiment)?, + }; + if human { + write_table( + &mut std::io::stdout().lock(), + &["NAME", "KIND", "ID", "FILES", "CREATED AT"], + response + .items + .iter() + .map(|artifact| { + vec![ + artifact.name.clone(), + artifact.kind.clone(), + artifact.id.clone(), + manifest_files(&artifact.manifest) + .map(|files| files.len().to_string()) + .unwrap_or_else(|| "-".into()), + artifact.created_at.clone(), + ] + }) + .collect(), + )?; + } + Ok(serde_json::to_value(response)?) + } + ArtifactsCommands::Download(args) => { + let artifacts = client.list_artifacts(namespace, name, args.experiment)?; + let index = select_artifact( + artifacts + .items + .iter() + .map(|artifact| (artifact.id.as_str(), artifact.name.as_str())), + &args.artifact, + args.experiment, + )?; + let artifact = &artifacts.items[index]; + let directory = match args.directory { + Some(directory) => directory, + None => default_directory(&artifact.name)?, + }; + let response = + client.presign_artifact_download(namespace, name, args.experiment, &artifact.id)?; + let files: Vec<_> = response + .files + .into_iter() + .map(|file| { + let (size_bytes, checksum) = file_integrity(&artifact.manifest, &file.rel_path); + DownloadFile { + rel_path: file.rel_path, + url: file.url, + size_bytes, + checksum, + } + }) + .collect(); + let files = download_files(&client, &directory, &files, args.force)?; + let bytes: u64 = files.iter().map(|file| file.bytes).sum(); + if human { + writeln!( + std::io::stdout().lock(), + "Downloaded artifact '{}' to {}.", + artifact.name, + directory.display() + )?; + } + Ok( + json!({"experiment": args.experiment, "artifact": artifact, "directory": directory, "files": files, "bytes": bytes}), + ) + } + } +} + +#[cfg(test)] +mod tests { + use clap::{CommandFactory, Parser}; + + use super::*; + use crate::cli::{CliArgs, Commands}; + + fn parse_command(arguments: &[&str]) -> ArtifactsArgs { + let args = CliArgs::try_parse_from(arguments).unwrap(); + let Some(Commands::Artifacts(args)) = args.command else { + panic!("Expected artifacts command"); + }; + args + } + + #[test] + fn definitions_and_arguments_are_valid() { + CliArgs::command().debug_assert(); + let args = parse_command(&["tracel", "artifacts", "list", "7", "--name", "weights"]); + let ArtifactsCommands::List(args) = args.command else { + panic!("Expected list"); + }; + assert_eq!(args.experiment, 7); + assert_eq!(args.name.as_deref(), Some("weights")); + let args = parse_command(&[ + "tracel", + "artifacts", + "download", + "7", + "artifact-id", + "-d", + "weights", + "--force", + "-o", + "json", + ]); + let ArtifactsCommands::Download(args) = args.command else { + panic!("Expected download"); + }; + assert_eq!(args.experiment, 7); + assert_eq!(args.artifact, "artifact-id"); + assert_eq!(args.directory.unwrap(), Path::new("weights")); + assert!(args.force); + let args = parse_command(&["tracel", "artifacts", "download", "1", "weights"]); + let ArtifactsCommands::Download(args) = args.command else { + panic!("Expected download"); + }; + assert!(args.directory.is_none()); + assert!(!args.force); + for number in ["0", "-1", "2147483648", "latest"] { + assert!(CliArgs::try_parse_from(["tracel", "artifacts", "list", number]).is_err()); + } + } + + #[test] + fn default_directory_and_integrity_use_manifest_fields() { + assert_eq!( + default_directory("weights").unwrap(), + Path::new("./weights") + ); + for name in [ + "", + ".", + "..", + "../weights", + "weights/nested", + "/weights", + "C:\\weights", + ] { + assert_eq!(default_directory(name).unwrap_err().kind, ErrorKind::Usage); + } + let manifest = json!({"files": [{"rel_path": "a", "size_bytes": 7, "checksum": "abc"}, {"rel_path": "b"}]}); + assert_eq!(manifest_files(&manifest).unwrap().len(), 2); + assert_eq!( + file_integrity(&manifest, "a"), + (Some(7), Some("abc".into())) + ); + assert_eq!(file_integrity(&manifest, "b"), (None, None)); + assert_eq!(file_integrity(&manifest, "missing"), (None, None)); + assert!(manifest_files(&Value::Null).is_none()); + } +} diff --git a/crates/tracel-cli/src/commands/datasets.rs b/crates/tracel-cli/src/commands/datasets.rs new file mode 100644 index 0000000..184b0ea --- /dev/null +++ b/crates/tracel-cli/src/commands/datasets.rs @@ -0,0 +1,224 @@ +use std::io::Write; + +use clap::{Args, Subcommand}; +use serde_json::Value; +use tracel_client::console::dataset::request::{QueryDatasetVersionsRequest, QueryDatasetsRequest}; +use tracel_client::console::dataset::response::SourceKindResponse; + +use crate::commands::login::get_client_and_login_if_needed; +use crate::context::CliContext; +use crate::helpers::resolve_namespace_project; +use crate::output::{OutputMode, write_table}; + +#[derive(Args, Debug)] +pub struct DatasetsArgs { + #[command(subcommand)] + pub command: DatasetsCommands, +} + +#[derive(Subcommand, Debug)] +pub enum DatasetsCommands { + /// List datasets in the selected project. + List(ListArgs), + /// Show a dataset and its metadata. + Get(GetArgs), + /// List published dataset versions. + Versions(VersionsArgs), +} + +#[derive(Args, Debug)] +pub struct ListArgs { + /// Page number (zero-based). + #[arg(long, value_name = "N")] + pub page: Option, + /// Maximum datasets per page. + #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))] + pub per_page: Option, +} + +#[derive(Args, Debug)] +pub struct GetArgs { + #[arg(value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub dataset: String, +} + +#[derive(Args, Debug)] +pub struct VersionsArgs { + #[arg(value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub dataset: String, + /// Page number (zero-based). + #[arg(long, value_name = "N")] + pub page: Option, + /// Maximum versions per page. + #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))] + pub per_page: Option, +} + +pub fn handle_command(args: DatasetsArgs, context: CliContext) -> anyhow::Result { + context.terminal().command_title("Datasets"); + let project = resolve_namespace_project(&context)?.project; + let client = get_client_and_login_if_needed(&context)?; + let human = context.output() == OutputMode::Human; + match args.command { + DatasetsCommands::List(args) => { + let response = client.query_datasets( + &project.owner, + &project.name, + QueryDatasetsRequest { + page: args.page, + per_page: args.per_page, + }, + )?; + if human { + let mut stdout = std::io::stdout().lock(); + write_table( + &mut stdout, + &["NAME", "DESCRIPTION", "ID"], + response + .items + .iter() + .map(|dataset| { + vec![ + dataset.name.clone(), + dataset.description.clone().unwrap_or_default(), + dataset.id.clone(), + ] + }) + .collect(), + )?; + let shown = response.items.len() as u64; + if shown < response.total_count { + writeln!(stdout, "Showing {shown} of {}", response.total_count)?; + } + } + Ok(serde_json::to_value(response)?) + } + DatasetsCommands::Get(args) => { + let dataset = client.get_dataset(&project.owner, &project.name, &args.dataset)?; + if human { + let mut stdout = std::io::stdout().lock(); + writeln!(stdout, "Name: {}", dataset.name)?; + writeln!(stdout, "ID: {}", dataset.id)?; + writeln!( + stdout, + "Description: {}", + dataset.description.as_deref().unwrap_or("") + )?; + writeln!(stdout, "Metadata:")?; + serde_json::to_writer_pretty(&mut stdout, &dataset.metadata)?; + writeln!(stdout)?; + } + Ok(serde_json::to_value(dataset)?) + } + DatasetsCommands::Versions(args) => { + let response = client.query_dataset_versions( + &project.owner, + &project.name, + &args.dataset, + QueryDatasetVersionsRequest { + page: args.page, + per_page: args.per_page, + }, + )?; + if human { + write_table( + &mut std::io::stdout().lock(), + &["VERSION", "ITEMS", "SOURCE", "CREATED AT"], + response + .items + .iter() + .map(|version| { + let source = match version.source_kind { + SourceKindResponse::AnnotationSet => "annotation_set", + SourceKindResponse::DirectUpload => "direct_upload", + }; + vec![ + version.version.to_string(), + version.item_count.to_string(), + source.into(), + version.created_at.clone(), + ] + }) + .collect(), + )?; + } + Ok(serde_json::to_value(response)?) + } + } +} + +#[cfg(test)] +mod tests { + use clap::{CommandFactory, Parser}; + + use super::*; + use crate::cli::{CliArgs, Commands}; + + fn parse_command(arguments: &[&str]) -> DatasetsArgs { + let args = CliArgs::try_parse_from(arguments).unwrap(); + let Some(Commands::Datasets(args)) = args.command else { + panic!("Expected datasets command"); + }; + args + } + + #[test] + fn definitions_and_pagination_are_valid() { + CliArgs::command().debug_assert(); + let args = parse_command(&[ + "tracel", + "datasets", + "list", + "--page", + "0", + "--per-page", + "25", + ]); + let DatasetsCommands::List(args) = args.command else { + panic!("Expected list"); + }; + assert_eq!(args.page, Some(0)); + assert_eq!(args.per_page, Some(25)); + let args = parse_command(&["tracel", "datasets", "get", "images"]); + let DatasetsCommands::Get(args) = args.command else { + panic!("Expected get"); + }; + assert_eq!(args.dataset, "images"); + let args = parse_command(&[ + "tracel", + "datasets", + "versions", + "images", + "--page", + "2", + "--per-page", + "10", + ]); + let DatasetsCommands::Versions(args) = args.command else { + panic!("Expected versions"); + }; + assert_eq!(args.dataset, "images"); + assert_eq!(args.page, Some(2)); + assert_eq!(args.per_page, Some(10)); + let args = parse_command(&["tracel", "datasets", "list"]); + let DatasetsCommands::List(args) = args.command else { + panic!("Expected list"); + }; + assert!(args.page.is_none()); + assert!(args.per_page.is_none()); + for arguments in [ + vec!["tracel", "datasets", "list", "--page", "-1"], + vec!["tracel", "datasets", "list", "--per-page", "0"], + vec![ + "tracel", + "datasets", + "versions", + "images", + "--per-page", + "0", + ], + ] { + assert!(CliArgs::try_parse_from(arguments).is_err()); + } + } +} diff --git a/crates/tracel-cli/src/commands/experiments.rs b/crates/tracel-cli/src/commands/experiments.rs index e5ed3a8..9acb94d 100644 --- a/crates/tracel-cli/src/commands/experiments.rs +++ b/crates/tracel-cli/src/commands/experiments.rs @@ -18,7 +18,7 @@ use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::project::resolve_namespace_project; -use crate::output::{self, OutputMode}; +use crate::output::{self, OutputMode, write_table}; use crate::tools::tracel_config::TracelProject; #[derive(Args, Debug)] @@ -209,44 +209,6 @@ impl LogsArgs { } } -fn write_table( - stdout: &mut impl Write, - headers: &[&str], - rows: Vec>, -) -> std::io::Result<()> { - let rows: Vec> = - std::iter::once(headers.iter().map(|header| (*header).into()).collect()) - .chain(rows) - .map(|row: Vec| { - row.into_iter() - .map(|cell| cell.split_whitespace().collect::>().join(" ")) - .collect() - }) - .collect(); - let widths: Vec<_> = (0..headers.len()) - .map(|column| { - rows.iter() - .map(|row| console::measure_text_width(&row[column])) - .max() - .unwrap_or(0) - }) - .collect(); - for row in rows { - for (column, cell) in row.iter().enumerate() { - write!(stdout, "{cell}")?; - if column + 1 < headers.len() { - write!( - stdout, - "{} ", - " ".repeat(widths[column].saturating_sub(console::measure_text_width(cell))) - )?; - } - } - writeln!(stdout)?; - } - Ok(()) -} - fn print_list(response: &ListExperimentsResponse) -> anyhow::Result<()> { let rows = response .items diff --git a/crates/tracel-cli/src/commands/mod.rs b/crates/tracel-cli/src/commands/mod.rs index 23dcfb3..08c4f12 100644 --- a/crates/tracel-cli/src/commands/mod.rs +++ b/crates/tracel-cli/src/commands/mod.rs @@ -1,10 +1,12 @@ +pub mod artifacts; pub mod auth; +pub mod datasets; pub mod experiments; pub mod init; pub mod login; pub mod logout; pub mod me; -pub mod model; +pub mod models; pub mod package; pub mod project; pub mod training; diff --git a/crates/tracel-cli/src/commands/model.rs b/crates/tracel-cli/src/commands/model.rs deleted file mode 100644 index 3934b19..0000000 --- a/crates/tracel-cli/src/commands/model.rs +++ /dev/null @@ -1,141 +0,0 @@ -use std::path::PathBuf; - -use clap::{Args, Subcommand}; -use serde_json::{Value, json}; -use tracel_client::console::model::request::{ - ModelFileSpecRequest, RequestModelVersionUploadRequest, -}; - -use crate::context::CliContext; -use crate::error::{CliError, ErrorKind}; -use crate::helpers::{ - build_part_tasks, ensure_model_exists, resolve_namespace_project, upload_parts, -}; -use crate::tools::fs::{build_file_specs, collect_files}; - -#[derive(Args, Debug)] -pub struct ModelArgs { - #[command(subcommand)] - pub command: ModelCommands, -} - -#[derive(Subcommand, Debug)] -pub enum ModelCommands { - /// Upload a local directory of files as a new model version. - Upload(UploadModelArgs), -} - -#[derive(Args, Debug)] -pub struct UploadModelArgs { - /// Name of the model to upload a version to. - pub model_name: String, - /// Local directory containing the files to upload. - #[arg(short, long)] - pub directory: PathBuf, - /// Create a missing model (true/false); otherwise ask when interactive. - #[arg(long, short)] - pub auto_create: Option, - /// Description to use when auto-creating the model. Requires --auto-create true. - #[arg(long, requires = "auto_create")] - pub description: Option, -} - -pub fn handle_command(args: ModelArgs, context: CliContext) -> anyhow::Result { - match args.command { - ModelCommands::Upload(upload_args) => upload_model_version(upload_args, context), - } -} - -fn upload_model_version(args: UploadModelArgs, context: CliContext) -> anyhow::Result { - if args.description.is_some() && args.auto_create != Some(true) { - return Err(CliError::new( - ErrorKind::Usage, - "--description can only be used together with --auto-create true.", - ) - .into()); - } - - context.terminal().command_title("Model upload"); - - let resolved = resolve_namespace_project(&context)?; - let namespace = resolved.project.owner; - let project = resolved.project.name; - let client = crate::commands::login::get_client_and_login_if_needed(&context)?; - - context - .terminal() - .print(&format!("Uploading to {namespace}/{project}")); - - let spinner = context.terminal().spinner(); - spinner.start("Collecting files..."); - let files = collect_files(&args.directory).inspect_err(|_e| { - spinner.error("Failed to collect files."); - })?; - spinner.stop(format!("Found {} file(s).", files.len())); - - ensure_model_exists( - &context, - &client, - &namespace, - &project, - &args.model_name, - args.auto_create, - args.description.clone(), - )?; - - let spinner = context.terminal().spinner(); - spinner.start("Computing checksums..."); - let file_specs = build_file_specs(&files).inspect_err(|_e| { - spinner.error("Failed to compute checksums."); - })?; - spinner.stop("Checksums computed."); - - let file_sizes: std::collections::BTreeMap = file_specs - .iter() - .map(|f| (f.rel_path.clone(), f.size_bytes)) - .collect(); - let bytes: u64 = file_sizes.values().sum(); - - let spinner = context.terminal().spinner(); - spinner.start("Requesting upload URLs..."); - let upload_request = RequestModelVersionUploadRequest { - files: file_specs - .into_iter() - .map(|f| ModelFileSpecRequest { - rel_path: f.rel_path, - size_bytes: f.size_bytes, - checksum: f.checksum, - }) - .collect(), - metadata: None, - }; - let upload = client - .request_model_version_upload(&namespace, &project, &args.model_name, upload_request) - .map_err(|e| { - spinner.error("Failed to request upload URLs."); - anyhow::anyhow!(e) - })?; - spinner.stop(format!("Allocated model version {}.", upload.version)); - - let tasks = build_part_tasks(&files, &file_sizes, &upload.files)?; - upload_parts(&client, tasks, context.terminal())?; - - client.complete_model_version_upload(&namespace, &project, &args.model_name, upload.version)?; - - context.terminal().print_success(&format!( - "Uploaded model '{}' version {} to {}/{}.", - args.model_name, upload.version, namespace, project - )); - context - .terminal() - .finalize("Model version uploaded successfully."); - - Ok(json!({ - "namespace": namespace, - "project": project, - "model": args.model_name, - "version": upload.version, - "files": files.len(), - "bytes": bytes, - })) -} diff --git a/crates/tracel-cli/src/commands/models.rs b/crates/tracel-cli/src/commands/models.rs new file mode 100644 index 0000000..8432532 --- /dev/null +++ b/crates/tracel-cli/src/commands/models.rs @@ -0,0 +1,912 @@ +use std::collections::BTreeMap; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use clap::{Args, Subcommand}; +use serde_json::{Value, json}; +use tracel_client::console::Client; +use tracel_client::console::model::request::{ + ModelFileSpecRequest, ModelVersionListState, PromoteModelVersionRequest, + RequestModelVersionUploadRequest, SetModelAliasRequest, +}; +use tracel_client::console::model::response::{ + ModelAliasResponse, ModelResponse, ModelVersionResponse, ModelVersionSourceKindResponse, + ModelVersionStateResponse, +}; + +use crate::commands::login::get_client_and_login_if_needed; +use crate::context::CliContext; +use crate::error::{CliError, ErrorKind}; +use crate::helpers::{ + DownloadFile, build_part_tasks, download_files, ensure_model_exists, parse_metadata, + resolve_namespace_project, select_artifact, upload_parts, validate_auto_create, + validate_rel_path, +}; +use crate::output::{OutputMode, write_table}; +use crate::tools::fs::{build_file_specs, collect_files}; +use crate::tools::tracel_config::TracelProject; + +#[derive(Args, Debug)] +pub struct ModelsArgs { + #[command(subcommand)] + pub command: ModelsCommands, +} + +#[derive(Subcommand, Debug)] +pub enum ModelsCommands { + /// List models in the selected project. + List, + /// Show a model or a version selected by reference. + Get(GetArgs), + /// List ready versions, or versions in all states. + Versions(VersionsArgs), + /// Download a model version. + Pull(PullArgs), + /// Upload a local directory of files as a new model version. + Push(PushArgs), + /// Promote an experiment artifact into a new model version. + Promote(PromoteArgs), + /// List, set, or remove model aliases. + Alias(AliasArgs), +} + +#[derive(Args, Debug)] +pub struct GetArgs { + #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub model: String, + /// Version reference: latest, 7, v7, or an alias. + #[arg(long, value_name = "REF", value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub version: Option, +} + +#[derive(Args, Debug)] +pub struct VersionsArgs { + #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub model: String, + /// Include pending, failed, and deleted versions. + #[arg(long)] + pub all: bool, +} + +#[derive(Args, Debug)] +pub struct PullArgs { + #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub model: String, + /// Version reference: latest, 7, v7, or an alias. + #[arg(long, value_name = "REF", default_value = "latest", value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub version: String, + /// Destination directory (default: ./-v). + #[arg(short, long, value_name = "DIR")] + pub directory: Option, + /// Overwrite existing destination files. + #[arg(long)] + pub force: bool, +} + +#[derive(Args, Debug)] +pub struct PushArgs { + /// Name of the model to upload a version to. + #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub model_name: String, + /// Local directory containing the files to upload. + #[arg(short, long, value_name = "DIR")] + pub directory: PathBuf, + /// Create a missing model (true/false); otherwise ask when interactive. + #[arg(long, short)] + pub auto_create: Option, + /// Description to use when auto-creating the model. Requires --auto-create true. + #[arg(long, requires = "auto_create", value_name = "TEXT")] + pub description: Option, + /// Metadata for the new version, as a JSON object. + #[arg(long, value_name = "JSON", value_parser = parse_metadata)] + pub metadata: Option, +} + +#[derive(Args, Debug)] +pub struct PromoteArgs { + #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub model: String, + /// Project-scoped experiment number. + #[arg(long, value_name = "NUM", value_parser = clap::value_parser!(i32).range(1..))] + pub experiment: i32, + /// Artifact name or id in the experiment. + #[arg(long, value_name = "NAME|ID", value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub artifact: String, + /// Point this alias at the new version. + #[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub alias: Option, + /// Metadata for the new version, as a JSON object. + #[arg(long, value_name = "JSON", value_parser = parse_metadata)] + pub metadata: Option, + /// Create a missing model (true/false); otherwise ask when interactive. + #[arg(long)] + pub auto_create: Option, + /// Description to use when auto-creating the model. Requires --auto-create true. + #[arg(long, requires = "auto_create", value_name = "TEXT")] + pub description: Option, +} + +#[derive(Args, Debug)] +pub struct AliasArgs { + #[command(subcommand)] + pub command: AliasCommands, +} + +#[derive(Subcommand, Debug)] +pub enum AliasCommands { + /// List aliases for a model. + List(AliasListArgs), + /// Create or move an alias to a ready version. + Set(AliasSetArgs), + /// Remove an alias. + Remove(AliasRemoveArgs), +} + +#[derive(Args, Debug)] +pub struct AliasListArgs { + #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub model: String, +} + +#[derive(Args, Debug)] +pub struct AliasSetArgs { + #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub model: String, + #[arg(value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub alias: String, + #[arg(value_parser = clap::value_parser!(u32).range(1..))] + pub version: u32, + /// Move the alias only if it currently points at this version. + #[arg(long, value_name = "VERSION", value_parser = clap::value_parser!(u32).range(1..))] + pub expect: Option, +} + +#[derive(Args, Debug)] +pub struct AliasRemoveArgs { + #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub model: String, + #[arg(value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub alias: String, +} + +fn alias_summary(aliases: &[ModelAliasResponse]) -> String { + aliases + .iter() + .map(|alias| format!("{}=v{}", alias.alias, alias.version)) + .collect::>() + .join(", ") +} + +fn state_name(state: ModelVersionStateResponse) -> &'static str { + match state { + ModelVersionStateResponse::Pending => "pending", + ModelVersionStateResponse::Ready => "ready", + ModelVersionStateResponse::Failed => "failed", + ModelVersionStateResponse::Deleted => "deleted", + } +} + +fn source_name(source: ModelVersionSourceKindResponse) -> &'static str { + match source { + ModelVersionSourceKindResponse::Upload => "upload", + ModelVersionSourceKindResponse::Promotion => "promotion", + } +} + +fn print_model(model: &ModelResponse) -> anyhow::Result<()> { + let mut stdout = std::io::stdout().lock(); + writeln!(stdout, "Name: {}", model.name)?; + writeln!(stdout, "ID: {}", model.id)?; + writeln!(stdout, "Project ID: {}", model.project_id)?; + writeln!(stdout, "Display name: {}", model.display_name)?; + writeln!( + stdout, + "Description: {}", + model.description.as_deref().unwrap_or("") + )?; + writeln!(stdout, "Versions: {}", model.version_count)?; + writeln!( + stdout, + "Latest: {}", + model + .latest_version + .map(|version| format!("v{version}")) + .unwrap_or_else(|| "-".into()) + )?; + writeln!(stdout, "Aliases: {}", alias_summary(&model.aliases))?; + writeln!(stdout, "Created at: {}", model.created_at)?; + writeln!(stdout, "Created by: {}", model.created_by.username)?; + Ok(()) +} + +fn print_version(version: &ModelVersionResponse) -> anyhow::Result<()> { + let mut stdout = std::io::stdout().lock(); + writeln!(stdout, "Version: v{}", version.version)?; + writeln!(stdout, "ID: {}", version.id)?; + writeln!(stdout, "State: {}", state_name(version.state))?; + writeln!(stdout, "Source: {}", source_name(version.source_kind))?; + if let Some(experiment) = &version.experiment { + writeln!(stdout, "Experiment: {}", experiment.experiment_num)?; + } + writeln!(stdout, "Size: {}", version.size)?; + writeln!(stdout, "Digest: {}", version.digest)?; + writeln!(stdout, "Aliases: {}", version.aliases.join(", "))?; + writeln!(stdout, "Created at: {}", version.created_at)?; + writeln!(stdout, "Created by: {}", version.created_by.username)?; + if let Some(reason) = &version.failure_reason { + writeln!(stdout, "Failure reason: {reason}")?; + } + write_table( + &mut stdout, + &["REL PATH", "SIZE", "CHECKSUM"], + version + .manifest + .files + .iter() + .map(|file| { + vec![ + file.rel_path.clone(), + file.size_bytes.to_string(), + file.checksum.clone(), + ] + }) + .collect(), + )?; + writeln!(stdout, "Metadata:")?; + serde_json::to_writer_pretty(&mut stdout, &version.metadata)?; + writeln!(stdout)?; + Ok(()) +} + +fn validate_download_directory(directory: Option<&Path>, model: &str) -> anyhow::Result<()> { + if let Some(directory) = directory { + if directory.as_os_str().is_empty() || (directory.exists() && !directory.is_dir()) { + return Err(CliError::new( + ErrorKind::Usage, + format!("Invalid destination directory '{}'.", directory.display()), + ) + .into()); + } + } else if validate_rel_path(model).is_err() || Path::new(model).components().count() != 1 { + return Err(CliError::new( + ErrorKind::Usage, + format!("Model name '{model}' needs an explicit destination directory."), + ) + .with_hint("Pass --directory .") + .into()); + } + Ok(()) +} + +pub fn handle_command(args: ModelsArgs, context: CliContext) -> anyhow::Result { + let title = match &args.command { + ModelsCommands::Push(_) => "Model upload", + _ => "Models", + }; + context.terminal().command_title(title); + let project = resolve_namespace_project(&context)?.project; + let files = match &args.command { + ModelsCommands::Push(args) => { + validate_auto_create(args.auto_create, args.description.as_deref())?; + let spinner = context.terminal().spinner(); + spinner.start("Collecting files..."); + let files = collect_files(&args.directory).map_err(|error| { + spinner.error("Failed to collect files."); + CliError::new(ErrorKind::Usage, format!("{error:#}")) + })?; + spinner.stop(format!("Found {} file(s).", files.len())); + Some(files) + } + ModelsCommands::Promote(args) => { + validate_auto_create(args.auto_create, args.description.as_deref())?; + None + } + ModelsCommands::Pull(args) => { + validate_download_directory(args.directory.as_deref(), &args.model)?; + None + } + _ => None, + }; + let client = get_client_and_login_if_needed(&context)?; + let human = context.output() == OutputMode::Human; + let namespace = &project.owner; + let name = &project.name; + match args.command { + ModelsCommands::List => { + let response = client.list_models(namespace, name)?; + if human { + write_table( + &mut std::io::stdout().lock(), + &["NAME", "VERSIONS", "LATEST", "ALIASES", "CREATED AT"], + response + .items + .iter() + .map(|model| { + vec![ + model.name.clone(), + model.version_count.to_string(), + model + .latest_version + .map(|version| format!("v{version}")) + .unwrap_or_else(|| "-".into()), + alias_summary(&model.aliases), + model.created_at.clone(), + ] + }) + .collect(), + )?; + } + Ok(serde_json::to_value(response)?) + } + ModelsCommands::Get(args) => { + if let Some(reference) = args.version { + let version = + client.resolve_model_version_ref(namespace, name, &args.model, &reference)?; + if human { + print_version(&version)?; + } + Ok(serde_json::to_value(version)?) + } else { + let model = client.get_model(namespace, name, &args.model)?; + if human { + print_model(&model)?; + } + Ok(serde_json::to_value(model)?) + } + } + ModelsCommands::Versions(args) => { + let response = if args.all { + client.list_model_versions_in_state( + namespace, + name, + &args.model, + ModelVersionListState::All, + )? + } else { + client.list_model_versions(namespace, name, &args.model)? + }; + if human { + write_table( + &mut std::io::stdout().lock(), + &[ + "VERSION", + "STATE", + "SOURCE", + "SIZE", + "ALIASES", + "CREATED AT", + ], + response + .items + .iter() + .map(|version| { + let source = match &version.experiment { + Some(experiment) => format!( + "{} (experiment {})", + source_name(version.source_kind), + experiment.experiment_num + ), + None => source_name(version.source_kind).into(), + }; + vec![ + format!("v{}", version.version), + state_name(version.state).into(), + source, + version.size.to_string(), + version.aliases.join(", "), + version.created_at.clone(), + ] + }) + .collect(), + )?; + } + Ok(serde_json::to_value(response)?) + } + ModelsCommands::Pull(args) => { + let version = + client.resolve_model_version_ref(namespace, name, &args.model, &args.version)?; + let directory = args + .directory + .unwrap_or_else(|| PathBuf::from(format!("./{}-v{}", args.model, version.version))); + let response = + client.presign_model_download(namespace, name, &args.model, version.version)?; + let files: Vec<_> = response + .files + .into_iter() + .map(|file| DownloadFile { + rel_path: file.rel_path, + url: file.url, + size_bytes: Some(file.size_bytes), + checksum: Some(file.checksum), + }) + .collect(); + let files = download_files(&client, &directory, &files, args.force)?; + let bytes: u64 = files.iter().map(|file| file.bytes).sum(); + if human { + writeln!( + std::io::stdout().lock(), + "Downloaded '{}' v{} to {}.", + args.model, + version.version, + directory.display() + )?; + } + Ok( + json!({"model": args.model, "version": version, "directory": directory, "files": files, "bytes": bytes}), + ) + } + ModelsCommands::Push(args) => push( + args, + &context, + &client, + project, + files.expect("Push files were collected"), + ), + ModelsCommands::Promote(args) => { + let artifacts = client.list_artifacts(namespace, name, args.experiment)?; + let index = select_artifact( + artifacts + .items + .iter() + .map(|artifact| (artifact.id.as_str(), artifact.name.as_str())), + &args.artifact, + args.experiment, + )?; + let artifact = &artifacts.items[index]; + ensure_model_exists( + &context, + &client, + namespace, + name, + &args.model, + args.auto_create, + args.description, + )?; + let mut version = client.promote_model_version( + namespace, + name, + &args.model, + PromoteModelVersionRequest { + experiment_num: args.experiment, + experiment_file_id: artifact.id.clone(), + metadata: args.metadata, + }, + )?; + if let Some(alias) = args.alias { + client.set_model_alias( + namespace, + name, + &args.model, + &alias, + SetModelAliasRequest { + version: version.version, + expected_current_version: None, + }, + )?; + version = + client.get_model_version(namespace, name, &args.model, version.version)?; + } + if human { + writeln!( + std::io::stdout().lock(), + "Promoted artifact '{}' to '{}' v{}.", + artifact.name, + args.model, + version.version + )?; + } + Ok(serde_json::to_value(version)?) + } + ModelsCommands::Alias(args) => match args.command { + AliasCommands::List(args) => { + let response = client.list_model_aliases(namespace, name, &args.model)?; + if human { + write_table( + &mut std::io::stdout().lock(), + &["ALIAS", "VERSION"], + response + .items + .iter() + .map(|alias| vec![alias.alias.clone(), format!("v{}", alias.version)]) + .collect(), + )?; + } + Ok(serde_json::to_value(response)?) + } + AliasCommands::Set(args) => { + let response = client.set_model_alias( + namespace, + name, + &args.model, + &args.alias, + SetModelAliasRequest { + version: args.version, + expected_current_version: args.expect, + }, + )?; + if human { + writeln!( + std::io::stdout().lock(), + "{}: v{}", + response.alias, + response.version + )?; + } + Ok(serde_json::to_value(response)?) + } + AliasCommands::Remove(args) => { + client.remove_model_alias(namespace, name, &args.model, &args.alias)?; + if human { + writeln!( + std::io::stdout().lock(), + "Removed alias '{}' from '{}'.", + args.alias, + args.model + )?; + } + Ok(json!({"model": args.model, "alias": args.alias, "removed": true})) + } + }, + } +} + +fn push( + args: PushArgs, + context: &CliContext, + client: &Client, + project: TracelProject, + files: BTreeMap, +) -> anyhow::Result { + let namespace = project.owner; + let project = project.name; + context + .terminal() + .print(&format!("Uploading to {namespace}/{project}")); + + ensure_model_exists( + context, + client, + &namespace, + &project, + &args.model_name, + args.auto_create, + args.description.clone(), + )?; + + let spinner = context.terminal().spinner(); + spinner.start("Computing checksums..."); + let file_specs = build_file_specs(&files).inspect_err(|_e| { + spinner.error("Failed to compute checksums."); + })?; + spinner.stop("Checksums computed."); + + let file_sizes: std::collections::BTreeMap = file_specs + .iter() + .map(|f| (f.rel_path.clone(), f.size_bytes)) + .collect(); + + let spinner = context.terminal().spinner(); + spinner.start("Requesting upload URLs..."); + let upload_request = RequestModelVersionUploadRequest { + files: file_specs + .into_iter() + .map(|f| ModelFileSpecRequest { + rel_path: f.rel_path, + size_bytes: f.size_bytes, + checksum: f.checksum, + }) + .collect(), + metadata: args.metadata, + }; + let upload = client + .request_model_version_upload(&namespace, &project, &args.model_name, upload_request) + .map_err(|e| { + spinner.error("Failed to request upload URLs."); + anyhow::anyhow!(e) + })?; + spinner.stop(format!("Allocated model version {}.", upload.version)); + + let tasks = build_part_tasks(&files, &file_sizes, &upload.files)?; + upload_parts(client, tasks, context.terminal())?; + + client.complete_model_version_upload(&namespace, &project, &args.model_name, upload.version)?; + let version = + client.get_model_version(&namespace, &project, &args.model_name, upload.version)?; + + context.terminal().print_success(&format!( + "Uploaded model '{}' version {} to {}/{}.", + args.model_name, upload.version, namespace, project + )); + context + .terminal() + .finalize("Model version uploaded successfully."); + + Ok(serde_json::to_value(version)?) +} + +#[cfg(test)] +mod tests { + use clap::{CommandFactory, Parser}; + + use super::*; + use crate::cli::{CliArgs, Commands}; + + fn parse_command(arguments: &[&str]) -> ModelsArgs { + let args = CliArgs::try_parse_from(arguments).unwrap(); + let Some(Commands::Models(args)) = args.command else { + panic!("Expected models command"); + }; + args + } + + #[test] + fn definitions_and_read_commands_are_valid() { + CliArgs::command().debug_assert(); + assert!(matches!( + parse_command(&["tracel", "models", "list"]).command, + ModelsCommands::List + )); + let args = parse_command(&["tracel", "models", "get", "weights"]); + let ModelsCommands::Get(args) = args.command else { + panic!("Expected get"); + }; + assert_eq!(args.model, "weights"); + assert!(args.version.is_none()); + for reference in ["latest", "7", "v7", "production"] { + let args = + parse_command(&["tracel", "models", "get", "weights", "--version", reference]); + let ModelsCommands::Get(args) = args.command else { + panic!("Expected get"); + }; + assert_eq!(args.version.as_deref(), Some(reference)); + } + let args = parse_command(&["tracel", "models", "versions", "weights", "--all"]); + let ModelsCommands::Versions(args) = args.command else { + panic!("Expected versions"); + }; + assert!(args.all); + let args = parse_command(&["tracel", "models", "pull", "weights"]); + let ModelsCommands::Pull(args) = args.command else { + panic!("Expected pull"); + }; + assert_eq!(args.version, "latest"); + assert!(args.directory.is_none()); + assert!(!args.force); + let args = parse_command(&[ + "tracel", + "models", + "pull", + "weights", + "--version", + "production", + "-d", + "./out", + "--force", + "-o", + "json", + ]); + let ModelsCommands::Pull(args) = args.command else { + panic!("Expected pull"); + }; + assert_eq!(args.version, "production"); + assert_eq!(args.directory.unwrap(), Path::new("./out")); + assert!(args.force); + } + + #[test] + fn push_and_promote_parse_creation_and_metadata() { + let args = parse_command(&[ + "tracel", + "models", + "push", + "weights", + "-d", + ".", + "-a", + "true", + "--description", + "Weights", + "--metadata", + r#"{"format":"bin"}"#, + ]); + let ModelsCommands::Push(args) = args.command else { + panic!("Expected push"); + }; + assert_eq!(args.model_name, "weights"); + assert_eq!(args.directory, Path::new(".")); + assert_eq!(args.auto_create, Some(true)); + assert_eq!(args.description.as_deref(), Some("Weights")); + assert_eq!(args.metadata.unwrap(), json!({"format": "bin"})); + let args = parse_command(&[ + "tracel", + "models", + "promote", + "weights", + "--experiment", + "7", + "--artifact", + "artifact-id", + "--alias", + "production", + "--auto-create", + "false", + "--metadata", + "{}", + ]); + let ModelsCommands::Promote(args) = args.command else { + panic!("Expected promote"); + }; + assert_eq!(args.experiment, 7); + assert_eq!(args.artifact, "artifact-id"); + assert_eq!(args.alias.as_deref(), Some("production")); + assert_eq!(args.auto_create, Some(false)); + assert_eq!(args.metadata.unwrap(), json!({})); + for value in ["null", "[]", "1", "{invalid"] { + assert!( + CliArgs::try_parse_from([ + "tracel", + "models", + "push", + "weights", + "-d", + ".", + "--metadata", + value + ]) + .is_err() + ); + assert!( + CliArgs::try_parse_from([ + "tracel", + "models", + "promote", + "weights", + "--experiment", + "7", + "--artifact", + "weights", + "--metadata", + value + ]) + .is_err() + ); + } + for arguments in [ + vec!["tracel", "models", "push", "weights"], + vec![ + "tracel", + "models", + "push", + "weights", + "-d", + ".", + "--description", + "text", + ], + vec![ + "tracel", + "models", + "push", + "weights", + "-d", + ".", + "--auto-create", + "maybe", + ], + vec![ + "tracel", + "models", + "promote", + "weights", + "--artifact", + "weights", + ], + vec![ + "tracel", + "models", + "promote", + "weights", + "--experiment", + "0", + "--artifact", + "weights", + ], + ] { + assert!(CliArgs::try_parse_from(arguments).is_err()); + } + } + + #[test] + fn aliases_require_positive_versions_and_parse_the_guard() { + let args = parse_command(&["tracel", "models", "alias", "list", "weights"]); + let ModelsCommands::Alias(args) = args.command else { + panic!("Expected alias"); + }; + assert!( + matches!(args.command, AliasCommands::List(AliasListArgs { model }) if model == "weights") + ); + let args = parse_command(&[ + "tracel", + "models", + "alias", + "set", + "weights", + "production", + "7", + "--expect", + "6", + ]); + let ModelsCommands::Alias(args) = args.command else { + panic!("Expected alias"); + }; + let AliasCommands::Set(args) = args.command else { + panic!("Expected set"); + }; + assert_eq!(args.model, "weights"); + assert_eq!(args.alias, "production"); + assert_eq!(args.version, 7); + assert_eq!(args.expect, Some(6)); + let args = parse_command(&[ + "tracel", + "models", + "alias", + "remove", + "weights", + "production", + ]); + let ModelsCommands::Alias(args) = args.command else { + panic!("Expected alias"); + }; + assert!( + matches!(args.command, AliasCommands::Remove(AliasRemoveArgs { model, alias }) if model == "weights" && alias == "production") + ); + for version in ["0", "-1", "4294967296", "v7"] { + assert!( + CliArgs::try_parse_from([ + "tracel", + "models", + "alias", + "set", + "weights", + "production", + version + ]) + .is_err() + ); + assert!( + CliArgs::try_parse_from([ + "tracel", + "models", + "alias", + "set", + "weights", + "production", + "7", + "--expect", + version + ]) + .is_err() + ); + } + } + + #[test] + fn default_pull_destination_requires_a_single_normal_model_name() { + assert!(validate_download_directory(None, "weights").is_ok()); + for model in [ + "", + ".", + "..", + "/weights", + "../weights", + "nested/weights", + "C:\\weights", + ] { + assert_eq!( + crate::error::classify(&validate_download_directory(None, model).unwrap_err()), + ErrorKind::Usage + ); + assert!(validate_download_directory(Some(Path::new("./out")), model).is_ok()); + } + } +} diff --git a/crates/tracel-cli/src/commands/project.rs b/crates/tracel-cli/src/commands/project.rs index 2e120e0..feda7d5 100644 --- a/crates/tracel-cli/src/commands/project.rs +++ b/crates/tracel-cli/src/commands/project.rs @@ -1,12 +1,118 @@ use anyhow::Context; +use clap::{Args, Subcommand}; use serde_json::{Value, json}; +use tracel_client::console::project::request::Visibility; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::resolve_namespace_project; +use crate::output::{OutputMode, write_table}; -pub fn handle_command(context: CliContext) -> anyhow::Result { +#[derive(Args, Debug)] +pub struct ProjectArgs { + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand, Debug)] +pub enum ProjectCommands { + /// List your projects and your organizations' projects. + List(ListArgs), +} + +#[derive(Args, Debug)] +pub struct ListArgs { + /// Your namespace or one of your organizations; omit to list all. + #[arg(value_parser = clap::builder::NonEmptyStringValueParser::new())] + pub namespace: Option, +} + +pub fn handle_command(args: ProjectArgs, context: CliContext) -> anyhow::Result { + match args.command { + Some(ProjectCommands::List(args)) => list_projects(args, context), + None => show_project(context), + } +} + +fn list_projects(args: ListArgs, context: CliContext) -> anyhow::Result { + context.terminal().command_title("Projects"); + let client = get_client_and_login_if_needed(&context)?; + let user_namespace = &client.user().namespace; + let mut projects = Vec::new(); + if args + .namespace + .as_deref() + .is_none_or(|namespace| namespace == user_namespace) + { + projects.extend(client.list_user_projects(user_namespace)?); + if args.namespace.is_some() { + return print_projects(projects, context.output()); + } + } + let organizations = client.get_user_organizations()?; + if let Some(namespace) = args.namespace { + if !organizations + .organizations + .iter() + .any(|organization| organization.namespace == namespace) + { + let available = std::iter::once(user_namespace.as_str()) + .chain( + organizations + .organizations + .iter() + .map(|organization| organization.namespace.as_str()), + ) + .collect::>() + .join(", "); + return Err(CliError::new( + ErrorKind::NotFound, + format!("Namespace '{namespace}' is not available."), + ) + .with_hint(format!( + "Available namespaces: {available}. Use `tracel project list `." + )) + .into()); + } + projects.extend(client.list_organization_projects(&namespace)?); + } else { + for organization in organizations.organizations { + projects.extend(client.list_organization_projects(&organization.namespace)?); + } + } + print_projects(projects, context.output()) +} + +fn print_projects( + projects: Vec, + mode: OutputMode, +) -> anyhow::Result { + if mode == OutputMode::Human { + write_table( + &mut std::io::stdout().lock(), + &["PROJECT", "VISIBILITY", "DESCRIPTION", "CREATED AT"], + projects + .iter() + .map(|project| { + let visibility = match project.visibility { + Visibility::Private => "private", + Visibility::Public => "public", + }; + vec![ + format!("{}/{}", project.namespace_name, project.project_name), + visibility.into(), + project.description.clone(), + project.created_at.clone(), + ] + }) + .collect(), + )?; + } + Ok(serde_json::to_value(projects)?) +} + +fn show_project(context: CliContext) -> anyhow::Result { context.terminal().command_title("Project Information"); let resolved = resolve_namespace_project(&context)?; @@ -52,3 +158,34 @@ pub fn handle_command(context: CliContext) -> anyhow::Result { "source": resolved.source, })) } + +#[cfg(test)] +mod tests { + use clap::{CommandFactory, Parser}; + + use super::*; + use crate::cli::{CliArgs, Commands}; + + #[test] + fn bare_project_and_list_parse_without_a_selected_project() { + CliArgs::command().debug_assert(); + let args = CliArgs::try_parse_from(["tracel", "project"]).unwrap(); + let Some(Commands::Project(args)) = args.command else { + panic!("Expected project"); + }; + assert!(args.command.is_none()); + for namespace in [None, Some("alice"), Some("team")] { + let mut arguments = vec!["tracel", "project", "list"]; + arguments.extend(namespace); + let args = CliArgs::try_parse_from(arguments).unwrap(); + assert!(args.project.is_none()); + let Some(Commands::Project(args)) = args.command else { + panic!("Expected project"); + }; + let Some(ProjectCommands::List(args)) = args.command else { + panic!("Expected list"); + }; + assert_eq!(args.namespace.as_deref(), namespace); + } + } +} diff --git a/crates/tracel-cli/src/helpers/download.rs b/crates/tracel-cli/src/helpers/download.rs new file mode 100644 index 0000000..2b0b6bc --- /dev/null +++ b/crates/tracel-cli/src/helpers/download.rs @@ -0,0 +1,279 @@ +use std::fs::{self, File}; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; + +use anyhow::Context; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use tracel_client::console::Client; + +use crate::error::{CliError, ErrorKind}; + +pub struct DownloadFile { + pub rel_path: String, + pub url: String, + pub size_bytes: Option, + pub checksum: Option, +} + +#[derive(Debug, Serialize)] +pub struct DownloadResult { + pub rel_path: String, + pub path: PathBuf, + pub bytes: u64, +} + +pub fn validate_rel_path(rel_path: &str) -> Result<(), CliError> { + let path = Path::new(rel_path); + let windows_prefix = rel_path.as_bytes().get(1) == Some(&b':'); + if rel_path.is_empty() + || rel_path.contains(['\\', '\0']) + || windows_prefix + || rel_path.split('/').any(|part| part == ".") + || !path + .components() + .all(|part| matches!(part, Component::Normal(_))) + { + return Err(CliError::new( + ErrorKind::Internal, + format!("Unsafe download path '{rel_path}'."), + )); + } + Ok(()) +} + +fn check_destinations(directory: &Path, files: &[DownloadFile], force: bool) -> anyhow::Result<()> { + for file in files { + validate_rel_path(&file.rel_path)?; + } + for file in files { + let path = directory.join(&file.rel_path); + if !force && path.try_exists()? { + return Err(CliError::new( + ErrorKind::Conflict, + format!("Destination '{}' already exists.", path.display()), + ) + .with_hint("Pass --force to overwrite.") + .into()); + } + } + Ok(()) +} + +struct HashingWriter { + inner: W, + hasher: Sha256, + bytes: u64, +} + +impl HashingWriter { + fn new(inner: W) -> Self { + Self { + inner, + hasher: Sha256::new(), + bytes: 0, + } + } + + fn verify(&self, file: &DownloadFile) -> Result<(), CliError> { + if let Some(size) = file.size_bytes.filter(|size| *size != 0) { + if self.bytes != size { + return Err(CliError::new( + ErrorKind::Internal, + format!( + "Size mismatch for '{}': expected {size} bytes, received {}.", + file.rel_path, self.bytes + ), + )); + } + } + if let Some(checksum) = file + .checksum + .as_deref() + .filter(|checksum| !checksum.is_empty()) + { + let actual = format!("{:x}", self.hasher.clone().finalize()); + if !actual.eq_ignore_ascii_case(checksum) { + return Err(CliError::new( + ErrorKind::Internal, + format!( + "Checksum mismatch for '{}': expected {checksum}, received {actual}.", + file.rel_path + ), + )); + } + } + Ok(()) + } +} + +impl Write for HashingWriter { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + let written = self.inner.write(buffer)?; + self.hasher.update(&buffer[..written]); + self.bytes += written as u64; + Ok(written) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + +pub fn download_files( + client: &Client, + directory: &Path, + files: &[DownloadFile], + force: bool, +) -> anyhow::Result> { + check_destinations(directory, files, force)?; + let mut results = Vec::with_capacity(files.len()); + for file in files { + let path = directory.join(&file.rel_path); + let parent = path.parent().expect("Validated relative file has a parent"); + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create '{}'.", parent.display()))?; + let mut name = path + .file_name() + .expect("Validated relative file has a name") + .to_os_string(); + name.push(".part"); + let part = path.with_file_name(name); + let output = File::create(&part) + .with_context(|| format!("Failed to create '{}'.", part.display()))?; + let result = (|| -> anyhow::Result { + let mut writer = HashingWriter::new(output); + client.download_from_url(&file.url, &mut writer)?; + writer.flush()?; + writer.verify(file)?; + let bytes = writer.bytes; + drop(writer); + fs::rename(&part, &path) + .with_context(|| format!("Failed to save '{}'.", path.display()))?; + Ok(bytes) + })(); + let bytes = result.inspect_err(|_| { + let _ = fs::remove_file(&part); + })?; + results.push(DownloadResult { + rel_path: file.rel_path.clone(), + path, + bytes, + }); + } + Ok(results) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_must_contain_only_normal_relative_components() { + for path in ["a", "nested/weights.bin", "a-b/c_d"] { + assert!(validate_rel_path(path).is_ok()); + } + for path in [ + "", + ".", + "./a", + "a/./b", + "..", + "../a", + "a/../b", + "/a", + "C:\\a", + "C:a", + "C:/a", + "\\\\server\\file", + "\\a", + "a\0b", + ] { + let error = validate_rel_path(path).unwrap_err(); + assert_eq!(error.kind, ErrorKind::Internal); + assert!(error.to_string().contains(path)); + } + } + + fn file(size_bytes: Option, checksum: Option) -> DownloadFile { + DownloadFile { + rel_path: "hello".into(), + url: String::new(), + size_bytes, + checksum, + } + } + + #[test] + fn writer_hashes_and_counts_only_written_bytes() { + struct PartialWriter(Vec); + impl Write for PartialWriter { + fn write(&mut self, data: &[u8]) -> std::io::Result { + let count = data.len().min(2); + self.0.extend_from_slice(&data[..count]); + Ok(count) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut writer = HashingWriter::new(PartialWriter(Vec::new())); + writer.write_all(b"hello world").unwrap(); + assert_eq!(writer.bytes, 11); + assert_eq!(writer.inner.0, b"hello world"); + let checksum = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"; + assert!( + writer + .verify(&file(Some(11), Some(checksum.to_uppercase()))) + .is_ok() + ); + assert!(writer.verify(&file(Some(0), Some(String::new()))).is_ok()); + assert!(writer.verify(&file(None, None)).is_ok()); + assert_eq!( + writer.verify(&file(Some(10), None)).unwrap_err().kind, + ErrorKind::Internal + ); + assert_eq!( + writer + .verify(&file(None, Some("bad".into()))) + .unwrap_err() + .kind, + ErrorKind::Internal + ); + } + + #[test] + fn preflight_validates_all_paths_before_checking_all_conflicts() { + let directory = std::env::temp_dir().join(format!( + "tracel-download-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join("hello"), b"existing").unwrap(); + let mut files = vec![ + DownloadFile { + rel_path: "new".into(), + ..file(None, None) + }, + file(None, None), + ]; + let error = check_destinations(&directory, &files, false).unwrap_err(); + assert_eq!(crate::error::classify(&error), ErrorKind::Conflict); + assert!(error.to_string().contains("hello")); + assert!(!directory.join("new").exists()); + assert!(check_destinations(&directory, &files, true).is_ok()); + files.push(DownloadFile { + rel_path: "../unsafe".into(), + ..file(None, None) + }); + assert_eq!( + crate::error::classify(&check_destinations(&directory, &files, false).unwrap_err()), + ErrorKind::Internal + ); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/crates/tracel-cli/src/helpers/mod.rs b/crates/tracel-cli/src/helpers/mod.rs index fa128eb..d5f5ef9 100644 --- a/crates/tracel-cli/src/helpers/mod.rs +++ b/crates/tracel-cli/src/helpers/mod.rs @@ -1,5 +1,14 @@ pub mod model_upload; pub mod project; -pub use model_upload::*; -pub use project::*; +pub use model_upload::{build_part_tasks, ensure_model_exists, upload_parts}; +pub use project::{ + can_initialize_project, require_cargo_workspace, require_linked_project, + resolve_namespace_project, validate_project_exists_on_server, +}; + +mod download; +mod resources; + +pub use download::{DownloadFile, download_files, validate_rel_path}; +pub use resources::{parse_metadata, select_artifact, validate_auto_create}; diff --git a/crates/tracel-cli/src/helpers/model_upload/mod.rs b/crates/tracel-cli/src/helpers/model_upload/mod.rs index 5e8d35a..6397bec 100644 --- a/crates/tracel-cli/src/helpers/model_upload/mod.rs +++ b/crates/tracel-cli/src/helpers/model_upload/mod.rs @@ -1,4 +1,4 @@ -//! Helpers for `tracel model upload`: model existence check, and +//! Helpers for `tracel models push`: model existence check, and //! multi-threaded fail-fast multipart part upload. mod existence; diff --git a/crates/tracel-cli/src/helpers/resources.rs b/crates/tracel-cli/src/helpers/resources.rs new file mode 100644 index 0000000..b661dc2 --- /dev/null +++ b/crates/tracel-cli/src/helpers/resources.rs @@ -0,0 +1,119 @@ +use serde_json::Value; + +use crate::error::{CliError, ErrorKind}; + +pub fn parse_metadata(value: &str) -> Result { + let metadata: Value = serde_json::from_str(value).map_err(|error| { + CliError::new( + ErrorKind::Usage, + format!("Invalid --metadata JSON: {error}"), + ) + })?; + if !metadata.is_object() { + return Err(CliError::new( + ErrorKind::Usage, + "--metadata must be a JSON object.", + )); + } + Ok(metadata) +} + +pub fn validate_auto_create( + auto_create: Option, + description: Option<&str>, +) -> Result<(), CliError> { + if description.is_some() && auto_create != Some(true) { + return Err(CliError::new( + ErrorKind::Usage, + "--description can only be used together with --auto-create true.", + )); + } + Ok(()) +} + +pub fn select_artifact<'a>( + items: impl IntoIterator, + value: &str, + experiment: i32, +) -> Result { + let mut matches = Vec::new(); + for (index, (id, name)) in items.into_iter().enumerate() { + if id == value { + return Ok(index); + } + if name == value { + matches.push((index, id)); + } + } + match matches.as_slice() { + [] => Err(CliError::new( + ErrorKind::NotFound, + format!("No artifact '{value}' in experiment {experiment}."), + ) + .with_hint(format!( + "List artifacts with `tracel artifacts list {experiment}`." + ))), + [(index, _)] => Ok(*index), + _ => Err(CliError::new( + ErrorKind::Conflict, + format!( + "Several artifacts named '{value}' in experiment {experiment}: {}.", + matches + .iter() + .map(|(_, id)| *id) + .collect::>() + .join(", ") + ), + ) + .with_hint("Pass the artifact id instead.")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn artifact_selection_prefers_id_and_reports_missing_or_ambiguous_names() { + let items = [ + ("a", "weights"), + ("b", "weights"), + ("c", "a"), + ("d", "logs"), + ]; + assert_eq!(select_artifact(items, "a", 7).unwrap(), 0); + assert_eq!(select_artifact(items, "logs", 7).unwrap(), 3); + let error = select_artifact(items, "weights", 7).unwrap_err(); + assert_eq!(error.kind, ErrorKind::Conflict); + assert!(error.to_string().contains("a, b")); + let error = select_artifact(items, "missing", 7).unwrap_err(); + assert_eq!(error.kind, ErrorKind::NotFound); + assert_eq!(error.to_string(), "No artifact 'missing' in experiment 7."); + assert!(select_artifact([], "missing", 7).is_err()); + assert_eq!( + select_artifact([("a", "b"), ("b", "weights")], "b", 7).unwrap(), + 1 + ); + } + + #[test] + fn metadata_requires_a_json_object_and_description_requires_creation() { + assert_eq!( + parse_metadata(r#"{"format":"bin"}"#).unwrap()["format"], + "bin" + ); + for value in ["", "{", "null", "[]", "1", "true", r#""text""#] { + assert_eq!(parse_metadata(value).unwrap_err().kind, ErrorKind::Usage); + } + for auto_create in [None, Some(false)] { + assert_eq!( + validate_auto_create(auto_create, Some("text")) + .unwrap_err() + .kind, + ErrorKind::Usage + ); + } + assert!(validate_auto_create(Some(true), Some("text")).is_ok()); + assert!(validate_auto_create(None, None).is_ok()); + } +} diff --git a/crates/tracel-cli/src/output.rs b/crates/tracel-cli/src/output.rs index df73bf5..9a27847 100644 --- a/crates/tracel-cli/src/output.rs +++ b/crates/tracel-cli/src/output.rs @@ -79,6 +79,44 @@ fn write_line(value: &impl Serialize) -> anyhow::Result<()> { Ok(()) } +pub fn write_table( + stdout: &mut impl Write, + headers: &[&str], + rows: Vec>, +) -> std::io::Result<()> { + let rows: Vec> = + std::iter::once(headers.iter().map(|header| (*header).into()).collect()) + .chain(rows) + .map(|row: Vec| { + row.into_iter() + .map(|cell| cell.split_whitespace().collect::>().join(" ")) + .collect() + }) + .collect(); + let widths: Vec<_> = (0..headers.len()) + .map(|column| { + rows.iter() + .map(|row| console::measure_text_width(&row[column])) + .max() + .unwrap_or(0) + }) + .collect(); + for row in rows { + for (column, cell) in row.iter().enumerate() { + write!(stdout, "{cell}")?; + if column + 1 < headers.len() { + write!( + stdout, + "{} ", + " ".repeat(widths[column].saturating_sub(console::measure_text_width(cell))) + )?; + } + } + writeln!(stdout)?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; From dc78b2b9f115c630d53e571bcb8c10cb3f0c14d4 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 6 Oct 2026 17:51:20 -0400 Subject: [PATCH 12/32] feat: name the missing resource in not-found errors - `models`, `artifacts` and `datasets` report which model, version, alias, experiment or dataset is missing, with a hint naming the command that lists the valid values. - Filesystem errors no longer print a period before their cause. --- crates/tracel-cli/src/commands/artifacts.rs | 34 ++- crates/tracel-cli/src/commands/datasets.rs | 40 ++- crates/tracel-cli/src/commands/models.rs | 184 +++++++++---- crates/tracel-cli/src/helpers/mod.rs | 4 +- crates/tracel-cli/src/helpers/resources.rs | 276 ++++++++++++++++++++ crates/tracel-cli/src/tools/fs.rs | 8 +- 6 files changed, 474 insertions(+), 72 deletions(-) diff --git a/crates/tracel-cli/src/commands/artifacts.rs b/crates/tracel-cli/src/commands/artifacts.rs index 3205ce6..fa487ab 100644 --- a/crates/tracel-cli/src/commands/artifacts.rs +++ b/crates/tracel-cli/src/commands/artifacts.rs @@ -8,7 +8,8 @@ use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::{ - DownloadFile, download_files, resolve_namespace_project, select_artifact, validate_rel_path, + DownloadFile, Resource, download_files, map_resource_error, resolve_namespace_project, + select_artifact, validate_rel_path, }; use crate::output::{OutputMode, write_table}; @@ -106,10 +107,18 @@ pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Resul ArtifactsCommands::List(args) => { let response = match args.name { Some(filter) => { - client.list_artifacts_by_name(namespace, name, args.experiment, &filter)? + client.list_artifacts_by_name(namespace, name, args.experiment, &filter) } - None => client.list_artifacts(namespace, name, args.experiment)?, - }; + None => client.list_artifacts(namespace, name, args.experiment), + } + .map_err(|error| { + map_resource_error( + error, + namespace, + name, + Resource::Experiment(args.experiment), + ) + })?; if human { write_table( &mut std::io::stdout().lock(), @@ -134,7 +143,17 @@ pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Resul Ok(serde_json::to_value(response)?) } ArtifactsCommands::Download(args) => { - let artifacts = client.list_artifacts(namespace, name, args.experiment)?; + let map_error = |error| { + map_resource_error( + error, + namespace, + name, + Resource::Experiment(args.experiment), + ) + }; + let artifacts = client + .list_artifacts(namespace, name, args.experiment) + .map_err(map_error)?; let index = select_artifact( artifacts .items @@ -148,8 +167,9 @@ pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Resul Some(directory) => directory, None => default_directory(&artifact.name)?, }; - let response = - client.presign_artifact_download(namespace, name, args.experiment, &artifact.id)?; + let response = client + .presign_artifact_download(namespace, name, args.experiment, &artifact.id) + .map_err(map_error)?; let files: Vec<_> = response .files .into_iter() diff --git a/crates/tracel-cli/src/commands/datasets.rs b/crates/tracel-cli/src/commands/datasets.rs index 184b0ea..3dc0b5a 100644 --- a/crates/tracel-cli/src/commands/datasets.rs +++ b/crates/tracel-cli/src/commands/datasets.rs @@ -7,7 +7,7 @@ use tracel_client::console::dataset::response::SourceKindResponse; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; -use crate::helpers::resolve_namespace_project; +use crate::helpers::{Resource, map_resource_error, resolve_namespace_project}; use crate::output::{OutputMode, write_table}; #[derive(Args, Debug)] @@ -94,7 +94,16 @@ pub fn handle_command(args: DatasetsArgs, context: CliContext) -> anyhow::Result Ok(serde_json::to_value(response)?) } DatasetsCommands::Get(args) => { - let dataset = client.get_dataset(&project.owner, &project.name, &args.dataset)?; + let dataset = client + .get_dataset(&project.owner, &project.name, &args.dataset) + .map_err(|error| { + map_resource_error( + error, + &project.owner, + &project.name, + Resource::Dataset(&args.dataset), + ) + })?; if human { let mut stdout = std::io::stdout().lock(); writeln!(stdout, "Name: {}", dataset.name)?; @@ -111,15 +120,24 @@ pub fn handle_command(args: DatasetsArgs, context: CliContext) -> anyhow::Result Ok(serde_json::to_value(dataset)?) } DatasetsCommands::Versions(args) => { - let response = client.query_dataset_versions( - &project.owner, - &project.name, - &args.dataset, - QueryDatasetVersionsRequest { - page: args.page, - per_page: args.per_page, - }, - )?; + let response = client + .query_dataset_versions( + &project.owner, + &project.name, + &args.dataset, + QueryDatasetVersionsRequest { + page: args.page, + per_page: args.per_page, + }, + ) + .map_err(|error| { + map_resource_error( + error, + &project.owner, + &project.name, + Resource::Dataset(&args.dataset), + ) + })?; if human { write_table( &mut std::io::stdout().lock(), diff --git a/crates/tracel-cli/src/commands/models.rs b/crates/tracel-cli/src/commands/models.rs index 8432532..8f69949 100644 --- a/crates/tracel-cli/src/commands/models.rs +++ b/crates/tracel-cli/src/commands/models.rs @@ -18,9 +18,9 @@ use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::{ - DownloadFile, build_part_tasks, download_files, ensure_model_exists, parse_metadata, - resolve_namespace_project, select_artifact, upload_parts, validate_auto_create, - validate_rel_path, + DownloadFile, Resource, build_part_tasks, download_files, ensure_model_exists, + map_resource_error, parse_metadata, resolve_namespace_project, select_artifact, upload_parts, + validate_auto_create, validate_rel_path, }; use crate::output::{OutputMode, write_table}; use crate::tools::fs::{build_file_specs, collect_files}; @@ -340,14 +340,29 @@ pub fn handle_command(args: ModelsArgs, context: CliContext) -> anyhow::Result { if let Some(reference) = args.version { - let version = - client.resolve_model_version_ref(namespace, name, &args.model, &reference)?; + let version = client + .resolve_model_version_ref(namespace, name, &args.model, &reference) + .map_err(|error| { + map_resource_error( + error, + namespace, + name, + Resource::ModelVersionRef { + model: &args.model, + reference: &reference, + }, + ) + })?; if human { print_version(&version)?; } Ok(serde_json::to_value(version)?) } else { - let model = client.get_model(namespace, name, &args.model)?; + let model = client + .get_model(namespace, name, &args.model) + .map_err(|error| { + map_resource_error(error, namespace, name, Resource::Model(&args.model)) + })?; if human { print_model(&model)?; } @@ -361,10 +376,13 @@ pub fn handle_command(args: ModelsArgs, context: CliContext) -> anyhow::Result anyhow::Result { - let version = - client.resolve_model_version_ref(namespace, name, &args.model, &args.version)?; + let map_error = |error| { + map_resource_error( + error, + namespace, + name, + Resource::ModelVersionRef { + model: &args.model, + reference: &args.version, + }, + ) + }; + let version = client + .resolve_model_version_ref(namespace, name, &args.model, &args.version) + .map_err(map_error)?; let directory = args .directory .unwrap_or_else(|| PathBuf::from(format!("./{}-v{}", args.model, version.version))); - let response = - client.presign_model_download(namespace, name, &args.model, version.version)?; + let response = client + .presign_model_download(namespace, name, &args.model, version.version) + .map_err(map_error)?; let files: Vec<_> = response .files .into_iter() @@ -443,7 +474,16 @@ pub fn handle_command(args: ModelsArgs, context: CliContext) -> anyhow::Result { - let artifacts = client.list_artifacts(namespace, name, args.experiment)?; + let artifacts = client + .list_artifacts(namespace, name, args.experiment) + .map_err(|error| { + map_resource_error( + error, + namespace, + name, + Resource::Experiment(args.experiment), + ) + })?; let index = select_artifact( artifacts .items @@ -462,29 +502,36 @@ pub fn handle_command(args: ModelsArgs, context: CliContext) -> anyhow::Result anyhow::Result match args.command { AliasCommands::List(args) => { - let response = client.list_model_aliases(namespace, name, &args.model)?; + let response = client + .list_model_aliases(namespace, name, &args.model) + .map_err(|error| { + map_resource_error(error, namespace, name, Resource::Model(&args.model)) + })?; if human { write_table( &mut std::io::stdout().lock(), @@ -514,16 +565,28 @@ pub fn handle_command(args: ModelsArgs, context: CliContext) -> anyhow::Result { - let response = client.set_model_alias( - namespace, - name, - &args.model, - &args.alias, - SetModelAliasRequest { - version: args.version, - expected_current_version: args.expect, - }, - )?; + let response = client + .set_model_alias( + namespace, + name, + &args.model, + &args.alias, + SetModelAliasRequest { + version: args.version, + expected_current_version: args.expect, + }, + ) + .map_err(|error| { + map_resource_error( + error, + namespace, + name, + Resource::ModelVersion { + model: &args.model, + version: args.version, + }, + ) + })?; if human { writeln!( std::io::stdout().lock(), @@ -535,7 +598,19 @@ pub fn handle_command(args: ModelsArgs, context: CliContext) -> anyhow::Result { - client.remove_model_alias(namespace, name, &args.model, &args.alias)?; + client + .remove_model_alias(namespace, name, &args.model, &args.alias) + .map_err(|error| { + map_resource_error( + error, + namespace, + name, + Resource::ModelAlias { + model: &args.model, + alias: &args.alias, + }, + ) + })?; if human { writeln!( std::io::stdout().lock(), @@ -602,16 +677,27 @@ fn push( .request_model_version_upload(&namespace, &project, &args.model_name, upload_request) .map_err(|e| { spinner.error("Failed to request upload URLs."); - anyhow::anyhow!(e) + map_resource_error(e, &namespace, &project, Resource::Model(&args.model_name)) })?; spinner.stop(format!("Allocated model version {}.", upload.version)); let tasks = build_part_tasks(&files, &file_sizes, &upload.files)?; upload_parts(client, tasks, context.terminal())?; - client.complete_model_version_upload(&namespace, &project, &args.model_name, upload.version)?; - let version = - client.get_model_version(&namespace, &project, &args.model_name, upload.version)?; + let map_error = |error| { + map_resource_error( + error, + &namespace, + &project, + Resource::Model(&args.model_name), + ) + }; + client + .complete_model_version_upload(&namespace, &project, &args.model_name, upload.version) + .map_err(map_error)?; + let version = client + .get_model_version(&namespace, &project, &args.model_name, upload.version) + .map_err(map_error)?; context.terminal().print_success(&format!( "Uploaded model '{}' version {} to {}/{}.", diff --git a/crates/tracel-cli/src/helpers/mod.rs b/crates/tracel-cli/src/helpers/mod.rs index d5f5ef9..09f69e8 100644 --- a/crates/tracel-cli/src/helpers/mod.rs +++ b/crates/tracel-cli/src/helpers/mod.rs @@ -11,4 +11,6 @@ mod download; mod resources; pub use download::{DownloadFile, download_files, validate_rel_path}; -pub use resources::{parse_metadata, select_artifact, validate_auto_create}; +pub use resources::{ + Resource, map_resource_error, parse_metadata, select_artifact, validate_auto_create, +}; diff --git a/crates/tracel-cli/src/helpers/resources.rs b/crates/tracel-cli/src/helpers/resources.rs index b661dc2..fe9a41a 100644 --- a/crates/tracel-cli/src/helpers/resources.rs +++ b/crates/tracel-cli/src/helpers/resources.rs @@ -1,7 +1,83 @@ use serde_json::Value; +use tracel_client::{ApiErrorCode, ClientError}; use crate::error::{CliError, ErrorKind}; +#[derive(Clone, Copy)] +pub enum Resource<'a> { + Model(&'a str), + ModelVersionRef { model: &'a str, reference: &'a str }, + ModelVersion { model: &'a str, version: u32 }, + ModelAlias { model: &'a str, alias: &'a str }, + Experiment(i32), + Dataset(&'a str), +} + +pub fn map_resource_error( + error: ClientError, + namespace: &str, + project: &str, + resource: Resource<'_>, +) -> anyhow::Error { + let (message, hint) = match (&error, resource) { + ( + ClientError::NotFoundWithCode(ApiErrorCode::Model), + Resource::Model(model) + | Resource::ModelVersionRef { model, .. } + | Resource::ModelVersion { model, .. } + | Resource::ModelAlias { model, .. }, + ) => ( + format!("No model '{model}' in {namespace}/{project}."), + "List models with `tracel models list`.".to_string(), + ), + ( + ClientError::NotFound | ClientError::NotFoundWithCode(ApiErrorCode::Unknown), + Resource::Model(model), + ) => ( + format!("No model '{model}' in {namespace}/{project}."), + "List models with `tracel models list`.".to_string(), + ), + ( + ClientError::NotFoundWithCode(ApiErrorCode::ModelVersion | ApiErrorCode::ModelAlias), + Resource::ModelVersionRef { model, reference }, + ) => ( + format!("No version or alias '{reference}' on model '{model}'."), + format!( + "List versions with `tracel models versions {model}` and aliases with `tracel models alias list {model}`." + ), + ), + ( + ClientError::NotFoundWithCode(ApiErrorCode::ModelVersion), + Resource::ModelVersion { model, version }, + ) => ( + format!("Model '{model}' has no version {version}."), + format!("List versions with `tracel models versions {model}`."), + ), + ( + ClientError::NotFoundWithCode(ApiErrorCode::ModelAlias), + Resource::ModelAlias { model, alias }, + ) => ( + format!("Model '{model}' has no alias '{alias}'."), + format!("List aliases with `tracel models alias list {model}`."), + ), + ( + ClientError::NotFound | ClientError::NotFoundWithCode(ApiErrorCode::Unknown), + Resource::Experiment(experiment), + ) => ( + format!("No experiment {experiment} in {namespace}/{project}."), + "List experiments with `tracel experiments list`.".to_string(), + ), + (ClientError::NotFoundWithCode(ApiErrorCode::Dataset), Resource::Dataset(dataset)) => ( + format!("No dataset '{dataset}' in {namespace}/{project}."), + "List datasets with `tracel datasets list`.".to_string(), + ), + _ => return error.into(), + }; + CliError::new(ErrorKind::NotFound, message) + .with_hint(hint) + .into() +} + pub fn parse_metadata(value: &str) -> Result { let metadata: Value = serde_json::from_str(value).map_err(|error| { CliError::new( @@ -72,6 +148,206 @@ pub fn select_artifact<'a>( #[cfg(test)] mod tests { use super::*; + use crate::error::ErrorReport; + + #[test] + fn missing_models_use_the_resolved_project_in_every_model_context() { + for resource in [ + Resource::Model("weights"), + Resource::ModelVersionRef { + model: "weights", + reference: "production", + }, + Resource::ModelVersion { + model: "weights", + version: 7, + }, + Resource::ModelAlias { + model: "weights", + alias: "production", + }, + ] { + let error = map_resource_error( + ClientError::NotFoundWithCode(ApiErrorCode::Model), + "alice", + "demo", + resource, + ); + assert_eq!( + error.downcast_ref::().unwrap().kind, + ErrorKind::NotFound + ); + let report = ErrorReport::new(&error); + assert_eq!(report.message, "No model 'weights' in alice/demo."); + assert_eq!(report.hint, Some("List models with `tracel models list`.")); + assert_eq!(report.exit_code, 5); + } + for client_error in [ + ClientError::NotFound, + ClientError::NotFoundWithCode(ApiErrorCode::Unknown), + ] { + let error = + map_resource_error(client_error, "alice", "demo", Resource::Model("weights")); + let report = ErrorReport::new(&error); + assert_eq!(report.message, "No model 'weights' in alice/demo."); + assert_eq!(report.exit_code, 5); + } + } + + #[test] + fn missing_resources_have_specific_messages_and_listing_hints() { + for (client_error, resource, message, hint) in [ + ( + ClientError::NotFoundWithCode(ApiErrorCode::ModelVersion), + Resource::ModelVersionRef { + model: "weights", + reference: "v7", + }, + "No version or alias 'v7' on model 'weights'.", + "List versions with `tracel models versions weights` and aliases with `tracel models alias list weights`.", + ), + ( + ClientError::NotFoundWithCode(ApiErrorCode::ModelAlias), + Resource::ModelVersionRef { + model: "weights", + reference: "production", + }, + "No version or alias 'production' on model 'weights'.", + "List versions with `tracel models versions weights` and aliases with `tracel models alias list weights`.", + ), + ( + ClientError::NotFoundWithCode(ApiErrorCode::ModelVersion), + Resource::ModelVersion { + model: "weights", + version: 7, + }, + "Model 'weights' has no version 7.", + "List versions with `tracel models versions weights`.", + ), + ( + ClientError::NotFoundWithCode(ApiErrorCode::ModelAlias), + Resource::ModelAlias { + model: "weights", + alias: "production", + }, + "Model 'weights' has no alias 'production'.", + "List aliases with `tracel models alias list weights`.", + ), + ( + ClientError::NotFound, + Resource::Experiment(99), + "No experiment 99 in alice/demo.", + "List experiments with `tracel experiments list`.", + ), + ( + ClientError::NotFoundWithCode(ApiErrorCode::Unknown), + Resource::Experiment(99), + "No experiment 99 in alice/demo.", + "List experiments with `tracel experiments list`.", + ), + ( + ClientError::NotFoundWithCode(ApiErrorCode::Dataset), + Resource::Dataset("images"), + "No dataset 'images' in alice/demo.", + "List datasets with `tracel datasets list`.", + ), + ] { + let error = map_resource_error(client_error, "alice", "demo", resource); + assert_eq!( + error.downcast_ref::().unwrap().kind, + ErrorKind::NotFound + ); + let report = ErrorReport::new(&error); + assert_eq!(report.message, message); + assert_eq!(report.hint, Some(hint)); + assert_eq!(report.exit_code, 5); + } + } + + #[test] + fn unrelated_errors_and_codes_keep_the_original_client_error() { + for resource in [ + Resource::Model("weights"), + Resource::ModelVersionRef { + model: "weights", + reference: "production", + }, + Resource::ModelVersion { + model: "weights", + version: 7, + }, + Resource::ModelAlias { + model: "weights", + alias: "production", + }, + Resource::Experiment(99), + Resource::Dataset("images"), + ] { + for client_error in [ + ClientError::Unauthenticated, + ClientError::CredentialNotAllowed, + ClientError::InternalServerError, + ClientError::UnknownError("connection refused".into()), + ClientError::NotFoundWithCode(ApiErrorCode::DatasetVersion), + ClientError::NotFoundWithCode(ApiErrorCode::ModelVersionNotReady), + ClientError::ApiError { + status: 404.try_into().unwrap(), + body: tracel_client::error::ApiErrorBody { + code: ApiErrorCode::Model, + message: "original message".into(), + }, + }, + ] { + let original = format!("{client_error:?}"); + let error = map_resource_error(client_error, "alice", "demo", resource); + assert_eq!( + format!("{:?}", error.downcast_ref::().unwrap()), + original + ); + assert_eq!(ErrorReport::new(&error).hint, None); + } + } + for (client_error, resource) in [ + ( + ClientError::NotFoundWithCode(ApiErrorCode::Unknown), + Resource::Dataset("images"), + ), + ( + ClientError::NotFoundWithCode(ApiErrorCode::Model), + Resource::Experiment(99), + ), + ( + ClientError::NotFoundWithCode(ApiErrorCode::ModelVersion), + Resource::Model("weights"), + ), + ( + ClientError::NotFoundWithCode(ApiErrorCode::ModelAlias), + Resource::ModelVersion { + model: "weights", + version: 7, + }, + ), + ( + ClientError::NotFoundWithCode(ApiErrorCode::ModelVersion), + Resource::ModelAlias { + model: "weights", + alias: "production", + }, + ), + ( + ClientError::NotFoundWithCode(ApiErrorCode::Dataset), + Resource::Model("weights"), + ), + ] { + let original = format!("{client_error:?}"); + let error = map_resource_error(client_error, "alice", "demo", resource); + assert_eq!( + format!("{:?}", error.downcast_ref::().unwrap()), + original + ); + assert_eq!(ErrorReport::new(&error).hint, None); + } + } #[test] fn artifact_selection_prefers_id_and_reports_missing_or_ambiguous_names() { diff --git a/crates/tracel-cli/src/tools/fs.rs b/crates/tracel-cli/src/tools/fs.rs index 03d0fd1..f90b4c7 100644 --- a/crates/tracel-cli/src/tools/fs.rs +++ b/crates/tracel-cli/src/tools/fs.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; pub fn collect_files(directory: &Path) -> anyhow::Result> { let base_dir = std::fs::canonicalize(directory) - .with_context(|| format!("Failed to resolve directory '{}'.", directory.display()))?; + .with_context(|| format!("Failed to resolve directory '{}'", directory.display()))?; if !base_dir.is_dir() { anyhow::bail!("'{}' is not a directory.", base_dir.display()); @@ -16,7 +16,7 @@ pub fn collect_files(directory: &Path) -> anyhow::Result anyhow::Result<(String, u64)> { let mut file = std::fs::File::open(path) - .with_context(|| format!("Failed to open file '{}'.", path.display()))?; + .with_context(|| format!("Failed to open file '{}'", path.display()))?; let mut hasher = Sha256::new(); let mut buffer = [0u8; 1024 * 1024]; @@ -56,7 +56,7 @@ fn file_sha256_and_size(path: &Path) -> anyhow::Result<(String, u64)> { loop { let read = file .read(&mut buffer) - .with_context(|| format!("Failed reading file '{}'.", path.display()))?; + .with_context(|| format!("Failed reading file '{}'", path.display()))?; if read == 0 { break; } From 3c15c30f7d713af9166b2973471afc9dd8d13fde Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Wed, 7 Oct 2026 10:46:56 -0400 Subject: [PATCH 13/32] refactor: typed command results behind one stdout seam - Commands return an `Outcome` made from a typed result (`Render`: `Serialize` plus its text view) instead of `serde_json::Value`. Only `output` writes to stdout, and only `Terminal` draws with cliclack. - The output format is resolved once, with a per-command `auto`: `auth token` and `train` stay text when piped. This replaces the dispatcher's special cases. - Reads print their result on stdout without a frame. Actions report on stderr and print nothing on stdout. `me`, `project` and `auth status` move to stdout. - Tables and details share one layout: aligned values, "-" for empty cells, and "Showing N of TOTAL" on every list with rows left out. - Errors are reported once, on both channels, and close an open frame with the hint. A reader closing stdout ends the command quietly. - `Terminal` moves next to `output`. Upload progress moves behind it, and its dead parts are removed. --- README.md | 7 + crates/tracel-cli/src/cli.rs | 190 ++-- crates/tracel-cli/src/commands/artifacts.rs | 127 +-- crates/tracel-cli/src/commands/auth.rs | 127 ++- crates/tracel-cli/src/commands/datasets.rs | 183 ++-- crates/tracel-cli/src/commands/experiments.rs | 407 ++++----- crates/tracel-cli/src/commands/init.rs | 91 +- crates/tracel-cli/src/commands/login.rs | 74 +- crates/tracel-cli/src/commands/logout.rs | 18 +- crates/tracel-cli/src/commands/me.rs | 66 +- crates/tracel-cli/src/commands/models.rs | 835 +++++++++--------- crates/tracel-cli/src/commands/package.rs | 50 +- crates/tracel-cli/src/commands/project.rs | 125 +-- crates/tracel-cli/src/commands/training.rs | 7 +- crates/tracel-cli/src/commands/unlink.rs | 40 +- crates/tracel-cli/src/context.rs | 18 +- crates/tracel-cli/src/helpers/download.rs | 19 + crates/tracel-cli/src/helpers/mod.rs | 2 +- .../src/helpers/model_upload/upload.rs | 47 +- crates/tracel-cli/src/helpers/project.rs | 47 +- crates/tracel-cli/src/lib.rs | 1 + crates/tracel-cli/src/output.rs | 167 ---- crates/tracel-cli/src/output/format.rs | 111 +++ crates/tracel-cli/src/output/human.rs | 176 ++++ crates/tracel-cli/src/output/mod.rs | 12 + crates/tracel-cli/src/output/stdout.rs | 230 +++++ crates/tracel-cli/src/output/time.rs | 38 + crates/tracel-cli/src/{tools => }/terminal.rs | 161 +++- crates/tracel-cli/src/tools/build_driver.rs | 2 +- crates/tracel-cli/src/tools/linker.rs | 2 +- crates/tracel-cli/src/tools/mod.rs | 1 - crates/tracel-cli/src/tools/target.rs | 2 +- 32 files changed, 1960 insertions(+), 1423 deletions(-) delete mode 100644 crates/tracel-cli/src/output.rs create mode 100644 crates/tracel-cli/src/output/format.rs create mode 100644 crates/tracel-cli/src/output/human.rs create mode 100644 crates/tracel-cli/src/output/mod.rs create mode 100644 crates/tracel-cli/src/output/stdout.rs create mode 100644 crates/tracel-cli/src/output/time.rs rename crates/tracel-cli/src/{tools => }/terminal.rs (64%) diff --git a/README.md b/README.md index 443d029..73867d4 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,13 @@ JSON results are one line on stdout: `{"ok":true,"data":{...}}` on success, or on failure. Diagnostics go to stderr. Help and version output retain their normal format, and `train` inherits the executed program's output and exit code. +Human output keeps the same split: stdout carries only results, such as the +tables and details of commands that read, while progress, prompts, warnings, +and errors go to stderr. Commands that change something, such as `init`, +`login`, `models push`, or `artifacts download`, report on stderr and print +nothing on stdout. `auth token` prints the bare token unless JSON is requested, +even when stdout is redirected. + | Error code | Exit code | | --- | --- | | Success | 0 | diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index 85f5004..1e9dcfa 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -1,15 +1,14 @@ use std::io::IsTerminal; use clap::{CommandFactory, Parser, Subcommand}; -use serde_json::{Value, json}; use tracel_client::console::Env; use crate::commands; use crate::context::CliContext; use crate::error::{CliError, ErrorKind, ErrorReport}; use crate::helpers::project::parse_project; -use crate::output::{self, OutputMode}; -use crate::tools::terminal::Terminal; +use crate::output::{Format, FormatArg, Outcome, Output, StdoutClosed}; +use crate::terminal::Terminal; use crate::tools::tracel_config::TracelProject; #[derive(Parser, Debug)] @@ -20,7 +19,7 @@ pub struct CliArgs { /// Output format (default: auto) #[arg(short, long, global = true, value_enum, conflicts_with = "json")] - pub output: Option, + pub output: Option, /// Print machine-readable JSON #[arg(long, global = true, conflicts_with = "output")] @@ -85,30 +84,53 @@ pub enum Commands { Datasets(commands::datasets::DatasetsArgs), } +impl Commands { + /// What `auto` output means: human on a terminal and JSON otherwise, except where + /// stdout carries plain text: the bare token for `$(tracel auth token)`, or the + /// output of the program `train` runs. + fn auto_format(&self, stdout_is_terminal: bool) -> Format { + match self { + Self::Train(_) + | Self::Auth(commands::auth::AuthArgs { + command: commands::auth::AuthCommands::Token, + }) => Format::Human, + _ => Format::for_stdout(stdout_is_terminal), + } + } +} + pub fn cli_main() { let args = match CliArgs::try_parse() { Ok(args) => args, Err(error) if !error.use_stderr() => error.exit(), Err(error) => { - let mode = usage_output_mode(std::env::args_os().skip(1)); - if mode == OutputMode::Json { - let message = error.to_string(); - let message = message - .trim() - .strip_prefix("error: ") - .unwrap_or(message.trim()); - let error = anyhow::Error::new(CliError::new(ErrorKind::Usage, message)); - report_error(&error, mode, &Terminal::new(mode)); - } else { + let format = usage_format(std::env::args_os().skip(1)); + if format == Format::Human { error.exit(); } - return; + let message = error.to_string(); + let message = message + .trim() + .strip_prefix("error: ") + .unwrap_or(message.trim()); + fail(&CliError::new(ErrorKind::Usage, message).into(), format); } }; + let stdout_is_terminal = std::io::stdout().is_terminal(); + let auto = match &args.command { + Some(command) => command.auto_format(stdout_is_terminal), + None => Format::for_stdout(stdout_is_terminal), + }; + let format = Format::resolve( + args.output, + args.json, + std::env::var("TRACEL_OUTPUT").ok().as_deref(), + auto, + ); + if let Some(directory) = &args.working_directory { if let Err(error) = std::env::set_current_dir(directory) { - let mode = usage_output_mode(std::env::args_os().skip(1)); let error = CliError::new( ErrorKind::Usage, format!( @@ -116,8 +138,7 @@ pub fn cli_main() { directory.display() ), ); - report_error(&error.into(), mode, &Terminal::new(mode)); - return; + fail(&error.into(), format.unwrap_or(auto)); } } @@ -127,64 +148,23 @@ pub fn cli_main() { return; }; - let mode = match OutputMode::resolve( - args.output, - args.json, - std::env::var("TRACEL_OUTPUT").ok().as_deref(), - std::io::stdout().is_terminal(), - ) { - Ok(mode) => mode, - Err(error) => { - let mode = OutputMode::resolve(None, false, None, std::io::stdout().is_terminal()) - .expect("Auto output is valid"); - report_error(&error.into(), mode, &Terminal::new(mode)); - return; - } - }; - // `auth token` prints the bare token for `$(tracel auth token)` unless JSON is asked for. - let json_requested = args.json - || args.output == Some(OutputMode::Json) - || std::env::var("TRACEL_OUTPUT").as_deref() == Ok("json"); - let prints_raw_token = matches!( - &command, - Commands::Auth(commands::auth::AuthArgs { - command: commands::auth::AuthCommands::Token - }) - ) && !json_requested; - let mode = if matches!(command, Commands::Train(_)) || prints_raw_token { - OutputMode::Human - } else { - mode - }; - - let terminal = Terminal::new(mode).with_no_input(args.no_input); + let format = format.unwrap_or_else(|error| fail(&error.into(), auto)); + let terminal = Terminal::new(format).with_no_input(args.no_input); let environment_value = std::env::var_os("TRACEL_ENV").map(|value| value.to_string_lossy().into_owned()); - let environment = - match resolve_environment(args.dev, args.staging, environment_value.as_deref()) { - Ok(environment) => environment, - Err(error) => { - report_error(&error.into(), mode, &terminal); - return; - } - }; + let environment = resolve_environment(args.dev, args.staging, environment_value.as_deref()) + .unwrap_or_else(|error| fail(&error.into(), format)); if args.dev { terminal .print_warning("Running in development mode - using local server and dev credentials"); } - let context = CliContext::new(terminal.clone(), environment, mode, args.project); - - let streams_output = matches!(&command, Commands::Experiments(args) if args.streams_output()); - let result = handle_command(command, context).and_then(|data| { - if mode == OutputMode::Json && !streams_output { - output::write_success(&data)?; - } - Ok(()) - }); - if let Err(error) = result { - report_error(&error, mode, &terminal); + let output = Output::new(format); + let context = CliContext::new(terminal, output, environment, args.project); + if let Err(error) = handle_command(command, context).and_then(|outcome| output.finish(outcome)) + { + fail(&error, format); } } @@ -217,27 +197,20 @@ fn resolve_environment( } } -fn report_error(error: &anyhow::Error, mode: OutputMode, terminal: &Terminal) { - let report = ErrorReport::new(error); - if mode == OutputMode::Json { - let _ = output::write_failure(&report); - eprintln!( - "error: {}", - console::strip_ansi_codes(&report.message) - .split_whitespace() - .collect::>() - .join(" ") - ); - } else { - terminal.cancel_finalize(&format!("{error:#}")); - if let Some(hint) = report.hint { - terminal.print(hint); - } +/// Reports a failed command on both channels and exits with its code. A reader that +/// closed stdout got all it wanted, so that ends the command quietly. +fn fail(error: &anyhow::Error, format: Format) -> ! { + if error.chain().any(|cause| cause.is::()) { + std::process::exit(0); } - std::process::exit(report.exit_code); + let report = ErrorReport::new(error); + Output::new(format).error(&report); + Terminal::new(format).error(&report); + std::process::exit(report.exit_code) } -fn usage_output_mode(arguments: impl IntoIterator) -> OutputMode { +/// The format for an error in arguments that did not parse. +fn usage_format(arguments: impl IntoIterator) -> Format { let mut arguments = arguments.into_iter(); let mut flag = None; let mut json = false; @@ -261,33 +234,26 @@ fn usage_output_mode(arguments: impl IntoIterator) -> }; if let Some(value) = value { flag = Some(match value.as_str() { - "human" => OutputMode::Human, - "json" => OutputMode::Json, - _ => OutputMode::Auto, + "human" => FormatArg::Human, + "json" => FormatArg::Json, + _ => FormatArg::Auto, }); } } } - OutputMode::resolve( + let auto = Format::for_stdout(std::io::stdout().is_terminal()); + Format::resolve( flag, json, std::env::var("TRACEL_OUTPUT").ok().as_deref(), - std::io::stdout().is_terminal(), + auto, ) - .unwrap_or_else(|_| { - if std::io::stdout().is_terminal() { - OutputMode::Human - } else { - OutputMode::Json - } - }) + .unwrap_or(auto) } -fn handle_command(command: Commands, context: CliContext) -> anyhow::Result { +fn handle_command(command: Commands, context: CliContext) -> anyhow::Result { match command { - Commands::Train(run_args) => { - commands::training::handle_command(run_args, context).map(|()| json!({})) - } + Commands::Train(run_args) => commands::training::handle_command(run_args, context), Commands::Package(package_args) => commands::package::handle_command(package_args, context), Commands::Login(login_args) => commands::login::handle_command(login_args, context), Commands::Logout => commands::logout::handle_command(context), @@ -457,12 +423,30 @@ mod tests { CliArgs::try_parse_from(["tracel", "auth", "status", "-o", "human"]) .unwrap() .output, - Some(OutputMode::Human) + Some(FormatArg::Human) ); let error = CliArgs::try_parse_from(["tracel", "me", "--json", "-o", "json"]).unwrap_err(); assert_eq!(error.exit_code(), 2); } + #[test] + fn auto_output_is_text_where_another_program_reads_stdout() { + for arguments in [ + vec!["tracel", "auth", "token"], + vec!["tracel", "train", "--", "--epochs", "1"], + ] { + let command = CliArgs::try_parse_from(arguments).unwrap().command.unwrap(); + for stdout_is_terminal in [false, true] { + assert_eq!(command.auto_format(stdout_is_terminal), Format::Human); + } + } + for arguments in [vec!["tracel", "auth", "status"], vec!["tracel", "me"]] { + let command = CliArgs::try_parse_from(arguments).unwrap().command.unwrap(); + assert_eq!(command.auto_format(true), Format::Human); + assert_eq!(command.auto_format(false), Format::Json); + } + } + #[test] fn no_input_is_global() { for arguments in [ diff --git a/crates/tracel-cli/src/commands/artifacts.rs b/crates/tracel-cli/src/commands/artifacts.rs index fa487ab..6c650b6 100644 --- a/crates/tracel-cli/src/commands/artifacts.rs +++ b/crates/tracel-cli/src/commands/artifacts.rs @@ -1,17 +1,19 @@ -use std::io::Write; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; use clap::{Args, Subcommand}; -use serde_json::{Value, json}; +use serde::Serialize; +use serde_json::Value; +use tracel_client::console::artifact::response::{ArtifactListResponse, ArtifactResponse}; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::{ - DownloadFile, Resource, download_files, map_resource_error, resolve_namespace_project, - select_artifact, validate_rel_path, + DownloadFile, DownloadResult, Resource, download_files, map_resource_error, + resolve_namespace_project, select_artifact, validate_rel_path, }; -use crate::output::{OutputMode, write_table}; +use crate::output::{Outcome, Render, Table}; #[derive(Args, Debug)] pub struct ArtifactsArgs { @@ -85,8 +87,37 @@ fn file_integrity(manifest: &Value, rel_path: &str) -> (Option, Option anyhow::Result { - context.terminal().command_title("Artifacts"); +#[derive(Serialize)] +struct ArtifactDownloaded { + experiment: i32, + artifact: ArtifactResponse, + directory: PathBuf, + files: Vec, + bytes: u64, +} + +impl Render for ArtifactDownloaded {} + +impl Render for ArtifactListResponse { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + Table::new(["NAME", "KIND", "ID", "FILES", "CREATED AT"]) + .rows(self.items.iter().map(|artifact| { + [ + artifact.name.clone(), + artifact.kind.clone(), + artifact.id.clone(), + manifest_files(&artifact.manifest) + .map(|files| files.len().to_string()) + .unwrap_or_default(), + artifact.created_at.clone(), + ] + })) + .total(self.total) + .write(out) + } +} + +pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Result { let project = resolve_namespace_project(&context)?.project; if let ArtifactsCommands::Download(args) = &args.command { if let Some(directory) = &args.directory { @@ -100,49 +131,26 @@ pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Resul } } let client = get_client_and_login_if_needed(&context)?; - let human = context.output() == OutputMode::Human; let namespace = &project.owner; let name = &project.name; match args.command { - ArtifactsCommands::List(args) => { - let response = match args.name { - Some(filter) => { - client.list_artifacts_by_name(namespace, name, args.experiment, &filter) - } - None => client.list_artifacts(namespace, name, args.experiment), + ArtifactsCommands::List(args) => Ok(match args.name { + Some(filter) => { + client.list_artifacts_by_name(namespace, name, args.experiment, &filter) } - .map_err(|error| { - map_resource_error( - error, - namespace, - name, - Resource::Experiment(args.experiment), - ) - })?; - if human { - write_table( - &mut std::io::stdout().lock(), - &["NAME", "KIND", "ID", "FILES", "CREATED AT"], - response - .items - .iter() - .map(|artifact| { - vec![ - artifact.name.clone(), - artifact.kind.clone(), - artifact.id.clone(), - manifest_files(&artifact.manifest) - .map(|files| files.len().to_string()) - .unwrap_or_else(|| "-".into()), - artifact.created_at.clone(), - ] - }) - .collect(), - )?; - } - Ok(serde_json::to_value(response)?) + None => client.list_artifacts(namespace, name, args.experiment), } + .map_err(|error| { + map_resource_error( + error, + namespace, + name, + Resource::Experiment(args.experiment), + ) + })? + .into()), ArtifactsCommands::Download(args) => { + context.terminal().command_title("Artifact download"); let map_error = |error| { map_resource_error( error, @@ -151,7 +159,7 @@ pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Resul Resource::Experiment(args.experiment), ) }; - let artifacts = client + let mut artifacts = client .list_artifacts(namespace, name, args.experiment) .map_err(map_error)?; let index = select_artifact( @@ -162,7 +170,7 @@ pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Resul &args.artifact, args.experiment, )?; - let artifact = &artifacts.items[index]; + let artifact = artifacts.items.swap_remove(index); let directory = match args.directory { Some(directory) => directory, None => default_directory(&artifact.name)?, @@ -183,19 +191,21 @@ pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Resul } }) .collect(); - let files = download_files(&client, &directory, &files, args.force)?; - let bytes: u64 = files.iter().map(|file| file.bytes).sum(); - if human { - writeln!( - std::io::stdout().lock(), - "Downloaded artifact '{}' to {}.", - artifact.name, - directory.display() - )?; + let files = + download_files(&client, context.terminal(), &directory, &files, args.force)?; + context.terminal().finalize(&format!( + "Downloaded artifact '{}' to {}.", + artifact.name, + directory.display() + )); + Ok(ArtifactDownloaded { + experiment: args.experiment, + bytes: files.iter().map(|file| file.bytes).sum(), + artifact, + directory, + files, } - Ok( - json!({"experiment": args.experiment, "artifact": artifact, "directory": directory, "files": files, "bytes": bytes}), - ) + .into()) } } } @@ -203,6 +213,7 @@ pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Resul #[cfg(test)] mod tests { use clap::{CommandFactory, Parser}; + use serde_json::json; use super::*; use crate::cli::{CliArgs, Commands}; diff --git a/crates/tracel-cli/src/commands/auth.rs b/crates/tracel-cli/src/commands/auth.rs index b303b23..47005b1 100644 --- a/crates/tracel-cli/src/commands/auth.rs +++ b/crates/tracel-cli/src/commands/auth.rs @@ -1,15 +1,15 @@ +use std::io::{self, Write}; use std::time::SystemTime; -use chrono::{DateTime, Utc}; use clap::{Args, Subcommand}; -use serde_json::{Value, json}; +use serde::Serialize; use tracel_client::ClientError; use tracel_client::console::TracelCredentials; use crate::commands::login::environment_suffix; use crate::context::{CliContext, ClientCreationError}; use crate::error::{CliError, ErrorKind}; -use crate::output::OutputMode; +use crate::output::{Details, Outcome, Render, Timestamp}; #[derive(Args, Debug)] pub struct AuthArgs { @@ -26,14 +26,71 @@ pub enum AuthCommands { Status, } -pub fn handle_command(args: AuthArgs, context: CliContext) -> anyhow::Result { +pub fn handle_command(args: AuthArgs, context: CliContext) -> anyhow::Result { match args.command { - AuthCommands::Token => print_token(&context), - AuthCommands::Status => show_status(&context), + AuthCommands::Token => Ok(access_token(&context)?.into()), + AuthCommands::Status => Ok(status(&context)?.into()), } } -fn print_token(context: &CliContext) -> anyhow::Result { +/// Printed bare for `$(tracel auth token)`. +#[derive(Serialize)] +struct AccessToken { + access_token: String, +} + +impl Render for AccessToken { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + writeln!(out, "{}", self.access_token) + } +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +enum Credential { + ApiKey, + Login, + None, +} + +#[derive(Serialize)] +struct AuthStatus { + credential: Credential, + login_ends_at: Option, + user: User, + environment: String, +} + +#[derive(Serialize)] +struct User { + username: String, + namespace: String, +} + +impl Render for AuthStatus { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + let credential = match self.credential { + Credential::ApiKey => "TRACEL_API_KEY", + Credential::Login => "login", + Credential::None => "none", + }; + let now = SystemTime::now(); + let login = match self.login_ends_at { + Some(Timestamp(end)) if now >= end => "ended".to_string(), + Some(Timestamp(end)) => format!("ends in {}", time_left(end, now)), + None => "none".to_string(), + }; + Details::new() + .field("Credential", credential) + .field("Login", login) + .field("User", &self.user.username) + .field("Namespace", &self.user.namespace) + .field("Environment", &self.environment) + .write(out) + } +} + +fn access_token(context: &CliContext) -> anyhow::Result { let access_token = context .app_session()? .access_token() @@ -51,59 +108,37 @@ fn print_token(context: &CliContext) -> anyhow::Result { } })?; - if context.output() == OutputMode::Human { - println!("{}", access_token.as_str()); - } - - Ok(json!({"access_token": access_token.as_str()})) + Ok(AccessToken { + access_token: access_token.as_str().to_string(), + }) } -fn show_status(context: &CliContext) -> anyhow::Result { - context.terminal().command_title("Authentication Status"); - +fn status(context: &CliContext) -> anyhow::Result { let client = context.create_client(); let login = context.app_session()?.stored()?; let credential = match (TracelCredentials::from_env().is_ok(), &login) { - (true, _) => "TRACEL_API_KEY", - (false, Some(_)) => "login", - (false, None) => "none", + (true, _) => Credential::ApiKey, + (false, Some(_)) => Credential::Login, + (false, None) => Credential::None, }; - context - .terminal() - .print(&format!("Credential: {}", credential)); - - let now = SystemTime::now(); - let login_status = match &login { - Some(login) if login.has_ended_at(now) => "ended".to_string(), - Some(login) => format!("ends in {}", time_left(login.refresh_token_expires_at, now)), - None => "none".to_string(), - }; - context - .terminal() - .print(&format!("Login: {}", login_status)); - let env_msg = environment_suffix(&context.environment()); match client { - Ok(client) => { - context.terminal().finalize(&format!( - "Commands act as {}{}.", - client.user().username, - env_msg - )); - Ok(json!({ - "credential": if credential == "TRACEL_API_KEY" { "api_key" } else { credential }, - "login_ends_at": login.as_ref().map(|login| DateTime::::from(login.refresh_token_expires_at).to_rfc3339()), - "user": {"username": client.user().username, "namespace": client.user().namespace}, - "environment": context.environment_name(), - })) - } + Ok(client) => Ok(AuthStatus { + credential, + login_ends_at: login.map(|login| Timestamp(login.refresh_token_expires_at)), + user: User { + username: client.user().username.clone(), + namespace: client.user().namespace.clone(), + }, + environment: context.environment_name(), + }), Err(ClientCreationError::NoCredentials | ClientCreationError::InvalidCredentials) => { Err(CliError::new( ErrorKind::NotAuthenticated, format!( "Not logged in{}. Run 'tracel login' or set TRACEL_API_KEY.", - env_msg + environment_suffix(&context.environment()) ), ) .into()) diff --git a/crates/tracel-cli/src/commands/datasets.rs b/crates/tracel-cli/src/commands/datasets.rs index 3dc0b5a..d476d38 100644 --- a/crates/tracel-cli/src/commands/datasets.rs +++ b/crates/tracel-cli/src/commands/datasets.rs @@ -1,14 +1,15 @@ -use std::io::Write; +use std::io::{self, Write}; use clap::{Args, Subcommand}; -use serde_json::Value; use tracel_client::console::dataset::request::{QueryDatasetVersionsRequest, QueryDatasetsRequest}; -use tracel_client::console::dataset::response::SourceKindResponse; +use tracel_client::console::dataset::response::{ + DatasetListResponse, DatasetResponse, DatasetVersionListResponse, SourceKindResponse, +}; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::helpers::{Resource, map_resource_error, resolve_namespace_project}; -use crate::output::{OutputMode, write_table}; +use crate::output::{Details, Outcome, Render, Table, json_section}; #[derive(Args, Debug)] pub struct DatasetsArgs { @@ -54,114 +55,96 @@ pub struct VersionsArgs { pub per_page: Option, } -pub fn handle_command(args: DatasetsArgs, context: CliContext) -> anyhow::Result { - context.terminal().command_title("Datasets"); +pub fn handle_command(args: DatasetsArgs, context: CliContext) -> anyhow::Result { let project = resolve_namespace_project(&context)?.project; let client = get_client_and_login_if_needed(&context)?; - let human = context.output() == OutputMode::Human; match args.command { - DatasetsCommands::List(args) => { - let response = client.query_datasets( + DatasetsCommands::List(args) => Ok(client + .query_datasets( &project.owner, &project.name, QueryDatasetsRequest { page: args.page, per_page: args.per_page, }, - )?; - if human { - let mut stdout = std::io::stdout().lock(); - write_table( - &mut stdout, - &["NAME", "DESCRIPTION", "ID"], - response - .items - .iter() - .map(|dataset| { - vec![ - dataset.name.clone(), - dataset.description.clone().unwrap_or_default(), - dataset.id.clone(), - ] - }) - .collect(), - )?; - let shown = response.items.len() as u64; - if shown < response.total_count { - writeln!(stdout, "Showing {shown} of {}", response.total_count)?; - } - } - Ok(serde_json::to_value(response)?) - } - DatasetsCommands::Get(args) => { - let dataset = client - .get_dataset(&project.owner, &project.name, &args.dataset) - .map_err(|error| { - map_resource_error( - error, - &project.owner, - &project.name, - Resource::Dataset(&args.dataset), - ) - })?; - if human { - let mut stdout = std::io::stdout().lock(); - writeln!(stdout, "Name: {}", dataset.name)?; - writeln!(stdout, "ID: {}", dataset.id)?; - writeln!( - stdout, - "Description: {}", - dataset.description.as_deref().unwrap_or("") - )?; - writeln!(stdout, "Metadata:")?; - serde_json::to_writer_pretty(&mut stdout, &dataset.metadata)?; - writeln!(stdout)?; - } - Ok(serde_json::to_value(dataset)?) - } - DatasetsCommands::Versions(args) => { - let response = client - .query_dataset_versions( + )? + .into()), + DatasetsCommands::Get(args) => Ok(client + .get_dataset(&project.owner, &project.name, &args.dataset) + .map_err(|error| { + map_resource_error( + error, &project.owner, &project.name, - &args.dataset, - QueryDatasetVersionsRequest { - page: args.page, - per_page: args.per_page, - }, + Resource::Dataset(&args.dataset), ) - .map_err(|error| { - map_resource_error( - error, - &project.owner, - &project.name, - Resource::Dataset(&args.dataset), - ) - })?; - if human { - write_table( - &mut std::io::stdout().lock(), - &["VERSION", "ITEMS", "SOURCE", "CREATED AT"], - response - .items - .iter() - .map(|version| { - let source = match version.source_kind { - SourceKindResponse::AnnotationSet => "annotation_set", - SourceKindResponse::DirectUpload => "direct_upload", - }; - vec![ - version.version.to_string(), - version.item_count.to_string(), - source.into(), - version.created_at.clone(), - ] - }) - .collect(), - )?; - } - Ok(serde_json::to_value(response)?) - } + })? + .into()), + DatasetsCommands::Versions(args) => Ok(client + .query_dataset_versions( + &project.owner, + &project.name, + &args.dataset, + QueryDatasetVersionsRequest { + page: args.page, + per_page: args.per_page, + }, + ) + .map_err(|error| { + map_resource_error( + error, + &project.owner, + &project.name, + Resource::Dataset(&args.dataset), + ) + })? + .into()), + } +} + +impl Render for DatasetListResponse { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + Table::new(["NAME", "DESCRIPTION", "ID"]) + .rows(self.items.iter().map(|dataset| { + [ + dataset.name.clone(), + dataset.description.clone().unwrap_or_default(), + dataset.id.clone(), + ] + })) + .total(self.total_count) + .write(out) + } +} + +impl Render for DatasetResponse { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + Details::new() + .field("Name", &self.name) + .field("ID", &self.id) + .optional("Description", self.description.as_ref()) + .write(out)?; + json_section(out, "Metadata", &self.metadata) + } +} + +impl Render for DatasetVersionListResponse { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + Table::new(["VERSION", "ITEMS", "SOURCE", "CREATED AT"]) + .rows(self.items.iter().map(|version| { + let source = match version.source_kind { + SourceKindResponse::AnnotationSet => "annotation_set", + SourceKindResponse::DirectUpload => "direct_upload", + }; + [ + version.version.to_string(), + version.item_count.to_string(), + source.into(), + version.created_at.clone(), + ] + })) + .total(self.total_count) + .write(out) } } diff --git a/crates/tracel-cli/src/commands/experiments.rs b/crates/tracel-cli/src/commands/experiments.rs index 9acb94d..66f171d 100644 --- a/crates/tracel-cli/src/commands/experiments.rs +++ b/crates/tracel-cli/src/commands/experiments.rs @@ -1,24 +1,24 @@ -use std::io::Write; +use std::io::{self, Write}; use std::time::Duration; use clap::{Args, Subcommand, ValueEnum}; use serde::Serialize; -use serde_json::{Value, json}; use tracel_client::console::Client; use tracel_client::console::experiment::request::{ ExperimentLogQueryRequest, ListExperimentsQuery, LogLevelRequest, MetadataFilterRequest, MetricAggregatedQuery, MetricSummaryQuery, }; use tracel_client::console::experiment::response::{ - ExperimentDetailsResponse, ExperimentLogItemResponse, ListExperimentsResponse, - LogLevelResponse, MetricMetadataResponse, MetricResponse, MetricSummaryResponse, + ExperimentDetailsResponse, ExperimentLogItemResponse, ExperimentLogQueryResponse, + ListExperimentsResponse, LogLevelResponse, MetricMetadataResponse, MetricResponse, + MetricSummaryResponse, }; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::project::resolve_namespace_project; -use crate::output::{self, OutputMode, write_table}; +use crate::output::{Details, Outcome, Output, Render, Table, json_section}; use crate::tools::tracel_config::TracelProject; #[derive(Args, Debug)] @@ -27,15 +27,6 @@ pub struct ExperimentsArgs { pub command: ExperimentsCommands, } -impl ExperimentsArgs { - pub fn streams_output(&self) -> bool { - matches!( - &self.command, - ExperimentsCommands::Logs(LogsArgs { follow: true, .. }) - ) - } -} - #[derive(Subcommand, Debug)] pub enum ExperimentsCommands { /// List experiments in the selected project. @@ -209,148 +200,125 @@ impl LogsArgs { } } -fn print_list(response: &ListExperimentsResponse) -> anyhow::Result<()> { - let rows = response - .items - .iter() - .map(|experiment| { - vec![ - experiment.experiment_num.to_string(), - experiment.name.clone().unwrap_or_default(), - experiment.status.clone(), - experiment.created_at.clone(), - experiment.created_by.username.clone(), - ] - }) - .collect(); - let mut stdout = std::io::stdout().lock(); - write_table( - &mut stdout, - &["NUMBER", "NAME", "STATUS", "CREATED AT", "CREATED BY"], - rows, - )?; - let shown = response.items.len() as u64; - let end = u64::from(response.page) - .saturating_mul(u64::from(response.per_page)) - .saturating_add(shown); - if end < response.total { - writeln!(stdout, "Showing {shown} of {}", response.total)?; +impl Render for ListExperimentsResponse { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + Table::new(["NUMBER", "NAME", "STATUS", "CREATED AT", "CREATED BY"]) + .rows(self.items.iter().map(|experiment| { + [ + experiment.experiment_num.to_string(), + experiment.name.clone().unwrap_or_default(), + experiment.status.clone(), + experiment.created_at.clone(), + experiment.created_by.username.clone(), + ] + })) + .total(self.total) + .write(out) } - Ok(()) } -fn print_experiment(experiment: &ExperimentDetailsResponse) -> anyhow::Result<()> { - let mut stdout = std::io::stdout().lock(); - writeln!(stdout, "Number: {}", experiment.experiment_num)?; - writeln!(stdout, "ID: {}", experiment.id)?; - writeln!(stdout, "Project ID: {}", experiment.project_id)?; - writeln!(stdout, "Name: {}", experiment.name.as_deref().unwrap_or(""))?; - writeln!(stdout, "Status: {}", experiment.status)?; - writeln!(stdout, "Description: {}", experiment.description)?; - writeln!(stdout, "Created at: {}", experiment.created_at)?; - writeln!(stdout, "Created by: {}", experiment.created_by.username)?; - writeln!(stdout, "Config:")?; - serde_json::to_writer_pretty(&mut stdout, &experiment.config)?; - writeln!(stdout, "\nConfigurations:")?; - serde_json::to_writer_pretty(&mut stdout, &experiment.configurations)?; - writeln!(stdout, "\nAttributes:")?; - serde_json::to_writer_pretty(&mut stdout, &experiment.attributes)?; - writeln!(stdout)?; - Ok(()) +impl Render for ExperimentDetailsResponse { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + Details::new() + .field("Number", self.experiment_num) + .field("ID", self.id) + .field("Project ID", self.project_id) + .optional("Name", self.name.as_ref()) + .field("Status", &self.status) + .field("Description", &self.description) + .field("Created at", &self.created_at) + .field("Created by", &self.created_by.username) + .write(out)?; + json_section(out, "Config", &self.config)?; + json_section(out, "Configurations", &self.configurations)?; + json_section(out, "Attributes", &self.attributes) + } } -fn print_metric_metadata(metadata: &MetricMetadataResponse) -> anyhow::Result<()> { - let mut stdout = std::io::stdout().lock(); - write_table( - &mut stdout, - &["METRIC"], - metadata - .metric_types - .iter() - .map(|name| vec![name.clone()]) - .collect(), - )?; - writeln!(stdout)?; - write_table( - &mut stdout, - &["GROUP"], - metadata - .groups - .iter() - .map(|name| vec![name.clone()]) - .collect(), - )?; - Ok(()) +impl Render for MetricMetadataResponse { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + Table::new(["METRIC"]) + .rows(self.metric_types.iter().map(|name| [name.clone()])) + .write(out)?; + writeln!(out)?; + Table::new(["GROUP"]) + .rows(self.groups.iter().map(|name| [name.clone()])) + .write(out) + } } -fn print_metrics(response: &MetricResponse) -> anyhow::Result<()> { - let rows = response - .groups - .iter() - .flat_map(|group| { - group.entries.iter().map(|entry| { - vec![ - group.name.clone(), - entry.epoch.to_string(), - entry.iteration.to_string(), - entry.value.to_string(), - entry.low.to_string(), - entry.high.to_string(), - ] - }) - }) - .collect(); - write_table( - &mut std::io::stdout().lock(), - &["GROUP", "EPOCH", "ITERATION", "VALUE", "LOW", "HIGH"], - rows, - )?; - Ok(()) +impl Render for Option { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + let Some(metrics) = self else { + return writeln!(out, "No metric series available."); + }; + Table::new(["GROUP", "EPOCH", "ITERATION", "VALUE", "LOW", "HIGH"]) + .rows(metrics.groups.iter().flat_map(|group| { + group.entries.iter().map(|entry| { + [ + group.name.clone(), + entry.epoch.to_string(), + entry.iteration.to_string(), + entry.value.to_string(), + entry.low.to_string(), + entry.high.to_string(), + ] + }) + })) + .write(out) + } } -fn print_metric_summary(response: &MetricSummaryResponse) -> anyhow::Result<()> { - let rows = response - .groups - .iter() - .map(|group| { - vec![ - group.group.clone(), - group.optimal_value.to_string(), - group.epoch.to_string(), - ] - }) - .collect(); - write_table( - &mut std::io::stdout().lock(), - &["GROUP", "OPTIMAL VALUE", "EPOCH"], - rows, - )?; - Ok(()) +impl Render for Option { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + let Some(summary) = self else { + return writeln!(out, "No metric summary available."); + }; + Table::new(["GROUP", "OPTIMAL VALUE", "EPOCH"]) + .rows(summary.groups.iter().map(|group| { + [ + group.group.clone(), + group.optimal_value.to_string(), + group.epoch.to_string(), + ] + })) + .write(out) + } } -fn print_logs(items: &[ExperimentLogItemResponse]) -> anyhow::Result<()> { - let mut stdout = std::io::stdout().lock(); - for item in items { - let level = match item.log_level { - LogLevelResponse::Trace => "trace", - LogLevelResponse::Debug => "debug", - LogLevelResponse::Info => "info", - LogLevelResponse::Warn => "warn", - LogLevelResponse::Error => "error", - }; - let message = item.message.replace(['\r', '\n'], " "); - writeln!(stdout, "{} {level} {message}", item.timestamp)?; +impl Render for ExperimentLogQueryResponse { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + self.items.iter().try_for_each(|item| write_log(out, item)) } - stdout.flush()?; - Ok(()) } +fn write_log(out: &mut dyn Write, item: &ExperimentLogItemResponse) -> io::Result<()> { + let level = match item.log_level { + LogLevelResponse::Trace => "trace", + LogLevelResponse::Debug => "debug", + LogLevelResponse::Info => "info", + LogLevelResponse::Warn => "warn", + LogLevelResponse::Error => "error", + }; + let message = item.message.replace(['\r', '\n'], " "); + writeln!(out, "{} {level} {message}", item.timestamp) +} + +/// An event of `logs --follow`. As JSON, a log item gains `"type": "log"`. #[derive(Serialize)] -struct LogEvent<'a> { - #[serde(rename = "type")] - event_type: &'static str, - #[serde(flatten)] - item: &'a ExperimentLogItemResponse, +#[serde(tag = "type", rename_all = "snake_case")] +enum LogEvent<'a> { + Log(&'a ExperimentLogItemResponse), + End { running: bool }, +} + +impl Render for LogEvent<'_> { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + match self { + Self::Log(item) => write_log(out, item), + Self::End { .. } => Ok(()), + } + } } fn follow_logs( @@ -358,20 +326,13 @@ fn follow_logs( project: &TracelProject, num: i32, mut request: ExperimentLogQueryRequest, - mode: OutputMode, -) -> anyhow::Result { + output: &Output, +) -> anyhow::Result<()> { loop { let response = client.query_experiment_logs(&project.owner, &project.name, num, request.clone())?; - if mode == OutputMode::Json { - for item in &response.items { - output::write_stream_item(&LogEvent { - event_type: "log", - item, - })?; - } - } else { - print_logs(&response.items)?; + for item in &response.items { + output.event(&LogEvent::Log(item))?; } let previous = request.after.unwrap_or(0); let cursor = response @@ -383,11 +344,7 @@ fn follow_logs( .max(previous); request.after = Some(cursor); if !response.running && !response.has_more { - let end = json!({"type": "end", "running": false}); - if mode == OutputMode::Json { - output::write_stream_item(&end)?; - } - return Ok(end); + return output.event(&LogEvent::End { running: false }); } if response.has_more && cursor == previous { return Err(CliError::new( @@ -403,86 +360,54 @@ fn follow_logs( } } -pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Result { - context.terminal().command_title("Experiments"); +pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Result { let client = get_client_and_login_if_needed(&context)?; let project = resolve_namespace_project(&context)?.project; - let human = context.output() == OutputMode::Human; + let (owner, name) = (&project.owner, &project.name); match args.command { - ExperimentsCommands::List(args) => { - let response = client.get_project_experiments( - &project.owner, - &project.name, + ExperimentsCommands::List(args) => Ok(client + .get_project_experiments( + owner, + name, ListExperimentsQuery { page: args.page, limit: args.limit, sort: args.sort, }, - )?; - if human { - print_list(&response)?; - } - Ok(serde_json::to_value(response)?) - } + )? + .into()), ExperimentsCommands::Get(args) => { let experiment = match args.experiment { - ExperimentSelector::Number(num) => { - client.get_experiment(&project.owner, &project.name, num)? - } + ExperimentSelector::Number(num) => client.get_experiment(owner, name, num)?, ExperimentSelector::Latest => client - .get_project_latest_experiment(&project.owner, &project.name)? + .get_project_latest_experiment(owner, name)? .ok_or_else(|| { CliError::new(ErrorKind::NotFound, "No experiments found in this project.") })?, }; - if human { - print_experiment(&experiment)?; - } - Ok(serde_json::to_value(experiment)?) + Ok(experiment.into()) } ExperimentsCommands::Metrics(args) => { let Some(metric) = args.metric else { - let metadata = - client.get_metric_metadata(&project.owner, &project.name, args.num)?; - if human { - print_metric_metadata(&metadata)?; - } - return Ok(serde_json::to_value(metadata)?); + return Ok(client.get_metric_metadata(owner, name, args.num)?.into()); }; if args.summary { - let response = client.get_metric_summary( - &project.owner, - &project.name, - args.num, - MetricSummaryQuery { metric }, - )?; - if human { - if let Some(response) = &response { - print_metric_summary(response)?; - } else { - context.terminal().print("No metric summary available."); - } - } - Ok(serde_json::to_value(response)?) + Ok(client + .get_metric_summary(owner, name, args.num, MetricSummaryQuery { metric })? + .into()) } else { - let response = client.get_metrics( - &project.owner, - &project.name, - args.num, - MetricAggregatedQuery { - metric, - max_points: args.max_points, - downsampling_factor: args.downsampling, - }, - )?; - if human { - if let Some(response) = &response { - print_metrics(response)?; - } else { - context.terminal().print("No metric series available."); - } - } - Ok(serde_json::to_value(response)?) + Ok(client + .get_metrics( + owner, + name, + args.num, + MetricAggregatedQuery { + metric, + max_points: args.max_points, + downsampling_factor: args.downsampling, + }, + )? + .into()) } } ExperimentsCommands::Logs(args) => { @@ -490,14 +415,12 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res let follow = args.follow; let request = args.into_request(); if follow { - return follow_logs(&client, &project, num, request, context.output()); + follow_logs(&client, &project, num, request, context.output())?; + return Ok(Outcome::streamed()); } - let response = - client.query_experiment_logs(&project.owner, &project.name, num, request)?; - if human { - print_logs(&response.items)?; - } - Ok(serde_json::to_value(response)?) + Ok(client + .query_experiment_logs(owner, name, num, request)? + .into()) } } } @@ -505,6 +428,7 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res #[cfg(test)] mod tests { use clap::{CommandFactory, Parser}; + use serde_json::json; use super::*; use crate::cli::{CliArgs, Commands}; @@ -602,7 +526,6 @@ mod tests { "--metadata-exists", "epoch", ]); - assert!(!args.streams_output()); let ExperimentsCommands::Logs(args) = args.command else { panic!("Expected logs command"); }; @@ -628,14 +551,13 @@ mod tests { } #[test] - fn follow_uses_a_sequence_cursor_and_streams_output() { + fn follow_starts_from_a_sequence_cursor() { for after in [None, Some("12")] { let mut arguments = vec!["tracel", "exp", "logs", "42", "--follow", "--json"]; if let Some(after) = after { arguments.extend(["--after", after]); } let args = parse_command(&arguments); - assert!(args.streams_output()); let ExperimentsCommands::Logs(args) = args.command else { panic!("Expected logs command"); }; @@ -661,4 +583,37 @@ mod tests { } } } + + #[test] + fn followed_logs_are_typed_events() { + let item = ExperimentLogItemResponse { + seq: 7, + log_level: LogLevelResponse::Warn, + timestamp: "2026-10-07T00:00:00Z".into(), + message: "loss\nspiked".into(), + metadata: json!({"epoch": 3}), + }; + assert_eq!( + serde_json::to_value(LogEvent::Log(&item)).unwrap(), + json!({ + "type": "log", + "seq": 7, + "log_level": "warn", + "timestamp": "2026-10-07T00:00:00Z", + "message": "loss\nspiked", + "metadata": {"epoch": 3}, + }) + ); + assert_eq!( + serde_json::to_value(LogEvent::End { running: false }).unwrap(), + json!({"type": "end", "running": false}) + ); + let mut text = Vec::new(); + LogEvent::Log(&item).render(&mut text).unwrap(); + LogEvent::End { running: false }.render(&mut text).unwrap(); + assert_eq!( + String::from_utf8(text).unwrap(), + "2026-10-07T00:00:00Z warn loss spiked\n" + ); + } } diff --git a/crates/tracel-cli/src/commands/init.rs b/crates/tracel-cli/src/commands/init.rs index 5f6449d..9e4be0d 100644 --- a/crates/tracel-cli/src/commands/init.rs +++ b/crates/tracel-cli/src/commands/init.rs @@ -1,13 +1,14 @@ use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::{can_initialize_project, require_cargo_workspace, require_linked_project}; +use crate::output::{Outcome, Render}; +use crate::terminal::Terminal; use crate::tools::git; use crate::tools::project_context::ProjectContext; -use crate::tools::terminal::Terminal; use crate::tools::tracel_config::TracelProject; use anyhow::Context; use clap::Args; -use serde_json::{Value, json}; +use serde::Serialize; use tracel_client::console::Client; use tracel_client::console::project::request::Visibility; use tracel_client::console::project::response::ProjectResponse; @@ -37,7 +38,18 @@ pub struct InitArgs { pub allow_dirty: bool, } -pub fn handle_command(args: InitArgs, context: CliContext) -> anyhow::Result { +/// The project the repository is linked to. `url` is null when it already was. +#[derive(Serialize)] +struct ProjectLinked { + namespace: String, + name: String, + created: bool, + url: Option, +} + +impl Render for ProjectLinked {} + +pub fn handle_command(args: InitArgs, context: CliContext) -> anyhow::Result { if let Some(name) = &args.name { validate_project_name(name).map_err(|message| { CliError::new(ErrorKind::Usage, message).with_hint("Pass --name .") @@ -59,19 +71,26 @@ pub fn handle_command(args: InitArgs, context: CliContext) -> anyhow::Result anyhow::Result { +fn prompt_init( + args: InitArgs, + context: &CliContext, + client: &Client, +) -> anyhow::Result { let user = client.get_current_user()?; let workspace_info = require_cargo_workspace()?; @@ -82,14 +101,7 @@ pub fn prompt_init(args: InitArgs, context: &CliContext, client: &Client) -> any ensure_git_repo_initialized(&workspace_info.get_ws_root(), terminal)?; ensure_git_repo_clean(terminal, args.commit, args.allow_dirty)?; - let first_commit_hash = git::get_first_commit_hash(); - if let Err(e) = first_commit_hash { - terminal.cancel_finalize( - "No commits found in the repository. Please make an initial commit before proceeding.", - ); - return Err(anyhow::anyhow!("Failed to get first commit hash: {}", e)); - } - let _first_commit_hash = first_commit_hash?; + git::get_first_commit_hash().context("The repository needs an initial commit")?; let project_owner = prompt_owner_name( &user.username, @@ -122,18 +134,11 @@ pub fn prompt_init(args: InitArgs, context: &CliContext, client: &Client) -> any )?, true, ), - Err(e) => { - terminal.cancel_finalize(&format!("Failed to check for existing project: {e}")); - return Err(anyhow::anyhow!(e)); - } + Err(e) => return Err(e).context("Failed to check for an existing project"), }; - ProjectContext::init(project_info.clone(), &workspace_info.get_manifest_path()).map_err( - |e| { - terminal.cancel_finalize(&format!("Failed to initialize project metadata: {}", e)); - e - }, - )?; + ProjectContext::init(project_info.clone(), &workspace_info.get_manifest_path()) + .context("Failed to initialize project metadata")?; terminal.print("Created project metadata"); let frontend_url = project_url(context, &user.username, &project_owner, &project_name)?; @@ -143,12 +148,12 @@ pub fn prompt_init(args: InitArgs, context: &CliContext, client: &Client) -> any context.terminal().format_url(&frontend_url) )); - Ok(json!({ - "namespace": project_info.owner, - "name": project_info.name, - "created": created, - "url": frontend_url.as_str(), - })) + Ok(ProjectLinked { + namespace: project_info.owner, + name: project_info.name, + created, + url: Some(frontend_url.to_string()), + }) } fn prompt_owner_name( @@ -270,7 +275,6 @@ fn handle_existing_project( name: project.project_name.clone(), }) } else { - terminal.outro_cancel("Project initialization cancelled"); Err(anyhow::anyhow!("Project initialization cancelled by user")) } } @@ -325,16 +329,11 @@ fn create_new_project( ), }; - match created_project_path { - Ok(project) => Ok(TracelProject { - owner: project.namespace_name, - name: project.project_name, - }), - Err(e) => { - terminal.outro_cancel(&format!("Failed to create project: {e}")); - Err(e).context("Failed to create project") - } - } + let project = created_project_path.context("Failed to create project")?; + Ok(TracelProject { + owner: project.namespace_name, + name: project.project_name, + }) } pub fn ensure_git_repo_initialized( diff --git a/crates/tracel-cli/src/commands/login.rs b/crates/tracel-cli/src/commands/login.rs index 9bef9e8..b7fee09 100644 --- a/crates/tracel-cli/src/commands/login.rs +++ b/crates/tracel-cli/src/commands/login.rs @@ -3,9 +3,8 @@ mod pending; use std::sync::mpsc::{self, RecvTimeoutError}; use std::time::{Duration, Instant, SystemTime}; -use chrono::{DateTime, Utc}; use clap::Args; -use serde_json::{Value, json}; +use serde::Serialize; use tracel_client::console::auth::{DeviceAuthClient, DeviceFlowError, DevicePollOutcome}; use tracel_client::console::{Client, Env, TracelCredentials}; use url::Url; @@ -13,7 +12,7 @@ use url::Url; use self::pending::{PendingLogin, PendingLoginStore}; use crate::context::{CliContext, ClientCreationError}; use crate::error::{CliError, ErrorKind, classify}; -use crate::output::OutputMode; +use crate::output::{Outcome, Render, Timestamp}; #[derive(Args, Debug)] pub struct LoginArgs { @@ -110,11 +109,7 @@ fn log_in(context: &CliContext) -> anyhow::Result<()> { terminal.format_url(&Url::parse(&authorization.verification_uri_complete)?), console::style(&authorization.user_code).bold() ); - if context.output() == OutputMode::Json { - eprintln!("{}", console::strip_ansi_codes(&instructions)); - } else { - terminal.print(&instructions); - } + terminal.instruct(&instructions); let spinner = terminal.spinner(); spinner.start("Waiting for approval... Press Ctrl+C to cancel."); @@ -128,7 +123,26 @@ fn log_in(context: &CliContext) -> anyhow::Result<()> { Ok(()) } -fn start_pending_login(context: &CliContext) -> anyhow::Result { +#[derive(Serialize)] +struct LoggedIn { + username: String, + environment: String, +} + +impl Render for LoggedIn {} + +/// Serialized with `"status": "pending"`. +#[derive(Serialize)] +#[serde(tag = "status", rename = "pending")] +struct LoginPending { + verification_uri_complete: String, + user_code: String, + expires_at: Timestamp, +} + +impl Render for LoginPending {} + +fn start_pending_login(context: &CliContext) -> anyhow::Result { let authorization = context.device_auth().start()?; let pending = PendingLogin::new(authorization, SystemTime::now())?; let store = PendingLoginStore::for_server(&context.environment().get_url())?; @@ -144,12 +158,11 @@ fn start_pending_login(context: &CliContext) -> anyhow::Result { )); terminal.print("Then run 'tracel login --complete'."); - Ok(json!({ - "status": "pending", - "verification_uri_complete": pending.authorization.verification_uri_complete, - "user_code": pending.authorization.user_code, - "expires_at": DateTime::::from(pending.expires_at).to_rfc3339(), - })) + Ok(LoginPending { + verification_uri_complete: pending.authorization.verification_uri_complete, + user_code: pending.authorization.user_code, + expires_at: Timestamp(pending.expires_at), + }) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -288,11 +301,11 @@ fn complete_pending_login(context: &CliContext, timeout: Option) -> anyhow: result } -pub fn handle_command(args: LoginArgs, context: CliContext) -> anyhow::Result { +pub fn handle_command(args: LoginArgs, context: CliContext) -> anyhow::Result { context.terminal().command_title("Login"); if args.no_wait { - return start_pending_login(&context); + return Ok(start_pending_login(&context)?.into()); } if args.complete { complete_pending_login(&context, args.timeout)?; @@ -314,10 +327,11 @@ pub fn handle_command(args: LoginArgs, context: CliContext) -> anyhow::Result anyhow::Result { +impl Render for LoggedOut {} + +pub fn handle_command(context: CliContext) -> anyhow::Result { context.terminal().command_title("Logout"); let app_session = context.app_session()?; @@ -19,6 +29,8 @@ pub fn handle_command(context: CliContext) -> anyhow::Result { .finalize(&format!("Nobody was logged in{}.", env_msg)); } - Ok(json!({"logged_out": was_logged_in})) + Ok(LoggedOut { + logged_out: was_logged_in, + } + .into()) } -use serde_json::{Value, json}; diff --git a/crates/tracel-cli/src/commands/me.rs b/crates/tracel-cli/src/commands/me.rs index 59d279e..eba64a1 100644 --- a/crates/tracel-cli/src/commands/me.rs +++ b/crates/tracel-cli/src/commands/me.rs @@ -1,47 +1,45 @@ +use std::io::{self, Write}; + use anyhow::Context; -use serde_json::{Value, json}; +use serde::Serialize; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; -use tracel_client::console::Env; +use crate::output::{Details, Outcome, Render}; + +#[derive(Serialize)] +struct CurrentUser { + username: String, + email: Option, + namespace: String, + environment: String, + api_url: String, +} -pub fn handle_command(context: CliContext) -> anyhow::Result { - context.terminal().command_title("User Information"); +impl Render for CurrentUser { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + Details::new() + .field("Username", &self.username) + .optional("Email", self.email.as_ref()) + .field("Namespace", &self.namespace) + .field("Environment", &self.environment) + .field("API URL", &self.api_url) + .write(out) + } +} +pub fn handle_command(context: CliContext) -> anyhow::Result { let client = get_client_and_login_if_needed(&context)?; - let user = client .get_current_user() .context("Failed to retrieve user information")?; - context - .terminal() - .print(&format!("Username: {}", user.username)); - if let Some(email) = &user.email { - context.terminal().print(&format!("Email: {}", email)); + Ok(CurrentUser { + username: user.username, + email: user.email, + namespace: user.namespace, + environment: context.environment_name(), + api_url: context.get_api_endpoint(), } - context - .terminal() - .print(&format!("Namespace: {}", user.namespace)); - - let env_name = match context.environment() { - Env::Development => &format!("Development ({})", context.get_api_endpoint()), - Env::Staging(_) => &format!("Staging ({})", context.get_api_endpoint()), - Env::Production => &format!("Production ({})", context.get_api_endpoint()), - }; - - context - .terminal() - .print(&format!("Environment: {}", env_name)); - context - .terminal() - .finalize("User information retrieved successfully."); - - Ok(json!({ - "username": user.username, - "email": user.email, - "namespace": user.namespace, - "environment": context.environment_name(), - "api_url": context.get_api_endpoint(), - })) + .into()) } diff --git a/crates/tracel-cli/src/commands/models.rs b/crates/tracel-cli/src/commands/models.rs index 8f69949..79b3ae2 100644 --- a/crates/tracel-cli/src/commands/models.rs +++ b/crates/tracel-cli/src/commands/models.rs @@ -1,16 +1,17 @@ use std::collections::BTreeMap; -use std::io::Write; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; use clap::{Args, Subcommand}; -use serde_json::{Value, json}; -use tracel_client::console::Client; +use serde::Serialize; +use serde_json::Value; use tracel_client::console::model::request::{ ModelFileSpecRequest, ModelVersionListState, PromoteModelVersionRequest, RequestModelVersionUploadRequest, SetModelAliasRequest, }; use tracel_client::console::model::response::{ - ModelAliasResponse, ModelResponse, ModelVersionResponse, ModelVersionSourceKindResponse, + ModelAliasListResponse, ModelAliasResponse, ModelListResponse, ModelResponse, + ModelVersionListResponse, ModelVersionResponse, ModelVersionSourceKindResponse, ModelVersionStateResponse, }; @@ -18,11 +19,11 @@ use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::{ - DownloadFile, Resource, build_part_tasks, download_files, ensure_model_exists, + DownloadFile, DownloadResult, Resource, build_part_tasks, download_files, ensure_model_exists, map_resource_error, parse_metadata, resolve_namespace_project, select_artifact, upload_parts, validate_auto_create, validate_rel_path, }; -use crate::output::{OutputMode, write_table}; +use crate::output::{Details, Outcome, Render, Table, json_section}; use crate::tools::fs::{build_file_specs, collect_files}; use crate::tools::tracel_config::TracelProject; @@ -169,6 +170,161 @@ pub struct AliasRemoveArgs { pub alias: String, } +/// A version made by `push` or `promote`. +#[derive(Serialize)] +#[serde(transparent)] +struct NewVersion(ModelVersionResponse); + +impl Render for NewVersion {} + +#[derive(Serialize)] +struct ModelPulled { + model: String, + version: ModelVersionResponse, + directory: PathBuf, + files: Vec, + bytes: u64, +} + +impl Render for ModelPulled {} + +#[derive(Serialize)] +#[serde(transparent)] +struct AliasSet(ModelAliasResponse); + +impl Render for AliasSet {} + +#[derive(Serialize)] +struct AliasRemoved { + model: String, + alias: String, + removed: bool, +} + +impl Render for AliasRemoved {} + +impl Render for ModelListResponse { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + Table::new(["NAME", "VERSIONS", "LATEST", "ALIASES", "CREATED AT"]) + .rows(self.items.iter().map(|model| { + [ + model.name.clone(), + model.version_count.to_string(), + latest_version(model), + alias_summary(&model.aliases), + model.created_at.clone(), + ] + })) + .total(self.total) + .write(out) + } +} + +impl Render for ModelResponse { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + Details::new() + .field("Name", &self.name) + .field("ID", &self.id) + .field("Project ID", self.project_id) + .field("Display name", &self.display_name) + .optional("Description", self.description.as_ref()) + .field("Versions", self.version_count) + .field("Latest", latest_version(self)) + .field("Aliases", alias_summary(&self.aliases)) + .field("Created at", &self.created_at) + .field("Created by", &self.created_by.username) + .write(out) + } +} + +impl Render for ModelVersionResponse { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + Details::new() + .field("Version", format!("v{}", self.version)) + .field("ID", &self.id) + .field("State", state_name(self.state)) + .field("Source", source_name(self.source_kind)) + .optional( + "Experiment", + self.experiment + .as_ref() + .map(|experiment| experiment.experiment_num), + ) + .field("Size", self.size) + .field("Digest", &self.digest) + .field("Aliases", self.aliases.join(", ")) + .field("Created at", &self.created_at) + .field("Created by", &self.created_by.username) + .optional("Failure reason", self.failure_reason.as_ref()) + .write(out)?; + writeln!(out)?; + Table::new(["REL PATH", "SIZE", "CHECKSUM"]) + .rows(self.manifest.files.iter().map(|file| { + [ + file.rel_path.clone(), + file.size_bytes.to_string(), + file.checksum.clone(), + ] + })) + .write(out)?; + writeln!(out)?; + json_section(out, "Metadata", &self.metadata) + } +} + +impl Render for ModelVersionListResponse { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + Table::new([ + "VERSION", + "STATE", + "SOURCE", + "SIZE", + "ALIASES", + "CREATED AT", + ]) + .rows(self.items.iter().map(|version| { + let source = match &version.experiment { + Some(experiment) => format!( + "{} (experiment {})", + source_name(version.source_kind), + experiment.experiment_num + ), + None => source_name(version.source_kind).into(), + }; + [ + format!("v{}", version.version), + state_name(version.state).into(), + source, + version.size.to_string(), + version.aliases.join(", "), + version.created_at.clone(), + ] + })) + .total(self.total) + .write(out) + } +} + +impl Render for ModelAliasListResponse { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + Table::new(["ALIAS", "VERSION"]) + .rows( + self.items + .iter() + .map(|alias| [alias.alias.clone(), format!("v{}", alias.version)]), + ) + .total(self.total) + .write(out) + } +} + +fn latest_version(model: &ModelResponse) -> String { + model + .latest_version + .map(|version| format!("v{version}")) + .unwrap_or_default() +} + fn alias_summary(aliases: &[ModelAliasResponse]) -> String { aliases .iter() @@ -193,71 +349,6 @@ fn source_name(source: ModelVersionSourceKindResponse) -> &'static str { } } -fn print_model(model: &ModelResponse) -> anyhow::Result<()> { - let mut stdout = std::io::stdout().lock(); - writeln!(stdout, "Name: {}", model.name)?; - writeln!(stdout, "ID: {}", model.id)?; - writeln!(stdout, "Project ID: {}", model.project_id)?; - writeln!(stdout, "Display name: {}", model.display_name)?; - writeln!( - stdout, - "Description: {}", - model.description.as_deref().unwrap_or("") - )?; - writeln!(stdout, "Versions: {}", model.version_count)?; - writeln!( - stdout, - "Latest: {}", - model - .latest_version - .map(|version| format!("v{version}")) - .unwrap_or_else(|| "-".into()) - )?; - writeln!(stdout, "Aliases: {}", alias_summary(&model.aliases))?; - writeln!(stdout, "Created at: {}", model.created_at)?; - writeln!(stdout, "Created by: {}", model.created_by.username)?; - Ok(()) -} - -fn print_version(version: &ModelVersionResponse) -> anyhow::Result<()> { - let mut stdout = std::io::stdout().lock(); - writeln!(stdout, "Version: v{}", version.version)?; - writeln!(stdout, "ID: {}", version.id)?; - writeln!(stdout, "State: {}", state_name(version.state))?; - writeln!(stdout, "Source: {}", source_name(version.source_kind))?; - if let Some(experiment) = &version.experiment { - writeln!(stdout, "Experiment: {}", experiment.experiment_num)?; - } - writeln!(stdout, "Size: {}", version.size)?; - writeln!(stdout, "Digest: {}", version.digest)?; - writeln!(stdout, "Aliases: {}", version.aliases.join(", "))?; - writeln!(stdout, "Created at: {}", version.created_at)?; - writeln!(stdout, "Created by: {}", version.created_by.username)?; - if let Some(reason) = &version.failure_reason { - writeln!(stdout, "Failure reason: {reason}")?; - } - write_table( - &mut stdout, - &["REL PATH", "SIZE", "CHECKSUM"], - version - .manifest - .files - .iter() - .map(|file| { - vec![ - file.rel_path.clone(), - file.size_bytes.to_string(), - file.checksum.clone(), - ] - }) - .collect(), - )?; - writeln!(stdout, "Metadata:")?; - serde_json::to_writer_pretty(&mut stdout, &version.metadata)?; - writeln!(stdout)?; - Ok(()) -} - fn validate_download_directory(directory: Option<&Path>, model: &str) -> anyhow::Result<()> { if let Some(directory) = directory { if directory.as_os_str().is_empty() || (directory.exists() && !directory.is_dir()) { @@ -278,69 +369,16 @@ fn validate_download_directory(directory: Option<&Path>, model: &str) -> anyhow: Ok(()) } -pub fn handle_command(args: ModelsArgs, context: CliContext) -> anyhow::Result { - let title = match &args.command { - ModelsCommands::Push(_) => "Model upload", - _ => "Models", - }; - context.terminal().command_title(title); +pub fn handle_command(args: ModelsArgs, context: CliContext) -> anyhow::Result { let project = resolve_namespace_project(&context)?.project; - let files = match &args.command { - ModelsCommands::Push(args) => { - validate_auto_create(args.auto_create, args.description.as_deref())?; - let spinner = context.terminal().spinner(); - spinner.start("Collecting files..."); - let files = collect_files(&args.directory).map_err(|error| { - spinner.error("Failed to collect files."); - CliError::new(ErrorKind::Usage, format!("{error:#}")) - })?; - spinner.stop(format!("Found {} file(s).", files.len())); - Some(files) - } - ModelsCommands::Promote(args) => { - validate_auto_create(args.auto_create, args.description.as_deref())?; - None - } - ModelsCommands::Pull(args) => { - validate_download_directory(args.directory.as_deref(), &args.model)?; - None - } - _ => None, - }; - let client = get_client_and_login_if_needed(&context)?; - let human = context.output() == OutputMode::Human; - let namespace = &project.owner; - let name = &project.name; + let (namespace, name) = (&project.owner, &project.name); + let client = || get_client_and_login_if_needed(&context); match args.command { - ModelsCommands::List => { - let response = client.list_models(namespace, name)?; - if human { - write_table( - &mut std::io::stdout().lock(), - &["NAME", "VERSIONS", "LATEST", "ALIASES", "CREATED AT"], - response - .items - .iter() - .map(|model| { - vec![ - model.name.clone(), - model.version_count.to_string(), - model - .latest_version - .map(|version| format!("v{version}")) - .unwrap_or_else(|| "-".into()), - alias_summary(&model.aliases), - model.created_at.clone(), - ] - }) - .collect(), - )?; - } - Ok(serde_json::to_value(response)?) - } + ModelsCommands::List => Ok(client()?.list_models(namespace, name)?.into()), ModelsCommands::Get(args) => { - if let Some(reference) = args.version { - let version = client + let client = client()?; + match args.version { + Some(reference) => Ok(client .resolve_model_version_ref(namespace, name, &args.model, &reference) .map_err(|error| { map_resource_error( @@ -352,25 +390,19 @@ pub fn handle_command(args: ModelsArgs, context: CliContext) -> anyhow::Result Ok(client .get_model(namespace, name, &args.model) .map_err(|error| { map_resource_error(error, namespace, name, Resource::Model(&args.model)) - })?; - if human { - print_model(&model)?; - } - Ok(serde_json::to_value(model)?) + })? + .into()), } } ModelsCommands::Versions(args) => { - let response = if args.all { + let client = client()?; + Ok(if args.all { client.list_model_versions_in_state( namespace, name, @@ -382,267 +414,90 @@ pub fn handle_command(args: ModelsArgs, context: CliContext) -> anyhow::Result format!( - "{} (experiment {})", - source_name(version.source_kind), - experiment.experiment_num - ), - None => source_name(version.source_kind).into(), - }; - vec![ - format!("v{}", version.version), - state_name(version.state).into(), - source, - version.size.to_string(), - version.aliases.join(", "), - version.created_at.clone(), - ] - }) - .collect(), - )?; - } - Ok(serde_json::to_value(response)?) + })? + .into()) } - ModelsCommands::Pull(args) => { - let map_error = |error| { - map_resource_error( - error, - namespace, - name, - Resource::ModelVersionRef { - model: &args.model, - reference: &args.version, - }, - ) - }; - let version = client - .resolve_model_version_ref(namespace, name, &args.model, &args.version) - .map_err(map_error)?; - let directory = args - .directory - .unwrap_or_else(|| PathBuf::from(format!("./{}-v{}", args.model, version.version))); - let response = client - .presign_model_download(namespace, name, &args.model, version.version) - .map_err(map_error)?; - let files: Vec<_> = response - .files - .into_iter() - .map(|file| DownloadFile { - rel_path: file.rel_path, - url: file.url, - size_bytes: Some(file.size_bytes), - checksum: Some(file.checksum), - }) - .collect(); - let files = download_files(&client, &directory, &files, args.force)?; - let bytes: u64 = files.iter().map(|file| file.bytes).sum(); - if human { - writeln!( - std::io::stdout().lock(), - "Downloaded '{}' v{} to {}.", - args.model, - version.version, - directory.display() - )?; - } - Ok( - json!({"model": args.model, "version": version, "directory": directory, "files": files, "bytes": bytes}), - ) - } - ModelsCommands::Push(args) => push( - args, - &context, - &client, - project, - files.expect("Push files were collected"), - ), - ModelsCommands::Promote(args) => { - let artifacts = client - .list_artifacts(namespace, name, args.experiment) - .map_err(|error| { - map_resource_error( - error, - namespace, - name, - Resource::Experiment(args.experiment), - ) - })?; - let index = select_artifact( - artifacts - .items - .iter() - .map(|artifact| (artifact.id.as_str(), artifact.name.as_str())), - &args.artifact, - args.experiment, - )?; - let artifact = &artifacts.items[index]; - ensure_model_exists( - &context, - &client, - namespace, - name, - &args.model, - args.auto_create, - args.description, - )?; - let map_error = - |error| map_resource_error(error, namespace, name, Resource::Model(&args.model)); - let mut version = client - .promote_model_version( - namespace, - name, - &args.model, - PromoteModelVersionRequest { - experiment_num: args.experiment, - experiment_file_id: artifact.id.clone(), - metadata: args.metadata, - }, - ) - .map_err(map_error)?; - if let Some(alias) = args.alias { - client - .set_model_alias( - namespace, - name, - &args.model, - &alias, - SetModelAliasRequest { - version: version.version, - expected_current_version: None, - }, - ) - .map_err(map_error)?; - version = client - .get_model_version(namespace, name, &args.model, version.version) - .map_err(map_error)?; - } - if human { - writeln!( - std::io::stdout().lock(), - "Promoted artifact '{}' to '{}' v{}.", - artifact.name, - args.model, - version.version - )?; - } - Ok(serde_json::to_value(version)?) - } - ModelsCommands::Alias(args) => match args.command { - AliasCommands::List(args) => { - let response = client - .list_model_aliases(namespace, name, &args.model) - .map_err(|error| { - map_resource_error(error, namespace, name, Resource::Model(&args.model)) - })?; - if human { - write_table( - &mut std::io::stdout().lock(), - &["ALIAS", "VERSION"], - response - .items - .iter() - .map(|alias| vec![alias.alias.clone(), format!("v{}", alias.version)]) - .collect(), - )?; - } - Ok(serde_json::to_value(response)?) - } - AliasCommands::Set(args) => { - let response = client - .set_model_alias( - namespace, - name, - &args.model, - &args.alias, - SetModelAliasRequest { - version: args.version, - expected_current_version: args.expect, - }, - ) - .map_err(|error| { - map_resource_error( - error, - namespace, - name, - Resource::ModelVersion { - model: &args.model, - version: args.version, - }, - ) - })?; - if human { - writeln!( - std::io::stdout().lock(), - "{}: v{}", - response.alias, - response.version - )?; - } - Ok(serde_json::to_value(response)?) - } - AliasCommands::Remove(args) => { - client - .remove_model_alias(namespace, name, &args.model, &args.alias) - .map_err(|error| { - map_resource_error( - error, - namespace, - name, - Resource::ModelAlias { - model: &args.model, - alias: &args.alias, - }, - ) - })?; - if human { - writeln!( - std::io::stdout().lock(), - "Removed alias '{}' from '{}'.", - args.alias, - args.model - )?; - } - Ok(json!({"model": args.model, "alias": args.alias, "removed": true})) - } - }, + ModelsCommands::Pull(args) => Ok(pull(args, &context, &project)?.into()), + ModelsCommands::Push(args) => Ok(push(args, &context, &project)?.into()), + ModelsCommands::Promote(args) => Ok(promote(args, &context, &project)?.into()), + ModelsCommands::Alias(args) => alias(args.command, &context, &project), } } +fn pull( + args: PullArgs, + context: &CliContext, + project: &TracelProject, +) -> anyhow::Result { + let (namespace, name) = (&project.owner, &project.name); + context.terminal().command_title("Model download"); + validate_download_directory(args.directory.as_deref(), &args.model)?; + let client = get_client_and_login_if_needed(context)?; + let map_error = |error| { + map_resource_error( + error, + namespace, + name, + Resource::ModelVersionRef { + model: &args.model, + reference: &args.version, + }, + ) + }; + let version = client + .resolve_model_version_ref(namespace, name, &args.model, &args.version) + .map_err(map_error)?; + let directory = args + .directory + .unwrap_or_else(|| PathBuf::from(format!("./{}-v{}", args.model, version.version))); + let response = client + .presign_model_download(namespace, name, &args.model, version.version) + .map_err(map_error)?; + let files: Vec<_> = response + .files + .into_iter() + .map(|file| DownloadFile { + rel_path: file.rel_path, + url: file.url, + size_bytes: Some(file.size_bytes), + checksum: Some(file.checksum), + }) + .collect(); + let files = download_files(&client, context.terminal(), &directory, &files, args.force)?; + context.terminal().finalize(&format!( + "Downloaded '{}' v{} to {}.", + args.model, + version.version, + directory.display() + )); + Ok(ModelPulled { + bytes: files.iter().map(|file| file.bytes).sum(), + model: args.model, + version, + directory, + files, + }) +} + fn push( args: PushArgs, context: &CliContext, - client: &Client, - project: TracelProject, - files: BTreeMap, -) -> anyhow::Result { - let namespace = project.owner; - let project = project.name; + project: &TracelProject, +) -> anyhow::Result { + let (namespace, name) = (&project.owner, &project.name); + context.terminal().command_title("Model upload"); + validate_auto_create(args.auto_create, args.description.as_deref())?; + let files = collect_push_files(&args.directory, context)?; + let client = get_client_and_login_if_needed(context)?; context .terminal() - .print(&format!("Uploading to {namespace}/{project}")); + .print(&format!("Uploading to {namespace}/{name}")); ensure_model_exists( context, - client, - &namespace, - &project, + &client, + namespace, + name, &args.model_name, args.auto_create, args.description.clone(), @@ -655,7 +510,7 @@ fn push( })?; spinner.stop("Checksums computed."); - let file_sizes: std::collections::BTreeMap = file_specs + let file_sizes: BTreeMap = file_specs .iter() .map(|f| (f.rel_path.clone(), f.size_bytes)) .collect(); @@ -674,45 +529,197 @@ fn push( metadata: args.metadata, }; let upload = client - .request_model_version_upload(&namespace, &project, &args.model_name, upload_request) + .request_model_version_upload(namespace, name, &args.model_name, upload_request) .map_err(|e| { spinner.error("Failed to request upload URLs."); - map_resource_error(e, &namespace, &project, Resource::Model(&args.model_name)) + map_resource_error(e, namespace, name, Resource::Model(&args.model_name)) })?; spinner.stop(format!("Allocated model version {}.", upload.version)); let tasks = build_part_tasks(&files, &file_sizes, &upload.files)?; - upload_parts(client, tasks, context.terminal())?; + upload_parts(&client, tasks, context.terminal())?; - let map_error = |error| { - map_resource_error( - error, - &namespace, - &project, - Resource::Model(&args.model_name), - ) - }; + let map_error = + |error| map_resource_error(error, namespace, name, Resource::Model(&args.model_name)); client - .complete_model_version_upload(&namespace, &project, &args.model_name, upload.version) + .complete_model_version_upload(namespace, name, &args.model_name, upload.version) .map_err(map_error)?; let version = client - .get_model_version(&namespace, &project, &args.model_name, upload.version) + .get_model_version(namespace, name, &args.model_name, upload.version) .map_err(map_error)?; - context.terminal().print_success(&format!( - "Uploaded model '{}' version {} to {}/{}.", - args.model_name, upload.version, namespace, project + context.terminal().finalize(&format!( + "Uploaded model '{}' v{} to {namespace}/{name}.", + args.model_name, upload.version )); - context - .terminal() - .finalize("Model version uploaded successfully."); + Ok(NewVersion(version)) +} + +fn collect_push_files( + directory: &Path, + context: &CliContext, +) -> anyhow::Result> { + let spinner = context.terminal().spinner(); + spinner.start("Collecting files..."); + let files = collect_files(directory).map_err(|error| { + spinner.error("Failed to collect files."); + CliError::new(ErrorKind::Usage, format!("{error:#}")) + })?; + spinner.stop(format!("Found {} file(s).", files.len())); + Ok(files) +} - Ok(serde_json::to_value(version)?) +fn promote( + args: PromoteArgs, + context: &CliContext, + project: &TracelProject, +) -> anyhow::Result { + let (namespace, name) = (&project.owner, &project.name); + context.terminal().command_title("Model promotion"); + validate_auto_create(args.auto_create, args.description.as_deref())?; + let client = get_client_and_login_if_needed(context)?; + let artifacts = client + .list_artifacts(namespace, name, args.experiment) + .map_err(|error| { + map_resource_error( + error, + namespace, + name, + Resource::Experiment(args.experiment), + ) + })?; + let index = select_artifact( + artifacts + .items + .iter() + .map(|artifact| (artifact.id.as_str(), artifact.name.as_str())), + &args.artifact, + args.experiment, + )?; + let artifact = &artifacts.items[index]; + ensure_model_exists( + context, + &client, + namespace, + name, + &args.model, + args.auto_create, + args.description, + )?; + let map_error = + |error| map_resource_error(error, namespace, name, Resource::Model(&args.model)); + let mut version = client + .promote_model_version( + namespace, + name, + &args.model, + PromoteModelVersionRequest { + experiment_num: args.experiment, + experiment_file_id: artifact.id.clone(), + metadata: args.metadata, + }, + ) + .map_err(map_error)?; + if let Some(alias) = args.alias { + client + .set_model_alias( + namespace, + name, + &args.model, + &alias, + SetModelAliasRequest { + version: version.version, + expected_current_version: None, + }, + ) + .map_err(map_error)?; + version = client + .get_model_version(namespace, name, &args.model, version.version) + .map_err(map_error)?; + } + context.terminal().finalize(&format!( + "Promoted artifact '{}' to '{}' v{}.", + artifact.name, args.model, version.version + )); + Ok(NewVersion(version)) +} + +fn alias( + command: AliasCommands, + context: &CliContext, + project: &TracelProject, +) -> anyhow::Result { + let (namespace, name) = (&project.owner, &project.name); + match command { + AliasCommands::List(args) => Ok(get_client_and_login_if_needed(context)? + .list_model_aliases(namespace, name, &args.model) + .map_err(|error| { + map_resource_error(error, namespace, name, Resource::Model(&args.model)) + })? + .into()), + AliasCommands::Set(args) => { + context.terminal().command_title("Model alias"); + let alias = get_client_and_login_if_needed(context)? + .set_model_alias( + namespace, + name, + &args.model, + &args.alias, + SetModelAliasRequest { + version: args.version, + expected_current_version: args.expect, + }, + ) + .map_err(|error| { + map_resource_error( + error, + namespace, + name, + Resource::ModelVersion { + model: &args.model, + version: args.version, + }, + ) + })?; + context.terminal().finalize(&format!( + "Alias '{}' of '{}' points to v{}.", + alias.alias, args.model, alias.version + )); + Ok(AliasSet(alias).into()) + } + AliasCommands::Remove(args) => { + context.terminal().command_title("Model alias"); + get_client_and_login_if_needed(context)? + .remove_model_alias(namespace, name, &args.model, &args.alias) + .map_err(|error| { + map_resource_error( + error, + namespace, + name, + Resource::ModelAlias { + model: &args.model, + alias: &args.alias, + }, + ) + })?; + context.terminal().finalize(&format!( + "Removed alias '{}' from '{}'.", + args.alias, args.model + )); + Ok(AliasRemoved { + model: args.model, + alias: args.alias, + removed: true, + } + .into()) + } + } } #[cfg(test)] mod tests { use clap::{CommandFactory, Parser}; + use serde_json::json; use super::*; use crate::cli::{CliArgs, Commands}; diff --git a/crates/tracel-cli/src/commands/package.rs b/crates/tracel-cli/src/commands/package.rs index 7cdb054..7f22dcb 100644 --- a/crates/tracel-cli/src/commands/package.rs +++ b/crates/tracel-cli/src/commands/package.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use anyhow::Context; use clap::{Args, ValueEnum}; -use serde_json::{Value, json}; +use serde::Serialize; use sha2::{Digest, Sha256}; use tracel_client::console::Client; use tracel_client::console::project::request::{ @@ -19,6 +19,7 @@ use crate::error::{CliError, ErrorKind}; use crate::helpers::{ require_cargo_workspace, resolve_namespace_project, validate_project_exists_on_server, }; +use crate::output::{Outcome, Render}; use crate::tools::build_driver::{self, BuildDriver}; use crate::tools::packager::{PackageEvent, package_workspace}; use crate::tools::project_context::ProjectContext; @@ -46,7 +47,8 @@ pub struct PackageArgs { pub commit: bool, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum, Serialize)] +#[serde(rename_all = "lowercase")] pub enum Mode { Binary, Source, @@ -61,7 +63,21 @@ struct PreparedArtifact { targets: Vec, } -pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result { +/// A code version of the project. `uploaded` is false when it was already packaged. +#[derive(Serialize)] +struct Packaged { + namespace: String, + project: String, + digest: String, + version_id: String, + mode: Mode, + targets: Vec, + uploaded: bool, +} + +impl Render for Packaged {} + +pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result { if args.mode.is_none() && !context.terminal().is_interactive() { return Err(CliError::new( ErrorKind::Usage, @@ -90,7 +106,7 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< project: resolved.project, }; let client = get_client_and_login_if_needed(&context)?; - validate_project_exists_on_server(&context, &project, &client)?; + validate_project_exists_on_server(&project, &client)?; // 1. Dirty check — warn and offer to commit, but allow proceeding. let has_commit = git::get_last_commit_hash().is_ok(); @@ -171,7 +187,7 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< }; // 4. Upload. - upload(&context, &client, &project, &digest, mode, artifact) + Ok(upload(&context, &client, &project, &digest, mode, artifact)?.into()) } fn build_source_artifact( @@ -390,7 +406,7 @@ fn upload( digest: &str, mode: Mode, prepared: PreparedArtifact, -) -> anyhow::Result { +) -> anyhow::Result { let bc_project = project.get_project(); let response = client @@ -409,16 +425,14 @@ fn upload( ) })?; - let data = |uploaded| { - json!({ - "namespace": bc_project.owner, - "project": bc_project.name, - "digest": digest, - "version_id": response.id, - "mode": match mode { Mode::Binary => "binary", Mode::Source => "source" }, - "targets": prepared.targets, - "uploaded": uploaded, - }) + let packaged = |uploaded| Packaged { + namespace: bc_project.owner.clone(), + project: bc_project.name.clone(), + digest: digest.to_string(), + version_id: response.id.clone(), + mode, + targets: prepared.targets.clone(), + uploaded, }; let Some(urls) = response.urls else { context.terminal().print_success(&format!( @@ -426,7 +440,7 @@ fn upload( response.id )); context.terminal().finalize("Nothing to upload."); - return Ok(data(false)); + return Ok(packaged(false)); }; let spinner = context.terminal().spinner(); @@ -460,5 +474,5 @@ fn upload( context .terminal() .finalize("Project packaged successfully."); - Ok(data(true)) + Ok(packaged(true)) } diff --git a/crates/tracel-cli/src/commands/project.rs b/crates/tracel-cli/src/commands/project.rs index feda7d5..30c57c3 100644 --- a/crates/tracel-cli/src/commands/project.rs +++ b/crates/tracel-cli/src/commands/project.rs @@ -1,13 +1,17 @@ +use std::io::{self, Write}; + use anyhow::Context; use clap::{Args, Subcommand}; -use serde_json::{Value, json}; +use serde::Serialize; use tracel_client::console::project::request::Visibility; +use tracel_client::console::project::response::ProjectResponse; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; +use crate::helpers::project::ProjectSource; use crate::helpers::resolve_namespace_project; -use crate::output::{OutputMode, write_table}; +use crate::output::{Details, Outcome, Render, Table}; #[derive(Args, Debug)] pub struct ProjectArgs { @@ -28,15 +32,63 @@ pub struct ListArgs { pub namespace: Option, } -pub fn handle_command(args: ProjectArgs, context: CliContext) -> anyhow::Result { +#[derive(Serialize)] +#[serde(transparent)] +struct Projects(Vec); + +impl Render for Projects { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + Table::new(["PROJECT", "VISIBILITY", "DESCRIPTION", "CREATED AT"]) + .rows(self.0.iter().map(|project| { + [ + format!("{}/{}", project.namespace_name, project.project_name), + visibility_name(&project.visibility).into(), + project.description.clone(), + project.created_at.clone(), + ] + })) + .write(out) + } +} + +#[derive(Serialize)] +struct ProjectInfo { + namespace: String, + name: String, + description: String, + created_by: String, + visibility: Visibility, + source: ProjectSource, +} + +impl Render for ProjectInfo { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + Details::new() + .field("Project", &self.name) + .field("Namespace", &self.namespace) + .field("Description", &self.description) + .field("Visibility", visibility_name(&self.visibility)) + .field("Created by", &self.created_by) + .field("Selected by", &self.source) + .write(out) + } +} + +fn visibility_name(visibility: &Visibility) -> &'static str { + match visibility { + Visibility::Private => "private", + Visibility::Public => "public", + } +} + +pub fn handle_command(args: ProjectArgs, context: CliContext) -> anyhow::Result { match args.command { - Some(ProjectCommands::List(args)) => list_projects(args, context), - None => show_project(context), + Some(ProjectCommands::List(args)) => Ok(list_projects(args, context)?.into()), + None => Ok(show_project(context)?.into()), } } -fn list_projects(args: ListArgs, context: CliContext) -> anyhow::Result { - context.terminal().command_title("Projects"); +fn list_projects(args: ListArgs, context: CliContext) -> anyhow::Result { let client = get_client_and_login_if_needed(&context)?; let user_namespace = &client.user().namespace; let mut projects = Vec::new(); @@ -47,7 +99,7 @@ fn list_projects(args: ListArgs, context: CliContext) -> anyhow::Result { { projects.extend(client.list_user_projects(user_namespace)?); if args.namespace.is_some() { - return print_projects(projects, context.output()); + return Ok(Projects(projects)); } } let organizations = client.get_user_organizations()?; @@ -81,40 +133,10 @@ fn list_projects(args: ListArgs, context: CliContext) -> anyhow::Result { projects.extend(client.list_organization_projects(&organization.namespace)?); } } - print_projects(projects, context.output()) + Ok(Projects(projects)) } -fn print_projects( - projects: Vec, - mode: OutputMode, -) -> anyhow::Result { - if mode == OutputMode::Human { - write_table( - &mut std::io::stdout().lock(), - &["PROJECT", "VISIBILITY", "DESCRIPTION", "CREATED AT"], - projects - .iter() - .map(|project| { - let visibility = match project.visibility { - Visibility::Private => "private", - Visibility::Public => "public", - }; - vec![ - format!("{}/{}", project.namespace_name, project.project_name), - visibility.into(), - project.description.clone(), - project.created_at.clone(), - ] - }) - .collect(), - )?; - } - Ok(serde_json::to_value(projects)?) -} - -fn show_project(context: CliContext) -> anyhow::Result { - context.terminal().command_title("Project Information"); - +fn show_project(context: CliContext) -> anyhow::Result { let resolved = resolve_namespace_project(&context)?; let client = get_client_and_login_if_needed(&context)?; @@ -142,21 +164,14 @@ fn show_project(context: CliContext) -> anyhow::Result { } }; - let terminal = context.terminal(); - terminal.print(&format!("Project: {}", project.project_name)); - terminal.print(&format!("Namespace: {}", project.namespace_name)); - terminal.print(&format!("Description: {}", project.description)); - terminal.print(&format!("Created By: {}", project.created_by)); - terminal.finalize("Project information retrieved successfully."); - - Ok(json!({ - "namespace": project.namespace_name, - "name": project.project_name, - "description": project.description, - "created_by": project.created_by, - "visibility": project.visibility, - "source": resolved.source, - })) + Ok(ProjectInfo { + namespace: project.namespace_name, + name: project.project_name, + description: project.description, + created_by: project.created_by, + visibility: project.visibility, + source: resolved.source, + }) } #[cfg(test)] diff --git a/crates/tracel-cli/src/commands/training.rs b/crates/tracel-cli/src/commands/training.rs index 00747e5..4ed5426 100644 --- a/crates/tracel-cli/src/commands/training.rs +++ b/crates/tracel-cli/src/commands/training.rs @@ -3,7 +3,7 @@ use clap::Parser; use tracel_client::console::Env; -use crate::{context::CliContext, tools::cargo}; +use crate::{context::CliContext, output::Outcome, tools::cargo}; #[derive(Parser, Debug, Default)] pub struct TrainingArgs { @@ -12,8 +12,9 @@ pub struct TrainingArgs { forwarded: Vec, } -pub fn handle_command(args: TrainingArgs, context: CliContext) -> anyhow::Result<()> { - run_cargo(&args.forwarded, context) +pub fn handle_command(args: TrainingArgs, context: CliContext) -> anyhow::Result { + run_cargo(&args.forwarded, context)?; + Ok(Outcome::streamed()) } /// Run `cargo run` in the current directory, forwarding `forwarded` after `--`. diff --git a/crates/tracel-cli/src/commands/unlink.rs b/crates/tracel-cli/src/commands/unlink.rs index 463d280..96a8922 100644 --- a/crates/tracel-cli/src/commands/unlink.rs +++ b/crates/tracel-cli/src/commands/unlink.rs @@ -1,9 +1,12 @@ use anyhow::Context; use clap::Args; -use serde_json::{Value, json}; +use serde::Serialize; use crate::{ - context::CliContext, helpers::require_linked_project, tools::project_context::ProjectContext, + context::CliContext, + helpers::require_linked_project, + output::{Outcome, Render}, + tools::project_context::ProjectContext, }; #[derive(Args, Debug)] @@ -13,7 +16,19 @@ pub struct UnlinkArgs { pub yes: bool, } -pub fn handle_command(args: UnlinkArgs, context: CliContext) -> anyhow::Result { +/// `namespace` and `name` are the project that was unlinked, absent when cancelled. +#[derive(Serialize)] +struct Unlinked { + unlinked: bool, + #[serde(skip_serializing_if = "Option::is_none")] + namespace: Option, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, +} + +impl Render for Unlinked {} + +pub fn handle_command(args: UnlinkArgs, context: CliContext) -> anyhow::Result { let project = require_linked_project()?; context.terminal().command_title("Unlink"); @@ -25,16 +40,23 @@ pub fn handle_command(args: UnlinkArgs, context: CliContext) -> anyhow::Result, } impl CliContext { pub fn new( terminal: Terminal, + output: Output, environment: Env, - output: OutputMode, project: Option, ) -> Self { Self { terminal, - environment, output, + environment, project, } } @@ -101,8 +102,9 @@ impl CliContext { self.environment.clone() } - pub fn output(&self) -> OutputMode { - self.output + /// Stdout, for commands that stream their output while they run. + pub fn output(&self) -> &Output { + &self.output } pub fn project(&self) -> Option<&TracelProject> { diff --git a/crates/tracel-cli/src/helpers/download.rs b/crates/tracel-cli/src/helpers/download.rs index 2b0b6bc..e9231f2 100644 --- a/crates/tracel-cli/src/helpers/download.rs +++ b/crates/tracel-cli/src/helpers/download.rs @@ -8,6 +8,7 @@ use sha2::{Digest, Sha256}; use tracel_client::console::Client; use crate::error::{CliError, ErrorKind}; +use crate::terminal::Terminal; pub struct DownloadFile { pub rel_path: String, @@ -122,11 +123,29 @@ impl Write for HashingWriter { pub fn download_files( client: &Client, + terminal: &Terminal, directory: &Path, files: &[DownloadFile], force: bool, ) -> anyhow::Result> { check_destinations(directory, files, force)?; + let spinner = terminal.spinner(); + spinner.start(format!("Downloading {} file(s)...", files.len())); + let results = download_each(client, directory, files) + .inspect_err(|_| spinner.error("Download failed."))?; + let bytes: u64 = results.iter().map(|result| result.bytes).sum(); + spinner.stop(format!( + "Downloaded {} file(s), {bytes} bytes.", + results.len() + )); + Ok(results) +} + +fn download_each( + client: &Client, + directory: &Path, + files: &[DownloadFile], +) -> anyhow::Result> { let mut results = Vec::with_capacity(files.len()); for file in files { let path = directory.join(&file.rel_path); diff --git a/crates/tracel-cli/src/helpers/mod.rs b/crates/tracel-cli/src/helpers/mod.rs index 09f69e8..3c7814a 100644 --- a/crates/tracel-cli/src/helpers/mod.rs +++ b/crates/tracel-cli/src/helpers/mod.rs @@ -10,7 +10,7 @@ pub use project::{ mod download; mod resources; -pub use download::{DownloadFile, download_files, validate_rel_path}; +pub use download::{DownloadFile, DownloadResult, download_files, validate_rel_path}; pub use resources::{ Resource, map_resource_error, parse_metadata, select_artifact, validate_auto_create, }; diff --git a/crates/tracel-cli/src/helpers/model_upload/upload.rs b/crates/tracel-cli/src/helpers/model_upload/upload.rs index 3034e19..d66af35 100644 --- a/crates/tracel-cli/src/helpers/model_upload/upload.rs +++ b/crates/tracel-cli/src/helpers/model_upload/upload.rs @@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::Duration; use tracel_client::ClientError; -use crate::tools::terminal::Terminal; +use crate::terminal::Terminal; use super::parts::PartUploadTask; @@ -103,17 +103,11 @@ pub fn upload_parts( let cancelled = AtomicBool::new(false); let completed = AtomicU64::new(0); - let message = format!("Uploading {total_parts} part(s) with {worker_count} worker(s)"); - let progress = if terminal.is_styled() { - let multi = cliclack::multi_progress(&message); - let bar = - multi.add(cliclack::ProgressBar::new(total_parts as u64).with_download_template()); - bar.start(format!("Uploading (0/{total_parts})")); - Some((multi, bar)) - } else { - terminal.print(&message); - None - }; + let progress = terminal.progress( + &format!("Uploading {total_parts} part(s) with {worker_count} worker(s)"), + total_parts as u64, + ); + progress.start(format!("Uploading (0/{total_parts})")); let failure = std::thread::scope(|scope| { let handles: Vec<_> = chunks @@ -129,10 +123,7 @@ pub fn upload_parts( while handles.iter().any(|handle| !handle.is_finished()) { let done = completed.load(Ordering::Relaxed); if done > reported { - if let Some((_, bar)) = &progress { - bar.inc(done - reported); - bar.set_message(format!("Uploading ({done}/{total_parts})")); - } + progress.set(done, format!("Uploading ({done}/{total_parts})")); reported = done; } std::thread::sleep(Duration::from_millis(50)); @@ -151,28 +142,13 @@ pub fn upload_parts( .next() }); - let done = completed.load(Ordering::Relaxed); - if let Some((_, bar)) = &progress { - bar.set_message(format!("Uploading ({done}/{total_parts})")); - } - match failure { Some(e) => { - if let Some((multi, bar)) = &progress { - bar.error(format!("Upload failed: {e}")); - multi.error("Model upload failed"); - } else { - terminal.print_err(&format!("Upload failed: {e}")); - } + progress.error("Upload failed."); Err(e.context("Failed to upload model file part")) } None => { - if let Some((multi, bar)) = &progress { - bar.stop(format!("Uploaded {total_parts}/{total_parts}")); - multi.stop(); - } else { - terminal.print_success(&format!("Uploaded {total_parts}/{total_parts}")); - } + progress.stop(format!("Uploaded {total_parts}/{total_parts}")); Ok(()) } } @@ -181,6 +157,7 @@ pub fn upload_parts( #[cfg(test)] mod tests { use super::*; + use crate::output::Format; use std::fs; use std::path::PathBuf; use std::sync::Mutex as StdMutex; @@ -245,7 +222,7 @@ mod tests { ]; let uploader = FakeUploader::new(None); - let result = upload_parts(&uploader, tasks, &Terminal::default()); + let result = upload_parts(&uploader, tasks, &Terminal::new(Format::Json)); assert!(result.is_ok()); assert_eq!(uploader.calls.lock().unwrap().len(), 2); @@ -262,7 +239,7 @@ mod tests { ]; let uploader = FakeUploader::new(Some("https://example.com/b")); - let result = upload_parts(&uploader, tasks, &Terminal::default()); + let result = upload_parts(&uploader, tasks, &Terminal::new(Format::Json)); assert!(result.is_err()); diff --git a/crates/tracel-cli/src/helpers/project.rs b/crates/tracel-cli/src/helpers/project.rs index e2661d0..c287949 100644 --- a/crates/tracel-cli/src/helpers/project.rs +++ b/crates/tracel-cli/src/helpers/project.rs @@ -98,6 +98,16 @@ pub enum ProjectSource { TracelToml, } +impl std::fmt::Display for ProjectSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Flag => "--project", + Self::Env => "TRACEL_NAMESPACE or TRACEL_PROJECT", + Self::TracelToml => "tracel.toml", + }) + } +} + #[derive(Debug)] pub struct ResolvedProject { pub project: TracelProject, @@ -215,7 +225,6 @@ pub fn can_initialize_project(context: &CliContext, force: bool) -> anyhow::Resu /// Validate that the linked project exists on Tracel Console server pub fn validate_project_exists_on_server( - context: &CliContext, project: &ProjectContext, client: &Client, ) -> anyhow::Result<()> { @@ -223,34 +232,16 @@ pub fn validate_project_exists_on_server( match client.get_project(&bc_project.owner, &bc_project.name) { Ok(_) => Ok(()), - Err(e) if e.is_not_found() => { - context.terminal().print_err(&format!( - "Project {}/{} does not exist on Tracel Console.", + Err(e) if e.is_not_found() => Err(CliError::new( + CliErrorKind::NotFound, + format!( + "Project {}/{} not found on Tracel Console. It may have been deleted or renamed.", bc_project.owner, bc_project.name - )); - context - .terminal() - .print("The linked project may have been deleted or renamed on the server."); - context.terminal().print( - "Run 'tracel init --force' to reinitialize and link to a different project.", - ); - Err(CliError::new( - CliErrorKind::NotFound, - format!( - "Project {}/{} not found on Tracel Console", - bc_project.owner, bc_project.name - ), - ) - .with_hint("Run 'tracel init --force' to link another project.") - .into()) - } - Err(e) => { - context.terminal().print_err(&format!( - "Failed to verify project on Tracel Console: {}", - e - )); - Err(e).context("Failed to verify project exists on server") - } + ), + ) + .with_hint("Run 'tracel init --force' to link another project.") + .into()), + Err(e) => Err(e).context("Failed to verify project exists on server"), } } diff --git a/crates/tracel-cli/src/lib.rs b/crates/tracel-cli/src/lib.rs index 58d9f86..23842ff 100644 --- a/crates/tracel-cli/src/lib.rs +++ b/crates/tracel-cli/src/lib.rs @@ -4,6 +4,7 @@ mod context; mod error; mod helpers; mod output; +mod terminal; mod tools; pub use cli::cli_main; diff --git a/crates/tracel-cli/src/output.rs b/crates/tracel-cli/src/output.rs deleted file mode 100644 index 9a27847..0000000 --- a/crates/tracel-cli/src/output.rs +++ /dev/null @@ -1,167 +0,0 @@ -use std::io::Write; - -use clap::ValueEnum; -use serde::Serialize; - -use crate::error::{CliError, ErrorKind, ErrorReport}; - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] -pub enum OutputMode { - #[default] - Auto, - Human, - Json, -} - -impl OutputMode { - pub fn resolve( - flag: Option, - json: bool, - environment: Option<&str>, - stdout_is_terminal: bool, - ) -> Result { - let mode = if json { - Self::Json - } else if let Some(mode) = flag { - mode - } else { - match environment { - Some("human") => Self::Human, - Some("json") => Self::Json, - None => Self::Auto, - Some(value) => { - return Err(CliError::new( - ErrorKind::Usage, - format!("Invalid TRACEL_OUTPUT value '{value}': expected human or json."), - )); - } - } - }; - - Ok(match mode { - Self::Auto if stdout_is_terminal => Self::Human, - Self::Auto => Self::Json, - mode => mode, - }) - } -} - -#[derive(Serialize)] -struct Success<'a, T> { - ok: bool, - data: &'a T, -} - -#[derive(Serialize)] -struct Failure<'a> { - ok: bool, - error: &'a ErrorReport<'a>, -} - -pub fn write_success(data: &impl Serialize) -> anyhow::Result<()> { - write_line(&Success { ok: true, data }) -} - -pub fn write_failure(error: &ErrorReport<'_>) -> anyhow::Result<()> { - write_line(&Failure { ok: false, error }) -} - -pub fn write_stream_item(value: &impl Serialize) -> anyhow::Result<()> { - write_line(value)?; - std::io::stdout().flush()?; - Ok(()) -} - -fn write_line(value: &impl Serialize) -> anyhow::Result<()> { - let line = serde_json::to_string(value)?; - let mut stdout = std::io::stdout().lock(); - writeln!(stdout, "{line}")?; - Ok(()) -} - -pub fn write_table( - stdout: &mut impl Write, - headers: &[&str], - rows: Vec>, -) -> std::io::Result<()> { - let rows: Vec> = - std::iter::once(headers.iter().map(|header| (*header).into()).collect()) - .chain(rows) - .map(|row: Vec| { - row.into_iter() - .map(|cell| cell.split_whitespace().collect::>().join(" ")) - .collect() - }) - .collect(); - let widths: Vec<_> = (0..headers.len()) - .map(|column| { - rows.iter() - .map(|row| console::measure_text_width(&row[column])) - .max() - .unwrap_or(0) - }) - .collect(); - for row in rows { - for (column, cell) in row.iter().enumerate() { - write!(stdout, "{cell}")?; - if column + 1 < headers.len() { - write!( - stdout, - "{} ", - " ".repeat(widths[column].saturating_sub(console::measure_text_width(cell))) - )?; - } - } - writeln!(stdout)?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn auto_uses_stdout_terminal_status() { - assert_eq!( - OutputMode::resolve(None, false, None, true).unwrap(), - OutputMode::Human - ); - assert_eq!( - OutputMode::resolve(None, false, None, false).unwrap(), - OutputMode::Json - ); - } - - #[test] - fn flags_override_environment() { - for environment in [Some("human"), Some("json"), Some("invalid")] { - assert_eq!( - OutputMode::resolve(Some(OutputMode::Human), false, environment, false).unwrap(), - OutputMode::Human - ); - assert_eq!( - OutputMode::resolve(None, true, environment, true).unwrap(), - OutputMode::Json - ); - assert_eq!( - OutputMode::resolve(Some(OutputMode::Auto), false, environment, false).unwrap(), - OutputMode::Json - ); - } - } - - #[test] - fn environment_selects_mode_or_reports_usage() { - assert_eq!( - OutputMode::resolve(None, false, Some("human"), false).unwrap(), - OutputMode::Human - ); - assert_eq!( - OutputMode::resolve(None, false, Some("json"), true).unwrap(), - OutputMode::Json - ); - let error = OutputMode::resolve(None, false, Some("auto"), true).unwrap_err(); - assert_eq!(error.kind, ErrorKind::Usage); - } -} diff --git a/crates/tracel-cli/src/output/format.rs b/crates/tracel-cli/src/output/format.rs new file mode 100644 index 0000000..b9259bd --- /dev/null +++ b/crates/tracel-cli/src/output/format.rs @@ -0,0 +1,111 @@ +use clap::ValueEnum; + +use crate::error::{CliError, ErrorKind}; + +/// The `--output` value. +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum FormatArg { + Auto, + Human, + Json, +} + +/// How a command writes its result and errors. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + Human, + Json, +} + +impl Format { + /// `--json`, then `--output`, then `TRACEL_OUTPUT`. `auto` and no choice at all + /// mean `auto`, which the command decides. + pub fn resolve( + flag: Option, + json: bool, + environment: Option<&str>, + auto: Format, + ) -> Result { + if json { + return Ok(Self::Json); + } + let choice = match flag { + Some(choice) => choice, + None => match environment { + None => FormatArg::Auto, + Some("human") => FormatArg::Human, + Some("json") => FormatArg::Json, + Some(value) => { + return Err(CliError::new( + ErrorKind::Usage, + format!("Invalid TRACEL_OUTPUT value '{value}': expected human or json."), + )); + } + }, + }; + Ok(match choice { + FormatArg::Auto => auto, + FormatArg::Human => Self::Human, + FormatArg::Json => Self::Json, + }) + } + + /// Human output for a person at a terminal, JSON for a program reading stdout. + pub fn for_stdout(stdout_is_terminal: bool) -> Self { + if stdout_is_terminal { + Self::Human + } else { + Self::Json + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auto_is_the_command_default() { + for auto in [Format::Human, Format::Json] { + assert_eq!(Format::resolve(None, false, None, auto).unwrap(), auto); + assert_eq!( + Format::resolve(Some(FormatArg::Auto), false, Some("json"), auto).unwrap(), + auto + ); + } + assert_eq!(Format::for_stdout(true), Format::Human); + assert_eq!(Format::for_stdout(false), Format::Json); + } + + #[test] + fn flags_override_environment() { + for environment in [Some("human"), Some("json"), Some("invalid")] { + assert_eq!( + Format::resolve(Some(FormatArg::Human), false, environment, Format::Json).unwrap(), + Format::Human + ); + assert_eq!( + Format::resolve(None, true, environment, Format::Human).unwrap(), + Format::Json + ); + assert_eq!( + Format::resolve(Some(FormatArg::Auto), false, environment, Format::Json).unwrap(), + Format::Json + ); + } + } + + #[test] + fn environment_selects_format_or_reports_usage() { + assert_eq!( + Format::resolve(None, false, Some("human"), Format::Json).unwrap(), + Format::Human + ); + assert_eq!( + Format::resolve(None, false, Some("json"), Format::Human).unwrap(), + Format::Json + ); + let error = Format::resolve(None, false, Some("auto"), Format::Human).unwrap_err(); + assert_eq!(error.kind, ErrorKind::Usage); + } +} diff --git a/crates/tracel-cli/src/output/human.rs b/crates/tracel-cli/src/output/human.rs new file mode 100644 index 0000000..1c5a3d6 --- /dev/null +++ b/crates/tracel-cli/src/output/human.rs @@ -0,0 +1,176 @@ +use std::fmt::Display; +use std::io::{self, Write}; + +use serde::Serialize; + +/// Columns aligned under their headers, one row per line. +pub struct Table { + headers: [&'static str; N], + rows: Vec<[String; N]>, + total: Option, +} + +impl Table { + pub fn new(headers: [&'static str; N]) -> Self { + Self { + headers, + rows: Vec::new(), + total: None, + } + } + + pub fn rows(mut self, rows: impl IntoIterator) -> Self { + self.rows.extend(rows); + self + } + + /// Ends the table with "Showing N of TOTAL" when it holds fewer rows than exist. + pub fn total(mut self, total: impl TryInto) -> Self { + self.total = total.try_into().ok(); + self + } + + pub fn write(&self, out: &mut dyn Write) -> io::Result<()> { + let header = self.headers.map(String::from); + let lines: Vec<[String; N]> = std::iter::once(&header) + .chain(&self.rows) + .map(|row| row.clone().map(|cell| cell_text(&cell))) + .collect(); + let widths: [usize; N] = std::array::from_fn(|column| { + lines + .iter() + .map(|line| console::measure_text_width(&line[column])) + .max() + .unwrap_or(0) + }); + for line in &lines { + for (column, cell) in line.iter().enumerate() { + write!(out, "{cell}")?; + if column + 1 < N { + let padding = widths[column] - console::measure_text_width(cell); + write!(out, "{} ", " ".repeat(padding))?; + } + } + writeln!(out)?; + } + let shown = self.rows.len() as u64; + match self.total { + Some(total) if shown < total => writeln!(out, "Showing {shown} of {total}"), + _ => Ok(()), + } + } +} + +/// Labelled values, one per line, with the values aligned. +#[derive(Default)] +pub struct Details { + fields: Vec<(&'static str, String)>, +} + +impl Details { + pub fn new() -> Self { + Self::default() + } + + pub fn field(mut self, label: &'static str, value: impl Display) -> Self { + self.fields.push((label, value.to_string())); + self + } + + pub fn optional(self, label: &'static str, value: Option) -> Self { + match value { + Some(value) => self.field(label, value), + None => self.field(label, ""), + } + } + + pub fn write(&self, out: &mut dyn Write) -> io::Result<()> { + let width = self + .fields + .iter() + .map(|(label, _)| label.len()) + .max() + .unwrap_or(0); + for (label, value) in &self.fields { + let padding = " ".repeat(width - label.len()); + writeln!(out, "{label}:{padding} {}", cell_text(value))?; + } + Ok(()) + } +} + +/// A titled JSON value, pretty-printed below its title. +pub fn json_section(out: &mut dyn Write, title: &str, value: &impl Serialize) -> io::Result<()> { + writeln!(out, "{title}:")?; + serde_json::to_writer_pretty(&mut *out, value)?; + writeln!(out) +} + +/// A value on one line, with "-" standing in for nothing. +fn cell_text(value: &str) -> String { + let text = value.split_whitespace().collect::>().join(" "); + if text.is_empty() { "-".into() } else { text } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text(write: impl FnOnce(&mut dyn Write) -> io::Result<()>) -> String { + let mut out = Vec::new(); + write(&mut out).unwrap(); + String::from_utf8(out).unwrap() + } + + #[test] + fn table_aligns_columns_and_keeps_cells_on_one_line() { + let table = Table::new(["NAME", "DESCRIPTION", "ID"]).rows([ + ["weights".into(), "two\nlines".into(), "1".into()], + ["w".into(), String::new(), "22".into()], + ]); + assert_eq!( + text(|out| table.write(out)), + "NAME DESCRIPTION ID\n\ + weights two lines 1\n\ + w - 22\n" + ); + } + + #[test] + fn table_counts_rows_only_when_some_are_left_out() { + let rows = || [["a".to_string()], ["b".to_string()]]; + for (total, footer) in [(2, ""), (3, "Showing 2 of 3\n")] { + let table = Table::new(["NAME"]).rows(rows()).total(total); + assert_eq!( + text(|out| table.write(out)), + format!("NAME\na\nb\n{footer}") + ); + } + assert_eq!( + text(|out| Table::new(["NAME"]).total(0_usize).write(out)), + "NAME\n" + ); + } + + #[test] + fn details_align_values_and_mark_empty_ones() { + let details = Details::new() + .field("Name", "weights") + .optional("Description", None::<&str>) + .field("Created at", "today"); + assert_eq!( + text(|out| details.write(out)), + "Name: weights\n\ + Description: -\n\ + Created at: today\n" + ); + } + + #[test] + fn json_sections_are_pretty_printed() { + assert_eq!( + text(|out| json_section(out, "Metadata", &serde_json::json!({"lr": 0.1}))), + "Metadata:\n{\n \"lr\": 0.1\n}\n" + ); + } +} diff --git a/crates/tracel-cli/src/output/mod.rs b/crates/tracel-cli/src/output/mod.rs new file mode 100644 index 0000000..06e127d --- /dev/null +++ b/crates/tracel-cli/src/output/mod.rs @@ -0,0 +1,12 @@ +//! Stdout. It carries a command's result and nothing else, so it can be piped or +//! captured. Progress, prompts and diagnostics go to stderr through `Terminal`. + +mod format; +mod human; +mod stdout; +mod time; + +pub use format::{Format, FormatArg}; +pub use human::{Details, Table, json_section}; +pub use stdout::{Outcome, Output, Render, StdoutClosed}; +pub use time::Timestamp; diff --git a/crates/tracel-cli/src/output/stdout.rs b/crates/tracel-cli/src/output/stdout.rs new file mode 100644 index 0000000..257a1d2 --- /dev/null +++ b/crates/tracel-cli/src/output/stdout.rs @@ -0,0 +1,230 @@ +use std::io::{self, Write}; + +use serde::Serialize; + +use super::Format; +use crate::error::ErrorReport; + +/// A command result: serialized for JSON output, written as text for people. +pub trait Render: Serialize { + /// The text a person reads on stdout. The default writes nothing, for actions + /// that report what they did on stderr as they go. + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + let _ = out; + Ok(()) + } +} + +/// What a command leaves for stdout when it finishes. +pub struct Outcome(Option>); + +impl Outcome { + /// The command wrote to stdout while it ran: streamed events, or a program it ran. + pub fn streamed() -> Self { + Self(None) + } +} + +impl From for Outcome { + fn from(result: T) -> Self { + Self(Some(Box::new(result))) + } +} + +trait Report { + fn write_json(&self, out: &mut dyn Write) -> serde_json::Result<()>; + fn write_text(&self, out: &mut dyn Write) -> io::Result<()>; +} + +impl Report for T { + fn write_json(&self, out: &mut dyn Write) -> serde_json::Result<()> { + serde_json::to_writer( + out, + &Success { + ok: true, + data: self, + }, + ) + } + + fn write_text(&self, out: &mut dyn Write) -> io::Result<()> { + self.render(out) + } +} + +#[derive(Serialize)] +struct Success<'a, T> { + ok: bool, + data: &'a T, +} + +#[derive(Serialize)] +struct Failure<'a> { + ok: bool, + error: &'a ErrorReport<'a>, +} + +/// Stdout was closed by its reader, as in `tracel ... | head`. +#[derive(Debug, thiserror::Error)] +#[error("stdout was closed")] +pub struct StdoutClosed; + +/// Stdout, which carries only command results, in the format the user chose. +#[derive(Clone, Copy)] +pub struct Output { + format: Format, +} + +impl Output { + pub fn new(format: Format) -> Self { + Self { format } + } + + /// Writes the result of a finished command. + pub fn finish(&self, outcome: Outcome) -> anyhow::Result<()> { + write_stdout(|out| finish(self.format, outcome, out)) + } + + /// Writes one event of a stream as it happens: an NDJSON line, or its text. + pub fn event(&self, event: &impl Render) -> anyhow::Result<()> { + write_stdout(|out| { + write_event(self.format, event, out)?; + out.flush() + }) + } + + /// Writes the error envelope of a failed command; text errors go to stderr. + pub fn error(&self, report: &ErrorReport<'_>) { + if self.format == Format::Json { + let _ = write_stdout(|out| write_failure(report, out)); + } + } +} + +fn write_stdout(write: impl FnOnce(&mut dyn Write) -> io::Result<()>) -> anyhow::Result<()> { + match write(&mut io::stdout().lock()) { + Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Err(StdoutClosed.into()), + result => Ok(result?), + } +} + +fn finish(format: Format, outcome: Outcome, out: &mut dyn Write) -> io::Result<()> { + let Some(report) = outcome.0 else { + return Ok(()); + }; + match format { + Format::Human => report.write_text(out), + Format::Json => { + report.write_json(out)?; + writeln!(out) + } + } +} + +fn write_event(format: Format, event: &impl Render, out: &mut dyn Write) -> io::Result<()> { + match format { + Format::Human => event.render(out), + Format::Json => { + serde_json::to_writer(&mut *out, event)?; + writeln!(out) + } + } +} + +fn write_failure(report: &ErrorReport<'_>, out: &mut dyn Write) -> io::Result<()> { + serde_json::to_writer( + &mut *out, + &Failure { + ok: false, + error: report, + }, + )?; + writeln!(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::{CliError, ErrorKind}; + + #[derive(Serialize)] + struct Greeting { + name: &'static str, + } + + impl Render for Greeting { + fn render(&self, out: &mut dyn Write) -> io::Result<()> { + writeln!(out, "Hello, {}.", self.name) + } + } + + #[derive(Serialize)] + struct Done {} + + impl Render for Done {} + + fn written(write: impl FnOnce(&mut dyn Write) -> io::Result<()>) -> String { + let mut out = Vec::new(); + write(&mut out).unwrap(); + String::from_utf8(out).unwrap() + } + + #[test] + fn results_are_an_envelope_or_their_text() { + let greeting = || Outcome::from(Greeting { name: "Ada" }); + assert_eq!( + written(|out| finish(Format::Json, greeting(), out)), + "{\"ok\":true,\"data\":{\"name\":\"Ada\"}}\n" + ); + assert_eq!( + written(|out| finish(Format::Human, greeting(), out)), + "Hello, Ada.\n" + ); + assert_eq!( + written(|out| finish(Format::Human, Done {}.into(), out)), + "" + ); + } + + #[test] + fn streamed_output_is_not_followed_by_an_envelope() { + for format in [Format::Human, Format::Json] { + assert_eq!(written(|out| finish(format, Outcome::streamed(), out)), ""); + } + } + + #[test] + fn events_are_bare_json_lines_or_their_text() { + let event = Greeting { name: "Ada" }; + assert_eq!( + written(|out| write_event(Format::Json, &event, out)), + "{\"name\":\"Ada\"}\n" + ); + assert_eq!( + written(|out| write_event(Format::Human, &event, out)), + "Hello, Ada.\n" + ); + } + + #[test] + fn failures_carry_the_error_report() { + let error = anyhow::Error::new( + CliError::new(ErrorKind::NotFound, "No model 'weights'.").with_hint("List models."), + ); + let report = ErrorReport::new(&error); + let value: serde_json::Value = + serde_json::from_str(&written(|out| write_failure(&report, out))).unwrap(); + assert_eq!( + value, + serde_json::json!({ + "ok": false, + "error": { + "code": "NOT_FOUND", + "message": "No model 'weights'.", + "hint": "List models.", + "exit_code": 5, + }, + }) + ); + } +} diff --git a/crates/tracel-cli/src/output/time.rs b/crates/tracel-cli/src/output/time.rs new file mode 100644 index 0000000..95d6848 --- /dev/null +++ b/crates/tracel-cli/src/output/time.rs @@ -0,0 +1,38 @@ +use std::fmt::{self, Display}; +use std::time::SystemTime; + +use chrono::{DateTime, Utc}; +use serde::{Serialize, Serializer}; + +/// A point in time, written in RFC 3339 in UTC. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Timestamp(pub SystemTime); + +impl Display for Timestamp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&DateTime::::from(self.0).to_rfc3339()) + } +} + +impl Serialize for Timestamp { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, UNIX_EPOCH}; + + use super::*; + + #[test] + fn timestamps_are_rfc_3339_in_utc() { + let time = Timestamp(UNIX_EPOCH + Duration::from_secs(1_900_000_000)); + assert_eq!(time.to_string(), "2030-03-17T17:46:40+00:00"); + assert_eq!( + serde_json::to_value(time).unwrap(), + "2030-03-17T17:46:40+00:00" + ); + } +} diff --git a/crates/tracel-cli/src/tools/terminal.rs b/crates/tracel-cli/src/terminal.rs similarity index 64% rename from crates/tracel-cli/src/tools/terminal.rs rename to crates/tracel-cli/src/terminal.rs index 4f238c0..5dc8f6b 100644 --- a/crates/tracel-cli/src/tools/terminal.rs +++ b/crates/tracel-cli/src/terminal.rs @@ -1,35 +1,29 @@ -use std::{fmt::Display, io::IsTerminal}; +use std::fmt::Display; +use std::io::IsTerminal; +use std::sync::atomic::{AtomicBool, Ordering}; -use cliclack::{ProgressBar, clear_screen}; +use cliclack::{MultiProgress, ProgressBar}; -use colored::CustomColor; +use crate::error::{CliError, ErrorKind, ErrorReport}; +use crate::output::Format; -use crate::error::{CliError, ErrorKind}; -use crate::output::OutputMode; - -#[allow(dead_code)] -pub const BURN_ORANGE: CustomColor = CustomColor { - r: 254, - g: 75, - b: 0, -}; +/// Whether `command_title` opened a frame that is still open. Stderr is one per +/// process, and so is the frame drawn on it. +static FRAMED: AtomicBool = AtomicBool::new(false); +/// Stderr, for the person running a command: what it is doing, its progress, and +/// questions for them. With JSON output it carries only errors and the instructions +/// a person must follow, as plain lines. #[derive(Clone)] pub struct Terminal { - output: OutputMode, + format: Format, no_input: bool, } -impl Default for Terminal { - fn default() -> Self { - Self::new(OutputMode::Human) - } -} - impl Terminal { - pub fn new(output: OutputMode) -> Self { + pub fn new(format: Format) -> Self { Self { - output, + format, no_input: false, } } @@ -40,10 +34,10 @@ impl Terminal { } fn is_human(&self) -> bool { - self.output != OutputMode::Json + self.format == Format::Human } - pub fn is_styled(&self) -> bool { + fn is_styled(&self) -> bool { self.is_human() && std::io::stderr().is_terminal() } @@ -87,11 +81,36 @@ impl Terminal { } } - pub fn outro_cancel(&self, message: &str) { - if self.is_styled() { - cliclack::outro_cancel(message).expect("To be able to print message"); + /// Instructions a person must follow before the command can go on, shown in + /// every format. + pub fn instruct(&self, message: &str) { + if self.is_human() { + self.print(message); } else { - self.print_err(message); + eprintln!("{}", console::strip_ansi_codes(message)); + } + } + + /// Reports a failed command: the message and hint here, the envelope on stdout + /// with JSON output. An open frame ends with the hint, or with the error itself. + pub fn error(&self, report: &ErrorReport<'_>) { + if !self.is_human() { + eprintln!("error: {}", one_line(&report.message)); + } else if self.is_styled() && FRAMED.load(Ordering::Relaxed) { + match report.hint { + Some(hint) => { + cliclack::log::error(&report.message).expect("To be able to print message"); + cliclack::outro_cancel(hint).expect("To be able to print message"); + FRAMED.store(false, Ordering::Relaxed); + } + None => self.cancel_finalize(&report.message), + } + } else { + let label = console::style("error:").for_stderr().red().bold(); + eprintln!("{label} {}", report.message); + if let Some(hint) = report.hint { + eprintln!("{hint}"); + } } } @@ -102,10 +121,19 @@ impl Terminal { } } - #[allow(dead_code)] - pub fn clear(&self) { - if self.is_styled() { - clear_screen().expect("Failed to clear screen"); + /// A bar that counts `total` steps under `title`. + pub fn progress(&self, title: &str, total: u64) -> TerminalProgress { + let bars = self.is_styled().then(|| { + let group = cliclack::multi_progress(title); + let bar = group.add(ProgressBar::new(total).with_download_template()); + (group, bar) + }); + if bars.is_none() { + self.print(title); + } + TerminalProgress { + bars, + terminal: self.clone(), } } @@ -201,11 +229,13 @@ impl Terminal { .map_err(anyhow::Error::from) } + /// Opens the frame that an action's progress is drawn in. pub fn command_title(&self, title: &str) { if self.is_styled() { let title = format!(" {} {} ", "▶", title); cliclack::intro(console::style(title).black().on_green()) .expect("To be able to print title"); + FRAMED.store(true, Ordering::Relaxed); } } @@ -213,6 +243,7 @@ impl Terminal { if self.is_styled() { cliclack::outro(console::style(format!(" {} ", msg)).black().on_green()) .expect("To be able to print message"); + FRAMED.store(false, Ordering::Relaxed); } else { self.print_success(msg); } @@ -222,6 +253,7 @@ impl Terminal { if self.is_styled() { cliclack::outro_cancel(console::style(format!(" {} ", msg)).black().on_red()) .expect("To be able to print message"); + FRAMED.store(false, Ordering::Relaxed); } else { self.print_err(msg); } @@ -233,7 +265,7 @@ impl Terminal { allows_input( std::io::stdin().is_terminal(), std::io::stderr().is_terminal(), - self.output, + self.format, ci.as_ref().map(|value| value.to_str().unwrap_or("true")), self.no_input, ) @@ -243,17 +275,24 @@ impl Terminal { fn allows_input( stdin_is_terminal: bool, stderr_is_terminal: bool, - output: OutputMode, + format: Format, ci: Option<&str>, no_input: bool, ) -> bool { stdin_is_terminal && stderr_is_terminal - && output != OutputMode::Json + && format == Format::Human && matches!(ci, None | Some("" | "0" | "false")) && !no_input } +fn one_line(message: &str) -> String { + console::strip_ansi_codes(message) + .split_whitespace() + .collect::>() + .join(" ") +} + #[cfg(test)] mod tests { use super::*; @@ -261,13 +300,13 @@ mod tests { #[test] fn interactivity_requires_terminals_human_output_and_allowed_environment() { for ci in [None, Some(""), Some("0"), Some("false")] { - for output in [OutputMode::Auto, OutputMode::Human, OutputMode::Json] { + for format in [Format::Human, Format::Json] { for stdin in [false, true] { for stderr in [false, true] { for no_input in [false, true] { assert_eq!( - allows_input(stdin, stderr, output, ci, no_input), - stdin && stderr && output != OutputMode::Json && !no_input + allows_input(stdin, stderr, format, ci, no_input), + stdin && stderr && format == Format::Human && !no_input ); } } @@ -275,13 +314,7 @@ mod tests { } } for ci in ["1", "true", "yes", "FALSE", " "] { - assert!(!allows_input( - true, - true, - OutputMode::Human, - Some(ci), - false - )); + assert!(!allows_input(true, true, Format::Human, Some(ci), false)); } } } @@ -325,3 +358,43 @@ impl TerminalSpinner { } } } + +pub struct TerminalProgress { + bars: Option<(MultiProgress, ProgressBar)>, + terminal: Terminal, +} + +impl TerminalProgress { + pub fn start(&self, message: impl Display) { + if let Some((_, bar)) = &self.bars { + bar.start(message); + } + } + + pub fn set(&self, done: u64, message: impl Display) { + if let Some((_, bar)) = &self.bars { + bar.set_position(done); + bar.set_message(message); + } + } + + pub fn stop(&self, message: impl Display) { + match &self.bars { + Some((group, bar)) => { + bar.stop(message); + group.stop(); + } + None => self.terminal.print_success(&message.to_string()), + } + } + + pub fn error(&self, message: impl Display) { + match &self.bars { + Some((group, bar)) => { + bar.error(&message); + group.error(message); + } + None => self.terminal.print_err(&message.to_string()), + } + } +} diff --git a/crates/tracel-cli/src/tools/build_driver.rs b/crates/tracel-cli/src/tools/build_driver.rs index a42e120..92be868 100644 --- a/crates/tracel-cli/src/tools/build_driver.rs +++ b/crates/tracel-cli/src/tools/build_driver.rs @@ -10,9 +10,9 @@ use std::process::{Command, Stdio}; use tracel_client::console::project::request::{Arch, Os}; +use crate::terminal::Terminal; use crate::tools::linker; use crate::tools::target::target_triple; -use crate::tools::terminal::Terminal; /// How to drive the build of a given target. #[derive(Clone, Copy, PartialEq, Eq, Debug)] diff --git a/crates/tracel-cli/src/tools/linker.rs b/crates/tracel-cli/src/tools/linker.rs index 7e21ce8..a61183d 100644 --- a/crates/tracel-cli/src/tools/linker.rs +++ b/crates/tracel-cli/src/tools/linker.rs @@ -13,8 +13,8 @@ use std::process::{Command, Stdio}; use toml_edit::DocumentMut; use tracel_client::console::project::request::{Arch, Os}; +use crate::terminal::Terminal; use crate::tools::target::target_triple; -use crate::tools::terminal::Terminal; /// What (if anything) is needed to link `target` while running on `host`. pub enum LinkerNeed { diff --git a/crates/tracel-cli/src/tools/mod.rs b/crates/tracel-cli/src/tools/mod.rs index 8fcd6b8..1db46ba 100644 --- a/crates/tracel-cli/src/tools/mod.rs +++ b/crates/tracel-cli/src/tools/mod.rs @@ -7,6 +7,5 @@ pub mod linker; pub mod packager; pub mod project_context; pub mod target; -pub mod terminal; pub mod tracel_config; pub mod workspace; diff --git a/crates/tracel-cli/src/tools/target.rs b/crates/tracel-cli/src/tools/target.rs index 299eead..3618612 100644 --- a/crates/tracel-cli/src/tools/target.rs +++ b/crates/tracel-cli/src/tools/target.rs @@ -8,7 +8,7 @@ use colored::Colorize; use tracel_client::console::project::request::{Arch, Os}; use crate::error::{CliError, ErrorKind}; -use crate::tools::terminal::Terminal; +use crate::terminal::Terminal; /// Every (os, arch) target we offer to build for, in canonical display order. /// The host is surfaced separately and pulled to the front by `prompt_targets`. From baa99ddc9dd89046814ab0b336e83e9c48bb7abc Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Wed, 7 Oct 2026 11:36:20 -0400 Subject: [PATCH 14/32] refactor: draw stdout and stderr on one screen - `output` and `terminal` become one `ui` module. `Output` and `Terminal` come from `ui::channels` and share a private `Screen`. It knows the format, which streams are terminals, the open frame, and the live spinner or bar. - One thing draws at a time. Log lines, prompts, frames and stdout writes settle the live widget first, since cliclack cannot pause one. A failure marks it failed. - Stdout written inside an open frame gets the frame's gutter. Redirected output stays plain. - `Render` writes to a `Human` writer that knows the width and whether colors are on. Tables shorten the columns marked `shrink` to fit a terminal. Headers, labels and empty values are styled when colors are on. - `fail` reports through the command's own channels, replacing the static that tracked the frame. --- README.md | 3 +- crates/tracel-cli/src/cli.rs | 30 +- crates/tracel-cli/src/commands/artifacts.rs | 6 +- crates/tracel-cli/src/commands/auth.rs | 6 +- crates/tracel-cli/src/commands/datasets.rs | 11 +- crates/tracel-cli/src/commands/experiments.rs | 24 +- crates/tracel-cli/src/commands/init.rs | 3 +- crates/tracel-cli/src/commands/login.rs | 2 +- crates/tracel-cli/src/commands/logout.rs | 2 +- crates/tracel-cli/src/commands/me.rs | 6 +- crates/tracel-cli/src/commands/models.rs | 14 +- crates/tracel-cli/src/commands/package.rs | 2 +- crates/tracel-cli/src/commands/project.rs | 9 +- crates/tracel-cli/src/commands/training.rs | 2 +- crates/tracel-cli/src/commands/unlink.rs | 2 +- crates/tracel-cli/src/context.rs | 3 +- crates/tracel-cli/src/helpers/download.rs | 2 +- .../src/helpers/model_upload/upload.rs | 10 +- crates/tracel-cli/src/lib.rs | 3 +- crates/tracel-cli/src/output/human.rs | 176 --------- crates/tracel-cli/src/output/mod.rs | 12 - crates/tracel-cli/src/tools/build_driver.rs | 2 +- crates/tracel-cli/src/tools/linker.rs | 2 +- crates/tracel-cli/src/tools/target.rs | 2 +- .../tracel-cli/src/{output => ui}/format.rs | 0 crates/tracel-cli/src/ui/human.rs | 361 ++++++++++++++++++ crates/tracel-cli/src/ui/mod.rs | 28 ++ crates/tracel-cli/src/ui/screen.rs | 262 +++++++++++++ .../src/{terminal.rs => ui/stderr.rs} | 256 +++++++------ .../tracel-cli/src/{output => ui}/stdout.rs | 74 ++-- crates/tracel-cli/src/{output => ui}/time.rs | 0 31 files changed, 910 insertions(+), 405 deletions(-) delete mode 100644 crates/tracel-cli/src/output/human.rs delete mode 100644 crates/tracel-cli/src/output/mod.rs rename crates/tracel-cli/src/{output => ui}/format.rs (100%) create mode 100644 crates/tracel-cli/src/ui/human.rs create mode 100644 crates/tracel-cli/src/ui/mod.rs create mode 100644 crates/tracel-cli/src/ui/screen.rs rename crates/tracel-cli/src/{terminal.rs => ui/stderr.rs} (64%) rename crates/tracel-cli/src/{output => ui}/stdout.rs (68%) rename crates/tracel-cli/src/{output => ui}/time.rs (100%) diff --git a/README.md b/README.md index 73867d4..cd3cb42 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,8 @@ tables and details of commands that read, while progress, prompts, warnings, and errors go to stderr. Commands that change something, such as `init`, `login`, `models push`, or `artifacts download`, report on stderr and print nothing on stdout. `auth token` prints the bare token unless JSON is requested, -even when stdout is redirected. +even when stdout is redirected. On a terminal, tables shorten long descriptions +to fit its width; redirected output is never shortened. | Error code | Exit code | | --- | --- | diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index 1e9dcfa..8f5564a 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -7,9 +7,8 @@ use crate::commands; use crate::context::CliContext; use crate::error::{CliError, ErrorKind, ErrorReport}; use crate::helpers::project::parse_project; -use crate::output::{Format, FormatArg, Outcome, Output, StdoutClosed}; -use crate::terminal::Terminal; use crate::tools::tracel_config::TracelProject; +use crate::ui::{self, Format, FormatArg, Outcome, Output, StdoutClosed, Terminal}; #[derive(Parser, Debug)] #[clap(name = "tracel", author, version, about, long_about = None)] @@ -113,7 +112,7 @@ pub fn cli_main() { .trim() .strip_prefix("error: ") .unwrap_or(message.trim()); - fail(&CliError::new(ErrorKind::Usage, message).into(), format); + fail_early(&CliError::new(ErrorKind::Usage, message).into(), format); } }; @@ -138,7 +137,7 @@ pub fn cli_main() { directory.display() ), ); - fail(&error.into(), format.unwrap_or(auto)); + fail_early(&error.into(), format.unwrap_or(auto)); } } @@ -148,23 +147,22 @@ pub fn cli_main() { return; }; - let format = format.unwrap_or_else(|error| fail(&error.into(), auto)); - let terminal = Terminal::new(format).with_no_input(args.no_input); + let format = format.unwrap_or_else(|error| fail_early(&error.into(), auto)); + let (output, terminal) = ui::channels(format, args.no_input); let environment_value = std::env::var_os("TRACEL_ENV").map(|value| value.to_string_lossy().into_owned()); let environment = resolve_environment(args.dev, args.staging, environment_value.as_deref()) - .unwrap_or_else(|error| fail(&error.into(), format)); + .unwrap_or_else(|error| fail(&error.into(), &output, &terminal)); if args.dev { terminal .print_warning("Running in development mode - using local server and dev credentials"); } - let output = Output::new(format); - let context = CliContext::new(terminal, output, environment, args.project); + let context = CliContext::new(terminal.clone(), output.clone(), environment, args.project); if let Err(error) = handle_command(command, context).and_then(|outcome| output.finish(outcome)) { - fail(&error, format); + fail(&error, &output, &terminal); } } @@ -199,16 +197,22 @@ fn resolve_environment( /// Reports a failed command on both channels and exits with its code. A reader that /// closed stdout got all it wanted, so that ends the command quietly. -fn fail(error: &anyhow::Error, format: Format) -> ! { +fn fail(error: &anyhow::Error, output: &Output, terminal: &Terminal) -> ! { if error.chain().any(|cause| cause.is::()) { std::process::exit(0); } let report = ErrorReport::new(error); - Output::new(format).error(&report); - Terminal::new(format).error(&report); + output.error(&report); + terminal.error(&report); std::process::exit(report.exit_code) } +/// Fails before the command's channels exist. +fn fail_early(error: &anyhow::Error, format: Format) -> ! { + let (output, terminal) = ui::channels(format, true); + fail(error, &output, &terminal) +} + /// The format for an error in arguments that did not parse. fn usage_format(arguments: impl IntoIterator) -> Format { let mut arguments = arguments.into_iter(); diff --git a/crates/tracel-cli/src/commands/artifacts.rs b/crates/tracel-cli/src/commands/artifacts.rs index 6c650b6..6fe8861 100644 --- a/crates/tracel-cli/src/commands/artifacts.rs +++ b/crates/tracel-cli/src/commands/artifacts.rs @@ -1,4 +1,4 @@ -use std::io::{self, Write}; +use std::io; use std::path::{Path, PathBuf}; use clap::{Args, Subcommand}; @@ -13,7 +13,7 @@ use crate::helpers::{ DownloadFile, DownloadResult, Resource, download_files, map_resource_error, resolve_namespace_project, select_artifact, validate_rel_path, }; -use crate::output::{Outcome, Render, Table}; +use crate::ui::{Human, Outcome, Render, Table}; #[derive(Args, Debug)] pub struct ArtifactsArgs { @@ -99,7 +99,7 @@ struct ArtifactDownloaded { impl Render for ArtifactDownloaded {} impl Render for ArtifactListResponse { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Table::new(["NAME", "KIND", "ID", "FILES", "CREATED AT"]) .rows(self.items.iter().map(|artifact| { [ diff --git a/crates/tracel-cli/src/commands/auth.rs b/crates/tracel-cli/src/commands/auth.rs index 47005b1..9e04d18 100644 --- a/crates/tracel-cli/src/commands/auth.rs +++ b/crates/tracel-cli/src/commands/auth.rs @@ -9,7 +9,7 @@ use tracel_client::console::TracelCredentials; use crate::commands::login::environment_suffix; use crate::context::{CliContext, ClientCreationError}; use crate::error::{CliError, ErrorKind}; -use crate::output::{Details, Outcome, Render, Timestamp}; +use crate::ui::{Details, Human, Outcome, Render, Timestamp}; #[derive(Args, Debug)] pub struct AuthArgs { @@ -40,7 +40,7 @@ struct AccessToken { } impl Render for AccessToken { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { writeln!(out, "{}", self.access_token) } } @@ -68,7 +68,7 @@ struct User { } impl Render for AuthStatus { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { let credential = match self.credential { Credential::ApiKey => "TRACEL_API_KEY", Credential::Login => "login", diff --git a/crates/tracel-cli/src/commands/datasets.rs b/crates/tracel-cli/src/commands/datasets.rs index d476d38..f05d4e8 100644 --- a/crates/tracel-cli/src/commands/datasets.rs +++ b/crates/tracel-cli/src/commands/datasets.rs @@ -1,4 +1,4 @@ -use std::io::{self, Write}; +use std::io; use clap::{Args, Subcommand}; use tracel_client::console::dataset::request::{QueryDatasetVersionsRequest, QueryDatasetsRequest}; @@ -9,7 +9,7 @@ use tracel_client::console::dataset::response::{ use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::helpers::{Resource, map_resource_error, resolve_namespace_project}; -use crate::output::{Details, Outcome, Render, Table, json_section}; +use crate::ui::{Details, Human, Outcome, Render, Table, json_section}; #[derive(Args, Debug)] pub struct DatasetsArgs { @@ -103,8 +103,9 @@ pub fn handle_command(args: DatasetsArgs, context: CliContext) -> anyhow::Result } impl Render for DatasetListResponse { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Table::new(["NAME", "DESCRIPTION", "ID"]) + .shrink("DESCRIPTION") .rows(self.items.iter().map(|dataset| { [ dataset.name.clone(), @@ -118,7 +119,7 @@ impl Render for DatasetListResponse { } impl Render for DatasetResponse { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Details::new() .field("Name", &self.name) .field("ID", &self.id) @@ -129,7 +130,7 @@ impl Render for DatasetResponse { } impl Render for DatasetVersionListResponse { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Table::new(["VERSION", "ITEMS", "SOURCE", "CREATED AT"]) .rows(self.items.iter().map(|version| { let source = match version.source_kind { diff --git a/crates/tracel-cli/src/commands/experiments.rs b/crates/tracel-cli/src/commands/experiments.rs index 66f171d..bf72210 100644 --- a/crates/tracel-cli/src/commands/experiments.rs +++ b/crates/tracel-cli/src/commands/experiments.rs @@ -18,8 +18,8 @@ use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::project::resolve_namespace_project; -use crate::output::{Details, Outcome, Output, Render, Table, json_section}; use crate::tools::tracel_config::TracelProject; +use crate::ui::{Details, Human, Outcome, Output, Render, Table, json_section}; #[derive(Args, Debug)] pub struct ExperimentsArgs { @@ -201,8 +201,9 @@ impl LogsArgs { } impl Render for ListExperimentsResponse { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Table::new(["NUMBER", "NAME", "STATUS", "CREATED AT", "CREATED BY"]) + .shrink("NAME") .rows(self.items.iter().map(|experiment| { [ experiment.experiment_num.to_string(), @@ -218,7 +219,7 @@ impl Render for ListExperimentsResponse { } impl Render for ExperimentDetailsResponse { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Details::new() .field("Number", self.experiment_num) .field("ID", self.id) @@ -236,7 +237,7 @@ impl Render for ExperimentDetailsResponse { } impl Render for MetricMetadataResponse { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Table::new(["METRIC"]) .rows(self.metric_types.iter().map(|name| [name.clone()])) .write(out)?; @@ -248,7 +249,7 @@ impl Render for MetricMetadataResponse { } impl Render for Option { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { let Some(metrics) = self else { return writeln!(out, "No metric series available."); }; @@ -270,7 +271,7 @@ impl Render for Option { } impl Render for Option { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { let Some(summary) = self else { return writeln!(out, "No metric summary available."); }; @@ -287,12 +288,12 @@ impl Render for Option { } impl Render for ExperimentLogQueryResponse { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { self.items.iter().try_for_each(|item| write_log(out, item)) } } -fn write_log(out: &mut dyn Write, item: &ExperimentLogItemResponse) -> io::Result<()> { +fn write_log(out: &mut Human<'_>, item: &ExperimentLogItemResponse) -> io::Result<()> { let level = match item.log_level { LogLevelResponse::Trace => "trace", LogLevelResponse::Debug => "debug", @@ -313,7 +314,7 @@ enum LogEvent<'a> { } impl Render for LogEvent<'_> { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { match self { Self::Log(item) => write_log(out, item), Self::End { .. } => Ok(()), @@ -609,8 +610,9 @@ mod tests { json!({"type": "end", "running": false}) ); let mut text = Vec::new(); - LogEvent::Log(&item).render(&mut text).unwrap(); - LogEvent::End { running: false }.render(&mut text).unwrap(); + let mut out = Human::plain(&mut text); + LogEvent::Log(&item).render(&mut out).unwrap(); + LogEvent::End { running: false }.render(&mut out).unwrap(); assert_eq!( String::from_utf8(text).unwrap(), "2026-10-07T00:00:00Z warn loss spiked\n" diff --git a/crates/tracel-cli/src/commands/init.rs b/crates/tracel-cli/src/commands/init.rs index 9e4be0d..028a49f 100644 --- a/crates/tracel-cli/src/commands/init.rs +++ b/crates/tracel-cli/src/commands/init.rs @@ -1,11 +1,10 @@ use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::{can_initialize_project, require_cargo_workspace, require_linked_project}; -use crate::output::{Outcome, Render}; -use crate::terminal::Terminal; use crate::tools::git; use crate::tools::project_context::ProjectContext; use crate::tools::tracel_config::TracelProject; +use crate::ui::{Outcome, Render, Terminal}; use anyhow::Context; use clap::Args; use serde::Serialize; diff --git a/crates/tracel-cli/src/commands/login.rs b/crates/tracel-cli/src/commands/login.rs index b7fee09..406697a 100644 --- a/crates/tracel-cli/src/commands/login.rs +++ b/crates/tracel-cli/src/commands/login.rs @@ -12,7 +12,7 @@ use url::Url; use self::pending::{PendingLogin, PendingLoginStore}; use crate::context::{CliContext, ClientCreationError}; use crate::error::{CliError, ErrorKind, classify}; -use crate::output::{Outcome, Render, Timestamp}; +use crate::ui::{Outcome, Render, Timestamp}; #[derive(Args, Debug)] pub struct LoginArgs { diff --git a/crates/tracel-cli/src/commands/logout.rs b/crates/tracel-cli/src/commands/logout.rs index 056137b..6899de5 100644 --- a/crates/tracel-cli/src/commands/logout.rs +++ b/crates/tracel-cli/src/commands/logout.rs @@ -2,7 +2,7 @@ use serde::Serialize; use crate::commands::login::environment_suffix; use crate::context::CliContext; -use crate::output::{Outcome, Render}; +use crate::ui::{Outcome, Render}; #[derive(Serialize)] struct LoggedOut { diff --git a/crates/tracel-cli/src/commands/me.rs b/crates/tracel-cli/src/commands/me.rs index eba64a1..614b0e2 100644 --- a/crates/tracel-cli/src/commands/me.rs +++ b/crates/tracel-cli/src/commands/me.rs @@ -1,11 +1,11 @@ -use std::io::{self, Write}; +use std::io; use anyhow::Context; use serde::Serialize; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; -use crate::output::{Details, Outcome, Render}; +use crate::ui::{Details, Human, Outcome, Render}; #[derive(Serialize)] struct CurrentUser { @@ -17,7 +17,7 @@ struct CurrentUser { } impl Render for CurrentUser { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Details::new() .field("Username", &self.username) .optional("Email", self.email.as_ref()) diff --git a/crates/tracel-cli/src/commands/models.rs b/crates/tracel-cli/src/commands/models.rs index 79b3ae2..043667e 100644 --- a/crates/tracel-cli/src/commands/models.rs +++ b/crates/tracel-cli/src/commands/models.rs @@ -23,9 +23,9 @@ use crate::helpers::{ map_resource_error, parse_metadata, resolve_namespace_project, select_artifact, upload_parts, validate_auto_create, validate_rel_path, }; -use crate::output::{Details, Outcome, Render, Table, json_section}; use crate::tools::fs::{build_file_specs, collect_files}; use crate::tools::tracel_config::TracelProject; +use crate::ui::{Details, Human, Outcome, Render, Table, json_section}; #[derive(Args, Debug)] pub struct ModelsArgs { @@ -204,8 +204,9 @@ struct AliasRemoved { impl Render for AliasRemoved {} impl Render for ModelListResponse { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Table::new(["NAME", "VERSIONS", "LATEST", "ALIASES", "CREATED AT"]) + .shrink("ALIASES") .rows(self.items.iter().map(|model| { [ model.name.clone(), @@ -221,7 +222,7 @@ impl Render for ModelListResponse { } impl Render for ModelResponse { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Details::new() .field("Name", &self.name) .field("ID", &self.id) @@ -238,7 +239,7 @@ impl Render for ModelResponse { } impl Render for ModelVersionResponse { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Details::new() .field("Version", format!("v{}", self.version)) .field("ID", &self.id) @@ -273,7 +274,7 @@ impl Render for ModelVersionResponse { } impl Render for ModelVersionListResponse { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Table::new([ "VERSION", "STATE", @@ -282,6 +283,7 @@ impl Render for ModelVersionListResponse { "ALIASES", "CREATED AT", ]) + .shrink("ALIASES") .rows(self.items.iter().map(|version| { let source = match &version.experiment { Some(experiment) => format!( @@ -306,7 +308,7 @@ impl Render for ModelVersionListResponse { } impl Render for ModelAliasListResponse { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Table::new(["ALIAS", "VERSION"]) .rows( self.items diff --git a/crates/tracel-cli/src/commands/package.rs b/crates/tracel-cli/src/commands/package.rs index 7f22dcb..bc6dfe1 100644 --- a/crates/tracel-cli/src/commands/package.rs +++ b/crates/tracel-cli/src/commands/package.rs @@ -19,11 +19,11 @@ use crate::error::{CliError, ErrorKind}; use crate::helpers::{ require_cargo_workspace, resolve_namespace_project, validate_project_exists_on_server, }; -use crate::output::{Outcome, Render}; use crate::tools::build_driver::{self, BuildDriver}; use crate::tools::packager::{PackageEvent, package_workspace}; use crate::tools::project_context::ProjectContext; use crate::tools::{cargo, git, target}; +use crate::ui::{Outcome, Render}; #[derive(Args, Debug)] pub struct PackageArgs { diff --git a/crates/tracel-cli/src/commands/project.rs b/crates/tracel-cli/src/commands/project.rs index 30c57c3..e54d1cc 100644 --- a/crates/tracel-cli/src/commands/project.rs +++ b/crates/tracel-cli/src/commands/project.rs @@ -1,4 +1,4 @@ -use std::io::{self, Write}; +use std::io; use anyhow::Context; use clap::{Args, Subcommand}; @@ -11,7 +11,7 @@ use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::project::ProjectSource; use crate::helpers::resolve_namespace_project; -use crate::output::{Details, Outcome, Render, Table}; +use crate::ui::{Details, Human, Outcome, Render, Table}; #[derive(Args, Debug)] pub struct ProjectArgs { @@ -37,8 +37,9 @@ pub struct ListArgs { struct Projects(Vec); impl Render for Projects { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Table::new(["PROJECT", "VISIBILITY", "DESCRIPTION", "CREATED AT"]) + .shrink("DESCRIPTION") .rows(self.0.iter().map(|project| { [ format!("{}/{}", project.namespace_name, project.project_name), @@ -62,7 +63,7 @@ struct ProjectInfo { } impl Render for ProjectInfo { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Details::new() .field("Project", &self.name) .field("Namespace", &self.namespace) diff --git a/crates/tracel-cli/src/commands/training.rs b/crates/tracel-cli/src/commands/training.rs index 4ed5426..a264059 100644 --- a/crates/tracel-cli/src/commands/training.rs +++ b/crates/tracel-cli/src/commands/training.rs @@ -3,7 +3,7 @@ use clap::Parser; use tracel_client::console::Env; -use crate::{context::CliContext, output::Outcome, tools::cargo}; +use crate::{context::CliContext, tools::cargo, ui::Outcome}; #[derive(Parser, Debug, Default)] pub struct TrainingArgs { diff --git a/crates/tracel-cli/src/commands/unlink.rs b/crates/tracel-cli/src/commands/unlink.rs index 96a8922..4da0708 100644 --- a/crates/tracel-cli/src/commands/unlink.rs +++ b/crates/tracel-cli/src/commands/unlink.rs @@ -5,8 +5,8 @@ use serde::Serialize; use crate::{ context::CliContext, helpers::require_linked_project, - output::{Outcome, Render}, tools::project_context::ProjectContext, + ui::{Outcome, Render}, }; #[derive(Args, Debug)] diff --git a/crates/tracel-cli/src/context.rs b/crates/tracel-cli/src/context.rs index f5c0162..356ff4f 100644 --- a/crates/tracel-cli/src/context.rs +++ b/crates/tracel-cli/src/context.rs @@ -1,8 +1,7 @@ use std::sync::Arc; -use crate::output::Output; -use crate::terminal::Terminal; use crate::tools::tracel_config::TracelProject; +use crate::ui::{Output, Terminal}; use tracel_client::ClientError; use tracel_client::console::auth::DeviceAuthClient; use tracel_client::console::{AppSession, Client, Env, FileSessionStore, TracelCredentials}; diff --git a/crates/tracel-cli/src/helpers/download.rs b/crates/tracel-cli/src/helpers/download.rs index e9231f2..81aa2ec 100644 --- a/crates/tracel-cli/src/helpers/download.rs +++ b/crates/tracel-cli/src/helpers/download.rs @@ -8,7 +8,7 @@ use sha2::{Digest, Sha256}; use tracel_client::console::Client; use crate::error::{CliError, ErrorKind}; -use crate::terminal::Terminal; +use crate::ui::Terminal; pub struct DownloadFile { pub rel_path: String, diff --git a/crates/tracel-cli/src/helpers/model_upload/upload.rs b/crates/tracel-cli/src/helpers/model_upload/upload.rs index d66af35..3517f34 100644 --- a/crates/tracel-cli/src/helpers/model_upload/upload.rs +++ b/crates/tracel-cli/src/helpers/model_upload/upload.rs @@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::Duration; use tracel_client::ClientError; -use crate::terminal::Terminal; +use crate::ui::Terminal; use super::parts::PartUploadTask; @@ -107,7 +107,7 @@ pub fn upload_parts( &format!("Uploading {total_parts} part(s) with {worker_count} worker(s)"), total_parts as u64, ); - progress.start(format!("Uploading (0/{total_parts})")); + progress.set(0, format!("Uploading (0/{total_parts})")); let failure = std::thread::scope(|scope| { let handles: Vec<_> = chunks @@ -157,7 +157,7 @@ pub fn upload_parts( #[cfg(test)] mod tests { use super::*; - use crate::output::Format; + use crate::ui::{Format, channels}; use std::fs; use std::path::PathBuf; use std::sync::Mutex as StdMutex; @@ -222,7 +222,7 @@ mod tests { ]; let uploader = FakeUploader::new(None); - let result = upload_parts(&uploader, tasks, &Terminal::new(Format::Json)); + let result = upload_parts(&uploader, tasks, &channels(Format::Json, true).1); assert!(result.is_ok()); assert_eq!(uploader.calls.lock().unwrap().len(), 2); @@ -239,7 +239,7 @@ mod tests { ]; let uploader = FakeUploader::new(Some("https://example.com/b")); - let result = upload_parts(&uploader, tasks, &Terminal::new(Format::Json)); + let result = upload_parts(&uploader, tasks, &channels(Format::Json, true).1); assert!(result.is_err()); diff --git a/crates/tracel-cli/src/lib.rs b/crates/tracel-cli/src/lib.rs index 23842ff..b9c3c86 100644 --- a/crates/tracel-cli/src/lib.rs +++ b/crates/tracel-cli/src/lib.rs @@ -3,8 +3,7 @@ mod commands; mod context; mod error; mod helpers; -mod output; -mod terminal; mod tools; +mod ui; pub use cli::cli_main; diff --git a/crates/tracel-cli/src/output/human.rs b/crates/tracel-cli/src/output/human.rs deleted file mode 100644 index 1c5a3d6..0000000 --- a/crates/tracel-cli/src/output/human.rs +++ /dev/null @@ -1,176 +0,0 @@ -use std::fmt::Display; -use std::io::{self, Write}; - -use serde::Serialize; - -/// Columns aligned under their headers, one row per line. -pub struct Table { - headers: [&'static str; N], - rows: Vec<[String; N]>, - total: Option, -} - -impl Table { - pub fn new(headers: [&'static str; N]) -> Self { - Self { - headers, - rows: Vec::new(), - total: None, - } - } - - pub fn rows(mut self, rows: impl IntoIterator) -> Self { - self.rows.extend(rows); - self - } - - /// Ends the table with "Showing N of TOTAL" when it holds fewer rows than exist. - pub fn total(mut self, total: impl TryInto) -> Self { - self.total = total.try_into().ok(); - self - } - - pub fn write(&self, out: &mut dyn Write) -> io::Result<()> { - let header = self.headers.map(String::from); - let lines: Vec<[String; N]> = std::iter::once(&header) - .chain(&self.rows) - .map(|row| row.clone().map(|cell| cell_text(&cell))) - .collect(); - let widths: [usize; N] = std::array::from_fn(|column| { - lines - .iter() - .map(|line| console::measure_text_width(&line[column])) - .max() - .unwrap_or(0) - }); - for line in &lines { - for (column, cell) in line.iter().enumerate() { - write!(out, "{cell}")?; - if column + 1 < N { - let padding = widths[column] - console::measure_text_width(cell); - write!(out, "{} ", " ".repeat(padding))?; - } - } - writeln!(out)?; - } - let shown = self.rows.len() as u64; - match self.total { - Some(total) if shown < total => writeln!(out, "Showing {shown} of {total}"), - _ => Ok(()), - } - } -} - -/// Labelled values, one per line, with the values aligned. -#[derive(Default)] -pub struct Details { - fields: Vec<(&'static str, String)>, -} - -impl Details { - pub fn new() -> Self { - Self::default() - } - - pub fn field(mut self, label: &'static str, value: impl Display) -> Self { - self.fields.push((label, value.to_string())); - self - } - - pub fn optional(self, label: &'static str, value: Option) -> Self { - match value { - Some(value) => self.field(label, value), - None => self.field(label, ""), - } - } - - pub fn write(&self, out: &mut dyn Write) -> io::Result<()> { - let width = self - .fields - .iter() - .map(|(label, _)| label.len()) - .max() - .unwrap_or(0); - for (label, value) in &self.fields { - let padding = " ".repeat(width - label.len()); - writeln!(out, "{label}:{padding} {}", cell_text(value))?; - } - Ok(()) - } -} - -/// A titled JSON value, pretty-printed below its title. -pub fn json_section(out: &mut dyn Write, title: &str, value: &impl Serialize) -> io::Result<()> { - writeln!(out, "{title}:")?; - serde_json::to_writer_pretty(&mut *out, value)?; - writeln!(out) -} - -/// A value on one line, with "-" standing in for nothing. -fn cell_text(value: &str) -> String { - let text = value.split_whitespace().collect::>().join(" "); - if text.is_empty() { "-".into() } else { text } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn text(write: impl FnOnce(&mut dyn Write) -> io::Result<()>) -> String { - let mut out = Vec::new(); - write(&mut out).unwrap(); - String::from_utf8(out).unwrap() - } - - #[test] - fn table_aligns_columns_and_keeps_cells_on_one_line() { - let table = Table::new(["NAME", "DESCRIPTION", "ID"]).rows([ - ["weights".into(), "two\nlines".into(), "1".into()], - ["w".into(), String::new(), "22".into()], - ]); - assert_eq!( - text(|out| table.write(out)), - "NAME DESCRIPTION ID\n\ - weights two lines 1\n\ - w - 22\n" - ); - } - - #[test] - fn table_counts_rows_only_when_some_are_left_out() { - let rows = || [["a".to_string()], ["b".to_string()]]; - for (total, footer) in [(2, ""), (3, "Showing 2 of 3\n")] { - let table = Table::new(["NAME"]).rows(rows()).total(total); - assert_eq!( - text(|out| table.write(out)), - format!("NAME\na\nb\n{footer}") - ); - } - assert_eq!( - text(|out| Table::new(["NAME"]).total(0_usize).write(out)), - "NAME\n" - ); - } - - #[test] - fn details_align_values_and_mark_empty_ones() { - let details = Details::new() - .field("Name", "weights") - .optional("Description", None::<&str>) - .field("Created at", "today"); - assert_eq!( - text(|out| details.write(out)), - "Name: weights\n\ - Description: -\n\ - Created at: today\n" - ); - } - - #[test] - fn json_sections_are_pretty_printed() { - assert_eq!( - text(|out| json_section(out, "Metadata", &serde_json::json!({"lr": 0.1}))), - "Metadata:\n{\n \"lr\": 0.1\n}\n" - ); - } -} diff --git a/crates/tracel-cli/src/output/mod.rs b/crates/tracel-cli/src/output/mod.rs deleted file mode 100644 index 06e127d..0000000 --- a/crates/tracel-cli/src/output/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Stdout. It carries a command's result and nothing else, so it can be piped or -//! captured. Progress, prompts and diagnostics go to stderr through `Terminal`. - -mod format; -mod human; -mod stdout; -mod time; - -pub use format::{Format, FormatArg}; -pub use human::{Details, Table, json_section}; -pub use stdout::{Outcome, Output, Render, StdoutClosed}; -pub use time::Timestamp; diff --git a/crates/tracel-cli/src/tools/build_driver.rs b/crates/tracel-cli/src/tools/build_driver.rs index 92be868..3920cf9 100644 --- a/crates/tracel-cli/src/tools/build_driver.rs +++ b/crates/tracel-cli/src/tools/build_driver.rs @@ -10,9 +10,9 @@ use std::process::{Command, Stdio}; use tracel_client::console::project::request::{Arch, Os}; -use crate::terminal::Terminal; use crate::tools::linker; use crate::tools::target::target_triple; +use crate::ui::Terminal; /// How to drive the build of a given target. #[derive(Clone, Copy, PartialEq, Eq, Debug)] diff --git a/crates/tracel-cli/src/tools/linker.rs b/crates/tracel-cli/src/tools/linker.rs index a61183d..3dee09f 100644 --- a/crates/tracel-cli/src/tools/linker.rs +++ b/crates/tracel-cli/src/tools/linker.rs @@ -13,8 +13,8 @@ use std::process::{Command, Stdio}; use toml_edit::DocumentMut; use tracel_client::console::project::request::{Arch, Os}; -use crate::terminal::Terminal; use crate::tools::target::target_triple; +use crate::ui::Terminal; /// What (if anything) is needed to link `target` while running on `host`. pub enum LinkerNeed { diff --git a/crates/tracel-cli/src/tools/target.rs b/crates/tracel-cli/src/tools/target.rs index 3618612..60c77d9 100644 --- a/crates/tracel-cli/src/tools/target.rs +++ b/crates/tracel-cli/src/tools/target.rs @@ -8,7 +8,7 @@ use colored::Colorize; use tracel_client::console::project::request::{Arch, Os}; use crate::error::{CliError, ErrorKind}; -use crate::terminal::Terminal; +use crate::ui::Terminal; /// Every (os, arch) target we offer to build for, in canonical display order. /// The host is surfaced separately and pulled to the front by `prompt_targets`. diff --git a/crates/tracel-cli/src/output/format.rs b/crates/tracel-cli/src/ui/format.rs similarity index 100% rename from crates/tracel-cli/src/output/format.rs rename to crates/tracel-cli/src/ui/format.rs diff --git a/crates/tracel-cli/src/ui/human.rs b/crates/tracel-cli/src/ui/human.rs new file mode 100644 index 0000000..cdbbe2e --- /dev/null +++ b/crates/tracel-cli/src/ui/human.rs @@ -0,0 +1,361 @@ +use std::fmt::Display; +use std::io::{self, Write}; + +use serde::Serialize; + +/// How text on stdout may look. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Style { + pub color: bool, + /// The terminal's width in columns, when stdout is a terminal. + pub width: Option, +} + +impl Style { + pub fn plain() -> Self { + Self { + color: false, + width: None, + } + } +} + +/// Stdout for a person: plain text when piped, styled and fitted on a terminal. +pub struct Human<'a> { + out: &'a mut dyn Write, + style: Style, +} + +impl<'a> Human<'a> { + pub fn new(out: &'a mut dyn Write, style: Style) -> Self { + Self { out, style } + } + + /// Text without color or a width, as when stdout is piped. + #[cfg(test)] + pub fn plain(out: &'a mut dyn Write) -> Self { + Self::new(out, Style::plain()) + } + + pub fn width(&self) -> Option { + self.style.width + } + + fn styled(&self, text: impl Display, style: console::Style) -> String { + style + .apply_to(text) + .force_styling(self.style.color) + .to_string() + } + + pub fn bold(&self, text: impl Display) -> String { + self.styled(text, console::Style::new().bold()) + } + + pub fn dim(&self, text: impl Display) -> String { + self.styled(text, console::Style::new().dim()) + } +} + +impl Write for Human<'_> { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.out.write(buffer) + } + + fn flush(&mut self) -> io::Result<()> { + self.out.flush() + } +} + +/// Columns aligned under their headers, one row per line. On a terminal too narrow +/// for it, the columns marked with `shrink` are shortened to fit. +pub struct Table { + headers: [&'static str; N], + rows: Vec<[String; N]>, + shrinkable: [bool; N], + total: Option, +} + +impl Table { + pub fn new(headers: [&'static str; N]) -> Self { + Self { + headers, + rows: Vec::new(), + shrinkable: [false; N], + total: None, + } + } + + pub fn rows(mut self, rows: impl IntoIterator) -> Self { + self.rows.extend(rows); + self + } + + /// Lets the column with this header be shortened, for free text such as a + /// description. Columns holding what other commands take, such as names and + /// ids, stay whole. + pub fn shrink(mut self, header: &'static str) -> Self { + let column = self.headers.iter().position(|&name| name == header); + debug_assert!(column.is_some(), "no column named {header}"); + if let Some(column) = column { + self.shrinkable[column] = true; + } + self + } + + /// Ends the table with "Showing N of TOTAL" when it holds fewer rows than exist. + pub fn total(mut self, total: impl TryInto) -> Self { + self.total = total.try_into().ok(); + self + } + + pub fn write(&self, out: &mut Human<'_>) -> io::Result<()> { + let rows: Vec<[String; N]> = self + .rows + .iter() + .map(|row| row.clone().map(|cell| cell_text(&cell))) + .collect(); + let mut widths: [usize; N] = std::array::from_fn(|column| { + rows.iter() + .map(|row| console::measure_text_width(&row[column])) + .chain([self.headers[column].len()]) + .max() + .unwrap_or(0) + }); + if let Some(available) = out.width() { + self.fit(&mut widths, available); + } + + let header = self.headers.map(|header| out.bold(header)); + self.write_line(out, &header, &widths)?; + for row in &rows { + let cells = std::array::from_fn(|column| { + let cell = &row[column]; + if cell == "-" { + out.dim(cell) + } else if console::measure_text_width(cell) > widths[column] { + shorten(cell, widths[column]) + } else { + cell.clone() + } + }); + self.write_line(out, &cells, &widths)?; + } + let shown = self.rows.len() as u64; + match self.total { + Some(total) if shown < total => { + let footer = out.dim(format!("Showing {shown} of {total}")); + writeln!(out, "{footer}") + } + _ => Ok(()), + } + } + + /// Narrows the widest shrinkable columns, never below their header, until the + /// table fits in `available` columns or nothing can shrink. + fn fit(&self, widths: &mut [usize; N], available: usize) { + let gaps = 2 * N.saturating_sub(1); + while widths.iter().sum::() + gaps > available { + let widest = (0..N) + .filter(|&column| { + self.shrinkable[column] && widths[column] > self.headers[column].len() + }) + .max_by_key(|&column| widths[column]); + match widest { + Some(column) => widths[column] -= 1, + None => break, + } + } + } + + fn write_line( + &self, + out: &mut Human<'_>, + cells: &[String; N], + widths: &[usize; N], + ) -> io::Result<()> { + for (column, cell) in cells.iter().enumerate() { + write!(out, "{cell}")?; + if column + 1 < N { + let padding = widths[column].saturating_sub(console::measure_text_width(cell)); + write!(out, "{} ", " ".repeat(padding))?; + } + } + writeln!(out) + } +} + +/// Labelled values, one per line, with the values aligned. +#[derive(Default)] +pub struct Details { + fields: Vec<(&'static str, String)>, +} + +impl Details { + pub fn new() -> Self { + Self::default() + } + + pub fn field(mut self, label: &'static str, value: impl Display) -> Self { + self.fields.push((label, value.to_string())); + self + } + + pub fn optional(self, label: &'static str, value: Option) -> Self { + match value { + Some(value) => self.field(label, value), + None => self.field(label, ""), + } + } + + pub fn write(&self, out: &mut Human<'_>) -> io::Result<()> { + let width = self + .fields + .iter() + .map(|(label, _)| label.len()) + .max() + .unwrap_or(0); + for (label, value) in &self.fields { + let padding = " ".repeat(width - label.len()); + let label = out.dim(format!("{label}:")); + let value = match cell_text(value) { + text if text == "-" => out.dim(text), + text => text, + }; + writeln!(out, "{label}{padding} {value}")?; + } + Ok(()) + } +} + +/// A titled JSON value, pretty-printed below its title. +pub fn json_section(out: &mut Human<'_>, title: &str, value: &impl Serialize) -> io::Result<()> { + let title = out.dim(format!("{title}:")); + writeln!(out, "{title}")?; + serde_json::to_writer_pretty(&mut *out, value)?; + writeln!(out) +} + +/// `text` cut to `width` columns, ending in an ellipsis. +fn shorten(text: &str, width: usize) -> String { + let kept = console::truncate_str(text, width.saturating_sub(1), ""); + format!("{}…", kept.trim_end()) +} + +/// A value on one line, with "-" standing in for nothing. +fn cell_text(value: &str) -> String { + let text = value.split_whitespace().collect::>().join(" "); + if text.is_empty() { "-".into() } else { text } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text(style: Style, write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> String { + let mut out = Vec::new(); + write(&mut Human::new(&mut out, style)).unwrap(); + String::from_utf8(out).unwrap() + } + + fn plain(write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> String { + text(Style::plain(), write) + } + + fn table() -> Table<3> { + Table::new(["NAME", "DESCRIPTION", "ID"]).rows([ + ["weights".into(), "two\nlines".into(), "1".into()], + ["w".into(), String::new(), "22".into()], + ]) + } + + #[test] + fn table_aligns_columns_and_keeps_cells_on_one_line() { + assert_eq!( + plain(|out| table().write(out)), + "NAME DESCRIPTION ID\n\ + weights two lines 1\n\ + w - 22\n" + ); + } + + #[test] + fn table_counts_rows_only_when_some_are_left_out() { + let rows = || [["a".to_string()], ["b".to_string()]]; + for (total, footer) in [(2, ""), (3, "Showing 2 of 3\n")] { + let table = Table::new(["NAME"]).rows(rows()).total(total); + assert_eq!( + plain(|out| table.write(out)), + format!("NAME\na\nb\n{footer}") + ); + } + assert_eq!( + plain(|out| Table::new(["NAME"]).total(0_usize).write(out)), + "NAME\n" + ); + } + + #[test] + fn a_narrow_terminal_shortens_only_shrinkable_columns() { + let table = || { + Table::new(["NAME", "DESCRIPTION", "ID"]).rows([[ + "weights".into(), + "a rather long description".into(), + "0123456789".into(), + ]]) + }; + let narrow = Style { + color: false, + width: Some(36), + }; + assert_eq!( + text(narrow, |out| table().shrink("DESCRIPTION").write(out)), + "NAME DESCRIPTION ID\n\ + weights a rather long… 0123456789\n" + ); + assert_eq!( + text(narrow, |out| table().write(out)), + plain(|out| table().write(out)) + ); + let wide = Style { + color: false, + width: Some(200), + }; + assert_eq!( + text(wide, |out| table().shrink("DESCRIPTION").write(out)), + plain(|out| table().write(out)) + ); + } + + #[test] + fn colors_apply_only_when_enabled() { + let colored = Style { + color: true, + width: None, + }; + assert!(text(colored, |out| table().write(out)).contains("\u{1b}[")); + assert!(!plain(|out| table().write(out)).contains("\u{1b}[")); + } + + #[test] + fn details_align_values_and_mark_empty_ones() { + let details = Details::new() + .field("Name", "weights") + .optional("Description", None::<&str>) + .field("Created at", "today"); + assert_eq!( + plain(|out| details.write(out)), + "Name: weights\n\ + Description: -\n\ + Created at: today\n" + ); + } + + #[test] + fn json_sections_are_pretty_printed() { + assert_eq!( + plain(|out| json_section(out, "Metadata", &serde_json::json!({"lr": 0.1}))), + "Metadata:\n{\n \"lr\": 0.1\n}\n" + ); + } +} diff --git a/crates/tracel-cli/src/ui/mod.rs b/crates/tracel-cli/src/ui/mod.rs new file mode 100644 index 0000000..9c9589a --- /dev/null +++ b/crates/tracel-cli/src/ui/mod.rs @@ -0,0 +1,28 @@ +//! What the person running a command sees. Stdout carries the command's result and +//! nothing else, so it can be piped or captured: an envelope with JSON output, or +//! the text of its `Render` impl. Stderr carries progress, prompts and diagnostics +//! through `Terminal`. Both draw on one screen when they share a terminal. + +mod format; +mod human; +mod screen; +mod stderr; +mod stdout; +mod time; + +use std::sync::Arc; + +pub use format::{Format, FormatArg}; +pub use human::{Details, Human, Table, json_section}; +pub use stderr::Terminal; +pub use stdout::{Outcome, Output, Render, StdoutClosed}; +pub use time::Timestamp; + +/// Stdout and stderr for one command, drawing on the same screen. +pub fn channels(format: Format, no_input: bool) -> (Output, Terminal) { + let screen = Arc::new(screen::Screen::new(format)); + ( + Output::new(Arc::clone(&screen)), + Terminal::new(screen, no_input), + ) +} diff --git a/crates/tracel-cli/src/ui/screen.rs b/crates/tracel-cli/src/ui/screen.rs new file mode 100644 index 0000000..8f61992 --- /dev/null +++ b/crates/tracel-cli/src/ui/screen.rs @@ -0,0 +1,262 @@ +use std::io::{self, IsTerminal, Write}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +use cliclack::{MultiProgress, ProgressBar}; + +use super::Format; +use super::human::Style; + +/// The terminal a command draws on. Stderr draws cliclack frames and widgets on it +/// when it is a styled terminal, and stdout shares it when stdout is a terminal too. +/// Only one thing draws at a time: anything new settles the live widget first. +pub struct Screen { + format: Format, + styled: bool, + stdout_is_terminal: bool, + frame: AtomicBool, + line_start: AtomicBool, + live: Mutex>, + next_id: AtomicU64, +} + +/// A spinner or progress bar that redraws itself until it is finished. +struct Live { + id: u64, + bar: ProgressBar, + group: Option, + message: String, +} + +#[derive(Clone, Copy)] +pub enum Ending { + Done, + Failed, +} + +impl Screen { + pub fn new(format: Format) -> Self { + Self { + format, + styled: format == Format::Human && io::stderr().is_terminal(), + stdout_is_terminal: io::stdout().is_terminal(), + frame: AtomicBool::new(false), + line_start: AtomicBool::new(true), + live: Mutex::new(None), + next_id: AtomicU64::new(0), + } + } + + pub fn format(&self) -> Format { + self.format + } + + /// Whether stderr draws with cliclack. + pub fn is_styled(&self) -> bool { + self.styled + } + + /// Whether stdout lands among what stderr draws. + pub fn shares_stdout(&self) -> bool { + self.styled && self.stdout_is_terminal + } + + /// How text on stdout may look: colors and width only on a terminal. + pub fn text_style(&self) -> Style { + if !self.stdout_is_terminal { + return Style::plain(); + } + Style { + color: console::colors_enabled(), + width: console::Term::stdout() + .size_checked() + .map(|(_, columns)| usize::from(columns)), + } + } + + pub fn frame_open(&self) -> bool { + self.frame.load(Ordering::Relaxed) + } + + pub fn set_frame(&self, open: bool) { + self.frame.store(open, Ordering::Relaxed); + } + + pub fn next_id(&self) -> u64 { + self.next_id.fetch_add(1, Ordering::Relaxed) + } + + /// Settles the live widget, then draws the one `draw` makes as live widget `id`. + pub fn show( + &self, + id: u64, + message: String, + draw: impl FnOnce() -> (ProgressBar, Option), + ) { + let mut live = self.lock(); + if let Some(previous) = live.take() { + previous.end(Ending::Done, None); + } + let (bar, group) = draw(); + bar.start(&message); + *live = Some(Live { + id, + bar, + group, + message, + }); + } + + /// Changes the live widget `id`; nothing when it was settled. + pub fn update(&self, id: u64, message: String, position: Option) { + if let Some(live) = self.lock().as_mut().filter(|live| live.id == id) { + if let Some(position) = position { + live.bar.set_position(position); + } + live.bar.set_message(&message); + live.message = message; + } + } + + /// Finishes the live widget `id`. False when something else settled it first. + pub fn finish(&self, id: u64, ending: Ending, message: &str) -> bool { + let mut live = self.lock(); + match live.take() { + Some(widget) if widget.id == id => { + widget.end(ending, Some(message)); + true + } + other => { + *live = other; + false + } + } + } + + /// Finishes the live widget, if any, with its last message. + pub fn settle(&self, ending: Ending) { + if let Some(widget) = self.lock().take() { + widget.end(ending, None); + } + } + + /// Stdout for text: inside an open frame, each line starts with the frame's bar. + pub fn stdout(&self) -> impl Write + '_ { + if self.shares_stdout() { + self.settle(Ending::Done); + } + Gutter { + out: io::stdout().lock(), + bar: (self.shares_stdout() && self.frame_open()).then(gutter), + line_start: &self.line_start, + } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Option> { + self.live + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +impl Live { + fn end(self, ending: Ending, message: Option<&str>) { + let message = message.unwrap_or(&self.message); + match ending { + Ending::Done => { + self.bar.stop(message); + if let Some(group) = &self.group { + group.stop(); + } + } + Ending::Failed => { + self.bar.error(message); + if let Some(group) = &self.group { + group.error(message); + } + } + } + } +} + +/// The bar cliclack draws down the left of a frame. +fn gutter() -> String { + let bar = console::style(console::Emoji("│", "|")).bright().black(); + format!("{} ", bar.for_stdout()) +} + +/// Writes through to `out`, starting each line with `bar` when there is one. +/// `line_start` persists across writes, since a stream arrives in pieces. +struct Gutter<'a, W> { + out: W, + bar: Option, + line_start: &'a AtomicBool, +} + +impl Write for Gutter<'_, W> { + fn write(&mut self, buffer: &[u8]) -> io::Result { + let Some(bar) = &self.bar else { + return self.out.write(buffer); + }; + for line in buffer.split_inclusive(|&byte| byte == b'\n') { + if self.line_start.load(Ordering::Relaxed) { + self.out.write_all(bar.as_bytes())?; + } + self.out.write_all(line)?; + self.line_start + .store(line.ends_with(b"\n"), Ordering::Relaxed); + } + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.out.flush() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn through_gutter(pieces: &[&str]) -> String { + let line_start = AtomicBool::new(true); + let mut written = Vec::new(); + for piece in pieces { + let mut gutter = Gutter { + out: &mut written, + bar: Some("| ".into()), + line_start: &line_start, + }; + gutter.write_all(piece.as_bytes()).unwrap(); + } + String::from_utf8(written).unwrap() + } + + #[test] + fn every_line_in_a_frame_starts_with_its_bar() { + assert_eq!(through_gutter(&["a\nb\n"]), "| a\n| b\n"); + assert_eq!(through_gutter(&["\n"]), "| \n"); + } + + #[test] + fn lines_split_across_writes_get_one_bar() { + assert_eq!( + through_gutter(&["par", "tial\nnext", "\n"]), + "| partial\n| next\n" + ); + } + + #[test] + fn without_a_frame_text_passes_through() { + let line_start = AtomicBool::new(true); + let mut written = Vec::new(); + Gutter { + out: &mut written, + bar: None, + line_start: &line_start, + } + .write_all(b"a\nb") + .unwrap(); + assert_eq!(written, b"a\nb"); + } +} diff --git a/crates/tracel-cli/src/terminal.rs b/crates/tracel-cli/src/ui/stderr.rs similarity index 64% rename from crates/tracel-cli/src/terminal.rs rename to crates/tracel-cli/src/ui/stderr.rs index 5dc8f6b..8e6a496 100644 --- a/crates/tracel-cli/src/terminal.rs +++ b/crates/tracel-cli/src/ui/stderr.rs @@ -1,81 +1,85 @@ use std::fmt::Display; -use std::io::IsTerminal; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::io::{self, IsTerminal, Write}; +use std::sync::Arc; -use cliclack::{MultiProgress, ProgressBar}; +use cliclack::ProgressBar; +use super::Format; +use super::screen::{Ending, Screen}; use crate::error::{CliError, ErrorKind, ErrorReport}; -use crate::output::Format; - -/// Whether `command_title` opened a frame that is still open. Stderr is one per -/// process, and so is the frame drawn on it. -static FRAMED: AtomicBool = AtomicBool::new(false); /// Stderr, for the person running a command: what it is doing, its progress, and /// questions for them. With JSON output it carries only errors and the instructions /// a person must follow, as plain lines. #[derive(Clone)] pub struct Terminal { - format: Format, + screen: Arc, no_input: bool, } impl Terminal { - pub fn new(format: Format) -> Self { - Self { - format, - no_input: false, - } - } - - pub fn with_no_input(mut self, no_input: bool) -> Self { - self.no_input = no_input; - self + pub fn new(screen: Arc, no_input: bool) -> Self { + Self { screen, no_input } } fn is_human(&self) -> bool { - self.format == Format::Human + self.screen.format() == Format::Human } fn is_styled(&self) -> bool { - self.is_human() && std::io::stderr().is_terminal() + self.screen.is_styled() + } + + /// Draws with cliclack once nothing else is drawing. + fn draw(&self, draw: impl FnOnce() -> io::Result<()>) { + self.screen.settle(Ending::Done); + let _ = draw(); + } + + /// A plain line, for stderr that is not a styled terminal. + fn line(&self, text: impl Display) { + let _ = writeln!( + io::stderr(), + "{}", + console::strip_ansi_codes(&text.to_string()) + ); } pub fn print_warning(&self, message: &str) { if self.is_styled() { - cliclack::log::warning(message).expect("To be able to print remark"); + self.draw(|| cliclack::log::warning(message)); } else if self.is_human() { - eprintln!("warning: {}", console::strip_ansi_codes(message)); + self.line(format_args!("warning: {message}")); } } pub fn print(&self, message: &str) { if self.is_styled() { - cliclack::log::info(message).expect("To be able to print message"); + self.draw(|| cliclack::log::info(message)); } else if self.is_human() { - eprintln!("{}", console::strip_ansi_codes(message)); + self.line(message); } } pub fn print_err(&self, message: &str) { if self.is_styled() { - cliclack::log::error(message).expect("To be able to print message"); + self.draw(|| cliclack::log::error(message)); } else if self.is_human() { - eprintln!("error: {}", console::strip_ansi_codes(message)); + self.line(format_args!("error: {message}")); } } pub fn print_success(&self, message: &str) { if self.is_styled() { - cliclack::log::success(message).expect("To be able to print success message"); + self.draw(|| cliclack::log::success(message)); } else if self.is_human() { - eprintln!("{}", console::strip_ansi_codes(message)); + self.line(message); } } pub fn step(&self, message: &str) { if self.is_styled() { - cliclack::log::step(message).expect("To be able to print message"); + self.draw(|| cliclack::log::step(message)); } else { self.print(message); } @@ -87,53 +91,58 @@ impl Terminal { if self.is_human() { self.print(message); } else { - eprintln!("{}", console::strip_ansi_codes(message)); + self.line(message); } } /// Reports a failed command: the message and hint here, the envelope on stdout /// with JSON output. An open frame ends with the hint, or with the error itself. pub fn error(&self, report: &ErrorReport<'_>) { + self.screen.settle(Ending::Failed); if !self.is_human() { - eprintln!("error: {}", one_line(&report.message)); - } else if self.is_styled() && FRAMED.load(Ordering::Relaxed) { + self.line(format_args!("error: {}", one_line(&report.message))); + } else if self.is_styled() && self.screen.frame_open() { match report.hint { Some(hint) => { - cliclack::log::error(&report.message).expect("To be able to print message"); - cliclack::outro_cancel(hint).expect("To be able to print message"); - FRAMED.store(false, Ordering::Relaxed); + self.draw(|| { + cliclack::log::error(&report.message)?; + cliclack::outro_cancel(hint) + }); + self.screen.set_frame(false); } None => self.cancel_finalize(&report.message), } } else { let label = console::style("error:").for_stderr().red().bold(); - eprintln!("{label} {}", report.message); + let _ = writeln!(io::stderr(), "{label} {}", report.message); if let Some(hint) = report.hint { - eprintln!("{hint}"); + let _ = writeln!(io::stderr(), "{hint}"); } } } pub fn spinner(&self) -> TerminalSpinner { TerminalSpinner { - bar: self.is_styled().then(cliclack::spinner), terminal: self.clone(), + id: self.screen.next_id(), } } /// A bar that counts `total` steps under `title`. pub fn progress(&self, title: &str, total: u64) -> TerminalProgress { - let bars = self.is_styled().then(|| { - let group = cliclack::multi_progress(title); - let bar = group.add(ProgressBar::new(total).with_download_template()); - (group, bar) - }); - if bars.is_none() { + let id = self.screen.next_id(); + if self.is_styled() { + self.screen.show(id, String::new(), || { + let group = cliclack::multi_progress(title); + let bar = group.add(ProgressBar::new(total).with_download_template()); + (bar, Some(group)) + }); + } else { self.print(title); } TerminalProgress { - bars, terminal: self.clone(), + id, } } @@ -150,6 +159,7 @@ impl Terminal { self.require_answer(ErrorKind::Usage, message, flag, values) } + /// Fails when nobody can answer, and otherwise clears the screen for a prompt. fn require_answer( &self, kind: ErrorKind, @@ -166,6 +176,7 @@ impl Terminal { .with_hint(format!("Pass --{flag} to answer without a prompt.")) .into()); } + self.screen.settle(Ending::Done); Ok(()) } @@ -232,18 +243,17 @@ impl Terminal { /// Opens the frame that an action's progress is drawn in. pub fn command_title(&self, title: &str) { if self.is_styled() { - let title = format!(" {} {} ", "▶", title); - cliclack::intro(console::style(title).black().on_green()) - .expect("To be able to print title"); - FRAMED.store(true, Ordering::Relaxed); + let title = console::style(format!(" ▶ {title} ")).black().on_green(); + self.draw(|| cliclack::intro(title)); + self.screen.set_frame(true); } } pub fn finalize(&self, msg: &str) { if self.is_styled() { - cliclack::outro(console::style(format!(" {} ", msg)).black().on_green()) - .expect("To be able to print message"); - FRAMED.store(false, Ordering::Relaxed); + let message = console::style(format!(" {msg} ")).black().on_green(); + self.draw(|| cliclack::outro(message)); + self.screen.set_frame(false); } else { self.print_success(msg); } @@ -251,9 +261,9 @@ impl Terminal { pub fn cancel_finalize(&self, msg: &str) { if self.is_styled() { - cliclack::outro_cancel(console::style(format!(" {} ", msg)).black().on_red()) - .expect("To be able to print message"); - FRAMED.store(false, Ordering::Relaxed); + let message = console::style(format!(" {msg} ")).black().on_red(); + self.draw(|| cliclack::outro_cancel(message)); + self.screen.set_frame(false); } else { self.print_err(msg); } @@ -263,9 +273,9 @@ impl Terminal { pub fn is_interactive(&self) -> bool { let ci = std::env::var_os("CI"); allows_input( - std::io::stdin().is_terminal(), - std::io::stderr().is_terminal(), - self.format, + io::stdin().is_terminal(), + io::stderr().is_terminal(), + self.screen.format(), ci.as_ref().map(|value| value.to_str().unwrap_or("true")), self.no_input, ) @@ -293,108 +303,114 @@ fn one_line(message: &str) -> String { .join(" ") } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn interactivity_requires_terminals_human_output_and_allowed_environment() { - for ci in [None, Some(""), Some("0"), Some("false")] { - for format in [Format::Human, Format::Json] { - for stdin in [false, true] { - for stderr in [false, true] { - for no_input in [false, true] { - assert_eq!( - allows_input(stdin, stderr, format, ci, no_input), - stdin && stderr && format == Format::Human && !no_input - ); - } - } - } - } - } - for ci in ["1", "true", "yes", "FALSE", " "] { - assert!(!allows_input(true, true, Format::Human, Some(ci), false)); - } - } -} - +/// A spinner for one step. It is drawn when started, and stops early when something +/// else draws before it ends; its last message then appears as a plain line. #[derive(Clone)] pub struct TerminalSpinner { - bar: Option, terminal: Terminal, + id: u64, } impl TerminalSpinner { pub fn start(&self, message: impl Display) { - if let Some(bar) = &self.bar { - bar.start(message); + if self.terminal.is_styled() { + self.terminal + .screen + .show(self.id, message.to_string(), || (cliclack::spinner(), None)); } else { self.terminal.print(&message.to_string()); } } pub fn set_message(&self, message: impl Display) { - if let Some(bar) = &self.bar { - bar.set_message(message); + if self.terminal.is_styled() { + self.terminal + .screen + .update(self.id, message.to_string(), None); } else { self.terminal.print(&message.to_string()); } } pub fn stop(&self, message: impl Display) { - if let Some(bar) = &self.bar { - bar.stop(message); - } else { - self.terminal.print_success(&message.to_string()); + let message = message.to_string(); + if !self.terminal.is_styled() + || !self.terminal.screen.finish(self.id, Ending::Done, &message) + { + self.terminal.print_success(&message); } } pub fn error(&self, message: impl Display) { - if let Some(bar) = &self.bar { - bar.error(message); - } else { - self.terminal.print_err(&message.to_string()); + let message = message.to_string(); + if !self.terminal.is_styled() + || !self + .terminal + .screen + .finish(self.id, Ending::Failed, &message) + { + self.terminal.print_err(&message); } } } +/// A bar counting the steps of one task, drawn from creation until it ends. pub struct TerminalProgress { - bars: Option<(MultiProgress, ProgressBar)>, terminal: Terminal, + id: u64, } impl TerminalProgress { - pub fn start(&self, message: impl Display) { - if let Some((_, bar)) = &self.bars { - bar.start(message); - } - } - pub fn set(&self, done: u64, message: impl Display) { - if let Some((_, bar)) = &self.bars { - bar.set_position(done); - bar.set_message(message); - } + self.terminal + .screen + .update(self.id, message.to_string(), Some(done)); } pub fn stop(&self, message: impl Display) { - match &self.bars { - Some((group, bar)) => { - bar.stop(message); - group.stop(); - } - None => self.terminal.print_success(&message.to_string()), + let message = message.to_string(); + if !self.terminal.is_styled() + || !self.terminal.screen.finish(self.id, Ending::Done, &message) + { + self.terminal.print_success(&message); } } pub fn error(&self, message: impl Display) { - match &self.bars { - Some((group, bar)) => { - bar.error(&message); - group.error(message); + let message = message.to_string(); + if !self.terminal.is_styled() + || !self + .terminal + .screen + .finish(self.id, Ending::Failed, &message) + { + self.terminal.print_err(&message); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interactivity_requires_terminals_human_output_and_allowed_environment() { + for ci in [None, Some(""), Some("0"), Some("false")] { + for format in [Format::Human, Format::Json] { + for stdin in [false, true] { + for stderr in [false, true] { + for no_input in [false, true] { + assert_eq!( + allows_input(stdin, stderr, format, ci, no_input), + stdin && stderr && format == Format::Human && !no_input + ); + } + } + } } - None => self.terminal.print_err(&message.to_string()), + } + for ci in ["1", "true", "yes", "FALSE", " "] { + assert!(!allows_input(true, true, Format::Human, Some(ci), false)); } } } diff --git a/crates/tracel-cli/src/output/stdout.rs b/crates/tracel-cli/src/ui/stdout.rs similarity index 68% rename from crates/tracel-cli/src/output/stdout.rs rename to crates/tracel-cli/src/ui/stdout.rs index 257a1d2..dc9d4e3 100644 --- a/crates/tracel-cli/src/output/stdout.rs +++ b/crates/tracel-cli/src/ui/stdout.rs @@ -1,15 +1,18 @@ use std::io::{self, Write}; +use std::sync::Arc; use serde::Serialize; use super::Format; +use super::human::{Human, Style}; +use super::screen::Screen; use crate::error::ErrorReport; /// A command result: serialized for JSON output, written as text for people. pub trait Render: Serialize { /// The text a person reads on stdout. The default writes nothing, for actions /// that report what they did on stderr as they go. - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { let _ = out; Ok(()) } @@ -33,7 +36,7 @@ impl From for Outcome { trait Report { fn write_json(&self, out: &mut dyn Write) -> serde_json::Result<()>; - fn write_text(&self, out: &mut dyn Write) -> io::Result<()>; + fn write_text(&self, out: &mut Human<'_>) -> io::Result<()>; } impl Report for T { @@ -47,7 +50,7 @@ impl Report for T { ) } - fn write_text(&self, out: &mut dyn Write) -> io::Result<()> { + fn write_text(&self, out: &mut Human<'_>) -> io::Result<()> { self.render(out) } } @@ -70,50 +73,57 @@ struct Failure<'a> { pub struct StdoutClosed; /// Stdout, which carries only command results, in the format the user chose. -#[derive(Clone, Copy)] +#[derive(Clone)] pub struct Output { - format: Format, + screen: Arc, } impl Output { - pub fn new(format: Format) -> Self { - Self { format } + pub fn new(screen: Arc) -> Self { + Self { screen } } /// Writes the result of a finished command. pub fn finish(&self, outcome: Outcome) -> anyhow::Result<()> { - write_stdout(|out| finish(self.format, outcome, out)) + let format = self.screen.format(); + let style = self.screen.text_style(); + self.write_stdout(|out| finish(format, style, outcome, out)) } /// Writes one event of a stream as it happens: an NDJSON line, or its text. pub fn event(&self, event: &impl Render) -> anyhow::Result<()> { - write_stdout(|out| { - write_event(self.format, event, out)?; + let format = self.screen.format(); + let style = self.screen.text_style(); + self.write_stdout(|out| { + write_event(format, style, event, out)?; out.flush() }) } /// Writes the error envelope of a failed command; text errors go to stderr. pub fn error(&self, report: &ErrorReport<'_>) { - if self.format == Format::Json { - let _ = write_stdout(|out| write_failure(report, out)); + if self.screen.format() == Format::Json { + let _ = self.write_stdout(|out| write_failure(report, out)); } } -} -fn write_stdout(write: impl FnOnce(&mut dyn Write) -> io::Result<()>) -> anyhow::Result<()> { - match write(&mut io::stdout().lock()) { - Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Err(StdoutClosed.into()), - result => Ok(result?), + fn write_stdout( + &self, + write: impl FnOnce(&mut dyn Write) -> io::Result<()>, + ) -> anyhow::Result<()> { + match write(&mut self.screen.stdout()) { + Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Err(StdoutClosed.into()), + result => Ok(result?), + } } } -fn finish(format: Format, outcome: Outcome, out: &mut dyn Write) -> io::Result<()> { +fn finish(format: Format, style: Style, outcome: Outcome, out: &mut dyn Write) -> io::Result<()> { let Some(report) = outcome.0 else { return Ok(()); }; match format { - Format::Human => report.write_text(out), + Format::Human => report.write_text(&mut Human::new(out, style)), Format::Json => { report.write_json(out)?; writeln!(out) @@ -121,9 +131,14 @@ fn finish(format: Format, outcome: Outcome, out: &mut dyn Write) -> io::Result<( } } -fn write_event(format: Format, event: &impl Render, out: &mut dyn Write) -> io::Result<()> { +fn write_event( + format: Format, + style: Style, + event: &impl Render, + out: &mut dyn Write, +) -> io::Result<()> { match format { - Format::Human => event.render(out), + Format::Human => event.render(&mut Human::new(out, style)), Format::Json => { serde_json::to_writer(&mut *out, event)?; writeln!(out) @@ -153,7 +168,7 @@ mod tests { } impl Render for Greeting { - fn render(&self, out: &mut dyn Write) -> io::Result<()> { + fn render(&self, out: &mut Human<'_>) -> io::Result<()> { writeln!(out, "Hello, {}.", self.name) } } @@ -173,15 +188,15 @@ mod tests { fn results_are_an_envelope_or_their_text() { let greeting = || Outcome::from(Greeting { name: "Ada" }); assert_eq!( - written(|out| finish(Format::Json, greeting(), out)), + written(|out| finish(Format::Json, Style::plain(), greeting(), out)), "{\"ok\":true,\"data\":{\"name\":\"Ada\"}}\n" ); assert_eq!( - written(|out| finish(Format::Human, greeting(), out)), + written(|out| finish(Format::Human, Style::plain(), greeting(), out)), "Hello, Ada.\n" ); assert_eq!( - written(|out| finish(Format::Human, Done {}.into(), out)), + written(|out| finish(Format::Human, Style::plain(), Done {}.into(), out)), "" ); } @@ -189,7 +204,10 @@ mod tests { #[test] fn streamed_output_is_not_followed_by_an_envelope() { for format in [Format::Human, Format::Json] { - assert_eq!(written(|out| finish(format, Outcome::streamed(), out)), ""); + assert_eq!( + written(|out| finish(format, Style::plain(), Outcome::streamed(), out)), + "" + ); } } @@ -197,11 +215,11 @@ mod tests { fn events_are_bare_json_lines_or_their_text() { let event = Greeting { name: "Ada" }; assert_eq!( - written(|out| write_event(Format::Json, &event, out)), + written(|out| write_event(Format::Json, Style::plain(), &event, out)), "{\"name\":\"Ada\"}\n" ); assert_eq!( - written(|out| write_event(Format::Human, &event, out)), + written(|out| write_event(Format::Human, Style::plain(), &event, out)), "Hello, Ada.\n" ); } diff --git a/crates/tracel-cli/src/output/time.rs b/crates/tracel-cli/src/ui/time.rs similarity index 100% rename from crates/tracel-cli/src/output/time.rs rename to crates/tracel-cli/src/ui/time.rs From 20a845ef71b38a04ccc3c218e663748c1c3908ac Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Wed, 7 Oct 2026 16:29:10 -0400 Subject: [PATCH 15/32] chore: build against tracel-client from git - Pin tracel-client to commit 1f0095d of feat/cli-read-api in burn-central-client, so the branch builds without a sibling checkout. --- Cargo.lock | 1 + Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 3b9789c..57d65fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3644,6 +3644,7 @@ dependencies = [ [[package]] name = "tracel-client" version = "0.21.0" +source = "git+https://github.com/tracel-ai/burn-central-client?rev=1f0095dae2933a70f3a07eaf02138ca664d1708c#1f0095dae2933a70f3a07eaf02138ca664d1708c" dependencies = [ "directories 6.0.0", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 912dfad..22636ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ strum = { version = "0.27.2", features = ["derive"] } ## External Crate -tracel-client = { path = "../burn-central-client/tracel-client" } +tracel-client = { git = "https://github.com/tracel-ai/burn-central-client", rev = "1f0095dae2933a70f3a07eaf02138ca664d1708c" } ## Crate tracel-cli = { version = "0.5.0", path = "crates/tracel-cli" } From 0de73436f3438ca2b9e3e3e455761ba0289f3bed Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Fri, 9 Oct 2026 16:49:51 -0400 Subject: [PATCH 16/32] feat: draw every command in a cliclack frame on a terminal - `ui::channels` opens the frame from the command's title. Actions close it with `outro` or `outro_cancel`, which replace `finalize` and `cancel_finalize`. Reads are closed by `Output::finish` with the result's new `Render::summary`. Commands no longer open frames themselves. - Inside the frame, details are a card of bullets, JSON values such as an experiment's config are listed as fields under a heading, and tables sit after the bar. - Tables fit the width: a shrinkable column, header included, is cut down to four columns. A list with no rows says so in its summary. Server times show in the local zone, to the minute. - Errors always end on a red badge on a terminal, and the --dev warning now comes after the title. - Redirected output stays plain and ends with the summary line. JSON output is unchanged. --- README.md | 8 +- crates/tracel-cli/src/cli.rs | 24 +- crates/tracel-cli/src/commands/artifacts.rs | 26 +- crates/tracel-cli/src/commands/auth.rs | 21 + crates/tracel-cli/src/commands/datasets.rs | 38 +- crates/tracel-cli/src/commands/experiments.rs | 81 +++- crates/tracel-cli/src/commands/init.rs | 13 +- crates/tracel-cli/src/commands/login.rs | 6 +- crates/tracel-cli/src/commands/logout.rs | 8 +- crates/tracel-cli/src/commands/me.rs | 13 +- crates/tracel-cli/src/commands/models.rs | 98 ++++- crates/tracel-cli/src/commands/package.rs | 7 +- crates/tracel-cli/src/commands/project.rs | 26 +- crates/tracel-cli/src/commands/unlink.rs | 5 +- .../src/helpers/model_upload/upload.rs | 4 +- crates/tracel-cli/src/helpers/project.rs | 19 +- crates/tracel-cli/src/ui/human.rs | 414 ++++++++++++++---- crates/tracel-cli/src/ui/mod.rs | 14 +- crates/tracel-cli/src/ui/screen.rs | 153 +++---- crates/tracel-cli/src/ui/stderr.rs | 52 +-- crates/tracel-cli/src/ui/stdout.rs | 94 +++- crates/tracel-cli/src/ui/time.rs | 33 +- 22 files changed, 823 insertions(+), 334 deletions(-) diff --git a/README.md b/README.md index cd3cb42..b79f226 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,12 @@ tables and details of commands that read, while progress, prompts, warnings, and errors go to stderr. Commands that change something, such as `init`, `login`, `models push`, or `artifacts download`, report on stderr and print nothing on stdout. `auth token` prints the bare token unless JSON is requested, -even when stdout is redirected. On a terminal, tables shorten long descriptions -to fit its width; redirected output is never shortened. +even when stdout is redirected. + +On a terminal, each command draws in one frame: a title, its progress and result, +and a closing line that sums it up. Tables shorten long descriptions to fit the +width and show times in your time zone. Redirected output stays plain text, is +never shortened, and ends with that summary line. | Error code | Exit code | | --- | --- | diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index 8f5564a..a06702a 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -84,6 +84,26 @@ pub enum Commands { } impl Commands { + /// The title of the frame a terminal shows the command in. None where stdout is + /// a bare value or another program's output. + fn title(&self) -> Option<&'static str> { + Some(match self { + Self::Train(_) => return None, + Self::Auth(args) => return args.title(), + Self::Package(_) => "Package project", + Self::Login(_) => "Login", + Self::Logout => "Logout", + Self::Experiments(args) => args.title(), + Self::Init(_) => "Project initialization", + Self::Unlink(_) => "Unlink", + Self::Me => "User information", + Self::Project(args) => args.title(), + Self::Models(args) => args.title(), + Self::Artifacts(args) => args.title(), + Self::Datasets(args) => args.title(), + }) + } + /// What `auto` output means: human on a terminal and JSON otherwise, except where /// stdout carries plain text: the bare token for `$(tracel auth token)`, or the /// output of the program `train` runs. @@ -148,7 +168,7 @@ pub fn cli_main() { }; let format = format.unwrap_or_else(|error| fail_early(&error.into(), auto)); - let (output, terminal) = ui::channels(format, args.no_input); + let (output, terminal) = ui::channels(format, args.no_input, command.title()); let environment_value = std::env::var_os("TRACEL_ENV").map(|value| value.to_string_lossy().into_owned()); let environment = resolve_environment(args.dev, args.staging, environment_value.as_deref()) @@ -209,7 +229,7 @@ fn fail(error: &anyhow::Error, output: &Output, terminal: &Terminal) -> ! { /// Fails before the command's channels exist. fn fail_early(error: &anyhow::Error, format: Format) -> ! { - let (output, terminal) = ui::channels(format, true); + let (output, terminal) = ui::channels(format, true, None); fail(error, &output, &terminal) } diff --git a/crates/tracel-cli/src/commands/artifacts.rs b/crates/tracel-cli/src/commands/artifacts.rs index 6fe8861..71117e5 100644 --- a/crates/tracel-cli/src/commands/artifacts.rs +++ b/crates/tracel-cli/src/commands/artifacts.rs @@ -13,7 +13,7 @@ use crate::helpers::{ DownloadFile, DownloadResult, Resource, download_files, map_resource_error, resolve_namespace_project, select_artifact, validate_rel_path, }; -use crate::ui::{Human, Outcome, Render, Table}; +use crate::ui::{Human, Outcome, Render, Table, counted, when}; #[derive(Args, Debug)] pub struct ArtifactsArgs { @@ -21,6 +21,15 @@ pub struct ArtifactsArgs { pub command: ArtifactsCommands, } +impl ArtifactsArgs { + pub fn title(&self) -> &'static str { + match self.command { + ArtifactsCommands::List(_) => "Artifacts", + ArtifactsCommands::Download(_) => "Artifact download", + } + } +} + #[derive(Subcommand, Debug)] pub enum ArtifactsCommands { /// List artifacts in an experiment. @@ -109,12 +118,20 @@ impl Render for ArtifactListResponse { manifest_files(&artifact.manifest) .map(|files| files.len().to_string()) .unwrap_or_default(), - artifact.created_at.clone(), + when(&artifact.created_at), ] })) - .total(self.total) .write(out) } + + fn summary(&self) -> Option { + Some(counted( + self.items.len(), + self.total as u64, + ["artifact", "artifacts"], + "No artifacts in this experiment.", + )) + } } pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Result { @@ -150,7 +167,6 @@ pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Resul })? .into()), ArtifactsCommands::Download(args) => { - context.terminal().command_title("Artifact download"); let map_error = |error| { map_resource_error( error, @@ -193,7 +209,7 @@ pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Resul .collect(); let files = download_files(&client, context.terminal(), &directory, &files, args.force)?; - context.terminal().finalize(&format!( + context.terminal().outro(&format!( "Downloaded artifact '{}' to {}.", artifact.name, directory.display() diff --git a/crates/tracel-cli/src/commands/auth.rs b/crates/tracel-cli/src/commands/auth.rs index 9e04d18..a89573f 100644 --- a/crates/tracel-cli/src/commands/auth.rs +++ b/crates/tracel-cli/src/commands/auth.rs @@ -26,6 +26,16 @@ pub enum AuthCommands { Status, } +impl AuthArgs { + /// None for `token`, whose bare value is read by other programs. + pub fn title(&self) -> Option<&'static str> { + match self.command { + AuthCommands::Token => None, + AuthCommands::Status => Some("Authentication status"), + } + } +} + pub fn handle_command(args: AuthArgs, context: CliContext) -> anyhow::Result { match args.command { AuthCommands::Token => Ok(access_token(&context)?.into()), @@ -59,6 +69,9 @@ struct AuthStatus { login_ends_at: Option, user: User, environment: String, + /// " (development environment)" and the like, empty for production. + #[serde(skip)] + environment_suffix: String, } #[derive(Serialize)] @@ -88,6 +101,13 @@ impl Render for AuthStatus { .field("Environment", &self.environment) .write(out) } + + fn summary(&self) -> Option { + Some(format!( + "Commands act as {}{}.", + self.user.username, self.environment_suffix + )) + } } fn access_token(context: &CliContext) -> anyhow::Result { @@ -132,6 +152,7 @@ fn status(context: &CliContext) -> anyhow::Result { namespace: client.user().namespace.clone(), }, environment: context.environment_name(), + environment_suffix: environment_suffix(&context.environment()), }), Err(ClientCreationError::NoCredentials | ClientCreationError::InvalidCredentials) => { Err(CliError::new( diff --git a/crates/tracel-cli/src/commands/datasets.rs b/crates/tracel-cli/src/commands/datasets.rs index f05d4e8..8f5abaa 100644 --- a/crates/tracel-cli/src/commands/datasets.rs +++ b/crates/tracel-cli/src/commands/datasets.rs @@ -9,7 +9,7 @@ use tracel_client::console::dataset::response::{ use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::helpers::{Resource, map_resource_error, resolve_namespace_project}; -use crate::ui::{Details, Human, Outcome, Render, Table, json_section}; +use crate::ui::{Details, Human, Outcome, Render, Table, counted, when}; #[derive(Args, Debug)] pub struct DatasetsArgs { @@ -17,6 +17,16 @@ pub struct DatasetsArgs { pub command: DatasetsCommands, } +impl DatasetsArgs { + pub fn title(&self) -> &'static str { + match self.command { + DatasetsCommands::List(_) => "Datasets", + DatasetsCommands::Get(_) => "Dataset", + DatasetsCommands::Versions(_) => "Dataset versions", + } + } +} + #[derive(Subcommand, Debug)] pub enum DatasetsCommands { /// List datasets in the selected project. @@ -113,9 +123,17 @@ impl Render for DatasetListResponse { dataset.id.clone(), ] })) - .total(self.total_count) .write(out) } + + fn summary(&self) -> Option { + Some(counted( + self.items.len(), + self.total_count, + ["dataset", "datasets"], + "No datasets in this project yet.", + )) + } } impl Render for DatasetResponse { @@ -124,8 +142,8 @@ impl Render for DatasetResponse { .field("Name", &self.name) .field("ID", &self.id) .optional("Description", self.description.as_ref()) - .write(out)?; - json_section(out, "Metadata", &self.metadata) + .section("Metadata", &self.metadata) + .write(out) } } @@ -141,12 +159,20 @@ impl Render for DatasetVersionListResponse { version.version.to_string(), version.item_count.to_string(), source.into(), - version.created_at.clone(), + when(&version.created_at), ] })) - .total(self.total_count) .write(out) } + + fn summary(&self) -> Option { + Some(counted( + self.items.len(), + self.total_count, + ["version", "versions"], + "No published versions yet.", + )) + } } #[cfg(test)] diff --git a/crates/tracel-cli/src/commands/experiments.rs b/crates/tracel-cli/src/commands/experiments.rs index bf72210..31a3d63 100644 --- a/crates/tracel-cli/src/commands/experiments.rs +++ b/crates/tracel-cli/src/commands/experiments.rs @@ -19,7 +19,7 @@ use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::project::resolve_namespace_project; use crate::tools::tracel_config::TracelProject; -use crate::ui::{Details, Human, Outcome, Output, Render, Table, json_section}; +use crate::ui::{Details, Human, Outcome, Output, Render, Table, counted, when}; #[derive(Args, Debug)] pub struct ExperimentsArgs { @@ -27,6 +27,17 @@ pub struct ExperimentsArgs { pub command: ExperimentsCommands, } +impl ExperimentsArgs { + pub fn title(&self) -> &'static str { + match self.command { + ExperimentsCommands::List(_) => "Experiments", + ExperimentsCommands::Get(_) => "Experiment", + ExperimentsCommands::Metrics(_) => "Experiment metrics", + ExperimentsCommands::Logs(_) => "Experiment logs", + } + } +} + #[derive(Subcommand, Debug)] pub enum ExperimentsCommands { /// List experiments in the selected project. @@ -209,13 +220,21 @@ impl Render for ListExperimentsResponse { experiment.experiment_num.to_string(), experiment.name.clone().unwrap_or_default(), experiment.status.clone(), - experiment.created_at.clone(), + when(&experiment.created_at), experiment.created_by.username.clone(), ] })) - .total(self.total) .write(out) } + + fn summary(&self) -> Option { + Some(counted( + self.items.len(), + self.total, + ["experiment", "experiments"], + "No experiments in this project yet.", + )) + } } impl Render for ExperimentDetailsResponse { @@ -227,33 +246,59 @@ impl Render for ExperimentDetailsResponse { .optional("Name", self.name.as_ref()) .field("Status", &self.status) .field("Description", &self.description) - .field("Created at", &self.created_at) + .field("Created at", when(&self.created_at)) .field("Created by", &self.created_by.username) - .write(out)?; - json_section(out, "Config", &self.config)?; - json_section(out, "Configurations", &self.configurations)?; - json_section(out, "Attributes", &self.attributes) + .section("Config", &self.config) + .section("Configurations", &self.configurations) + .section("Attributes", &self.attributes) + .write(out) + } + + fn summary(&self) -> Option { + Some(format!( + "Experiment {} is {}.", + self.experiment_num, self.status + )) } } impl Render for MetricMetadataResponse { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Table::new(["METRIC"]) + .empty("No metrics recorded.") .rows(self.metric_types.iter().map(|name| [name.clone()])) .write(out)?; - writeln!(out)?; + if out.framed() { + out.spacer()?; + } else { + writeln!(out)?; + } Table::new(["GROUP"]) + .empty("No metric groups.") .rows(self.groups.iter().map(|name| [name.clone()])) .write(out) } + + fn summary(&self) -> Option { + let plural = |count: usize, one: &str, many: &str| { + format!("{count} {}", if count == 1 { one } else { many }) + }; + Some(format!( + "{} in {}.", + plural(self.metric_types.len(), "metric", "metrics"), + plural(self.groups.len(), "group", "groups") + )) + } } impl Render for Option { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { let Some(metrics) = self else { - return writeln!(out, "No metric series available."); + let none = out.dim("No metric series available."); + return writeln!(out, "{none}"); }; Table::new(["GROUP", "EPOCH", "ITERATION", "VALUE", "LOW", "HIGH"]) + .empty("No points in this series.") .rows(metrics.groups.iter().flat_map(|group| { group.entries.iter().map(|entry| { [ @@ -273,9 +318,11 @@ impl Render for Option { impl Render for Option { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { let Some(summary) = self else { - return writeln!(out, "No metric summary available."); + let none = out.dim("No metric summary available."); + return writeln!(out, "{none}"); }; Table::new(["GROUP", "OPTIMAL VALUE", "EPOCH"]) + .empty("No metric groups.") .rows(summary.groups.iter().map(|group| { [ group.group.clone(), @@ -291,6 +338,15 @@ impl Render for ExperimentLogQueryResponse { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { self.items.iter().try_for_each(|item| write_log(out, item)) } + + fn summary(&self) -> Option { + Some(counted( + self.items.len(), + self.total, + ["log entry", "log entries"], + "No log entries.", + )) + } } fn write_log(out: &mut Human<'_>, item: &ExperimentLogItemResponse) -> io::Result<()> { @@ -417,6 +473,9 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res let request = args.into_request(); if follow { follow_logs(&client, &project, num, request, context.output())?; + context + .terminal() + .outro(&format!("Experiment {num} has finished.")); return Ok(Outcome::streamed()); } Ok(client diff --git a/crates/tracel-cli/src/commands/init.rs b/crates/tracel-cli/src/commands/init.rs index 028a49f..b8d5a46 100644 --- a/crates/tracel-cli/src/commands/init.rs +++ b/crates/tracel-cli/src/commands/init.rs @@ -54,7 +54,7 @@ pub fn handle_command(args: InitArgs, context: CliContext) -> anyhow::Result.") })?; } - if !can_initialize_project(&context, args.force)? { + if !can_initialize_project(args.force)? { let project = require_linked_project()?; let linked = project.get_project(); let other_owner = args.owner.as_deref().is_some_and(|o| o != linked.owner); @@ -70,6 +70,13 @@ pub fn handle_command(args: InitArgs, context: CliContext) -> anyhow::Result anyhow::Result { )?), console::style(&pending.authorization.user_code).bold() )); - terminal.print("Then run 'tracel login --complete'."); + terminal.outro("Then run 'tracel login --complete'."); Ok(LoginPending { verification_uri_complete: pending.authorization.verification_uri_complete, @@ -302,8 +302,6 @@ fn complete_pending_login(context: &CliContext, timeout: Option) -> anyhow: } pub fn handle_command(args: LoginArgs, context: CliContext) -> anyhow::Result { - context.terminal().command_title("Login"); - if args.no_wait { return Ok(start_pending_login(&context)?.into()); } @@ -321,7 +319,7 @@ pub fn handle_command(args: LoginArgs, context: CliContext) -> anyhow::Result anyhow::Result { - context.terminal().command_title("Logout"); - let app_session = context.app_session()?; let was_logged_in = app_session.stored()?.is_some(); app_session.sign_out()?; let env_msg = environment_suffix(&context.environment()); if was_logged_in { - context - .terminal() - .finalize(&format!("Logged out{}.", env_msg)); + context.terminal().outro(&format!("Logged out{}.", env_msg)); } else { context .terminal() - .finalize(&format!("Nobody was logged in{}.", env_msg)); + .outro(&format!("Nobody was logged in{}.", env_msg)); } Ok(LoggedOut { diff --git a/crates/tracel-cli/src/commands/me.rs b/crates/tracel-cli/src/commands/me.rs index 614b0e2..8e64824 100644 --- a/crates/tracel-cli/src/commands/me.rs +++ b/crates/tracel-cli/src/commands/me.rs @@ -3,7 +3,7 @@ use std::io; use anyhow::Context; use serde::Serialize; -use crate::commands::login::get_client_and_login_if_needed; +use crate::commands::login::{environment_suffix, get_client_and_login_if_needed}; use crate::context::CliContext; use crate::ui::{Details, Human, Outcome, Render}; @@ -14,6 +14,9 @@ struct CurrentUser { namespace: String, environment: String, api_url: String, + /// " (development environment)" and the like, empty for production. + #[serde(skip)] + environment_suffix: String, } impl Render for CurrentUser { @@ -26,6 +29,13 @@ impl Render for CurrentUser { .field("API URL", &self.api_url) .write(out) } + + fn summary(&self) -> Option { + Some(format!( + "Signed in as {}{}.", + self.username, self.environment_suffix + )) + } } pub fn handle_command(context: CliContext) -> anyhow::Result { @@ -40,6 +50,7 @@ pub fn handle_command(context: CliContext) -> anyhow::Result { namespace: user.namespace, environment: context.environment_name(), api_url: context.get_api_endpoint(), + environment_suffix: environment_suffix(&context.environment()), } .into()) } diff --git a/crates/tracel-cli/src/commands/models.rs b/crates/tracel-cli/src/commands/models.rs index 043667e..3517d30 100644 --- a/crates/tracel-cli/src/commands/models.rs +++ b/crates/tracel-cli/src/commands/models.rs @@ -1,5 +1,5 @@ use std::collections::BTreeMap; -use std::io::{self, Write}; +use std::io; use std::path::{Path, PathBuf}; use clap::{Args, Subcommand}; @@ -25,7 +25,7 @@ use crate::helpers::{ }; use crate::tools::fs::{build_file_specs, collect_files}; use crate::tools::tracel_config::TracelProject; -use crate::ui::{Details, Human, Outcome, Render, Table, json_section}; +use crate::ui::{Details, Human, Outcome, Render, Table, counted, when}; #[derive(Args, Debug)] pub struct ModelsArgs { @@ -33,6 +33,23 @@ pub struct ModelsArgs { pub command: ModelsCommands, } +impl ModelsArgs { + pub fn title(&self) -> &'static str { + match &self.command { + ModelsCommands::List => "Models", + ModelsCommands::Get(_) => "Model", + ModelsCommands::Versions(_) => "Model versions", + ModelsCommands::Pull(_) => "Model download", + ModelsCommands::Push(_) => "Model upload", + ModelsCommands::Promote(_) => "Model promotion", + ModelsCommands::Alias(AliasArgs { + command: AliasCommands::List(_), + }) => "Model aliases", + ModelsCommands::Alias(_) => "Model alias", + } + } +} + #[derive(Subcommand, Debug)] pub enum ModelsCommands { /// List models in the selected project. @@ -213,12 +230,20 @@ impl Render for ModelListResponse { model.version_count.to_string(), latest_version(model), alias_summary(&model.aliases), - model.created_at.clone(), + when(&model.created_at), ] })) - .total(self.total) .write(out) } + + fn summary(&self) -> Option { + Some(counted( + self.items.len(), + self.total as u64, + ["model", "models"], + "No models in this project yet.", + )) + } } impl Render for ModelResponse { @@ -232,10 +257,17 @@ impl Render for ModelResponse { .field("Versions", self.version_count) .field("Latest", latest_version(self)) .field("Aliases", alias_summary(&self.aliases)) - .field("Created at", &self.created_at) + .field("Created at", when(&self.created_at)) .field("Created by", &self.created_by.username) .write(out) } + + fn summary(&self) -> Option { + Some(match self.latest_version { + Some(version) => format!("Model '{}' is at v{version}.", self.name), + None => format!("Model '{}' has no versions yet.", self.name), + }) + } } impl Render for ModelVersionResponse { @@ -254,12 +286,13 @@ impl Render for ModelVersionResponse { .field("Size", self.size) .field("Digest", &self.digest) .field("Aliases", self.aliases.join(", ")) - .field("Created at", &self.created_at) + .field("Created at", when(&self.created_at)) .field("Created by", &self.created_by.username) .optional("Failure reason", self.failure_reason.as_ref()) .write(out)?; - writeln!(out)?; + out.heading("Files")?; Table::new(["REL PATH", "SIZE", "CHECKSUM"]) + .empty("No files.") .rows(self.manifest.files.iter().map(|file| { [ file.rel_path.clone(), @@ -268,8 +301,18 @@ impl Render for ModelVersionResponse { ] })) .write(out)?; - writeln!(out)?; - json_section(out, "Metadata", &self.metadata) + out.spacer()?; + Details::new() + .section("Metadata", &self.metadata) + .write(out) + } + + fn summary(&self) -> Option { + Some(format!( + "Version v{} is {}.", + self.version, + state_name(self.state) + )) } } @@ -299,12 +342,20 @@ impl Render for ModelVersionListResponse { source, version.size.to_string(), version.aliases.join(", "), - version.created_at.clone(), + when(&version.created_at), ] })) - .total(self.total) .write(out) } + + fn summary(&self) -> Option { + Some(counted( + self.items.len(), + self.total as u64, + ["version", "versions"], + "No versions yet.", + )) + } } impl Render for ModelAliasListResponse { @@ -315,9 +366,17 @@ impl Render for ModelAliasListResponse { .iter() .map(|alias| [alias.alias.clone(), format!("v{}", alias.version)]), ) - .total(self.total) .write(out) } + + fn summary(&self) -> Option { + Some(counted( + self.items.len(), + self.total as u64, + ["alias", "aliases"], + "No aliases yet.", + )) + } } fn latest_version(model: &ModelResponse) -> String { @@ -432,7 +491,6 @@ fn pull( project: &TracelProject, ) -> anyhow::Result { let (namespace, name) = (&project.owner, &project.name); - context.terminal().command_title("Model download"); validate_download_directory(args.directory.as_deref(), &args.model)?; let client = get_client_and_login_if_needed(context)?; let map_error = |error| { @@ -466,7 +524,7 @@ fn pull( }) .collect(); let files = download_files(&client, context.terminal(), &directory, &files, args.force)?; - context.terminal().finalize(&format!( + context.terminal().outro(&format!( "Downloaded '{}' v{} to {}.", args.model, version.version, @@ -487,7 +545,6 @@ fn push( project: &TracelProject, ) -> anyhow::Result { let (namespace, name) = (&project.owner, &project.name); - context.terminal().command_title("Model upload"); validate_auto_create(args.auto_create, args.description.as_deref())?; let files = collect_push_files(&args.directory, context)?; let client = get_client_and_login_if_needed(context)?; @@ -550,7 +607,7 @@ fn push( .get_model_version(namespace, name, &args.model_name, upload.version) .map_err(map_error)?; - context.terminal().finalize(&format!( + context.terminal().outro(&format!( "Uploaded model '{}' v{} to {namespace}/{name}.", args.model_name, upload.version )); @@ -577,7 +634,6 @@ fn promote( project: &TracelProject, ) -> anyhow::Result { let (namespace, name) = (&project.owner, &project.name); - context.terminal().command_title("Model promotion"); validate_auto_create(args.auto_create, args.description.as_deref())?; let client = get_client_and_login_if_needed(context)?; let artifacts = client @@ -639,7 +695,7 @@ fn promote( .get_model_version(namespace, name, &args.model, version.version) .map_err(map_error)?; } - context.terminal().finalize(&format!( + context.terminal().outro(&format!( "Promoted artifact '{}' to '{}' v{}.", artifact.name, args.model, version.version )); @@ -660,7 +716,6 @@ fn alias( })? .into()), AliasCommands::Set(args) => { - context.terminal().command_title("Model alias"); let alias = get_client_and_login_if_needed(context)? .set_model_alias( namespace, @@ -683,14 +738,13 @@ fn alias( }, ) })?; - context.terminal().finalize(&format!( + context.terminal().outro(&format!( "Alias '{}' of '{}' points to v{}.", alias.alias, args.model, alias.version )); Ok(AliasSet(alias).into()) } AliasCommands::Remove(args) => { - context.terminal().command_title("Model alias"); get_client_and_login_if_needed(context)? .remove_model_alias(namespace, name, &args.model, &args.alias) .map_err(|error| { @@ -704,7 +758,7 @@ fn alias( }, ) })?; - context.terminal().finalize(&format!( + context.terminal().outro(&format!( "Removed alias '{}' from '{}'.", args.alias, args.model )); diff --git a/crates/tracel-cli/src/commands/package.rs b/crates/tracel-cli/src/commands/package.rs index bc6dfe1..ac4583a 100644 --- a/crates/tracel-cli/src/commands/package.rs +++ b/crates/tracel-cli/src/commands/package.rs @@ -96,7 +96,6 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< for triple in &args.targets { target::parse_target(triple)?; } - context.terminal().command_title("Package project"); // 0. Require a workspace and a project that exists on the server. let workspace_info = require_cargo_workspace()?; @@ -439,7 +438,7 @@ fn upload( "This commit ({digest}) is already packaged (version {}).", response.id )); - context.terminal().finalize("Nothing to upload."); + context.terminal().outro("Nothing to upload."); return Ok(packaged(false)); }; @@ -471,8 +470,6 @@ fn upload( context .terminal() .print_success(&format!("New code version uploaded: {}", response.digest)); - context - .terminal() - .finalize("Project packaged successfully."); + context.terminal().outro("Project packaged successfully."); Ok(packaged(true)) } diff --git a/crates/tracel-cli/src/commands/project.rs b/crates/tracel-cli/src/commands/project.rs index e54d1cc..926d015 100644 --- a/crates/tracel-cli/src/commands/project.rs +++ b/crates/tracel-cli/src/commands/project.rs @@ -11,7 +11,7 @@ use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::project::ProjectSource; use crate::helpers::resolve_namespace_project; -use crate::ui::{Details, Human, Outcome, Render, Table}; +use crate::ui::{Details, Human, Outcome, Render, Table, counted, when}; #[derive(Args, Debug)] pub struct ProjectArgs { @@ -19,6 +19,15 @@ pub struct ProjectArgs { pub command: Option, } +impl ProjectArgs { + pub fn title(&self) -> &'static str { + match self.command { + None => "Project", + Some(ProjectCommands::List(_)) => "Projects", + } + } +} + #[derive(Subcommand, Debug)] pub enum ProjectCommands { /// List your projects and your organizations' projects. @@ -45,11 +54,20 @@ impl Render for Projects { format!("{}/{}", project.namespace_name, project.project_name), visibility_name(&project.visibility).into(), project.description.clone(), - project.created_at.clone(), + when(&project.created_at), ] })) .write(out) } + + fn summary(&self) -> Option { + Some(counted( + self.0.len(), + self.0.len() as u64, + ["project", "projects"], + "No projects yet. Create one with `tracel init`.", + )) + } } #[derive(Serialize)] @@ -73,6 +91,10 @@ impl Render for ProjectInfo { .field("Selected by", &self.source) .write(out) } + + fn summary(&self) -> Option { + Some(format!("Commands use {}/{}.", self.namespace, self.name)) + } } fn visibility_name(visibility: &Visibility) -> &'static str { diff --git a/crates/tracel-cli/src/commands/unlink.rs b/crates/tracel-cli/src/commands/unlink.rs index 4da0708..8ec05a1 100644 --- a/crates/tracel-cli/src/commands/unlink.rs +++ b/crates/tracel-cli/src/commands/unlink.rs @@ -30,7 +30,6 @@ impl Render for Unlinked {} pub fn handle_command(args: UnlinkArgs, context: CliContext) -> anyhow::Result { let project = require_linked_project()?; - context.terminal().command_title("Unlink"); if !args.yes { let confirmed = context.terminal().confirm( @@ -39,7 +38,7 @@ pub fn handle_command(args: UnlinkArgs, context: CliContext) -> anyhow::Result anyhow::Result anyhow::Result { ProjectContext::load_workspace_info(&manifest_path).map_err(explain_project_context_error) } -/// Whether `tracel init` should go ahead. Fails outside a Rust project, and stops -/// without error when the project is already linked and `force` is off. -pub fn can_initialize_project(context: &CliContext, force: bool) -> anyhow::Result { +/// Whether `tracel init` should go ahead: fails outside a Rust project, and is +/// false when the project is already linked and `force` is off. +pub fn can_initialize_project(force: bool) -> anyhow::Result { find_manifest()?; - - if is_tracel_project_linked() && !force { - context - .terminal() - .print("Project is already linked to Tracel Console."); - context - .terminal() - .print("Use --force flag to reinitialize."); - return Ok(false); - } - - Ok(true) + Ok(force || !is_tracel_project_linked()) } /// Validate that the linked project exists on Tracel Console server diff --git a/crates/tracel-cli/src/ui/human.rs b/crates/tracel-cli/src/ui/human.rs index cdbbe2e..9f45803 100644 --- a/crates/tracel-cli/src/ui/human.rs +++ b/crates/tracel-cli/src/ui/human.rs @@ -1,14 +1,21 @@ use std::fmt::Display; use std::io::{self, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; use serde::Serialize; +use serde_json::Value; + +/// The narrowest a shrinkable table column gets, ellipsis included. +const MIN_COLUMN_WIDTH: usize = 4; /// How text on stdout may look. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Style { pub color: bool, - /// The terminal's width in columns, when stdout is a terminal. + /// The columns text may use, when stdout is a terminal. pub width: Option, + /// Whether the text sits inside a cliclack frame, after its bar. + pub framed: bool, } impl Style { @@ -16,25 +23,45 @@ impl Style { Self { color: false, width: None, + framed: false, } } } -/// Stdout for a person: plain text when piped, styled and fitted on a terminal. +/// Stdout for a person. Inside a frame each line starts with the frame's bar, or with +/// a symbol as cliclack's own lines do; piped, it is plain text. pub struct Human<'a> { out: &'a mut dyn Write, style: Style, + line_start: &'a AtomicBool, + symbol: Option, + line_blank: bool, + last_blank: bool, } impl<'a> Human<'a> { - pub fn new(out: &'a mut dyn Write, style: Style) -> Self { - Self { out, style } + /// `line_start` carries over between the writes of one stream. + pub fn new(out: &'a mut dyn Write, style: Style, line_start: &'a AtomicBool) -> Self { + Self { + out, + style, + line_start, + symbol: None, + line_blank: true, + // A frame's intro ends with a bar of its own. + last_blank: true, + } } - /// Text without color or a width, as when stdout is piped. + /// Text without color, width or frame, as when stdout is piped. #[cfg(test)] pub fn plain(out: &'a mut dyn Write) -> Self { - Self::new(out, Style::plain()) + static LINE_START: AtomicBool = AtomicBool::new(true); + Self::new(out, Style::plain(), &LINE_START) + } + + pub fn framed(&self) -> bool { + self.style.framed } pub fn width(&self) -> Option { @@ -55,11 +82,69 @@ impl<'a> Human<'a> { pub fn dim(&self, text: impl Display) -> String { self.styled(text, console::Style::new().dim()) } + + /// One line of a card: in a frame, after a bullet and followed by a bar, as + /// cliclack logs a message. + pub fn item(&mut self, text: impl Display) -> io::Result<()> { + if self.framed() { + self.symbol = Some(self.styled(console::Emoji("●", "•"), console::Style::new().blue())); + writeln!(self, "{text}")?; + self.spacer() + } else { + writeln!(self, "{text}") + } + } + + /// A line that names what follows: a step in a frame, a title ending in a colon + /// otherwise. + pub fn heading(&mut self, text: impl Display) -> io::Result<()> { + if self.framed() { + self.symbol = + Some(self.styled(console::Emoji("◇", "o"), console::Style::new().green())); + writeln!(self, "{text}") + } else { + writeln!(self, "{text}:") + } + } + + /// An empty line of the frame's bar, unless the line before was one. + pub fn spacer(&mut self) -> io::Result<()> { + if self.framed() && !self.last_blank { + writeln!(self)?; + } + Ok(()) + } + + fn bar(&self) -> String { + self.styled( + console::Emoji("│", "|"), + console::Style::new().bright().black(), + ) + } } impl Write for Human<'_> { fn write(&mut self, buffer: &[u8]) -> io::Result { - self.out.write(buffer) + if !self.style.framed { + return self.out.write(buffer); + } + for line in buffer.split_inclusive(|&byte| byte == b'\n') { + if self.line_start.load(Ordering::Relaxed) { + let lead = self.symbol.take().unwrap_or_else(|| self.bar()); + write!(self.out, "{lead} ")?; + self.line_blank = true; + } + let ends_line = line.ends_with(b"\n"); + if line.len() > usize::from(ends_line) { + self.line_blank = false; + } + self.out.write_all(line)?; + if ends_line { + self.last_blank = self.line_blank; + } + self.line_start.store(ends_line, Ordering::Relaxed); + } + Ok(buffer.len()) } fn flush(&mut self) -> io::Result<()> { @@ -68,12 +153,13 @@ impl Write for Human<'_> { } /// Columns aligned under their headers, one row per line. On a terminal too narrow -/// for it, the columns marked with `shrink` are shortened to fit. +/// for it, the columns marked with `shrink` are shortened to fit. Without rows it +/// writes nothing, or its `empty` text: a list says it has none in its summary. pub struct Table { headers: [&'static str; N], rows: Vec<[String; N]>, shrinkable: [bool; N], - total: Option, + empty: Option<&'static str>, } impl Table { @@ -82,10 +168,16 @@ impl Table { headers, rows: Vec::new(), shrinkable: [false; N], - total: None, + empty: None, } } + /// Stands in for the rows when there are none, for a table inside other text. + pub fn empty(mut self, text: &'static str) -> Self { + self.empty = Some(text); + self + } + pub fn rows(mut self, rows: impl IntoIterator) -> Self { self.rows.extend(rows); self @@ -103,13 +195,16 @@ impl Table { self } - /// Ends the table with "Showing N of TOTAL" when it holds fewer rows than exist. - pub fn total(mut self, total: impl TryInto) -> Self { - self.total = total.try_into().ok(); - self - } - pub fn write(&self, out: &mut Human<'_>) -> io::Result<()> { + if self.rows.is_empty() { + return match self.empty { + Some(text) => { + let text = out.dim(text); + writeln!(out, "{text}") + } + None => Ok(()), + }; + } let rows: Vec<[String; N]> = self .rows .iter() @@ -126,40 +221,26 @@ impl Table { self.fit(&mut widths, available); } - let header = self.headers.map(|header| out.bold(header)); + let header = + std::array::from_fn(|column| out.bold(fitted(self.headers[column], widths[column]))); self.write_line(out, &header, &widths)?; for row in &rows { - let cells = std::array::from_fn(|column| { - let cell = &row[column]; - if cell == "-" { - out.dim(cell) - } else if console::measure_text_width(cell) > widths[column] { - shorten(cell, widths[column]) - } else { - cell.clone() - } + let cells = std::array::from_fn(|column| match row[column].as_str() { + "-" => out.dim("-"), + cell => fitted(cell, widths[column]), }); self.write_line(out, &cells, &widths)?; } - let shown = self.rows.len() as u64; - match self.total { - Some(total) if shown < total => { - let footer = out.dim(format!("Showing {shown} of {total}")); - writeln!(out, "{footer}") - } - _ => Ok(()), - } + Ok(()) } - /// Narrows the widest shrinkable columns, never below their header, until the - /// table fits in `available` columns or nothing can shrink. + /// Narrows the widest shrinkable columns until the table fits in `available` + /// columns or nothing can shrink further. fn fit(&self, widths: &mut [usize; N], available: usize) { let gaps = 2 * N.saturating_sub(1); while widths.iter().sum::() + gaps > available { let widest = (0..N) - .filter(|&column| { - self.shrinkable[column] && widths[column] > self.headers[column].len() - }) + .filter(|&column| self.shrinkable[column] && widths[column] > MIN_COLUMN_WIDTH) .max_by_key(|&column| widths[column]); match widest { Some(column) => widths[column] -= 1, @@ -185,10 +266,16 @@ impl Table { } } -/// Labelled values, one per line, with the values aligned. +/// Labelled values: a card of bullets in a frame, aligned lines otherwise. Sections +/// list the fields of a JSON value under a heading. #[derive(Default)] pub struct Details { - fields: Vec<(&'static str, String)>, + entries: Vec, +} + +enum Entry { + Field(&'static str, String), + Section(&'static str, Vec<(String, String)>), } impl Details { @@ -197,7 +284,7 @@ impl Details { } pub fn field(mut self, label: &'static str, value: impl Display) -> Self { - self.fields.push((label, value.to_string())); + self.entries.push(Entry::Field(label, value.to_string())); self } @@ -208,36 +295,104 @@ impl Details { } } + /// The fields of `value` under `title`, nested keys joined with dots. An empty or + /// null value adds nothing. + pub fn section(mut self, title: &'static str, value: &impl Serialize) -> Self { + let value = serde_json::to_value(value).unwrap_or(Value::Null); + let mut fields = Vec::new(); + match &value { + Value::Object(_) => flatten("", &value, &mut fields), + Value::Null => {} + other => fields.push(("value".into(), other.to_string())), + } + if !fields.is_empty() { + self.entries.push(Entry::Section(title, fields)); + } + self + } + pub fn write(&self, out: &mut Human<'_>) -> io::Result<()> { let width = self - .fields + .entries .iter() - .map(|(label, _)| label.len()) + .filter_map(|entry| match entry { + Entry::Field(label, _) => Some(label.len()), + Entry::Section(..) => None, + }) .max() .unwrap_or(0); - for (label, value) in &self.fields { - let padding = " ".repeat(width - label.len()); - let label = out.dim(format!("{label}:")); - let value = match cell_text(value) { - text if text == "-" => out.dim(text), - text => text, - }; - writeln!(out, "{label}{padding} {value}")?; + for entry in &self.entries { + match entry { + Entry::Field(label, value) => { + let value = shown(out, value); + if out.framed() { + out.item(format!("{label}: {value}"))?; + } else { + let padding = " ".repeat(width - label.len()); + let label = out.dim(format!("{label}:")); + writeln!(out, "{label}{padding} {value}")?; + } + } + Entry::Section(title, fields) => { + out.heading(title)?; + let width = fields.iter().map(|(key, _)| key.len()).max().unwrap_or(0); + let indent = if out.framed() { "" } else { " " }; + for (key, value) in fields { + let padding = " ".repeat(width - key.len()); + let (key, value) = (out.dim(key), shown(out, value)); + writeln!(out, "{indent}{key}{padding} {value}")?; + } + out.spacer()?; + } + } } Ok(()) } } -/// A titled JSON value, pretty-printed below its title. -pub fn json_section(out: &mut Human<'_>, title: &str, value: &impl Serialize) -> io::Result<()> { - let title = out.dim(format!("{title}:")); - writeln!(out, "{title}")?; - serde_json::to_writer_pretty(&mut *out, value)?; - writeln!(out) +/// The summary of a list: "Showing 10 of 16 experiments." when some are left out, +/// "16 experiments." when not, and `none` when there are none. +pub fn counted(shown: usize, total: u64, [one, many]: [&str; 2], none: &str) -> String { + let shown = shown as u64; + match total.max(shown) { + 0 => none.to_string(), + total if shown < total => format!("Showing {shown} of {total} {many}."), + 1 => format!("1 {one}."), + total => format!("{total} {many}."), + } +} + +fn flatten(prefix: &str, value: &Value, fields: &mut Vec<(String, String)>) { + match value { + Value::Object(map) if !map.is_empty() => { + for (key, value) in map { + let key = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + flatten(&key, value, fields); + } + } + Value::Object(_) if prefix.is_empty() => {} + Value::String(text) => fields.push((prefix.into(), text.clone())), + other => fields.push((prefix.into(), other.to_string())), + } +} + +/// A value on one line, with a dim "-" standing in for nothing. +fn shown(out: &Human<'_>, value: &str) -> String { + match cell_text(value) { + text if text == "-" => out.dim(text), + text => text, + } } -/// `text` cut to `width` columns, ending in an ellipsis. -fn shorten(text: &str, width: usize) -> String { +/// `text` cut to `width` columns with an ellipsis, when it is wider. +fn fitted(text: &str, width: usize) -> String { + if console::measure_text_width(text) <= width { + return text.to_string(); + } let kept = console::truncate_str(text, width.saturating_sub(1), ""); format!("{}…", kept.trim_end()) } @@ -253,8 +408,9 @@ mod tests { use super::*; fn text(style: Style, write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> String { + let line_start = AtomicBool::new(true); let mut out = Vec::new(); - write(&mut Human::new(&mut out, style)).unwrap(); + write(&mut Human::new(&mut out, style, &line_start)).unwrap(); String::from_utf8(out).unwrap() } @@ -262,6 +418,29 @@ mod tests { text(Style::plain(), write) } + fn framed(width: Option) -> Style { + Style { + color: false, + width, + framed: true, + } + } + + /// `text` with the frame's glyphs as this terminal draws them. + fn drawn(text: &str) -> String { + text.replace('│', &console::Emoji("│", "|").to_string()) + .replace('●', &console::Emoji("●", "•").to_string()) + .replace('◇', &console::Emoji("◇", "o").to_string()) + } + + fn narrow(width: usize) -> Style { + Style { + color: false, + width: Some(width), + framed: false, + } + } + fn table() -> Table<3> { Table::new(["NAME", "DESCRIPTION", "ID"]).rows([ ["weights".into(), "two\nlines".into(), "1".into()], @@ -280,18 +459,12 @@ mod tests { } #[test] - fn table_counts_rows_only_when_some_are_left_out() { - let rows = || [["a".to_string()], ["b".to_string()]]; - for (total, footer) in [(2, ""), (3, "Showing 2 of 3\n")] { - let table = Table::new(["NAME"]).rows(rows()).total(total); - assert_eq!( - plain(|out| table.write(out)), - format!("NAME\na\nb\n{footer}") - ); - } + fn an_empty_table_writes_its_empty_text_or_nothing() { + let empty = || Table::new(["NAME"]); + assert_eq!(plain(|out| empty().write(out)), ""); assert_eq!( - plain(|out| Table::new(["NAME"]).total(0_usize).write(out)), - "NAME\n" + plain(|out| empty().empty("No files.").write(out)), + "No files.\n" ); } @@ -304,26 +477,36 @@ mod tests { "0123456789".into(), ]]) }; - let narrow = Style { - color: false, - width: Some(36), - }; assert_eq!( - text(narrow, |out| table().shrink("DESCRIPTION").write(out)), + text(narrow(36), |out| table().shrink("DESCRIPTION").write(out)), "NAME DESCRIPTION ID\n\ weights a rather long… 0123456789\n" ); assert_eq!( - text(narrow, |out| table().write(out)), + text(narrow(36), |out| table().write(out)), plain(|out| table().write(out)) ); - let wide = Style { - color: false, - width: Some(200), - }; + } + + #[test] + fn a_shrinking_column_can_cut_its_header() { assert_eq!( - text(wide, |out| table().shrink("DESCRIPTION").write(out)), - plain(|out| table().write(out)) + text(narrow(18), |out| table().shrink("DESCRIPTION").write(out)), + "NAME DESC… ID\n\ + weights two… 1\n\ + w - 22\n" + ); + } + + #[test] + fn a_framed_table_sits_after_the_bar() { + assert_eq!( + text(framed(None), |out| table().write(out)), + drawn( + "│ NAME DESCRIPTION ID\n\ + │ weights two lines 1\n\ + │ w - 22\n" + ) ); } @@ -332,30 +515,75 @@ mod tests { let colored = Style { color: true, width: None, + framed: false, }; assert!(text(colored, |out| table().write(out)).contains("\u{1b}[")); assert!(!plain(|out| table().write(out)).contains("\u{1b}[")); } - #[test] - fn details_align_values_and_mark_empty_ones() { - let details = Details::new() + fn details() -> Details { + Details::new() .field("Name", "weights") .optional("Description", None::<&str>) - .field("Created at", "today"); + .section( + "Config", + &serde_json::json!({"lr": 0.1, "schedule": {"warmup": 2}, "tags": ["a"]}), + ) + .section("Attributes", &serde_json::json!({})) + } + + #[test] + fn details_align_values_and_list_json_fields() { assert_eq!( - plain(|out| details.write(out)), + plain(|out| details().write(out)), "Name: weights\n\ Description: -\n\ - Created at: today\n" + Config:\n \ + lr 0.1\n \ + schedule.warmup 2\n \ + tags [\"a\"]\n" + ); + } + + #[test] + fn framed_details_are_a_card() { + assert_eq!( + text(framed(None), |out| details().write(out)), + drawn( + "● Name: weights\n\ + │ \n\ + ● Description: -\n\ + │ \n\ + ◇ Config\n\ + │ lr 0.1\n\ + │ schedule.warmup 2\n\ + │ tags [\"a\"]\n\ + │ \n" + ) ); } #[test] - fn json_sections_are_pretty_printed() { + fn a_stream_keeps_one_bar_per_line_across_writes() { + let line_start = AtomicBool::new(true); + let mut out = Vec::new(); + for piece in ["par", "tial\nnext", "\n"] { + Human::new(&mut out, framed(None), &line_start) + .write_all(piece.as_bytes()) + .unwrap(); + } assert_eq!( - plain(|out| json_section(out, "Metadata", &serde_json::json!({"lr": 0.1}))), - "Metadata:\n{\n \"lr\": 0.1\n}\n" + String::from_utf8(out).unwrap(), + drawn("│ partial\n│ next\n") ); } + + #[test] + fn counts_mention_what_was_left_out() { + let count = |shown, total| counted(shown, total, ["model", "models"], "No models."); + assert_eq!(count(0, 0), "No models."); + assert_eq!(count(1, 1), "1 model."); + assert_eq!(count(3, 3), "3 models."); + assert_eq!(count(10, 16), "Showing 10 of 16 models."); + } } diff --git a/crates/tracel-cli/src/ui/mod.rs b/crates/tracel-cli/src/ui/mod.rs index 9c9589a..118b234 100644 --- a/crates/tracel-cli/src/ui/mod.rs +++ b/crates/tracel-cli/src/ui/mod.rs @@ -1,7 +1,7 @@ //! What the person running a command sees. Stdout carries the command's result and //! nothing else, so it can be piped or captured: an envelope with JSON output, or //! the text of its `Render` impl. Stderr carries progress, prompts and diagnostics -//! through `Terminal`. Both draw on one screen when they share a terminal. +//! through `Terminal`. On a terminal both draw in one cliclack frame per command. mod format; mod human; @@ -13,14 +13,18 @@ mod time; use std::sync::Arc; pub use format::{Format, FormatArg}; -pub use human::{Details, Human, Table, json_section}; +pub use human::{Details, Human, Table, counted}; pub use stderr::Terminal; pub use stdout::{Outcome, Output, Render, StdoutClosed}; -pub use time::Timestamp; +pub use time::{Timestamp, when}; -/// Stdout and stderr for one command, drawing on the same screen. -pub fn channels(format: Format, no_input: bool) -> (Output, Terminal) { +/// Stdout and stderr for one command, drawing on the same screen. With a `title`, a +/// terminal shows the command in a frame under it, from now until the command ends. +pub fn channels(format: Format, no_input: bool, title: Option<&str>) -> (Output, Terminal) { let screen = Arc::new(screen::Screen::new(format)); + if let Some(title) = title { + screen.intro(title); + } ( Output::new(Arc::clone(&screen)), Terminal::new(screen, no_input), diff --git a/crates/tracel-cli/src/ui/screen.rs b/crates/tracel-cli/src/ui/screen.rs index 8f61992..f56f11d 100644 --- a/crates/tracel-cli/src/ui/screen.rs +++ b/crates/tracel-cli/src/ui/screen.rs @@ -1,4 +1,4 @@ -use std::io::{self, IsTerminal, Write}; +use std::io::{self, IsTerminal}; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -7,6 +7,9 @@ use cliclack::{MultiProgress, ProgressBar}; use super::Format; use super::human::Style; +/// The width cliclack's bar and the space after it take at the start of a line. +const GUTTER_WIDTH: usize = 3; + /// The terminal a command draws on. Stderr draws cliclack frames and widgets on it /// when it is a styled terminal, and stdout shares it when stdout is a terminal too. /// Only one thing draws at a time: anything new settles the live widget first. @@ -34,6 +37,12 @@ pub enum Ending { Failed, } +/// How a frame ends: a green badge, a bare corner, or a red badge. +pub enum Outro<'a> { + Done(Option<&'a str>), + Cancelled(&'a str), +} + impl Screen { pub fn new(format: Format) -> Self { Self { @@ -61,16 +70,26 @@ impl Screen { self.styled && self.stdout_is_terminal } - /// How text on stdout may look: colors and width only on a terminal. + /// How text on stdout may look: colors and width only on a terminal, and drawn + /// inside the frame when one is open there. pub fn text_style(&self) -> Style { if !self.stdout_is_terminal { return Style::plain(); } + let framed = self.shares_stdout() && self.frame_open(); Style { color: console::colors_enabled(), width: console::Term::stdout() .size_checked() - .map(|(_, columns)| usize::from(columns)), + .map(|(_, columns)| usize::from(columns)) + .map(|columns| { + if framed { + columns.saturating_sub(GUTTER_WIDTH) + } else { + columns + } + }), + framed, } } @@ -78,8 +97,40 @@ impl Screen { self.frame.load(Ordering::Relaxed) } - pub fn set_frame(&self, open: bool) { - self.frame.store(open, Ordering::Relaxed); + /// Whether the next text on stdout starts a line, across the writes of a stream. + pub fn line_start(&self) -> &AtomicBool { + &self.line_start + } + + /// Opens the frame a command draws in, under a green title badge. + pub fn intro(&self, title: &str) { + if !self.styled { + return; + } + self.settle(Ending::Done); + let badge = console::style(format!(" ▶ {title} ")).black().on_green(); + let _ = cliclack::intro(badge); + self.line_start.store(true, Ordering::Relaxed); + self.frame.store(true, Ordering::Relaxed); + } + + /// Ends the frame, open or not, so a command always finishes on its badge. + pub fn outro(&self, outro: Outro<'_>) { + if !self.styled { + return; + } + self.settle(Ending::Done); + let _ = match outro { + Outro::Done(Some(message)) => { + cliclack::outro(console::style(format!(" {message} ")).black().on_green()) + } + Outro::Done(None) => cliclack::outro(""), + Outro::Cancelled(message) => { + cliclack::outro_cancel(console::style(format!(" {message} ")).black().on_red()) + } + }; + self.line_start.store(true, Ordering::Relaxed); + self.frame.store(false, Ordering::Relaxed); } pub fn next_id(&self) -> u64 { @@ -140,16 +191,12 @@ impl Screen { } } - /// Stdout for text: inside an open frame, each line starts with the frame's bar. - pub fn stdout(&self) -> impl Write + '_ { + /// Stdout, once nothing else draws where it lands. + pub fn stdout(&self) -> io::StdoutLock<'static> { if self.shares_stdout() { self.settle(Ending::Done); } - Gutter { - out: io::stdout().lock(), - bar: (self.shares_stdout() && self.frame_open()).then(gutter), - line_start: &self.line_start, - } + io::stdout().lock() } fn lock(&self) -> std::sync::MutexGuard<'_, Option> { @@ -178,85 +225,3 @@ impl Live { } } } - -/// The bar cliclack draws down the left of a frame. -fn gutter() -> String { - let bar = console::style(console::Emoji("│", "|")).bright().black(); - format!("{} ", bar.for_stdout()) -} - -/// Writes through to `out`, starting each line with `bar` when there is one. -/// `line_start` persists across writes, since a stream arrives in pieces. -struct Gutter<'a, W> { - out: W, - bar: Option, - line_start: &'a AtomicBool, -} - -impl Write for Gutter<'_, W> { - fn write(&mut self, buffer: &[u8]) -> io::Result { - let Some(bar) = &self.bar else { - return self.out.write(buffer); - }; - for line in buffer.split_inclusive(|&byte| byte == b'\n') { - if self.line_start.load(Ordering::Relaxed) { - self.out.write_all(bar.as_bytes())?; - } - self.out.write_all(line)?; - self.line_start - .store(line.ends_with(b"\n"), Ordering::Relaxed); - } - Ok(buffer.len()) - } - - fn flush(&mut self) -> io::Result<()> { - self.out.flush() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn through_gutter(pieces: &[&str]) -> String { - let line_start = AtomicBool::new(true); - let mut written = Vec::new(); - for piece in pieces { - let mut gutter = Gutter { - out: &mut written, - bar: Some("| ".into()), - line_start: &line_start, - }; - gutter.write_all(piece.as_bytes()).unwrap(); - } - String::from_utf8(written).unwrap() - } - - #[test] - fn every_line_in_a_frame_starts_with_its_bar() { - assert_eq!(through_gutter(&["a\nb\n"]), "| a\n| b\n"); - assert_eq!(through_gutter(&["\n"]), "| \n"); - } - - #[test] - fn lines_split_across_writes_get_one_bar() { - assert_eq!( - through_gutter(&["par", "tial\nnext", "\n"]), - "| partial\n| next\n" - ); - } - - #[test] - fn without_a_frame_text_passes_through() { - let line_start = AtomicBool::new(true); - let mut written = Vec::new(); - Gutter { - out: &mut written, - bar: None, - line_start: &line_start, - } - .write_all(b"a\nb") - .unwrap(); - assert_eq!(written, b"a\nb"); - } -} diff --git a/crates/tracel-cli/src/ui/stderr.rs b/crates/tracel-cli/src/ui/stderr.rs index 8e6a496..b5bbf46 100644 --- a/crates/tracel-cli/src/ui/stderr.rs +++ b/crates/tracel-cli/src/ui/stderr.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use cliclack::ProgressBar; use super::Format; -use super::screen::{Ending, Screen}; +use super::screen::{Ending, Outro, Screen}; use crate::error::{CliError, ErrorKind, ErrorReport}; /// Stderr, for the person running a command: what it is doing, its progress, and @@ -96,27 +96,24 @@ impl Terminal { } /// Reports a failed command: the message and hint here, the envelope on stdout - /// with JSON output. An open frame ends with the hint, or with the error itself. + /// with JSON output. On a terminal the frame ends on a red badge: the hint after + /// the error, or the error itself. pub fn error(&self, report: &ErrorReport<'_>) { self.screen.settle(Ending::Failed); if !self.is_human() { self.line(format_args!("error: {}", one_line(&report.message))); - } else if self.is_styled() && self.screen.frame_open() { + } else if self.is_styled() { match report.hint { Some(hint) => { - self.draw(|| { - cliclack::log::error(&report.message)?; - cliclack::outro_cancel(hint) - }); - self.screen.set_frame(false); + self.draw(|| cliclack::log::error(&report.message)); + self.screen.outro(Outro::Cancelled(hint)); } - None => self.cancel_finalize(&report.message), + None => self.screen.outro(Outro::Cancelled(&report.message)), } } else { - let label = console::style("error:").for_stderr().red().bold(); - let _ = writeln!(io::stderr(), "{label} {}", report.message); + self.line(format_args!("error: {}", report.message)); if let Some(hint) = report.hint { - let _ = writeln!(io::stderr(), "{hint}"); + self.line(hint); } } } @@ -240,32 +237,21 @@ impl Terminal { .map_err(anyhow::Error::from) } - /// Opens the frame that an action's progress is drawn in. - pub fn command_title(&self, title: &str) { - if self.is_styled() { - let title = console::style(format!(" ▶ {title} ")).black().on_green(); - self.draw(|| cliclack::intro(title)); - self.screen.set_frame(true); - } - } - - pub fn finalize(&self, msg: &str) { - if self.is_styled() { - let message = console::style(format!(" {msg} ")).black().on_green(); - self.draw(|| cliclack::outro(message)); - self.screen.set_frame(false); + /// Ends the command's frame on a green badge, for an action that did its work. + pub fn outro(&self, message: &str) { + if self.is_styled() && self.screen.frame_open() { + self.screen.outro(Outro::Done(Some(message))); } else { - self.print_success(msg); + self.print_success(message); } } - pub fn cancel_finalize(&self, msg: &str) { - if self.is_styled() { - let message = console::style(format!(" {msg} ")).black().on_red(); - self.draw(|| cliclack::outro_cancel(message)); - self.screen.set_frame(false); + /// Ends the command's frame on a red badge, for an action that stopped short. + pub fn outro_cancel(&self, message: &str) { + if self.is_styled() && self.screen.frame_open() { + self.screen.outro(Outro::Cancelled(message)); } else { - self.print_err(msg); + self.print_err(message); } } diff --git a/crates/tracel-cli/src/ui/stdout.rs b/crates/tracel-cli/src/ui/stdout.rs index dc9d4e3..445836d 100644 --- a/crates/tracel-cli/src/ui/stdout.rs +++ b/crates/tracel-cli/src/ui/stdout.rs @@ -1,11 +1,12 @@ use std::io::{self, Write}; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use serde::Serialize; use super::Format; use super::human::{Human, Style}; -use super::screen::Screen; +use super::screen::{Outro, Screen}; use crate::error::ErrorReport; /// A command result: serialized for JSON output, written as text for people. @@ -16,6 +17,12 @@ pub trait Render: Serialize { let _ = out; Ok(()) } + + /// One line that sums the result up: the badge that closes the frame on a + /// terminal, the last line of the text otherwise. + fn summary(&self) -> Option { + None + } } /// What a command leaves for stdout when it finishes. @@ -37,6 +44,7 @@ impl From for Outcome { trait Report { fn write_json(&self, out: &mut dyn Write) -> serde_json::Result<()>; fn write_text(&self, out: &mut Human<'_>) -> io::Result<()>; + fn summary(&self) -> Option; } impl Report for T { @@ -53,6 +61,10 @@ impl Report for T { fn write_text(&self, out: &mut Human<'_>) -> io::Result<()> { self.render(out) } + + fn summary(&self) -> Option { + Render::summary(self) + } } #[derive(Serialize)] @@ -83,19 +95,31 @@ impl Output { Self { screen } } - /// Writes the result of a finished command. + /// Writes the result of a finished command, and closes the frame it was drawn in + /// with its summary. pub fn finish(&self, outcome: Outcome) -> anyhow::Result<()> { let format = self.screen.format(); let style = self.screen.text_style(); - self.write_stdout(|out| finish(format, style, outcome, out)) + let framed = self.screen.frame_open(); + let summary = outcome.0.as_ref().and_then(|report| report.summary()); + let line_start = AtomicBool::new(true); + self.write_stdout(|out| { + let summary = if framed { None } else { summary.as_deref() }; + finish(format, style, &line_start, outcome, summary, out) + })?; + if framed { + self.screen.outro(Outro::Done(summary.as_deref())); + } + Ok(()) } /// Writes one event of a stream as it happens: an NDJSON line, or its text. pub fn event(&self, event: &impl Render) -> anyhow::Result<()> { let format = self.screen.format(); let style = self.screen.text_style(); + let line_start = self.screen.line_start(); self.write_stdout(|out| { - write_event(format, style, event, out)?; + write_event(format, style, line_start, event, out)?; out.flush() }) } @@ -118,12 +142,31 @@ impl Output { } } -fn finish(format: Format, style: Style, outcome: Outcome, out: &mut dyn Write) -> io::Result<()> { +/// `summary` is the result's last line, when no frame shows it. +fn finish( + format: Format, + style: Style, + line_start: &AtomicBool, + outcome: Outcome, + summary: Option<&str>, + out: &mut dyn Write, +) -> io::Result<()> { let Some(report) = outcome.0 else { return Ok(()); }; match format { - Format::Human => report.write_text(&mut Human::new(out, style)), + Format::Human => { + let mut out = Human::new(out, style, line_start); + report.write_text(&mut out)?; + out.spacer()?; + match summary { + Some(summary) => { + let summary = out.dim(summary); + writeln!(out, "{summary}") + } + None => Ok(()), + } + } Format::Json => { report.write_json(out)?; writeln!(out) @@ -134,11 +177,12 @@ fn finish(format: Format, style: Style, outcome: Outcome, out: &mut dyn Write) - fn write_event( format: Format, style: Style, + line_start: &AtomicBool, event: &impl Render, out: &mut dyn Write, ) -> io::Result<()> { match format { - Format::Human => event.render(&mut Human::new(out, style)), + Format::Human => event.render(&mut Human::new(out, style, line_start)), Format::Json => { serde_json::to_writer(&mut *out, event)?; writeln!(out) @@ -171,6 +215,10 @@ mod tests { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { writeln!(out, "Hello, {}.", self.name) } + + fn summary(&self) -> Option { + Some("Greeted.".into()) + } } #[derive(Serialize)] @@ -184,42 +232,52 @@ mod tests { String::from_utf8(out).unwrap() } + fn finished(format: Format, outcome: Outcome, summary: Option<&str>) -> String { + let line_start = AtomicBool::new(true); + written(|out| finish(format, Style::plain(), &line_start, outcome, summary, out)) + } + #[test] fn results_are_an_envelope_or_their_text() { let greeting = || Outcome::from(Greeting { name: "Ada" }); assert_eq!( - written(|out| finish(Format::Json, Style::plain(), greeting(), out)), + finished(Format::Json, greeting(), None), "{\"ok\":true,\"data\":{\"name\":\"Ada\"}}\n" ); + assert_eq!(finished(Format::Human, greeting(), None), "Hello, Ada.\n"); + assert_eq!(finished(Format::Human, Done {}.into(), None), ""); + } + + #[test] + fn without_a_frame_the_summary_ends_the_text() { + let greeting = || Outcome::from(Greeting { name: "Ada" }); assert_eq!( - written(|out| finish(Format::Human, Style::plain(), greeting(), out)), - "Hello, Ada.\n" + finished(Format::Human, greeting(), Some("Greeted.")), + "Hello, Ada.\nGreeted.\n" ); assert_eq!( - written(|out| finish(Format::Human, Style::plain(), Done {}.into(), out)), - "" + finished(Format::Json, greeting(), Some("Greeted.")), + "{\"ok\":true,\"data\":{\"name\":\"Ada\"}}\n" ); } #[test] fn streamed_output_is_not_followed_by_an_envelope() { for format in [Format::Human, Format::Json] { - assert_eq!( - written(|out| finish(format, Style::plain(), Outcome::streamed(), out)), - "" - ); + assert_eq!(finished(format, Outcome::streamed(), None), ""); } } #[test] fn events_are_bare_json_lines_or_their_text() { let event = Greeting { name: "Ada" }; + let line_start = AtomicBool::new(true); assert_eq!( - written(|out| write_event(Format::Json, Style::plain(), &event, out)), + written(|out| write_event(Format::Json, Style::plain(), &line_start, &event, out)), "{\"name\":\"Ada\"}\n" ); assert_eq!( - written(|out| write_event(Format::Human, Style::plain(), &event, out)), + written(|out| write_event(Format::Human, Style::plain(), &line_start, &event, out)), "Hello, Ada.\n" ); } diff --git a/crates/tracel-cli/src/ui/time.rs b/crates/tracel-cli/src/ui/time.rs index 95d6848..05b3ddd 100644 --- a/crates/tracel-cli/src/ui/time.rs +++ b/crates/tracel-cli/src/ui/time.rs @@ -1,7 +1,7 @@ use std::fmt::{self, Display}; use std::time::SystemTime; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Local, TimeZone, Utc}; use serde::{Serialize, Serializer}; /// A point in time, written in RFC 3339 in UTC. @@ -20,12 +20,43 @@ impl Serialize for Timestamp { } } +/// A server's RFC 3339 time as a person reads it: local, to the minute. Text that is +/// not such a time is kept as it is. +pub fn when(text: &str) -> String { + when_in(text, &Local) +} + +fn when_in(text: &str, zone: &Tz) -> String +where + Tz::Offset: Display, +{ + match DateTime::parse_from_rfc3339(text) { + Ok(time) => time + .with_timezone(zone) + .format("%Y-%m-%d %H:%M") + .to_string(), + Err(_) => text.to_string(), + } +} + #[cfg(test)] mod tests { use std::time::{Duration, UNIX_EPOCH}; use super::*; + #[test] + fn server_times_read_in_the_local_zone() { + let zone = chrono::FixedOffset::west_opt(4 * 3600).unwrap(); + assert_eq!( + when_in("2026-10-09T20:21:06.075Z", &zone), + "2026-10-09 16:21" + ); + assert_eq!(when_in("2026-10-09T20:21:06Z", &Utc), "2026-10-09 20:21"); + assert_eq!(when_in("yesterday", &Utc), "yesterday"); + assert_eq!(when_in("", &Utc), ""); + } + #[test] fn timestamps_are_rfc_3339_in_utc() { let time = Timestamp(UNIX_EPOCH + Duration::from_secs(1_900_000_000)); From 2ba94a82e683d890e66b8cd88252f1eb840e2822 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 13 Oct 2026 09:56:40 -0400 Subject: [PATCH 17/32] refactor: results on a terminal draw through cliclack When stdout and stderr are the same terminal, a command's result is drawn with cliclack's own log lines inside its frame: a line per card field, a step per heading with the lines under it, and a remark per table or block of text. Everywhere else results are plain text on stdout, with no colors or symbols, ending with their summary line also when stderr shows a frame. Streamed output stays raw between cliclack's lines. JSON is unchanged. Human keeps its builder API and drops the hand-drawn bar, bullets and headings; commands lose their framed branches. --- README.md | 10 +- crates/tracel-cli/src/commands/experiments.rs | 8 +- crates/tracel-cli/src/commands/models.rs | 1 - crates/tracel-cli/src/ui/human.rs | 493 ++++++++++-------- crates/tracel-cli/src/ui/mod.rs | 9 +- crates/tracel-cli/src/ui/screen.rs | 71 ++- crates/tracel-cli/src/ui/stderr.rs | 18 +- crates/tracel-cli/src/ui/stdout.rs | 112 ++-- 8 files changed, 394 insertions(+), 328 deletions(-) diff --git a/README.md b/README.md index b79f226..b181894 100644 --- a/README.md +++ b/README.md @@ -83,10 +83,12 @@ and errors go to stderr. Commands that change something, such as `init`, nothing on stdout. `auth token` prints the bare token unless JSON is requested, even when stdout is redirected. -On a terminal, each command draws in one frame: a title, its progress and result, -and a closing line that sums it up. Tables shorten long descriptions to fit the -width and show times in your time zone. Redirected output stays plain text, is -never shortened, and ends with that summary line. +On a terminal, each command draws everything in one frame: a title, its progress +and result, and a closing line that sums it up. Tables shorten long descriptions to +fit the width and show times in your time zone. What a command streams, such as +`experiments logs --follow`, is written as it arrives, without the frame's border. +Redirected output is plain text without colors or symbols, is never shortened, and +ends with that summary line. | Error code | Exit code | | --- | --- | diff --git a/crates/tracel-cli/src/commands/experiments.rs b/crates/tracel-cli/src/commands/experiments.rs index 31a3d63..9591ffe 100644 --- a/crates/tracel-cli/src/commands/experiments.rs +++ b/crates/tracel-cli/src/commands/experiments.rs @@ -268,11 +268,7 @@ impl Render for MetricMetadataResponse { .empty("No metrics recorded.") .rows(self.metric_types.iter().map(|name| [name.clone()])) .write(out)?; - if out.framed() { - out.spacer()?; - } else { - writeln!(out)?; - } + writeln!(out)?; Table::new(["GROUP"]) .empty("No metric groups.") .rows(self.groups.iter().map(|name| [name.clone()])) @@ -669,7 +665,7 @@ mod tests { json!({"type": "end", "running": false}) ); let mut text = Vec::new(); - let mut out = Human::plain(&mut text); + let mut out = Human::plain(&mut text, None); LogEvent::Log(&item).render(&mut out).unwrap(); LogEvent::End { running: false }.render(&mut out).unwrap(); assert_eq!( diff --git a/crates/tracel-cli/src/commands/models.rs b/crates/tracel-cli/src/commands/models.rs index 3517d30..bcc1279 100644 --- a/crates/tracel-cli/src/commands/models.rs +++ b/crates/tracel-cli/src/commands/models.rs @@ -301,7 +301,6 @@ impl Render for ModelVersionResponse { ] })) .write(out)?; - out.spacer()?; Details::new() .section("Metadata", &self.metadata) .write(out) diff --git a/crates/tracel-cli/src/ui/human.rs b/crates/tracel-cli/src/ui/human.rs index 9f45803..2990f6e 100644 --- a/crates/tracel-cli/src/ui/human.rs +++ b/crates/tracel-cli/src/ui/human.rs @@ -1,6 +1,5 @@ use std::fmt::Display; use std::io::{self, Write}; -use std::sync::atomic::{AtomicBool, Ordering}; use serde::Serialize; use serde_json::Value; @@ -8,71 +7,141 @@ use serde_json::Value; /// The narrowest a shrinkable table column gets, ellipsis included. const MIN_COLUMN_WIDTH: usize = 4; -/// How text on stdout may look. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Style { - pub color: bool, - /// The columns text may use, when stdout is a terminal. - pub width: Option, - /// Whether the text sits inside a cliclack frame, after its bar. - pub framed: bool, +/// A result for a person: lines cliclack draws in the command's frame on a terminal, +/// plain text everywhere else. Cards, headings and tables are written through +/// `Details`, `heading` and `Table`; other text with `write!`. +pub struct Human<'a> { + sink: Sink<'a>, + width: Option, + color: bool, } -impl Style { - pub fn plain() -> Self { - Self { - color: false, - width: None, - framed: false, +enum Sink<'a> { + Plain(&'a mut dyn Write), + Cliclack(Drawing), +} + +/// One cliclack log line of a result. +#[derive(Debug, PartialEq, Eq)] +pub enum Draw { + /// A field of a card. + Info(String), + /// A heading, with the lines under it. + Step(String), + /// A table, or other text. + Remark(String), +} + +impl Draw { + /// Draws the line with cliclack, on stderr. + pub fn show(&self) -> io::Result<()> { + match self { + Self::Info(text) => cliclack::log::info(text), + Self::Step(text) => cliclack::log::step(text), + Self::Remark(text) => cliclack::log::remark(text), } } } -/// Stdout for a person. Inside a frame each line starts with the frame's bar, or with -/// a symbol as cliclack's own lines do; piped, it is plain text. -pub struct Human<'a> { - out: &'a mut dyn Write, - style: Style, - line_start: &'a AtomicBool, - symbol: Option, - line_blank: bool, - last_blank: bool, +/// The lines drawn so far, and the one that text written now goes to. +#[derive(Default)] +struct Drawing { + draws: Vec, + open: Option, +} + +/// The line being written: a heading and the lines under it, or text of its own. +struct Open { + heading: bool, + text: Vec, +} + +impl Drawing { + fn open(&mut self, heading: bool, text: impl Into>) { + self.close(); + self.open = Some(Open { + heading, + text: text.into(), + }); + } + + /// Adds `text` to the open line, or to a new one. + fn write(&mut self, text: &[u8]) { + match &mut self.open { + Some(open) => open.text.extend_from_slice(text), + None => self.open(false, text), + } + } + + /// Adds `text` to the heading above it, or starts a line for it. + fn block(&mut self, text: &[u8]) { + if !self.open.as_ref().is_some_and(|open| open.heading) { + self.close(); + } + self.write(text); + } + + /// Ends the open line. One that is only blank lines draws nothing: cliclack + /// leaves one after each of its lines already. + fn close(&mut self) { + let Some(Open { heading, text }) = self.open.take() else { + return; + }; + let text = String::from_utf8_lossy(&text); + let text = text.trim_start_matches('\n').trim_end(); + if !text.is_empty() { + let draw = if heading { Draw::Step } else { Draw::Remark }; + self.draws.push(draw(text.into())); + } + } } impl<'a> Human<'a> { - /// `line_start` carries over between the writes of one stream. - pub fn new(out: &'a mut dyn Write, style: Style, line_start: &'a AtomicBool) -> Self { + /// Plain text on `out`. Tables fit in `width` columns, when there is one. + pub fn plain(out: &'a mut dyn Write, width: Option) -> Self { Self { - out, - style, - line_start, - symbol: None, - line_blank: true, - // A frame's intro ends with a bar of its own. - last_blank: true, + sink: Sink::Plain(out), + width, + color: false, } } +} - /// Text without color, width or frame, as when stdout is piped. - #[cfg(test)] - pub fn plain(out: &'a mut dyn Write) -> Self { - static LINE_START: AtomicBool = AtomicBool::new(true); - Self::new(out, Style::plain(), &LINE_START) +impl Human<'static> { + /// cliclack's lines, drawn by `Draw::show`. `width` is what fits after its bar. + pub fn cliclack(width: Option) -> Self { + Self { + sink: Sink::Cliclack(Drawing::default()), + width, + color: console::colors_enabled_stderr(), + } } +} - pub fn framed(&self) -> bool { - self.style.framed +impl Human<'_> { + pub fn width(&self) -> Option { + self.width } - pub fn width(&self) -> Option { - self.style.width + /// The cliclack lines of what was written, in order: none for plain text, which + /// is already out. + pub fn into_draws(self) -> Vec { + match self.sink { + Sink::Plain(_) => Vec::new(), + Sink::Cliclack(mut drawing) => { + drawing.close(); + drawing.draws + } + } } + /// `text` in `style` on cliclack, when stderr shows colors. fn styled(&self, text: impl Display, style: console::Style) -> String { - style - .apply_to(text) - .force_styling(self.style.color) - .to_string() + if self.color { + style.force_styling(true).apply_to(text).to_string() + } else { + text.to_string() + } } pub fn bold(&self, text: impl Display) -> String { @@ -83,78 +152,82 @@ impl<'a> Human<'a> { self.styled(text, console::Style::new().dim()) } - /// One line of a card: in a frame, after a bullet and followed by a bar, as - /// cliclack logs a message. - pub fn item(&mut self, text: impl Display) -> io::Result<()> { - if self.framed() { - self.symbol = Some(self.styled(console::Emoji("●", "•"), console::Style::new().blue())); - writeln!(self, "{text}")?; - self.spacer() - } else { - writeln!(self, "{text}") + /// Names what follows, up to the next heading or card: a step with the lines + /// under it on cliclack, a title ending in a colon in plain text. + pub fn heading(&mut self, text: impl Display) -> io::Result<()> { + match &mut self.sink { + Sink::Plain(out) => writeln!(out, "{text}:"), + Sink::Cliclack(drawing) => { + drawing.open(true, format!("{text}\n")); + Ok(()) + } } } - /// A line that names what follows: a step in a frame, a title ending in a colon - /// otherwise. - pub fn heading(&mut self, text: impl Display) -> io::Result<()> { - if self.framed() { - self.symbol = - Some(self.styled(console::Emoji("◇", "o"), console::Style::new().green())); - writeln!(self, "{text}") - } else { - writeln!(self, "{text}:") + /// One field of a card: a line of its own on cliclack, its value aligned after + /// labels `width` wide in plain text. + fn field(&mut self, label: &str, value: &str, width: usize) -> io::Result<()> { + match &mut self.sink { + Sink::Plain(out) => { + let padding = " ".repeat(width - label.len()); + writeln!(out, "{label}:{padding} {value}") + } + Sink::Cliclack(drawing) => { + drawing.close(); + drawing.draws.push(Draw::Info(format!("{label}: {value}"))); + Ok(()) + } } } - /// An empty line of the frame's bar, unless the line before was one. - pub fn spacer(&mut self) -> io::Result<()> { - if self.framed() && !self.last_blank { - writeln!(self)?; - } - Ok(()) + /// Lines under a heading, indented in plain text. + fn section(&mut self, title: &str, lines: &[String]) -> io::Result<()> { + self.heading(title)?; + let indent = match self.sink { + Sink::Plain(_) => " ", + Sink::Cliclack(_) => "", + }; + lines + .iter() + .try_for_each(|line| writeln!(self, "{indent}{line}")) } - fn bar(&self) -> String { - self.styled( - console::Emoji("│", "|"), - console::Style::new().bright().black(), - ) + /// Lines that belong together, such as a table: a cliclack line of their own, + /// unless they sit under a heading. + fn block(&mut self, text: &str) -> io::Result<()> { + match &mut self.sink { + Sink::Plain(out) => out.write_all(text.as_bytes()), + Sink::Cliclack(drawing) => { + drawing.block(text.as_bytes()); + Ok(()) + } + } } } impl Write for Human<'_> { fn write(&mut self, buffer: &[u8]) -> io::Result { - if !self.style.framed { - return self.out.write(buffer); - } - for line in buffer.split_inclusive(|&byte| byte == b'\n') { - if self.line_start.load(Ordering::Relaxed) { - let lead = self.symbol.take().unwrap_or_else(|| self.bar()); - write!(self.out, "{lead} ")?; - self.line_blank = true; - } - let ends_line = line.ends_with(b"\n"); - if line.len() > usize::from(ends_line) { - self.line_blank = false; + match &mut self.sink { + Sink::Plain(out) => out.write(buffer), + Sink::Cliclack(drawing) => { + drawing.write(buffer); + Ok(buffer.len()) } - self.out.write_all(line)?; - if ends_line { - self.last_blank = self.line_blank; - } - self.line_start.store(ends_line, Ordering::Relaxed); } - Ok(buffer.len()) } fn flush(&mut self) -> io::Result<()> { - self.out.flush() + match &mut self.sink { + Sink::Plain(out) => out.flush(), + Sink::Cliclack(_) => Ok(()), + } } } -/// Columns aligned under their headers, one row per line. On a terminal too narrow -/// for it, the columns marked with `shrink` are shortened to fit. Without rows it -/// writes nothing, or its `empty` text: a list says it has none in its summary. +/// Columns aligned under their headers, one row per line. When the width is too +/// narrow for it, the columns marked with `shrink` are shortened to fit. Without +/// rows it writes nothing, or its `empty` text: a list says it has none in its +/// summary. pub struct Table { headers: [&'static str; N], rows: Vec<[String; N]>, @@ -200,7 +273,7 @@ impl Table { return match self.empty { Some(text) => { let text = out.dim(text); - writeln!(out, "{text}") + out.block(&format!("{text}\n")) } None => Ok(()), }; @@ -223,15 +296,15 @@ impl Table { let header = std::array::from_fn(|column| out.bold(fitted(self.headers[column], widths[column]))); - self.write_line(out, &header, &widths)?; + let mut text = line(&header, &widths); for row in &rows { let cells = std::array::from_fn(|column| match row[column].as_str() { "-" => out.dim("-"), cell => fitted(cell, widths[column]), }); - self.write_line(out, &cells, &widths)?; + text.push_str(&line(&cells, &widths)); } - Ok(()) + out.block(&text) } /// Narrows the widest shrinkable columns until the table fits in `available` @@ -248,26 +321,24 @@ impl Table { } } } +} - fn write_line( - &self, - out: &mut Human<'_>, - cells: &[String; N], - widths: &[usize; N], - ) -> io::Result<()> { - for (column, cell) in cells.iter().enumerate() { - write!(out, "{cell}")?; - if column + 1 < N { - let padding = widths[column].saturating_sub(console::measure_text_width(cell)); - write!(out, "{} ", " ".repeat(padding))?; - } +/// One row of a table: each cell but the last padded to its column, then a gap. +fn line(cells: &[String; N], widths: &[usize; N]) -> String { + let mut line = String::new(); + for (column, cell) in cells.iter().enumerate() { + line.push_str(cell); + if column + 1 < N { + let padding = widths[column].saturating_sub(console::measure_text_width(cell)); + line.push_str(&" ".repeat(padding + 2)); } - writeln!(out) } + line.push('\n'); + line } -/// Labelled values: a card of bullets in a frame, aligned lines otherwise. Sections -/// list the fields of a JSON value under a heading. +/// Labelled values: a card of fields. Sections list the fields of a JSON value +/// under a heading. #[derive(Default)] pub struct Details { entries: Vec, @@ -325,24 +396,18 @@ impl Details { match entry { Entry::Field(label, value) => { let value = shown(out, value); - if out.framed() { - out.item(format!("{label}: {value}"))?; - } else { - let padding = " ".repeat(width - label.len()); - let label = out.dim(format!("{label}:")); - writeln!(out, "{label}{padding} {value}")?; - } + out.field(label, &value, width)?; } Entry::Section(title, fields) => { - out.heading(title)?; let width = fields.iter().map(|(key, _)| key.len()).max().unwrap_or(0); - let indent = if out.framed() { "" } else { " " }; - for (key, value) in fields { - let padding = " ".repeat(width - key.len()); - let (key, value) = (out.dim(key), shown(out, value)); - writeln!(out, "{indent}{key}{padding} {value}")?; - } - out.spacer()?; + let lines: Vec = fields + .iter() + .map(|(key, value)| { + let padding = " ".repeat(width - key.len()); + format!("{}{padding} {}", out.dim(key), shown(out, value)) + }) + .collect(); + out.section(title, &lines)?; } } } @@ -407,38 +472,22 @@ fn cell_text(value: &str) -> String { mod tests { use super::*; - fn text(style: Style, write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> String { - let line_start = AtomicBool::new(true); + fn text(width: Option, write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> String { let mut out = Vec::new(); - write(&mut Human::new(&mut out, style, &line_start)).unwrap(); + write(&mut Human::plain(&mut out, width)).unwrap(); String::from_utf8(out).unwrap() } fn plain(write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> String { - text(Style::plain(), write) - } - - fn framed(width: Option) -> Style { - Style { - color: false, - width, - framed: true, - } - } - - /// `text` with the frame's glyphs as this terminal draws them. - fn drawn(text: &str) -> String { - text.replace('│', &console::Emoji("│", "|").to_string()) - .replace('●', &console::Emoji("●", "•").to_string()) - .replace('◇', &console::Emoji("◇", "o").to_string()) + text(None, write) } - fn narrow(width: usize) -> Style { - Style { - color: false, - width: Some(width), - framed: false, - } + /// The cliclack lines `write` makes, unstyled. + fn drawn(write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> Vec { + let mut out = Human::cliclack(None); + out.color = false; + write(&mut out).unwrap(); + out.into_draws() } fn table() -> Table<3> { @@ -448,14 +497,13 @@ mod tests { ]) } + const TABLE: &str = "NAME DESCRIPTION ID\n\ + weights two lines 1\n\ + w - 22"; + #[test] fn table_aligns_columns_and_keeps_cells_on_one_line() { - assert_eq!( - plain(|out| table().write(out)), - "NAME DESCRIPTION ID\n\ - weights two lines 1\n\ - w - 22\n" - ); + assert_eq!(plain(|out| table().write(out)), format!("{TABLE}\n")); } #[test] @@ -469,7 +517,7 @@ mod tests { } #[test] - fn a_narrow_terminal_shortens_only_shrinkable_columns() { + fn a_narrow_width_shortens_only_shrinkable_columns() { let table = || { Table::new(["NAME", "DESCRIPTION", "ID"]).rows([[ "weights".into(), @@ -478,12 +526,12 @@ mod tests { ]]) }; assert_eq!( - text(narrow(36), |out| table().shrink("DESCRIPTION").write(out)), + text(Some(36), |out| table().shrink("DESCRIPTION").write(out)), "NAME DESCRIPTION ID\n\ weights a rather long… 0123456789\n" ); assert_eq!( - text(narrow(36), |out| table().write(out)), + text(Some(36), |out| table().write(out)), plain(|out| table().write(out)) ); } @@ -491,7 +539,7 @@ mod tests { #[test] fn a_shrinking_column_can_cut_its_header() { assert_eq!( - text(narrow(18), |out| table().shrink("DESCRIPTION").write(out)), + text(Some(18), |out| table().shrink("DESCRIPTION").write(out)), "NAME DESC… ID\n\ weights two… 1\n\ w - 22\n" @@ -499,26 +547,14 @@ mod tests { } #[test] - fn a_framed_table_sits_after_the_bar() { - assert_eq!( - text(framed(None), |out| table().write(out)), - drawn( - "│ NAME DESCRIPTION ID\n\ - │ weights two lines 1\n\ - │ w - 22\n" - ) - ); - } - - #[test] - fn colors_apply_only_when_enabled() { - let colored = Style { - color: true, - width: None, - framed: false, - }; - assert!(text(colored, |out| table().write(out)).contains("\u{1b}[")); - assert!(!plain(|out| table().write(out)).contains("\u{1b}[")); + fn plain_text_is_never_styled() { + let text = plain(|out| { + details().write(out)?; + table().write(out)?; + let (bold, dim) = (out.bold("bold"), out.dim("dim")); + writeln!(out, "{bold} {dim}") + }); + assert!(!text.contains('\u{1b}')); } fn details() -> Details { @@ -546,35 +582,74 @@ mod tests { } #[test] - fn framed_details_are_a_card() { + fn on_cliclack_details_are_a_line_per_field_and_a_step_per_section() { assert_eq!( - text(framed(None), |out| details().write(out)), - drawn( - "● Name: weights\n\ - │ \n\ - ● Description: -\n\ - │ \n\ - ◇ Config\n\ - │ lr 0.1\n\ - │ schedule.warmup 2\n\ - │ tags [\"a\"]\n\ - │ \n" - ) + drawn(|out| details().write(out)), + [ + Draw::Info("Name: weights".into()), + Draw::Info("Description: -".into()), + Draw::Step( + "Config\n\ + lr 0.1\n\ + schedule.warmup 2\n\ + tags [\"a\"]" + .into() + ), + ] ); } #[test] - fn a_stream_keeps_one_bar_per_line_across_writes() { - let line_start = AtomicBool::new(true); - let mut out = Vec::new(); - for piece in ["par", "tial\nnext", "\n"] { - Human::new(&mut out, framed(None), &line_start) - .write_all(piece.as_bytes()) - .unwrap(); - } + fn on_cliclack_a_heading_takes_the_table_under_it() { + let draws = drawn(|out| { + out.heading("Files")?; + table().write(out)?; + Details::new() + .section("Metadata", &serde_json::json!({"seed": 7})) + .write(out) + }); + assert_eq!( + draws, + [ + Draw::Step(format!("Files\n{TABLE}")), + Draw::Step("Metadata\nseed 7".into()), + ] + ); + } + + #[test] + fn on_cliclack_a_table_is_a_line_of_its_own() { + let draws = drawn(|out| { + writeln!(out, "Before.")?; + table().write(out)?; + writeln!(out)?; + Table::new(["GROUP"]).empty("No groups.").write(out) + }); + assert_eq!( + draws, + [ + Draw::Remark("Before.".into()), + Draw::Remark(TABLE.into()), + Draw::Remark("No groups.".into()), + ] + ); + } + + #[test] + fn on_cliclack_text_collects_until_the_next_field() { + let draws = drawn(|out| { + write!(out, "par")?; + writeln!(out, "tial")?; + writeln!(out, "next")?; + Details::new().field("Name", "weights").write(out)?; + writeln!(out) + }); assert_eq!( - String::from_utf8(out).unwrap(), - drawn("│ partial\n│ next\n") + draws, + [ + Draw::Remark("partial\nnext".into()), + Draw::Info("Name: weights".into()), + ] ); } diff --git a/crates/tracel-cli/src/ui/mod.rs b/crates/tracel-cli/src/ui/mod.rs index 118b234..9ec8be7 100644 --- a/crates/tracel-cli/src/ui/mod.rs +++ b/crates/tracel-cli/src/ui/mod.rs @@ -1,7 +1,8 @@ -//! What the person running a command sees. Stdout carries the command's result and -//! nothing else, so it can be piped or captured: an envelope with JSON output, or -//! the text of its `Render` impl. Stderr carries progress, prompts and diagnostics -//! through `Terminal`. On a terminal both draw in one cliclack frame per command. +//! What the person running a command sees. On a terminal, cliclack draws one frame +//! per command, and in it progress, prompts and diagnostics through `Terminal` and +//! the result through `Output`; what a command streams is written as it comes. +//! Otherwise stdout carries the result and nothing else, so it can be piped or +//! captured: an envelope with JSON output, or the plain text of its `Render` impl. mod format; mod human; diff --git a/crates/tracel-cli/src/ui/screen.rs b/crates/tracel-cli/src/ui/screen.rs index f56f11d..feb862c 100644 --- a/crates/tracel-cli/src/ui/screen.rs +++ b/crates/tracel-cli/src/ui/screen.rs @@ -5,20 +5,19 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use cliclack::{MultiProgress, ProgressBar}; use super::Format; -use super::human::Style; -/// The width cliclack's bar and the space after it take at the start of a line. +/// The columns cliclack's bar and the space after it take at the start of a line. const GUTTER_WIDTH: usize = 3; -/// The terminal a command draws on. Stderr draws cliclack frames and widgets on it -/// when it is a styled terminal, and stdout shares it when stdout is a terminal too. -/// Only one thing draws at a time: anything new settles the live widget first. +/// The terminal a command draws on. With human output on a styled stderr, cliclack +/// draws a frame and widgets there, and the result too when stdout is the same +/// terminal. Only one thing draws at a time: anything new settles the live widget +/// first. pub struct Screen { format: Format, styled: bool, stdout_is_terminal: bool, frame: AtomicBool, - line_start: AtomicBool, live: Mutex>, next_id: AtomicU64, } @@ -50,7 +49,6 @@ impl Screen { styled: format == Format::Human && io::stderr().is_terminal(), stdout_is_terminal: io::stdout().is_terminal(), frame: AtomicBool::new(false), - line_start: AtomicBool::new(true), live: Mutex::new(None), next_id: AtomicU64::new(0), } @@ -66,40 +64,31 @@ impl Screen { } /// Whether stdout lands among what stderr draws. - pub fn shares_stdout(&self) -> bool { + fn shares_stdout(&self) -> bool { self.styled && self.stdout_is_terminal } - /// How text on stdout may look: colors and width only on a terminal, and drawn - /// inside the frame when one is open there. - pub fn text_style(&self) -> Style { - if !self.stdout_is_terminal { - return Style::plain(); - } - let framed = self.shares_stdout() && self.frame_open(); - Style { - color: console::colors_enabled(), - width: console::Term::stdout() - .size_checked() - .map(|(_, columns)| usize::from(columns)) - .map(|columns| { - if framed { - columns.saturating_sub(GUTTER_WIDTH) - } else { - columns - } - }), - framed, - } - } - pub fn frame_open(&self) -> bool { self.frame.load(Ordering::Relaxed) } - /// Whether the next text on stdout starts a line, across the writes of a stream. - pub fn line_start(&self) -> &AtomicBool { - &self.line_start + /// Whether cliclack draws the result in the frame, which stdout shares. + pub fn frames_results(&self) -> bool { + self.shares_stdout() && self.frame_open() + } + + /// The columns inside the frame, after cliclack's bar. + pub fn frame_width(&self) -> Option { + columns(&console::Term::stderr()).map(|columns| columns.saturating_sub(GUTTER_WIDTH)) + } + + /// The columns of the terminal stdout is, if it is one. + pub fn stdout_width(&self) -> Option { + if self.stdout_is_terminal { + columns(&console::Term::stdout()) + } else { + None + } } /// Opens the frame a command draws in, under a green title badge. @@ -110,7 +99,6 @@ impl Screen { self.settle(Ending::Done); let badge = console::style(format!(" ▶ {title} ")).black().on_green(); let _ = cliclack::intro(badge); - self.line_start.store(true, Ordering::Relaxed); self.frame.store(true, Ordering::Relaxed); } @@ -129,7 +117,6 @@ impl Screen { cliclack::outro_cancel(console::style(format!(" {message} ")).black().on_red()) } }; - self.line_start.store(true, Ordering::Relaxed); self.frame.store(false, Ordering::Relaxed); } @@ -137,6 +124,12 @@ impl Screen { self.next_id.fetch_add(1, Ordering::Relaxed) } + /// Settles the live widget, then draws with cliclack. + pub fn draw(&self, draw: impl FnOnce() -> io::Result<()>) { + self.settle(Ending::Done); + let _ = draw(); + } + /// Settles the live widget, then draws the one `draw` makes as live widget `id`. pub fn show( &self, @@ -206,6 +199,12 @@ impl Screen { } } +fn columns(terminal: &console::Term) -> Option { + terminal + .size_checked() + .map(|(_, columns)| usize::from(columns)) +} + impl Live { fn end(self, ending: Ending, message: Option<&str>) { let message = message.unwrap_or(&self.message); diff --git a/crates/tracel-cli/src/ui/stderr.rs b/crates/tracel-cli/src/ui/stderr.rs index b5bbf46..a3bcb71 100644 --- a/crates/tracel-cli/src/ui/stderr.rs +++ b/crates/tracel-cli/src/ui/stderr.rs @@ -30,12 +30,6 @@ impl Terminal { self.screen.is_styled() } - /// Draws with cliclack once nothing else is drawing. - fn draw(&self, draw: impl FnOnce() -> io::Result<()>) { - self.screen.settle(Ending::Done); - let _ = draw(); - } - /// A plain line, for stderr that is not a styled terminal. fn line(&self, text: impl Display) { let _ = writeln!( @@ -47,7 +41,7 @@ impl Terminal { pub fn print_warning(&self, message: &str) { if self.is_styled() { - self.draw(|| cliclack::log::warning(message)); + self.screen.draw(|| cliclack::log::warning(message)); } else if self.is_human() { self.line(format_args!("warning: {message}")); } @@ -55,7 +49,7 @@ impl Terminal { pub fn print(&self, message: &str) { if self.is_styled() { - self.draw(|| cliclack::log::info(message)); + self.screen.draw(|| cliclack::log::info(message)); } else if self.is_human() { self.line(message); } @@ -63,7 +57,7 @@ impl Terminal { pub fn print_err(&self, message: &str) { if self.is_styled() { - self.draw(|| cliclack::log::error(message)); + self.screen.draw(|| cliclack::log::error(message)); } else if self.is_human() { self.line(format_args!("error: {message}")); } @@ -71,7 +65,7 @@ impl Terminal { pub fn print_success(&self, message: &str) { if self.is_styled() { - self.draw(|| cliclack::log::success(message)); + self.screen.draw(|| cliclack::log::success(message)); } else if self.is_human() { self.line(message); } @@ -79,7 +73,7 @@ impl Terminal { pub fn step(&self, message: &str) { if self.is_styled() { - self.draw(|| cliclack::log::step(message)); + self.screen.draw(|| cliclack::log::step(message)); } else { self.print(message); } @@ -105,7 +99,7 @@ impl Terminal { } else if self.is_styled() { match report.hint { Some(hint) => { - self.draw(|| cliclack::log::error(&report.message)); + self.screen.draw(|| cliclack::log::error(&report.message)); self.screen.outro(Outro::Cancelled(hint)); } None => self.screen.outro(Outro::Cancelled(&report.message)), diff --git a/crates/tracel-cli/src/ui/stdout.rs b/crates/tracel-cli/src/ui/stdout.rs index 445836d..7450abb 100644 --- a/crates/tracel-cli/src/ui/stdout.rs +++ b/crates/tracel-cli/src/ui/stdout.rs @@ -1,25 +1,24 @@ use std::io::{self, Write}; use std::sync::Arc; -use std::sync::atomic::AtomicBool; use serde::Serialize; use super::Format; -use super::human::{Human, Style}; +use super::human::{Draw, Human}; use super::screen::{Outro, Screen}; use crate::error::ErrorReport; -/// A command result: serialized for JSON output, written as text for people. +/// A command result: serialized for JSON output, written for people. pub trait Render: Serialize { - /// The text a person reads on stdout. The default writes nothing, for actions - /// that report what they did on stderr as they go. + /// What a person reads. The default writes nothing, for actions that report + /// what they did on stderr as they go. fn render(&self, out: &mut Human<'_>) -> io::Result<()> { let _ = out; Ok(()) } /// One line that sums the result up: the badge that closes the frame on a - /// terminal, the last line of the text otherwise. + /// terminal, and the last line of plain text. fn summary(&self) -> Option { None } @@ -84,7 +83,8 @@ struct Failure<'a> { #[error("stdout was closed")] pub struct StdoutClosed; -/// Stdout, which carries only command results, in the format the user chose. +/// Where command results go, in the format the user chose: drawn in the command's +/// frame on a terminal, and otherwise on stdout, which carries nothing else. #[derive(Clone)] pub struct Output { screen: Arc, @@ -98,28 +98,30 @@ impl Output { /// Writes the result of a finished command, and closes the frame it was drawn in /// with its summary. pub fn finish(&self, outcome: Outcome) -> anyhow::Result<()> { - let format = self.screen.format(); - let style = self.screen.text_style(); - let framed = self.screen.frame_open(); let summary = outcome.0.as_ref().and_then(|report| report.summary()); - let line_start = AtomicBool::new(true); - self.write_stdout(|out| { - let summary = if framed { None } else { summary.as_deref() }; - finish(format, style, &line_start, outcome, summary, out) - })?; - if framed { + if let Some(report) = outcome.0 { + if self.screen.frames_results() { + let draws = drawn(report.as_ref(), self.screen.frame_width())?; + self.screen.draw(|| draws.iter().try_for_each(Draw::show)); + } else { + let (format, width) = (self.screen.format(), self.screen.stdout_width()); + self.write_stdout(|out| { + write_result(format, report.as_ref(), summary.as_deref(), width, out) + })?; + } + } + if self.screen.frame_open() { self.screen.outro(Outro::Done(summary.as_deref())); } Ok(()) } - /// Writes one event of a stream as it happens: an NDJSON line, or its text. + /// Writes one event of a stream as it happens: an NDJSON line, or its plain text, + /// which stays the program's own output on a terminal too. pub fn event(&self, event: &impl Render) -> anyhow::Result<()> { - let format = self.screen.format(); - let style = self.screen.text_style(); - let line_start = self.screen.line_start(); + let (format, width) = (self.screen.format(), self.screen.stdout_width()); self.write_stdout(|out| { - write_event(format, style, line_start, event, out)?; + write_event(format, width, event, out)?; out.flush() }) } @@ -142,28 +144,27 @@ impl Output { } } -/// `summary` is the result's last line, when no frame shows it. -fn finish( +/// The cliclack lines of a result, fitted to `width`. +fn drawn(report: &dyn Report, width: Option) -> io::Result> { + let mut out = Human::cliclack(width); + report.write_text(&mut out)?; + Ok(out.into_draws()) +} + +/// A result on stdout: its envelope, or its plain text ending with `summary`. +fn write_result( format: Format, - style: Style, - line_start: &AtomicBool, - outcome: Outcome, + report: &dyn Report, summary: Option<&str>, + width: Option, out: &mut dyn Write, ) -> io::Result<()> { - let Some(report) = outcome.0 else { - return Ok(()); - }; match format { Format::Human => { - let mut out = Human::new(out, style, line_start); + let mut out = Human::plain(out, width); report.write_text(&mut out)?; - out.spacer()?; match summary { - Some(summary) => { - let summary = out.dim(summary); - writeln!(out, "{summary}") - } + Some(summary) => writeln!(out, "{summary}"), None => Ok(()), } } @@ -176,13 +177,12 @@ fn finish( fn write_event( format: Format, - style: Style, - line_start: &AtomicBool, + width: Option, event: &impl Render, out: &mut dyn Write, ) -> io::Result<()> { match format { - Format::Human => event.render(&mut Human::new(out, style, line_start)), + Format::Human => event.render(&mut Human::plain(out, width)), Format::Json => { serde_json::to_writer(&mut *out, event)?; writeln!(out) @@ -232,52 +232,52 @@ mod tests { String::from_utf8(out).unwrap() } - fn finished(format: Format, outcome: Outcome, summary: Option<&str>) -> String { - let line_start = AtomicBool::new(true); - written(|out| finish(format, Style::plain(), &line_start, outcome, summary, out)) + fn result(format: Format, report: &dyn Report, summary: Option<&str>) -> String { + written(|out| write_result(format, report, summary, None, out)) } #[test] fn results_are_an_envelope_or_their_text() { - let greeting = || Outcome::from(Greeting { name: "Ada" }); + let greeting = Greeting { name: "Ada" }; assert_eq!( - finished(Format::Json, greeting(), None), + result(Format::Json, &greeting, None), "{\"ok\":true,\"data\":{\"name\":\"Ada\"}}\n" ); - assert_eq!(finished(Format::Human, greeting(), None), "Hello, Ada.\n"); - assert_eq!(finished(Format::Human, Done {}.into(), None), ""); + assert_eq!(result(Format::Human, &greeting, None), "Hello, Ada.\n"); + assert_eq!(result(Format::Human, &Done {}, None), ""); } #[test] - fn without_a_frame_the_summary_ends_the_text() { - let greeting = || Outcome::from(Greeting { name: "Ada" }); + fn plain_text_ends_with_the_summary() { + let greeting = Greeting { name: "Ada" }; assert_eq!( - finished(Format::Human, greeting(), Some("Greeted.")), + result(Format::Human, &greeting, Some("Greeted.")), "Hello, Ada.\nGreeted.\n" ); assert_eq!( - finished(Format::Json, greeting(), Some("Greeted.")), + result(Format::Json, &greeting, Some("Greeted.")), "{\"ok\":true,\"data\":{\"name\":\"Ada\"}}\n" ); } #[test] - fn streamed_output_is_not_followed_by_an_envelope() { - for format in [Format::Human, Format::Json] { - assert_eq!(finished(format, Outcome::streamed(), None), ""); - } + fn in_a_frame_a_result_is_cliclack_lines() { + assert_eq!( + drawn(&Greeting { name: "Ada" }, None).unwrap(), + [Draw::Remark("Hello, Ada.".into())] + ); + assert_eq!(drawn(&Done {}, None).unwrap(), []); } #[test] fn events_are_bare_json_lines_or_their_text() { let event = Greeting { name: "Ada" }; - let line_start = AtomicBool::new(true); assert_eq!( - written(|out| write_event(Format::Json, Style::plain(), &line_start, &event, out)), + written(|out| write_event(Format::Json, None, &event, out)), "{\"name\":\"Ada\"}\n" ); assert_eq!( - written(|out| write_event(Format::Human, Style::plain(), &line_start, &event, out)), + written(|out| write_event(Format::Human, None, &event, out)), "Hello, Ada.\n" ); } From 33741c6f50f1bbf18082cf229ebf68b95735ac44 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 13 Oct 2026 09:22:36 -0400 Subject: [PATCH 18/32] feat: tracel login offers to open its link, and links open with a click - On a terminal, `tracel login` asks whether to open the approval page in a browser, answered yes with Enter. It skips the question when nobody can answer or no browser is available, and says to open the link when the browser fails to start. `$BROWSER` picks the browser. - The links in the CLI's messages are terminal hyperlinks in terminals that render them, so a click opens them. Plain output keeps them as text. --- Cargo.lock | 90 ++++++++++++++++++++++++- Cargo.toml | 2 + README.md | 5 +- crates/tracel-cli/Cargo.toml | 2 + crates/tracel-cli/src/commands/login.rs | 19 +++++- crates/tracel-cli/src/ui/stderr.rs | 42 ++++++++++-- 6 files changed, 152 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 57d65fa..6ec13e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -753,6 +753,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -2427,6 +2437,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "num-conv" version = "0.2.2" @@ -2442,6 +2458,54 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3297,6 +3361,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + [[package]] name = "syn" version = "2.0.119" @@ -3377,7 +3447,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3631,6 +3701,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "supports-hyperlinks", "tar", "thiserror 2.0.20", "toml", @@ -3639,6 +3710,7 @@ dependencies = [ "tracing", "url", "walkdir", + "webbrowser", ] [[package]] @@ -4022,6 +4094,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "url", + "web-sys", +] + [[package]] name = "webpki-root-certs" version = "1.0.9" diff --git a/Cargo.toml b/Cargo.toml index 22636ef..92dd48d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,8 @@ thiserror = "2.0.19" env_logger = "0.11.11" console = "0.15.11" cliclack = "0.3.8" +supports-hyperlinks = "3.2.0" +webbrowser = "1.2.4" directories = "5.0" url = "2.5.8" cargo_metadata = { version = "0.20.0", features = ["builder"] } diff --git a/README.md b/README.md index b181894..5650861 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,10 @@ commit hash. JSON data contains `namespace`, `project`, `digest`, `version_id`, ### `tracel login` Authenticate with the Console platform. The CLI prints a link and a code to -approve in your browser, and the login then lasts seven days. +approve in your browser, and the login then lasts seven days. On a terminal it +offers to open the link in your browser (`$BROWSER` names which one, when set), +and in terminals that render hyperlinks the link also opens with a click, like +the other links in the CLI's messages. ```bash tracel login diff --git a/crates/tracel-cli/Cargo.toml b/crates/tracel-cli/Cargo.toml index f4281b6..0957bb4 100644 --- a/crates/tracel-cli/Cargo.toml +++ b/crates/tracel-cli/Cargo.toml @@ -22,6 +22,8 @@ clap.workspace = true colored.workspace = true console.workspace = true cliclack.workspace = true +supports-hyperlinks.workspace = true +webbrowser.workspace = true directories.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/tracel-cli/src/commands/login.rs b/crates/tracel-cli/src/commands/login.rs index d75a682..3f24dd2 100644 --- a/crates/tracel-cli/src/commands/login.rs +++ b/crates/tracel-cli/src/commands/login.rs @@ -12,7 +12,7 @@ use url::Url; use self::pending::{PendingLogin, PendingLoginStore}; use crate::context::{CliContext, ClientCreationError}; use crate::error::{CliError, ErrorKind, classify}; -use crate::ui::{Outcome, Render, Timestamp}; +use crate::ui::{Outcome, Render, Terminal, Timestamp}; #[derive(Args, Debug)] pub struct LoginArgs { @@ -104,12 +104,14 @@ fn log_in(context: &CliContext) -> anyhow::Result<()> { let device_auth = context.device_auth(); let authorization = device_auth.start()?; + let approval_page = Url::parse(&authorization.verification_uri_complete)?; let instructions = format!( "Open {} and check that it shows the code {}.", - terminal.format_url(&Url::parse(&authorization.verification_uri_complete)?), + terminal.format_url(&approval_page), console::style(&authorization.user_code).bold() ); terminal.instruct(&instructions); + offer_browser(terminal, &approval_page)?; let spinner = terminal.spinner(); spinner.start("Waiting for approval... Press Ctrl+C to cancel."); @@ -123,6 +125,19 @@ fn log_in(context: &CliContext) -> anyhow::Result<()> { Ok(()) } +/// Offers to open the approval page in a browser, when one can open it and someone can answer. +fn offer_browser(terminal: &Terminal, approval_page: &Url) -> anyhow::Result<()> { + if !webbrowser::Browser::is_available() || !terminal.offer("Open it in your browser?")? { + return Ok(()); + } + if let Err(error) = webbrowser::open(approval_page.as_str()) { + terminal.print_warning(&format!( + "Could not open a browser ({error}). Open the link above instead." + )); + } + Ok(()) +} + #[derive(Serialize)] struct LoggedIn { username: String, diff --git a/crates/tracel-cli/src/ui/stderr.rs b/crates/tracel-cli/src/ui/stderr.rs index a3bcb71..612ac7f 100644 --- a/crates/tracel-cli/src/ui/stderr.rs +++ b/crates/tracel-cli/src/ui/stderr.rs @@ -137,12 +137,17 @@ impl Terminal { } } - /// A URL styled for stderr, plain when stderr is not a terminal or `NO_COLOR` is set. + /// A URL as cliclack shows it: styled unless `NO_COLOR` is set and, in terminals that + /// render hyperlinks, one, so a click opens it. Plain when stderr is not a terminal. pub fn format_url(&self, url: &url::Url) -> String { - if self.is_styled() { - console::style(url).for_stderr().blue().bold().to_string() + if !self.is_styled() { + return url.to_string(); + } + let text = console::style(url).for_stderr().blue().bold().to_string(); + if supports_hyperlinks::on(supports_hyperlinks::Stream::Stderr) { + hyperlink(url, &text) } else { - url.to_string() + text } } @@ -179,6 +184,19 @@ impl Terminal { .map_err(anyhow::Error::from) } + /// Offers a shortcut with a question answered yes by default. When nobody can answer, the + /// question is not asked and the answer is no. + pub fn offer(&self, message: &str) -> anyhow::Result { + if !self.is_interactive() { + return Ok(false); + } + self.screen.settle(Ending::Done); + cliclack::confirm(message) + .initial_value(true) + .interact() + .map_err(anyhow::Error::from) + } + pub fn input(&self, message: &str, flag: &str) -> anyhow::Result { self.input_validated(message, flag, "", |_| Ok(())) } @@ -283,6 +301,12 @@ fn one_line(message: &str) -> String { .join(" ") } +/// `text` as a terminal hyperlink to `url` (OSC 8): terminals that render hyperlinks open +/// `url` on a click on `text`. +fn hyperlink(url: &url::Url, text: &str) -> String { + format!("\u{1b}]8;;{url}\u{1b}\\{text}\u{1b}]8;;\u{1b}\\") +} + /// A spinner for one step. It is drawn when started, and stops early when something /// else draws before it ends; its last message then appears as a plain line. #[derive(Clone)] @@ -393,4 +417,14 @@ mod tests { assert!(!allows_input(true, true, Format::Human, Some(ci), false)); } } + + #[test] + fn a_hyperlink_shows_its_text_and_opens_its_url() { + let url = url::Url::parse("http://localhost:9001/verify?code=BCDF-GHJK").unwrap(); + + assert_eq!( + hyperlink(&url, "the approval page"), + "\u{1b}]8;;http://localhost:9001/verify?code=BCDF-GHJK\u{1b}\\the approval page\u{1b}]8;;\u{1b}\\" + ); + } } From 2ce0dd2d94b6ba396169742d3fc5906add14a85c Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 13 Oct 2026 11:05:15 -0400 Subject: [PATCH 19/32] feat: long lines of a table or text wrap inside the frame On a terminal, a line of a table or of text that is wider than the frame now wraps after cliclack's bar instead of running past it, as cliclack 0.5's termwrap does it. Tables already fitted to the frame are unchanged. Builds on cliclack 0.5.6 and console 0.16, the console its progress bars already used, so one console is linked. --- Cargo.lock | 32 +++-------------- Cargo.toml | 4 +-- README.md | 7 ++-- crates/tracel-cli/src/ui/human.rs | 56 ++++++++++++++++++++++++++++-- crates/tracel-cli/src/ui/screen.rs | 5 +-- 5 files changed, 68 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ec13e9..332e1e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -344,11 +344,11 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cliclack" -version = "0.3.8" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa510b739c618c679375ea9c5af44ce9f591289546e874ad5910e7ce7df79844" +checksum = "134778b6a125729ff495c23ba32f64e0c26d61edd52f1f6715a0d036785cf30b" dependencies = [ - "console 0.15.11", + "console", "indicatif", "once_cell", "strsim", @@ -399,19 +399,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.59.0", -] - [[package]] name = "console" version = "0.16.4" @@ -2162,7 +2149,7 @@ version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ - "console 0.16.4", + "console", "portable-atomic", "unicode-width", "unit-prefix", @@ -3693,7 +3680,7 @@ dependencies = [ "clap", "cliclack", "colored", - "console 0.15.11", + "console", "directories 5.0.1", "flate2", "gix", @@ -4234,15 +4221,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 92dd48d..03e1f6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,8 +30,8 @@ serde_json = "1.0.151" sha2 = { version = "0.10.9" } thiserror = "2.0.19" env_logger = "0.11.11" -console = "0.15.11" -cliclack = "0.3.8" +console = "0.16.4" +cliclack = "0.5.6" supports-hyperlinks = "3.2.0" webbrowser = "1.2.4" directories = "5.0" diff --git a/README.md b/README.md index 5650861..6af35d6 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,11 @@ even when stdout is redirected. On a terminal, each command draws everything in one frame: a title, its progress and result, and a closing line that sums it up. Tables shorten long descriptions to -fit the width and show times in your time zone. What a command streams, such as +fit the width and show times in your time zone; a line of a table or of text that +is still too long wraps inside the frame. What a command streams, such as `experiments logs --follow`, is written as it arrives, without the frame's border. -Redirected output is plain text without colors or symbols, is never shortened, and -ends with that summary line. +Redirected output is plain text without colors or symbols, is never shortened or +wrapped, and ends with that summary line. | Error code | Exit code | | --- | --- | diff --git a/crates/tracel-cli/src/ui/human.rs b/crates/tracel-cli/src/ui/human.rs index 2990f6e..a1e90c8 100644 --- a/crates/tracel-cli/src/ui/human.rs +++ b/crates/tracel-cli/src/ui/human.rs @@ -4,6 +4,8 @@ use std::io::{self, Write}; use serde::Serialize; use serde_json::Value; +use super::screen::GUTTER_WIDTH; + /// The narrowest a shrinkable table column gets, ellipsis included. const MIN_COLUMN_WIDTH: usize = 4; @@ -33,16 +35,23 @@ pub enum Draw { } impl Draw { - /// Draws the line with cliclack, on stderr. + /// Draws the line with cliclack, on stderr. A remark's lines that are wider than + /// the frame wrap inside its gutter. pub fn show(&self) -> io::Result<()> { match self { Self::Info(text) => cliclack::log::info(text), Self::Step(text) => cliclack::log::step(text), - Self::Remark(text) => cliclack::log::remark(text), + Self::Remark(text) => cliclack::log::remark(in_gutter(text)), } } } +/// `text` with each line wider than the frame wrapped to fit after cliclack's bar. The +/// width skips escape sequences, so styled text wraps where it shows. +fn in_gutter(text: &str) -> String { + cliclack::termwrap(text, GUTTER_WIDTH) +} + /// The lines drawn so far, and the one that text written now goes to. #[derive(Default)] struct Drawing { @@ -653,6 +662,49 @@ mod tests { ); } + /// The columns `in_gutter` wraps to, measured as `cliclack::termwrap` measures them. + fn gutter_width() -> usize { + usize::from( + console::Term::stderr() + .size() + .1 + .saturating_sub(GUTTER_WIDTH), + ) + } + + #[test] + fn wrapping_in_the_gutter_keeps_a_styled_table_fitted_to_the_frame() { + let width = gutter_width(); + let mut out = Human::cliclack(Some(width)); + out.color = true; + Table::new(["NAME", "DESCRIPTION", "ID"]) + .shrink("DESCRIPTION") + .rows([[ + "weights".into(), + "a rather long description ".repeat(width / 8), + String::new(), + ]]) + .write(&mut out) + .unwrap(); + let [Draw::Remark(table)] = <[Draw; 1]>::try_from(out.into_draws()).unwrap() else { + panic!("expected one remark"); + }; + assert!(table.contains('\u{1b}')); + assert_eq!(in_gutter(&table), table); + } + + #[test] + fn text_wider_than_the_frame_wraps_in_the_gutter() { + let width = gutter_width(); + let wrapped = in_gutter("word ".repeat(width).trim_end()); + assert!(wrapped.lines().count() > 1); + assert!( + wrapped + .lines() + .all(|line| console::measure_text_width(line) <= width) + ); + } + #[test] fn counts_mention_what_was_left_out() { let count = |shown, total| counted(shown, total, ["model", "models"], "No models."); diff --git a/crates/tracel-cli/src/ui/screen.rs b/crates/tracel-cli/src/ui/screen.rs index feb862c..c029cc9 100644 --- a/crates/tracel-cli/src/ui/screen.rs +++ b/crates/tracel-cli/src/ui/screen.rs @@ -7,7 +7,7 @@ use cliclack::{MultiProgress, ProgressBar}; use super::Format; /// The columns cliclack's bar and the space after it take at the start of a line. -const GUTTER_WIDTH: usize = 3; +pub const GUTTER_WIDTH: u16 = 3; /// The terminal a command draws on. With human output on a styled stderr, cliclack /// draws a frame and widgets there, and the result too when stdout is the same @@ -79,7 +79,8 @@ impl Screen { /// The columns inside the frame, after cliclack's bar. pub fn frame_width(&self) -> Option { - columns(&console::Term::stderr()).map(|columns| columns.saturating_sub(GUTTER_WIDTH)) + columns(&console::Term::stderr()) + .map(|columns| columns.saturating_sub(usize::from(GUTTER_WIDTH))) } /// The columns of the terminal stdout is, if it is one. From 1b9b1e4f5b54aef3d4d866cfd8417f76cd15789a Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 13 Oct 2026 11:41:45 -0400 Subject: [PATCH 20/32] feat: a read draws in one box, a command that does work in a session On a terminal, a command that reads shows its result in one box, titled with its summary, the project, and the environment when it is not production. An empty result is just that line. The intro, the per-field lines, and the development warning are gone from reads. A command that does work opens one session under a tracel badge, says what it does and where, and ends on its outcome or on the commands to run next. An error is its message and hint in one block; a failed session ends on what it did not do, anything else on a bare corner. Piped output is unchanged, except that `me` and `auth status` no longer repeat the environment in their summary, which already names it in their fields. --- README.md | 19 +- crates/tracel-cli/src/cli.rs | 82 ++-- crates/tracel-cli/src/commands/artifacts.rs | 16 +- crates/tracel-cli/src/commands/auth.rs | 19 +- crates/tracel-cli/src/commands/datasets.rs | 10 - crates/tracel-cli/src/commands/experiments.rs | 15 +- crates/tracel-cli/src/commands/login.rs | 25 +- crates/tracel-cli/src/commands/me.rs | 11 +- crates/tracel-cli/src/commands/models.rs | 43 +- crates/tracel-cli/src/commands/project.rs | 9 - crates/tracel-cli/src/context.rs | 15 +- .../src/helpers/model_upload/upload.rs | 14 +- crates/tracel-cli/src/helpers/project.rs | 28 +- crates/tracel-cli/src/ui/human.rs | 367 ++++-------------- crates/tracel-cli/src/ui/layout.rs | 69 ++++ crates/tracel-cli/src/ui/mod.rs | 30 +- crates/tracel-cli/src/ui/screen.rs | 146 +++++-- crates/tracel-cli/src/ui/stderr.rs | 45 ++- crates/tracel-cli/src/ui/stdout.rs | 53 ++- 19 files changed, 511 insertions(+), 505 deletions(-) create mode 100644 crates/tracel-cli/src/ui/layout.rs diff --git a/README.md b/README.md index 6af35d6..e733230 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,18 @@ and errors go to stderr. Commands that change something, such as `init`, nothing on stdout. `auth token` prints the bare token unless JSON is requested, even when stdout is redirected. -On a terminal, each command draws everything in one frame: a title, its progress -and result, and a closing line that sums it up. Tables shorten long descriptions to -fit the width and show times in your time zone; a line of a table or of text that -is still too long wraps inside the frame. What a command streams, such as -`experiments logs --follow`, is written as it arrives, without the frame's border. -Redirected output is plain text without colors or symbols, is never shortened or -wrapped, and ends with that summary line. +On a terminal, a read is a box and a flow is a session. A command that reads, such +as `me`, `project list`, or `experiments get`, shows its result in one box, titled +with a line that sums it up, the project, and the environment when it is not +production; a result with nothing in it is just that line. A command that does +work, such as `login`, `init`, `package`, or `models push`, runs as a session: it +says what it does and where, shows its progress and prompts as it goes, and ends on +its outcome, or on the commands to run next. An error shows its message and hint +together, and a session that fails ends on what it did not do. Tables shorten long +descriptions to fit the box and show times in your time zone. What a command +streams, such as `experiments logs --follow`, is written as it arrives. Redirected +output is plain text without colors or symbols, is never shortened, and ends with +the line that sums it up. | Error code | Exit code | | --- | --- | diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index a06702a..8538e1a 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -4,11 +4,11 @@ use clap::{CommandFactory, Parser, Subcommand}; use tracel_client::console::Env; use crate::commands; -use crate::context::CliContext; +use crate::context::{CliContext, environment_name}; use crate::error::{CliError, ErrorKind, ErrorReport}; -use crate::helpers::project::parse_project; +use crate::helpers::project::{parse_project, resolve_project}; use crate::tools::tracel_config::TracelProject; -use crate::ui::{self, Format, FormatArg, Outcome, Output, StdoutClosed, Terminal}; +use crate::ui::{self, Format, FormatArg, Layout, Outcome, Output, Place, StdoutClosed, Terminal}; #[derive(Parser, Debug)] #[clap(name = "tracel", author, version, about, long_about = None)] @@ -84,24 +84,42 @@ pub enum Commands { } impl Commands { - /// The title of the frame a terminal shows the command in. None where stdout is - /// a bare value or another program's output. - fn title(&self) -> Option<&'static str> { - Some(match self { - Self::Train(_) => return None, - Self::Auth(args) => return args.title(), - Self::Package(_) => "Package project", - Self::Login(_) => "Login", - Self::Logout => "Logout", - Self::Experiments(args) => args.title(), - Self::Init(_) => "Project initialization", - Self::Unlink(_) => "Unlink", - Self::Me => "User information", - Self::Project(args) => args.title(), - Self::Models(args) => args.title(), - Self::Artifacts(args) => args.title(), - Self::Datasets(args) => args.title(), - }) + /// How the command shows on a terminal: a read as a box, a flow as a session, and + /// nothing where stdout is a bare value or another program's output. + fn layout(&self) -> Layout { + match self { + Self::Train(_) => Layout::Bare, + Self::Auth(args) => args.layout(), + Self::Package(_) => { + Layout::flow("Package the project", "The project was not packaged.") + } + Self::Login(args) => args.layout(), + Self::Logout => Layout::flow("Log out of the Tracel Console", "Logout failed."), + Self::Experiments(args) => args.layout(), + Self::Init(_) => Layout::flow( + "Link this workspace to a Tracel Console project", + "The workspace was not linked.", + ), + Self::Unlink(_) => Layout::flow( + "Unlink this workspace from its Tracel Console project", + "The workspace is still linked.", + ), + Self::Me | Self::Project(_) | Self::Datasets(_) => Layout::Read, + Self::Models(args) => args.layout(), + Self::Artifacts(args) => args.layout(), + } + } + + /// Whether the command works in a project, which a terminal then names with it. + fn in_project(&self) -> bool { + matches!( + self, + Self::Package(_) + | Self::Experiments(_) + | Self::Models(_) + | Self::Artifacts(_) + | Self::Datasets(_) + ) } /// What `auto` output means: human on a terminal and JSON otherwise, except where @@ -168,16 +186,22 @@ pub fn cli_main() { }; let format = format.unwrap_or_else(|error| fail_early(&error.into(), auto)); - let (output, terminal) = ui::channels(format, args.no_input, command.title()); let environment_value = std::env::var_os("TRACEL_ENV").map(|value| value.to_string_lossy().into_owned()); let environment = resolve_environment(args.dev, args.staging, environment_value.as_deref()) - .unwrap_or_else(|error| fail(&error.into(), &output, &terminal)); - - if args.dev { - terminal - .print_warning("Running in development mode - using local server and dev credentials"); - } + .unwrap_or_else(|error| fail_early(&error.into(), format)); + let place = Place { + project: command + .in_project() + .then(|| resolve_project(args.project.as_ref()).ok()) + .flatten() + .map(|resolved| format!("{}/{}", resolved.project.owner, resolved.project.name)), + environment: match environment { + Env::Production => None, + _ => Some(environment_name(&environment)), + }, + }; + let (output, terminal) = ui::channels(format, args.no_input, command.layout(), place); let context = CliContext::new(terminal.clone(), output.clone(), environment, args.project); if let Err(error) = handle_command(command, context).and_then(|outcome| output.finish(outcome)) @@ -229,7 +253,7 @@ fn fail(error: &anyhow::Error, output: &Output, terminal: &Terminal) -> ! { /// Fails before the command's channels exist. fn fail_early(error: &anyhow::Error, format: Format) -> ! { - let (output, terminal) = ui::channels(format, true, None); + let (output, terminal) = ui::channels(format, true, Layout::Bare, Place::default()); fail(error, &output, &terminal) } diff --git a/crates/tracel-cli/src/commands/artifacts.rs b/crates/tracel-cli/src/commands/artifacts.rs index 71117e5..700822b 100644 --- a/crates/tracel-cli/src/commands/artifacts.rs +++ b/crates/tracel-cli/src/commands/artifacts.rs @@ -13,7 +13,7 @@ use crate::helpers::{ DownloadFile, DownloadResult, Resource, download_files, map_resource_error, resolve_namespace_project, select_artifact, validate_rel_path, }; -use crate::ui::{Human, Outcome, Render, Table, counted, when}; +use crate::ui::{Human, Layout, Outcome, Render, Table, counted, when}; #[derive(Args, Debug)] pub struct ArtifactsArgs { @@ -22,10 +22,16 @@ pub struct ArtifactsArgs { } impl ArtifactsArgs { - pub fn title(&self) -> &'static str { - match self.command { - ArtifactsCommands::List(_) => "Artifacts", - ArtifactsCommands::Download(_) => "Artifact download", + pub fn layout(&self) -> Layout { + match &self.command { + ArtifactsCommands::List(_) => Layout::Read, + ArtifactsCommands::Download(args) => Layout::flow( + format!( + "Download artifact '{}' of experiment {}", + args.artifact, args.experiment + ), + format!("Artifact '{}' was not downloaded.", args.artifact), + ), } } } diff --git a/crates/tracel-cli/src/commands/auth.rs b/crates/tracel-cli/src/commands/auth.rs index a89573f..c22bd88 100644 --- a/crates/tracel-cli/src/commands/auth.rs +++ b/crates/tracel-cli/src/commands/auth.rs @@ -9,7 +9,7 @@ use tracel_client::console::TracelCredentials; use crate::commands::login::environment_suffix; use crate::context::{CliContext, ClientCreationError}; use crate::error::{CliError, ErrorKind}; -use crate::ui::{Details, Human, Outcome, Render, Timestamp}; +use crate::ui::{Details, Human, Layout, Outcome, Render, Timestamp}; #[derive(Args, Debug)] pub struct AuthArgs { @@ -27,11 +27,11 @@ pub enum AuthCommands { } impl AuthArgs { - /// None for `token`, whose bare value is read by other programs. - pub fn title(&self) -> Option<&'static str> { + /// `token` draws nothing: its bare value is read by other programs. + pub fn layout(&self) -> Layout { match self.command { - AuthCommands::Token => None, - AuthCommands::Status => Some("Authentication status"), + AuthCommands::Token => Layout::Bare, + AuthCommands::Status => Layout::Read, } } } @@ -69,9 +69,6 @@ struct AuthStatus { login_ends_at: Option, user: User, environment: String, - /// " (development environment)" and the like, empty for production. - #[serde(skip)] - environment_suffix: String, } #[derive(Serialize)] @@ -103,10 +100,7 @@ impl Render for AuthStatus { } fn summary(&self) -> Option { - Some(format!( - "Commands act as {}{}.", - self.user.username, self.environment_suffix - )) + Some(format!("Commands act as {}.", self.user.username)) } } @@ -152,7 +146,6 @@ fn status(context: &CliContext) -> anyhow::Result { namespace: client.user().namespace.clone(), }, environment: context.environment_name(), - environment_suffix: environment_suffix(&context.environment()), }), Err(ClientCreationError::NoCredentials | ClientCreationError::InvalidCredentials) => { Err(CliError::new( diff --git a/crates/tracel-cli/src/commands/datasets.rs b/crates/tracel-cli/src/commands/datasets.rs index 8f5abaa..ce36641 100644 --- a/crates/tracel-cli/src/commands/datasets.rs +++ b/crates/tracel-cli/src/commands/datasets.rs @@ -17,16 +17,6 @@ pub struct DatasetsArgs { pub command: DatasetsCommands, } -impl DatasetsArgs { - pub fn title(&self) -> &'static str { - match self.command { - DatasetsCommands::List(_) => "Datasets", - DatasetsCommands::Get(_) => "Dataset", - DatasetsCommands::Versions(_) => "Dataset versions", - } - } -} - #[derive(Subcommand, Debug)] pub enum DatasetsCommands { /// List datasets in the selected project. diff --git a/crates/tracel-cli/src/commands/experiments.rs b/crates/tracel-cli/src/commands/experiments.rs index 9591ffe..5f13b4c 100644 --- a/crates/tracel-cli/src/commands/experiments.rs +++ b/crates/tracel-cli/src/commands/experiments.rs @@ -19,7 +19,7 @@ use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::project::resolve_namespace_project; use crate::tools::tracel_config::TracelProject; -use crate::ui::{Details, Human, Outcome, Output, Render, Table, counted, when}; +use crate::ui::{Details, Human, Layout, Outcome, Output, Render, Table, counted, when}; #[derive(Args, Debug)] pub struct ExperimentsArgs { @@ -28,12 +28,13 @@ pub struct ExperimentsArgs { } impl ExperimentsArgs { - pub fn title(&self) -> &'static str { - match self.command { - ExperimentsCommands::List(_) => "Experiments", - ExperimentsCommands::Get(_) => "Experiment", - ExperimentsCommands::Metrics(_) => "Experiment metrics", - ExperimentsCommands::Logs(_) => "Experiment logs", + pub fn layout(&self) -> Layout { + match &self.command { + ExperimentsCommands::Logs(args) if args.follow => Layout::flow( + format!("Follow the logs of experiment {}", args.num), + format!("Stopped following experiment {}.", args.num), + ), + _ => Layout::Read, } } } diff --git a/crates/tracel-cli/src/commands/login.rs b/crates/tracel-cli/src/commands/login.rs index 3f24dd2..7a45f41 100644 --- a/crates/tracel-cli/src/commands/login.rs +++ b/crates/tracel-cli/src/commands/login.rs @@ -12,7 +12,7 @@ use url::Url; use self::pending::{PendingLogin, PendingLoginStore}; use crate::context::{CliContext, ClientCreationError}; use crate::error::{CliError, ErrorKind, classify}; -use crate::ui::{Outcome, Render, Terminal, Timestamp}; +use crate::ui::{Layout, Outcome, Render, Terminal, Timestamp}; #[derive(Args, Debug)] pub struct LoginArgs { @@ -27,6 +27,24 @@ pub struct LoginArgs { pub timeout: Option, } +impl LoginArgs { + pub fn layout(&self) -> Layout { + if self.no_wait { + Layout::flow( + "Start a login to the Tracel Console", + "No login was started.", + ) + } else if self.complete { + Layout::flow( + "Finish the pending login to the Tracel Console", + "Login failed.", + ) + } else { + Layout::flow("Log in to the Tracel Console", "Login failed.") + } + } +} + pub fn get_client_and_login_if_needed(context: &CliContext) -> anyhow::Result { const MAX_RETRIES: u32 = 3; let mut attempts = 0; @@ -171,7 +189,10 @@ fn start_pending_login(context: &CliContext) -> anyhow::Result { )?), console::style(&pending.authorization.user_code).bold() )); - terminal.outro("Then run 'tracel login --complete'."); + terminal.next_steps( + "The login waits for your approval.", + &["tracel login --complete".to_string()], + ); Ok(LoginPending { verification_uri_complete: pending.authorization.verification_uri_complete, diff --git a/crates/tracel-cli/src/commands/me.rs b/crates/tracel-cli/src/commands/me.rs index 8e64824..e32cd59 100644 --- a/crates/tracel-cli/src/commands/me.rs +++ b/crates/tracel-cli/src/commands/me.rs @@ -3,7 +3,7 @@ use std::io; use anyhow::Context; use serde::Serialize; -use crate::commands::login::{environment_suffix, get_client_and_login_if_needed}; +use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::ui::{Details, Human, Outcome, Render}; @@ -14,9 +14,6 @@ struct CurrentUser { namespace: String, environment: String, api_url: String, - /// " (development environment)" and the like, empty for production. - #[serde(skip)] - environment_suffix: String, } impl Render for CurrentUser { @@ -31,10 +28,7 @@ impl Render for CurrentUser { } fn summary(&self) -> Option { - Some(format!( - "Signed in as {}{}.", - self.username, self.environment_suffix - )) + Some(format!("Signed in as {}.", self.username)) } } @@ -50,7 +44,6 @@ pub fn handle_command(context: CliContext) -> anyhow::Result { namespace: user.namespace, environment: context.environment_name(), api_url: context.get_api_endpoint(), - environment_suffix: environment_suffix(&context.environment()), } .into()) } diff --git a/crates/tracel-cli/src/commands/models.rs b/crates/tracel-cli/src/commands/models.rs index bcc1279..32b6018 100644 --- a/crates/tracel-cli/src/commands/models.rs +++ b/crates/tracel-cli/src/commands/models.rs @@ -25,7 +25,7 @@ use crate::helpers::{ }; use crate::tools::fs::{build_file_specs, collect_files}; use crate::tools::tracel_config::TracelProject; -use crate::ui::{Details, Human, Outcome, Render, Table, counted, when}; +use crate::ui::{Details, Human, Layout, Outcome, Render, Table, counted, when}; #[derive(Args, Debug)] pub struct ModelsArgs { @@ -34,18 +34,39 @@ pub struct ModelsArgs { } impl ModelsArgs { - pub fn title(&self) -> &'static str { + pub fn layout(&self) -> Layout { match &self.command { - ModelsCommands::List => "Models", - ModelsCommands::Get(_) => "Model", - ModelsCommands::Versions(_) => "Model versions", - ModelsCommands::Pull(_) => "Model download", - ModelsCommands::Push(_) => "Model upload", - ModelsCommands::Promote(_) => "Model promotion", + ModelsCommands::Pull(args) => Layout::flow( + format!("Download model '{}' {}", args.model, args.version), + format!("Model '{}' was not downloaded.", args.model), + ), + ModelsCommands::Push(args) => Layout::flow( + format!("Upload a version of model '{}'", args.model_name), + format!("No version of model '{}' was uploaded.", args.model_name), + ), + ModelsCommands::Promote(args) => Layout::flow( + format!( + "Promote artifact '{}' of experiment {} to model '{}'", + args.artifact, args.experiment, args.model + ), + format!("Artifact '{}' was not promoted.", args.artifact), + ), + ModelsCommands::Alias(AliasArgs { + command: AliasCommands::Set(args), + }) => Layout::flow( + format!( + "Point alias '{}' of model '{}' at v{}", + args.alias, args.model, args.version + ), + format!("Alias '{}' was not changed.", args.alias), + ), ModelsCommands::Alias(AliasArgs { - command: AliasCommands::List(_), - }) => "Model aliases", - ModelsCommands::Alias(_) => "Model alias", + command: AliasCommands::Remove(args), + }) => Layout::flow( + format!("Remove alias '{}' of model '{}'", args.alias, args.model), + format!("Alias '{}' was not removed.", args.alias), + ), + _ => Layout::Read, } } } diff --git a/crates/tracel-cli/src/commands/project.rs b/crates/tracel-cli/src/commands/project.rs index 926d015..9663f11 100644 --- a/crates/tracel-cli/src/commands/project.rs +++ b/crates/tracel-cli/src/commands/project.rs @@ -19,15 +19,6 @@ pub struct ProjectArgs { pub command: Option, } -impl ProjectArgs { - pub fn title(&self) -> &'static str { - match self.command { - None => "Project", - Some(ProjectCommands::List(_)) => "Projects", - } - } -} - #[derive(Subcommand, Debug)] pub enum ProjectCommands { /// List your projects and your organizations' projects. diff --git a/crates/tracel-cli/src/context.rs b/crates/tracel-cli/src/context.rs index 356ff4f..15ae615 100644 --- a/crates/tracel-cli/src/context.rs +++ b/crates/tracel-cli/src/context.rs @@ -111,14 +111,19 @@ impl CliContext { } pub fn environment_name(&self) -> String { - match self.environment { - Env::Production => "production".to_string(), - Env::Development => "development".to_string(), - Env::Staging(version) => format!("staging-{version}"), - } + environment_name(&self.environment) } pub fn get_api_endpoint(&self) -> String { self.environment.get_url().to_string() } } + +/// The name of an environment, as `TRACEL_ENV` and JSON data write it. +pub fn environment_name(environment: &Env) -> String { + match environment { + Env::Production => "production".to_string(), + Env::Development => "development".to_string(), + Env::Staging(version) => format!("staging-{version}"), + } +} diff --git a/crates/tracel-cli/src/helpers/model_upload/upload.rs b/crates/tracel-cli/src/helpers/model_upload/upload.rs index b67464d..405a1b6 100644 --- a/crates/tracel-cli/src/helpers/model_upload/upload.rs +++ b/crates/tracel-cli/src/helpers/model_upload/upload.rs @@ -157,7 +157,7 @@ pub fn upload_parts( #[cfg(test)] mod tests { use super::*; - use crate::ui::{Format, channels}; + use crate::ui::{Format, Layout, Place, channels}; use std::fs; use std::path::PathBuf; use std::sync::Mutex as StdMutex; @@ -222,7 +222,11 @@ mod tests { ]; let uploader = FakeUploader::new(None); - let result = upload_parts(&uploader, tasks, &channels(Format::Json, true, None).1); + let result = upload_parts( + &uploader, + tasks, + &channels(Format::Json, true, Layout::Bare, Place::default()).1, + ); assert!(result.is_ok()); assert_eq!(uploader.calls.lock().unwrap().len(), 2); @@ -239,7 +243,11 @@ mod tests { ]; let uploader = FakeUploader::new(Some("https://example.com/b")); - let result = upload_parts(&uploader, tasks, &channels(Format::Json, true, None).1); + let result = upload_parts( + &uploader, + tasks, + &channels(Format::Json, true, Layout::Bare, Place::default()).1, + ); assert!(result.is_err()); diff --git a/crates/tracel-cli/src/helpers/project.rs b/crates/tracel-cli/src/helpers/project.rs index 96afd96..bb8e56a 100644 --- a/crates/tracel-cli/src/helpers/project.rs +++ b/crates/tracel-cli/src/helpers/project.rs @@ -132,8 +132,13 @@ fn project_not_found() -> CliError { /// Resolve project identity from flags, environment variables, or tracel.toml. pub fn resolve_namespace_project(context: &CliContext) -> anyhow::Result { - resolve_project( - context.project(), + resolve_project(context.project()) +} + +/// The project `flag` names, or else the one the environment or `tracel.toml` names. +pub fn resolve_project(flag: Option<&TracelProject>) -> anyhow::Result { + resolve_project_from( + flag, std::env::var("TRACEL_NAMESPACE").ok(), std::env::var("TRACEL_PROJECT").ok(), || { @@ -164,7 +169,7 @@ pub fn resolve_namespace_project(context: &CliContext) -> anyhow::Result, namespace: Option, project: Option, @@ -261,7 +266,7 @@ mod tests { #[test] fn complete_overrides_skip_file_discovery() { let flag = parse_project("alice/demo").unwrap(); - let resolved = resolve_project( + let resolved = resolve_project_from( Some(&flag), Some("other".into()), Some("project".into()), @@ -272,10 +277,11 @@ mod tests { assert_eq!(resolved.project.name, "demo"); assert_eq!(resolved.source, ProjectSource::Flag); - let resolved = resolve_project(None, Some("alice".into()), Some("demo".into()), || { - panic!("File discovery must be skipped") - }) - .unwrap(); + let resolved = + resolve_project_from(None, Some("alice".into()), Some("demo".into()), || { + panic!("File discovery must be skipped") + }) + .unwrap(); assert_eq!(resolved.project.owner, "alice"); assert_eq!(resolved.project.name, "demo"); assert_eq!(resolved.source, ProjectSource::Env); @@ -300,7 +306,7 @@ mod tests { ), (None, Some("demo"), "file-owner", "demo", ProjectSource::Env), ] { - let resolved = resolve_project( + let resolved = resolve_project_from( None, namespace.map(str::to_owned), project.map(str::to_owned), @@ -325,7 +331,7 @@ mod tests { ("namespace = 'alice'", None, Some("demo")), ("owner = 'alice'\nname = 'demo'", None, None), ] { - let resolved = resolve_project( + let resolved = resolve_project_from( None, namespace.map(str::to_owned), project.map(str::to_owned), @@ -340,7 +346,7 @@ mod tests { #[test] fn incomplete_identity_reports_not_found_and_hint() { for (namespace, project) in [(None, None), (Some("alice"), None), (None, Some("demo"))] { - let error = resolve_project( + let error = resolve_project_from( None, namespace.map(str::to_owned), project.map(str::to_owned), diff --git a/crates/tracel-cli/src/ui/human.rs b/crates/tracel-cli/src/ui/human.rs index a1e90c8..524267a 100644 --- a/crates/tracel-cli/src/ui/human.rs +++ b/crates/tracel-cli/src/ui/human.rs @@ -4,125 +4,38 @@ use std::io::{self, Write}; use serde::Serialize; use serde_json::Value; -use super::screen::GUTTER_WIDTH; - /// The narrowest a shrinkable table column gets, ellipsis included. const MIN_COLUMN_WIDTH: usize = 4; -/// A result for a person: lines cliclack draws in the command's frame on a terminal, -/// plain text everywhere else. Cards, headings and tables are written through -/// `Details`, `heading` and `Table`; other text with `write!`. +/// A box's text is dim, and a styled span ends with a reset, so each span turns dim +/// back on after it. +const BOX_TEXT: &str = "\u{1b}[2m"; + +/// A result as text for a person: plain, as stdout carries it, or styled for the box a +/// terminal shows it in. Cards, headings and tables are written through `Details`, +/// `heading` and `Table`; other text with `write!`. pub struct Human<'a> { - sink: Sink<'a>, + out: &'a mut dyn Write, width: Option, - color: bool, -} - -enum Sink<'a> { - Plain(&'a mut dyn Write), - Cliclack(Drawing), -} - -/// One cliclack log line of a result. -#[derive(Debug, PartialEq, Eq)] -pub enum Draw { - /// A field of a card. - Info(String), - /// A heading, with the lines under it. - Step(String), - /// A table, or other text. - Remark(String), -} - -impl Draw { - /// Draws the line with cliclack, on stderr. A remark's lines that are wider than - /// the frame wrap inside its gutter. - pub fn show(&self) -> io::Result<()> { - match self { - Self::Info(text) => cliclack::log::info(text), - Self::Step(text) => cliclack::log::step(text), - Self::Remark(text) => cliclack::log::remark(in_gutter(text)), - } - } -} - -/// `text` with each line wider than the frame wrapped to fit after cliclack's bar. The -/// width skips escape sequences, so styled text wraps where it shows. -fn in_gutter(text: &str) -> String { - cliclack::termwrap(text, GUTTER_WIDTH) -} - -/// The lines drawn so far, and the one that text written now goes to. -#[derive(Default)] -struct Drawing { - draws: Vec, - open: Option, -} - -/// The line being written: a heading and the lines under it, or text of its own. -struct Open { - heading: bool, - text: Vec, -} - -impl Drawing { - fn open(&mut self, heading: bool, text: impl Into>) { - self.close(); - self.open = Some(Open { - heading, - text: text.into(), - }); - } - - /// Adds `text` to the open line, or to a new one. - fn write(&mut self, text: &[u8]) { - match &mut self.open { - Some(open) => open.text.extend_from_slice(text), - None => self.open(false, text), - } - } - - /// Adds `text` to the heading above it, or starts a line for it. - fn block(&mut self, text: &[u8]) { - if !self.open.as_ref().is_some_and(|open| open.heading) { - self.close(); - } - self.write(text); - } - - /// Ends the open line. One that is only blank lines draws nothing: cliclack - /// leaves one after each of its lines already. - fn close(&mut self) { - let Some(Open { heading, text }) = self.open.take() else { - return; - }; - let text = String::from_utf8_lossy(&text); - let text = text.trim_start_matches('\n').trim_end(); - if !text.is_empty() { - let draw = if heading { Draw::Step } else { Draw::Remark }; - self.draws.push(draw(text.into())); - } - } + styled: bool, } impl<'a> Human<'a> { /// Plain text on `out`. Tables fit in `width` columns, when there is one. pub fn plain(out: &'a mut dyn Write, width: Option) -> Self { Self { - sink: Sink::Plain(out), + out, width, - color: false, + styled: false, } } -} -impl Human<'static> { - /// cliclack's lines, drawn by `Draw::show`. `width` is what fits after its bar. - pub fn cliclack(width: Option) -> Self { + /// Text for a box on the terminal, styled unless stderr shows no colors. + pub fn boxed(out: &'a mut dyn Write, width: Option) -> Self { Self { - sink: Sink::Cliclack(Drawing::default()), + out, width, - color: console::colors_enabled_stderr(), + styled: console::colors_enabled_stderr(), } } } @@ -132,22 +45,9 @@ impl Human<'_> { self.width } - /// The cliclack lines of what was written, in order: none for plain text, which - /// is already out. - pub fn into_draws(self) -> Vec { - match self.sink { - Sink::Plain(_) => Vec::new(), - Sink::Cliclack(mut drawing) => { - drawing.close(); - drawing.draws - } - } - } - - /// `text` in `style` on cliclack, when stderr shows colors. fn styled(&self, text: impl Display, style: console::Style) -> String { - if self.color { - style.force_styling(true).apply_to(text).to_string() + if self.styled { + format!("{}{BOX_TEXT}", style.force_styling(true).apply_to(text)) } else { text.to_string() } @@ -161,75 +61,36 @@ impl Human<'_> { self.styled(text, console::Style::new().dim()) } - /// Names what follows, up to the next heading or card: a step with the lines - /// under it on cliclack, a title ending in a colon in plain text. + /// Names what follows: a title ending in a colon. pub fn heading(&mut self, text: impl Display) -> io::Result<()> { - match &mut self.sink { - Sink::Plain(out) => writeln!(out, "{text}:"), - Sink::Cliclack(drawing) => { - drawing.open(true, format!("{text}\n")); - Ok(()) - } - } + writeln!(self, "{text}:") } - /// One field of a card: a line of its own on cliclack, its value aligned after - /// labels `width` wide in plain text. + /// One field of a card, its value aligned after labels `width` wide. fn field(&mut self, label: &str, value: &str, width: usize) -> io::Result<()> { - match &mut self.sink { - Sink::Plain(out) => { - let padding = " ".repeat(width - label.len()); - writeln!(out, "{label}:{padding} {value}") - } - Sink::Cliclack(drawing) => { - drawing.close(); - drawing.draws.push(Draw::Info(format!("{label}: {value}"))); - Ok(()) - } - } + let padding = " ".repeat(width - label.len()); + writeln!(self, "{label}:{padding} {value}") } - /// Lines under a heading, indented in plain text. + /// Lines under a heading, indented. fn section(&mut self, title: &str, lines: &[String]) -> io::Result<()> { self.heading(title)?; - let indent = match self.sink { - Sink::Plain(_) => " ", - Sink::Cliclack(_) => "", - }; - lines - .iter() - .try_for_each(|line| writeln!(self, "{indent}{line}")) + lines.iter().try_for_each(|line| writeln!(self, " {line}")) } - /// Lines that belong together, such as a table: a cliclack line of their own, - /// unless they sit under a heading. + /// Lines that belong together, such as a table. fn block(&mut self, text: &str) -> io::Result<()> { - match &mut self.sink { - Sink::Plain(out) => out.write_all(text.as_bytes()), - Sink::Cliclack(drawing) => { - drawing.block(text.as_bytes()); - Ok(()) - } - } + self.write_all(text.as_bytes()) } } impl Write for Human<'_> { fn write(&mut self, buffer: &[u8]) -> io::Result { - match &mut self.sink { - Sink::Plain(out) => out.write(buffer), - Sink::Cliclack(drawing) => { - drawing.write(buffer); - Ok(buffer.len()) - } - } + self.out.write(buffer) } fn flush(&mut self) -> io::Result<()> { - match &mut self.sink { - Sink::Plain(out) => out.flush(), - Sink::Cliclack(_) => Ok(()), - } + self.out.flush() } } @@ -346,8 +207,8 @@ fn line(cells: &[String; N], widths: &[usize; N]) -> String { line } -/// Labelled values: a card of fields. Sections list the fields of a JSON value -/// under a heading. +/// Labelled values, aligned. Sections list the fields of a JSON value under a +/// heading. #[derive(Default)] pub struct Details { entries: Vec, @@ -480,6 +341,7 @@ fn cell_text(value: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::ui::screen::BOX_PADDING; fn text(width: Option, write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> String { let mut out = Vec::new(); @@ -491,12 +353,13 @@ mod tests { text(None, write) } - /// The cliclack lines `write` makes, unstyled. - fn drawn(write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> Vec { - let mut out = Human::cliclack(None); - out.color = false; - write(&mut out).unwrap(); - out.into_draws() + /// Text for a box, styled. + fn boxed(width: Option, write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> String { + let mut out = Vec::new(); + let mut human = Human::boxed(&mut out, width); + human.styled = true; + write(&mut human).unwrap(); + String::from_utf8(out).unwrap() } fn table() -> Table<3> { @@ -506,13 +369,14 @@ mod tests { ]) } - const TABLE: &str = "NAME DESCRIPTION ID\n\ - weights two lines 1\n\ - w - 22"; - #[test] fn table_aligns_columns_and_keeps_cells_on_one_line() { - assert_eq!(plain(|out| table().write(out)), format!("{TABLE}\n")); + assert_eq!( + plain(|out| table().write(out)), + "NAME DESCRIPTION ID\n\ + weights two lines 1\n\ + w - 22\n" + ); } #[test] @@ -555,17 +419,6 @@ mod tests { ); } - #[test] - fn plain_text_is_never_styled() { - let text = plain(|out| { - details().write(out)?; - table().write(out)?; - let (bold, dim) = (out.bold("bold"), out.dim("dim")); - writeln!(out, "{bold} {dim}") - }); - assert!(!text.contains('\u{1b}')); - } - fn details() -> Details { Details::new() .field("Name", "weights") @@ -591,118 +444,54 @@ mod tests { } #[test] - fn on_cliclack_details_are_a_line_per_field_and_a_step_per_section() { - assert_eq!( - drawn(|out| details().write(out)), - [ - Draw::Info("Name: weights".into()), - Draw::Info("Description: -".into()), - Draw::Step( - "Config\n\ - lr 0.1\n\ - schedule.warmup 2\n\ - tags [\"a\"]" - .into() - ), - ] - ); + fn plain_text_is_never_styled_and_a_box_is_the_same_text_styled() { + let write = |out: &mut Human<'_>| { + details().write(out)?; + table().write(out) + }; + let plain = plain(write); + let boxed = boxed(None, write); + assert!(!plain.contains('\u{1b}')); + assert!(boxed.contains('\u{1b}')); + assert_eq!(console::strip_ansi_codes(&boxed), plain); } #[test] - fn on_cliclack_a_heading_takes_the_table_under_it() { - let draws = drawn(|out| { - out.heading("Files")?; - table().write(out)?; - Details::new() - .section("Metadata", &serde_json::json!({"seed": 7})) - .write(out) - }); + fn a_styled_span_turns_the_box_dim_back_on() { assert_eq!( - draws, - [ - Draw::Step(format!("Files\n{TABLE}")), - Draw::Step("Metadata\nseed 7".into()), - ] + boxed(None, |out| { + let (bold, dim) = (out.bold("NAME"), out.dim("-")); + writeln!(out, "{bold} {dim} x") + }), + "\u{1b}[1mNAME\u{1b}[0m\u{1b}[2m \u{1b}[2m-\u{1b}[0m\u{1b}[2m x\n" ); } - #[test] - fn on_cliclack_a_table_is_a_line_of_its_own() { - let draws = drawn(|out| { - writeln!(out, "Before.")?; - table().write(out)?; - writeln!(out)?; - Table::new(["GROUP"]).empty("No groups.").write(out) - }); - assert_eq!( - draws, - [ - Draw::Remark("Before.".into()), - Draw::Remark(TABLE.into()), - Draw::Remark("No groups.".into()), - ] - ); + /// The columns inside a box, as cliclack measures them to wrap its text. + fn box_width() -> usize { + usize::from(console::Term::stderr().size().1.saturating_sub(BOX_PADDING)) } #[test] - fn on_cliclack_text_collects_until_the_next_field() { - let draws = drawn(|out| { - write!(out, "par")?; - writeln!(out, "tial")?; - writeln!(out, "next")?; - Details::new().field("Name", "weights").write(out)?; - writeln!(out) + fn a_table_fitted_to_the_box_is_not_wrapped_again() { + let width = box_width(); + let fitted = boxed(Some(width), |out| { + Table::new(["NAME", "DESCRIPTION", "ID"]) + .shrink("DESCRIPTION") + .rows([[ + "weights".into(), + "a rather long description ".repeat(width / 8), + String::new(), + ]]) + .write(out) }); - assert_eq!( - draws, - [ - Draw::Remark("partial\nnext".into()), - Draw::Info("Name: weights".into()), - ] - ); - } - - /// The columns `in_gutter` wraps to, measured as `cliclack::termwrap` measures them. - fn gutter_width() -> usize { - usize::from( - console::Term::stderr() - .size() - .1 - .saturating_sub(GUTTER_WIDTH), - ) - } - - #[test] - fn wrapping_in_the_gutter_keeps_a_styled_table_fitted_to_the_frame() { - let width = gutter_width(); - let mut out = Human::cliclack(Some(width)); - out.color = true; - Table::new(["NAME", "DESCRIPTION", "ID"]) - .shrink("DESCRIPTION") - .rows([[ - "weights".into(), - "a rather long description ".repeat(width / 8), - String::new(), - ]]) - .write(&mut out) - .unwrap(); - let [Draw::Remark(table)] = <[Draw; 1]>::try_from(out.into_draws()).unwrap() else { - panic!("expected one remark"); - }; - assert!(table.contains('\u{1b}')); - assert_eq!(in_gutter(&table), table); - } - - #[test] - fn text_wider_than_the_frame_wraps_in_the_gutter() { - let width = gutter_width(); - let wrapped = in_gutter("word ".repeat(width).trim_end()); - assert!(wrapped.lines().count() > 1); + let fitted = fitted.trim_end(); assert!( - wrapped + fitted .lines() - .all(|line| console::measure_text_width(line) <= width) + .any(|line| console::measure_text_width(line) == width) ); + assert_eq!(cliclack::termwrap(fitted, BOX_PADDING), fitted); } #[test] diff --git a/crates/tracel-cli/src/ui/layout.rs b/crates/tracel-cli/src/ui/layout.rs new file mode 100644 index 0000000..e1649d0 --- /dev/null +++ b/crates/tracel-cli/src/ui/layout.rs @@ -0,0 +1,69 @@ +use std::fmt::{self, Display}; + +/// How a command shows on a terminal: a read is a box, a flow is a session. +pub enum Layout { + /// A command that reads: its result in one box, under its summary. + Read, + /// A command that does work: a session that first says what it does and where. + Flow(Flow), + /// Nothing drawn: the output is a bare value, or another program's. + Bare, +} + +impl Layout { + pub fn flow(what: impl Into, failure: impl Into) -> Self { + Self::Flow(Flow { + what: what.into(), + failure: failure.into(), + }) + } +} + +/// What a flow does, such as "Run job 'train'", and the outcome that closes it when it +/// fails, such as "Job 'train' did not complete." +pub struct Flow { + pub what: String, + pub failure: String, +} + +/// Where a command works: the project it uses, and its environment unless that is +/// production. +#[derive(Default)] +pub struct Place { + pub project: Option, + pub environment: Option, +} + +impl Display for Place { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let parts: Vec<&str> = [&self.project, &self.environment] + .into_iter() + .flatten() + .map(String::as_str) + .collect(); + f.write_str(&parts.join(" · ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_place_names_what_it_has() { + let place = |project: Option<&str>, environment: Option<&str>| { + Place { + project: project.map(str::to_string), + environment: environment.map(str::to_string), + } + .to_string() + }; + assert_eq!( + place(Some("test/demo"), Some("development")), + "test/demo · development" + ); + assert_eq!(place(Some("test/demo"), None), "test/demo"); + assert_eq!(place(None, Some("development")), "development"); + assert_eq!(place(None, None), ""); + } +} diff --git a/crates/tracel-cli/src/ui/mod.rs b/crates/tracel-cli/src/ui/mod.rs index 9ec8be7..446337c 100644 --- a/crates/tracel-cli/src/ui/mod.rs +++ b/crates/tracel-cli/src/ui/mod.rs @@ -1,11 +1,13 @@ -//! What the person running a command sees. On a terminal, cliclack draws one frame -//! per command, and in it progress, prompts and diagnostics through `Terminal` and -//! the result through `Output`; what a command streams is written as it comes. -//! Otherwise stdout carries the result and nothing else, so it can be piped or -//! captured: an envelope with JSON output, or the plain text of its `Render` impl. +//! What the person running a command sees. On a terminal, a read draws its result in +//! one box, and a flow, a command that does work, draws a session: what it does and +//! where, its progress and prompts through `Terminal`, and its outcome. What a command +//! streams is written as it comes. Otherwise stdout carries the result and nothing +//! else, so it can be piped or captured: an envelope with JSON output, or the plain +//! text of its `Render` impl. mod format; mod human; +mod layout; mod screen; mod stderr; mod stdout; @@ -15,17 +17,21 @@ use std::sync::Arc; pub use format::{Format, FormatArg}; pub use human::{Details, Human, Table, counted}; +pub use layout::{Layout, Place}; pub use stderr::Terminal; pub use stdout::{Outcome, Output, Render, StdoutClosed}; pub use time::{Timestamp, when}; -/// Stdout and stderr for one command, drawing on the same screen. With a `title`, a -/// terminal shows the command in a frame under it, from now until the command ends. -pub fn channels(format: Format, no_input: bool, title: Option<&str>) -> (Output, Terminal) { - let screen = Arc::new(screen::Screen::new(format)); - if let Some(title) = title { - screen.intro(title); - } +/// Stdout and stderr for one command, drawing on the same screen. A flow's session +/// opens on the terminal now, saying what it does and where. +pub fn channels( + format: Format, + no_input: bool, + layout: Layout, + place: Place, +) -> (Output, Terminal) { + let screen = Arc::new(screen::Screen::new(format, layout, place)); + screen.begin(); ( Output::new(Arc::clone(&screen)), Terminal::new(screen, no_input), diff --git a/crates/tracel-cli/src/ui/screen.rs b/crates/tracel-cli/src/ui/screen.rs index c029cc9..365ffe0 100644 --- a/crates/tracel-cli/src/ui/screen.rs +++ b/crates/tracel-cli/src/ui/screen.rs @@ -5,19 +5,23 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use cliclack::{MultiProgress, ProgressBar}; use super::Format; +use super::layout::{Layout, Place}; -/// The columns cliclack's bar and the space after it take at the start of a line. -pub const GUTTER_WIDTH: u16 = 3; +/// The columns a cliclack box takes around its text: its bar and two spaces on the +/// left, two spaces and its bar on the right. +pub const BOX_PADDING: u16 = 6; /// The terminal a command draws on. With human output on a styled stderr, cliclack -/// draws a frame and widgets there, and the result too when stdout is the same -/// terminal. Only one thing draws at a time: anything new settles the live widget +/// draws there: a flow's session and widgets, and a read's result when stdout is the +/// same terminal. Only one thing draws at a time: anything new settles the live widget /// first. pub struct Screen { format: Format, styled: bool, stdout_is_terminal: bool, - frame: AtomicBool, + layout: Layout, + place: Place, + session: AtomicBool, live: Mutex>, next_id: AtomicU64, } @@ -36,19 +40,24 @@ pub enum Ending { Failed, } -/// How a frame ends: a green badge, a bare corner, or a red badge. -pub enum Outro<'a> { - Done(Option<&'a str>), - Cancelled(&'a str), +/// How a result reaches the person. +pub enum Delivery { + /// In one box on the terminal, its text fitted to the columns inside the box. + Box(Option), + /// On stdout, which carries nothing else: an envelope, or plain text ending with + /// its summary. + Stdout, } impl Screen { - pub fn new(format: Format) -> Self { + pub fn new(format: Format, layout: Layout, place: Place) -> Self { Self { format, styled: format == Format::Human && io::stderr().is_terminal(), stdout_is_terminal: io::stdout().is_terminal(), - frame: AtomicBool::new(false), + layout, + place, + session: AtomicBool::new(false), live: Mutex::new(None), next_id: AtomicU64::new(0), } @@ -68,19 +77,18 @@ impl Screen { self.styled && self.stdout_is_terminal } - pub fn frame_open(&self) -> bool { - self.frame.load(Ordering::Relaxed) + pub fn session_open(&self) -> bool { + self.session.load(Ordering::Relaxed) } - /// Whether cliclack draws the result in the frame, which stdout shares. - pub fn frames_results(&self) -> bool { - self.shares_stdout() && self.frame_open() - } - - /// The columns inside the frame, after cliclack's bar. - pub fn frame_width(&self) -> Option { - columns(&console::Term::stderr()) - .map(|columns| columns.saturating_sub(usize::from(GUTTER_WIDTH))) + pub fn delivery(&self) -> Delivery { + match self.layout { + Layout::Read if self.shares_stdout() => Delivery::Box( + columns(&console::Term::stderr()) + .map(|columns| columns.saturating_sub(usize::from(BOX_PADDING))), + ), + _ => Delivery::Stdout, + } } /// The columns of the terminal stdout is, if it is one. @@ -92,33 +100,91 @@ impl Screen { } } - /// Opens the frame a command draws in, under a green title badge. - pub fn intro(&self, title: &str) { + /// Opens a flow's session: the tracel badge, then what the flow does and where. + pub fn begin(&self) { + let Layout::Flow(flow) = &self.layout else { + return; + }; if !self.styled { return; } - self.settle(Ending::Done); - let badge = console::style(format!(" ▶ {title} ")).black().on_green(); + let mut what = flow.what.clone(); + if let Some(project) = &self.place.project { + what.push_str(&format!( + " in {}", + console::style(project).for_stderr().bold() + )); + } + if let Some(environment) = &self.place.environment { + let environment = format!(" · {environment}"); + what.push_str(&console::style(environment).for_stderr().dim().to_string()); + } + let badge = console::style(" tracel ").for_stderr().black().on_green(); let _ = cliclack::intro(badge); - self.frame.store(true, Ordering::Relaxed); + let _ = cliclack::log::remark(what); + self.session.store(true, Ordering::Relaxed); } - /// Ends the frame, open or not, so a command always finishes on its badge. - pub fn outro(&self, outro: Outro<'_>) { - if !self.styled { - return; - } - self.settle(Ending::Done); - let _ = match outro { - Outro::Done(Some(message)) => { - cliclack::outro(console::style(format!(" {message} ")).black().on_green()) + /// Shows a read's result: `body` in one box under `summary` and the place, or the + /// summary alone when there is no body. + pub fn show_result(&self, summary: Option<&str>, body: &str) { + let place = self.place.to_string(); + let dim = |text: &str| console::style(text).for_stderr().dim().to_string(); + let summary = summary.unwrap_or_default(); + self.draw(|| { + if body.trim().is_empty() { + let line = [summary, place.as_str()] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join(" "); + return cliclack::outro(dim(&line)); } - Outro::Done(None) => cliclack::outro(""), - Outro::Cancelled(message) => { - cliclack::outro_cancel(console::style(format!(" {message} ")).black().on_red()) + let summary = summary.strip_suffix('.').unwrap_or(summary); + let title = match (summary.is_empty(), place.is_empty()) { + (false, false) => format!( + "{} {}", + console::style(summary).for_stderr().bold(), + dim(&place) + ), + (false, true) => console::style(summary).for_stderr().bold().to_string(), + (true, _) => dim(&place), + }; + cliclack::outro_note(title, body.trim_end()) + }); + } + + /// Ends a flow's session with its outcome. + pub fn close(&self, outcome: &str) { + if self.session.swap(false, Ordering::Relaxed) { + self.draw(|| cliclack::outro(outcome)); + } + } + + /// Ends a flow's session on an outcome that stops short of what it set out to do. + pub fn close_short(&self, outcome: &str) { + if self.session.swap(false, Ordering::Relaxed) { + self.draw(|| cliclack::outro_cancel(outcome)); + } + } + + /// Ends a flow's session with the commands to run next, one per line. + pub fn close_with_next_steps(&self, steps: &str) { + if self.session.swap(false, Ordering::Relaxed) { + self.draw(|| cliclack::outro_note("Next steps", steps)); + } + } + + /// Closes what a failure was just reported in: a flow's session with its failure, or + /// a bare corner. + pub fn close_failed(&self) { + let outcome = match &self.layout { + Layout::Flow(flow) if self.session.swap(false, Ordering::Relaxed) => { + flow.failure.as_str() } + _ => "", }; - self.frame.store(false, Ordering::Relaxed); + self.draw(|| cliclack::outro_cancel(outcome)); } pub fn next_id(&self) -> u64 { diff --git a/crates/tracel-cli/src/ui/stderr.rs b/crates/tracel-cli/src/ui/stderr.rs index 612ac7f..4613ee4 100644 --- a/crates/tracel-cli/src/ui/stderr.rs +++ b/crates/tracel-cli/src/ui/stderr.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use cliclack::ProgressBar; use super::Format; -use super::screen::{Ending, Outro, Screen}; +use super::screen::{Ending, Screen}; use crate::error::{CliError, ErrorKind, ErrorReport}; /// Stderr, for the person running a command: what it is doing, its progress, and @@ -90,20 +90,19 @@ impl Terminal { } /// Reports a failed command: the message and hint here, the envelope on stdout - /// with JSON output. On a terminal the frame ends on a red badge: the hint after - /// the error, or the error itself. + /// with JSON output. On a terminal both are one error, then a flow's session ends + /// on its failure, and anything else on a bare corner. pub fn error(&self, report: &ErrorReport<'_>) { self.screen.settle(Ending::Failed); if !self.is_human() { self.line(format_args!("error: {}", one_line(&report.message))); } else if self.is_styled() { - match report.hint { - Some(hint) => { - self.screen.draw(|| cliclack::log::error(&report.message)); - self.screen.outro(Outro::Cancelled(hint)); - } - None => self.screen.outro(Outro::Cancelled(&report.message)), - } + let text = match report.hint { + Some(hint) => format!("{}\n{hint}", report.message), + None => report.message.clone(), + }; + self.screen.draw(|| cliclack::log::error(text)); + self.screen.close_failed(); } else { self.line(format_args!("error: {}", report.message)); if let Some(hint) = report.hint { @@ -249,19 +248,33 @@ impl Terminal { .map_err(anyhow::Error::from) } - /// Ends the command's frame on a green badge, for an action that did its work. + /// Ends a flow's session with what it did. pub fn outro(&self, message: &str) { - if self.is_styled() && self.screen.frame_open() { - self.screen.outro(Outro::Done(Some(message))); + if self.screen.session_open() { + self.screen.close(message); } else { self.print_success(message); } } - /// Ends the command's frame on a red badge, for an action that stopped short. + /// Ends a flow's session with what it did, then the commands to run next. + pub fn next_steps(&self, message: &str, steps: &[String]) { + if self.screen.session_open() { + self.print_success(message); + self.screen.close_with_next_steps(&steps.join("\n")); + } else if self.is_human() { + self.print_success(message); + self.line("Next steps:"); + for step in steps { + self.line(format_args!(" {step}")); + } + } + } + + /// Ends a flow's session on an outcome that stops short of what it set out to do. pub fn outro_cancel(&self, message: &str) { - if self.is_styled() && self.screen.frame_open() { - self.screen.outro(Outro::Cancelled(message)); + if self.screen.session_open() { + self.screen.close_short(message); } else { self.print_err(message); } diff --git a/crates/tracel-cli/src/ui/stdout.rs b/crates/tracel-cli/src/ui/stdout.rs index 7450abb..42dfb5a 100644 --- a/crates/tracel-cli/src/ui/stdout.rs +++ b/crates/tracel-cli/src/ui/stdout.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use serde::Serialize; use super::Format; -use super::human::{Draw, Human}; -use super::screen::{Outro, Screen}; +use super::human::Human; +use super::screen::{Delivery, Screen}; use crate::error::ErrorReport; /// A command result: serialized for JSON output, written for people. @@ -17,8 +17,8 @@ pub trait Render: Serialize { Ok(()) } - /// One line that sums the result up: the badge that closes the frame on a - /// terminal, and the last line of plain text. + /// One line that sums the result up: the title of its box on a terminal, the + /// outcome that ends a session, and the last line of plain text. fn summary(&self) -> Option { None } @@ -83,8 +83,8 @@ struct Failure<'a> { #[error("stdout was closed")] pub struct StdoutClosed; -/// Where command results go, in the format the user chose: drawn in the command's -/// frame on a terminal, and otherwise on stdout, which carries nothing else. +/// Where command results go, in the format the user chose: a read's in one box on a +/// terminal, and otherwise on stdout, which carries nothing else. #[derive(Clone)] pub struct Output { screen: Arc, @@ -95,24 +95,23 @@ impl Output { Self { screen } } - /// Writes the result of a finished command, and closes the frame it was drawn in - /// with its summary. + /// Writes the result of a finished command, and ends the session it ran in with + /// its summary. pub fn finish(&self, outcome: Outcome) -> anyhow::Result<()> { let summary = outcome.0.as_ref().and_then(|report| report.summary()); if let Some(report) = outcome.0 { - if self.screen.frames_results() { - let draws = drawn(report.as_ref(), self.screen.frame_width())?; - self.screen.draw(|| draws.iter().try_for_each(Draw::show)); - } else { - let (format, width) = (self.screen.format(), self.screen.stdout_width()); - self.write_stdout(|out| { + let (format, width) = (self.screen.format(), self.screen.stdout_width()); + match self.screen.delivery() { + Delivery::Box(width) => { + let body = boxed(report.as_ref(), width)?; + self.screen.show_result(summary.as_deref(), &body); + } + Delivery::Stdout => self.write_stdout(|out| { write_result(format, report.as_ref(), summary.as_deref(), width, out) - })?; + })?, } } - if self.screen.frame_open() { - self.screen.outro(Outro::Done(summary.as_deref())); - } + self.screen.close(summary.as_deref().unwrap_or_default()); Ok(()) } @@ -144,11 +143,11 @@ impl Output { } } -/// The cliclack lines of a result, fitted to `width`. -fn drawn(report: &dyn Report, width: Option) -> io::Result> { - let mut out = Human::cliclack(width); - report.write_text(&mut out)?; - Ok(out.into_draws()) +/// The text of a result for its box, fitted to `width`. +fn boxed(report: &dyn Report, width: Option) -> io::Result { + let mut text = Vec::new(); + report.write_text(&mut Human::boxed(&mut text, width))?; + Ok(String::from_utf8_lossy(&text).into_owned()) } /// A result on stdout: its envelope, or its plain text ending with `summary`. @@ -261,12 +260,12 @@ mod tests { } #[test] - fn in_a_frame_a_result_is_cliclack_lines() { + fn a_box_holds_the_text_of_a_result() { assert_eq!( - drawn(&Greeting { name: "Ada" }, None).unwrap(), - [Draw::Remark("Hello, Ada.".into())] + boxed(&Greeting { name: "Ada" }, None).unwrap(), + "Hello, Ada.\n" ); - assert_eq!(drawn(&Done {}, None).unwrap(), []); + assert_eq!(boxed(&Done {}, None).unwrap(), ""); } #[test] From 24607af95f0498dc152cac1196abdd5b4a0eed24 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 13 Oct 2026 12:05:42 -0400 Subject: [PATCH 21/32] fix: a result keeps its colours, and package shows cargo only when a build fails A result's box no longer dims everything in it: headers stay bold, labels and blanks dim, and links coloured. cliclack dims a box's text the way it dims a prompt's answer, so the box is drawn with a theme that leaves the text as written, and prompts keep cliclack's own. `tracel package` builds its binaries, native or cross, under a spinner that says what cargo compiles. Cargo's own output appears only when the build fails, after the spinner's error. --- crates/tracel-cli/src/commands/package.rs | 43 ++---- crates/tracel-cli/src/tools/cargo.rs | 166 ++++++++++++++++++++++ crates/tracel-cli/src/ui/human.rs | 13 +- crates/tracel-cli/src/ui/screen.rs | 20 ++- crates/tracel-cli/src/ui/stderr.rs | 10 ++ 5 files changed, 213 insertions(+), 39 deletions(-) diff --git a/crates/tracel-cli/src/commands/package.rs b/crates/tracel-cli/src/commands/package.rs index ac4583a..07ad033 100644 --- a/crates/tracel-cli/src/commands/package.rs +++ b/crates/tracel-cli/src/commands/package.rs @@ -1,5 +1,4 @@ use std::path::{Path, PathBuf}; -use std::process::Stdio; use std::sync::Arc; use anyhow::Context; @@ -305,15 +304,11 @@ fn build_release_binary( if let Some(linker) = linker { cmd_label.push_str(&format!(" (linker {linker})")); } - context - .terminal() - .print(&format!("Building release binary ({cmd_label})...")); - let mut command = cargo::command(); for arg in driver.subcommand_args() { command.arg(arg); } - command.arg("--release").arg("--message-format=json"); + command.arg("--release"); if let Some(triple) = target { command.arg("--target").arg(triple); if let Some(linker) = linker { @@ -322,32 +317,20 @@ fn build_release_binary( .arg(format!("target.{triple}.linker=\"{linker}\"")); } } - let output = command - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .output() - .with_context(|| format!("Failed to run `{cmd_label}`"))?; - - if !output.status.success() { - anyhow::bail!("`{cmd_label}` failed"); + let spinner = context.terminal().spinner(); + spinner.start(format!("Building release binary ({cmd_label})...")); + let built = cargo::build_quietly(command, &cmd_label, |status| { + spinner.set_status(format!("Building release binary: {status}")); + }); + match &built { + Ok(build) if build.succeeded() => spinner.stop("Built release binary."), + _ => spinner.error("Could not build the release binary."), } - - let mut executables: Vec<(String, PathBuf)> = Vec::new(); - for line in output.stdout.split(|&b| b == b'\n') { - if line.is_empty() { - continue; - } - if let Ok(msg) = serde_json::from_slice::(line) { - if msg.get("reason").and_then(|r| r.as_str()) == Some("compiler-artifact") { - if let Some(exe) = msg.get("executable").and_then(|e| e.as_str()) { - let name = msg["target"]["name"].as_str().ok_or_else(|| { - anyhow::anyhow!("Cargo did not return a name for binary {exe}") - })?; - executables.push((name.to_string(), PathBuf::from(exe))); - } - } - } + let built = built?; + if !built.succeeded() { + built.show_output(); } + let executables = built.executables()?; let names: Vec<&str> = executables.iter().map(|(name, _)| name.as_str()).collect(); if let Some(bin) = bin { diff --git a/crates/tracel-cli/src/tools/cargo.rs b/crates/tracel-cli/src/tools/cargo.rs index f136998..96999aa 100644 --- a/crates/tracel-cli/src/tools/cargo.rs +++ b/crates/tracel-cli/src/tools/cargo.rs @@ -1,4 +1,9 @@ use std::ffi::OsString; +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::path::PathBuf; +use std::process::Stdio; + +use anyhow::Context; pub fn try_locate_manifest() -> Option { let output = command() @@ -30,3 +35,164 @@ pub fn try_locate_manifest() -> Option { pub fn command() -> std::process::Command { std::process::Command::new(std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"))) } + +/// A cargo build that ran with cargo's own output kept back, as [`build_quietly`] runs it. +pub struct QuietBuild { + label: String, + succeeded: bool, + output: Vec, + messages: Vec, +} + +impl QuietBuild { + pub fn succeeded(&self) -> bool { + self.succeeded + } + + /// Prints cargo's output on stderr, as it would have printed it. + pub fn show_output(&self) { + let _ = io::stderr().write_all(&self.output); + } + + /// The executables the build made, as `(target name, path)` pairs, or why it failed. + pub fn executables(self) -> anyhow::Result> { + if !self.succeeded { + anyhow::bail!("`{}` failed", self.label); + } + executables(&self.messages) + } +} + +/// Run `command`, a cargo build named `label` in messages, keeping cargo's own output back: +/// each of its status lines, such as `Compiling trainer v0.1.0`, goes to `status` as the +/// build runs. +pub fn build_quietly( + mut command: std::process::Command, + label: &str, + mut status: impl FnMut(&str), +) -> anyhow::Result { + if console::colors_enabled_stderr() { + command.arg("--color=always"); + } + let mut child = command + .arg("--message-format=json-render-diagnostics") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("Failed to run `{label}`"))?; + let mut stdout = child.stdout.take().context("cargo's stdout is not piped")?; + let messages = std::thread::spawn(move || { + let mut messages = Vec::new(); + stdout.read_to_end(&mut messages).map(|_| messages) + }); + + let mut output = Vec::new(); + let mut stderr = BufReader::new(child.stderr.take().context("cargo's stderr is not piped")?); + let mut line = Vec::new(); + while stderr.read_until(b'\n', &mut line)? > 0 { + if let Some(line) = status_line(&String::from_utf8_lossy(&line)) { + status(&line); + } + output.append(&mut line); + } + let messages = messages + .join() + .map_err(|_| anyhow::anyhow!("Failed to read the output of `{label}`"))??; + Ok(QuietBuild { + label: label.to_string(), + succeeded: child.wait()?.success(), + output, + messages, + }) +} + +/// A line of cargo's output that says what it is doing, such as `Compiling trainer v0.1.0`, +/// without the path it may end with. +fn status_line(line: &str) -> Option { + const STATUSES: [&str; 6] = [ + "Blocking", + "Compiling", + "Downloading", + "Downloaded", + "Locking", + "Updating", + ]; + let line = console::strip_ansi_codes(line); + let line = line.trim(); + let (word, _) = line.split_once(' ')?; + if !STATUSES.contains(&word) { + return None; + } + let line = match line.split_once(" (") { + Some((line, _)) => line, + None => line, + }; + Some(line.to_string()) +} + +/// The executables in cargo's JSON build messages, as `(target name, path)` pairs. +fn executables(messages: &[u8]) -> anyhow::Result> { + let mut executables = Vec::new(); + for line in messages.split(|&b| b == b'\n') { + let Ok(message) = serde_json::from_slice::(line) else { + continue; + }; + if message["reason"] != "compiler-artifact" { + continue; + } + let Some(executable) = message["executable"].as_str() else { + continue; + }; + let name = message["target"]["name"].as_str().ok_or_else(|| { + anyhow::anyhow!("Cargo did not return a name for binary {executable}") + })?; + executables.push((name.to_string(), PathBuf::from(executable))); + } + Ok(executables) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn executables_are_the_artifacts_with_an_executable() { + let messages = br#"{"reason":"compiler-artifact","target":{"name":"helper","kind":["lib"]},"executable":null} +not json +{"reason":"build-script-executed","package_id":"x"} +{"reason":"compiler-artifact","target":{"name":"trainer","kind":["bin"]},"executable":"/work/target/debug/trainer"} +{"reason":"build-finished","success":true} +"#; + assert_eq!( + executables(messages).unwrap(), + [( + "trainer".to_string(), + PathBuf::from("/work/target/debug/trainer") + )] + ); + assert!(executables(b"").unwrap().is_empty()); + } + + #[test] + fn status_lines_say_what_cargo_does_without_paths() { + assert_eq!( + status_line( + "\u{1b}[1m\u{1b}[32m Compiling\u{1b}[0m trainer v0.1.0 (/work/trainer)\n" + ) + .as_deref(), + Some("Compiling trainer v0.1.0") + ); + assert_eq!( + status_line(" Downloaded serde v1.0.228\n").as_deref(), + Some("Downloaded serde v1.0.228") + ); + for line in [ + " Finished `release` profile [optimized] target(s) in 0.20s\n", + "warning: unused variable: `x`\n", + " --> src/main.rs:3:9\n", + "\n", + ] { + assert_eq!(status_line(line), None, "{line:?}"); + } + } +} diff --git a/crates/tracel-cli/src/ui/human.rs b/crates/tracel-cli/src/ui/human.rs index 524267a..67a81a6 100644 --- a/crates/tracel-cli/src/ui/human.rs +++ b/crates/tracel-cli/src/ui/human.rs @@ -7,10 +7,6 @@ use serde_json::Value; /// The narrowest a shrinkable table column gets, ellipsis included. const MIN_COLUMN_WIDTH: usize = 4; -/// A box's text is dim, and a styled span ends with a reset, so each span turns dim -/// back on after it. -const BOX_TEXT: &str = "\u{1b}[2m"; - /// A result as text for a person: plain, as stdout carries it, or styled for the box a /// terminal shows it in. Cards, headings and tables are written through `Details`, /// `heading` and `Table`; other text with `write!`. @@ -47,7 +43,7 @@ impl Human<'_> { fn styled(&self, text: impl Display, style: console::Style) -> String { if self.styled { - format!("{}{BOX_TEXT}", style.force_styling(true).apply_to(text)) + style.force_styling(true).apply_to(text).to_string() } else { text.to_string() } @@ -69,7 +65,8 @@ impl Human<'_> { /// One field of a card, its value aligned after labels `width` wide. fn field(&mut self, label: &str, value: &str, width: usize) -> io::Result<()> { let padding = " ".repeat(width - label.len()); - writeln!(self, "{label}:{padding} {value}") + let label = self.dim(format!("{label}:")); + writeln!(self, "{label}{padding} {value}") } /// Lines under a heading, indented. @@ -457,13 +454,13 @@ mod tests { } #[test] - fn a_styled_span_turns_the_box_dim_back_on() { + fn a_box_styles_its_spans() { assert_eq!( boxed(None, |out| { let (bold, dim) = (out.bold("NAME"), out.dim("-")); writeln!(out, "{bold} {dim} x") }), - "\u{1b}[1mNAME\u{1b}[0m\u{1b}[2m \u{1b}[2m-\u{1b}[0m\u{1b}[2m x\n" + "\u{1b}[1mNAME\u{1b}[0m \u{1b}[2m-\u{1b}[0m x\n" ); } diff --git a/crates/tracel-cli/src/ui/screen.rs b/crates/tracel-cli/src/ui/screen.rs index 365ffe0..97f3534 100644 --- a/crates/tracel-cli/src/ui/screen.rs +++ b/crates/tracel-cli/src/ui/screen.rs @@ -150,7 +150,10 @@ impl Screen { (false, true) => console::style(summary).for_stderr().bold().to_string(), (true, _) => dim(&place), }; - cliclack::outro_note(title, body.trim_end()) + cliclack::set_theme(ResultTheme); + let drawn = cliclack::outro_note(title, body.trim_end()); + cliclack::reset_theme(); + drawn }); } @@ -266,6 +269,21 @@ impl Screen { } } +/// cliclack's theme while a result's box is drawn. cliclack dims the text of a box, as +/// it dims a prompt's answer; a result keeps its own styles instead. tracel sets no +/// other theme, so drawing the box goes back to cliclack's own after it. +struct ResultTheme; + +impl cliclack::Theme for ResultTheme { + fn input_style(&self, state: &cliclack::ThemeState) -> console::Style { + match state { + cliclack::ThemeState::Cancel => console::Style::new().dim().strikethrough(), + cliclack::ThemeState::Submit => console::Style::new(), + _ => console::Style::new(), + } + } +} + fn columns(terminal: &console::Term) -> Option { terminal .size_checked() diff --git a/crates/tracel-cli/src/ui/stderr.rs b/crates/tracel-cli/src/ui/stderr.rs index 4613ee4..e8ca1b6 100644 --- a/crates/tracel-cli/src/ui/stderr.rs +++ b/crates/tracel-cli/src/ui/stderr.rs @@ -339,6 +339,16 @@ impl TerminalSpinner { } } + /// A passing detail of the step, such as what cargo compiles: shown on a terminal + /// only, where it replaces the spinner's message. + pub fn set_status(&self, message: impl Display) { + if self.terminal.is_styled() { + self.terminal + .screen + .update(self.id, message.to_string(), None); + } + } + pub fn set_message(&self, message: impl Display) { if self.terminal.is_styled() { self.terminal From 7ccf43086ecf345d9fb070b62d8c76b2fdee2e9b Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 13 Oct 2026 14:07:28 -0400 Subject: [PATCH 22/32] fix: init links the Console of its environment, declines are outcomes, login retries quietly - `tracel init` links to the web Console of the environment it ran in: localhost:9000 for --dev, s-console.tracel.ai for --staging, and the production Console otherwise. A user's project link uses the namespace, as the Console's routes do. - A prompt answered no ends the command on what it didn't do, such as "Nothing was linked.", and exits 0, in init, unlink, models push and promote, and package's target install. Before, init and models push failed with an internal error. - package commits once when asked to commit first; it asked twice and then failed when the second answer was no. - When the Tracel Console can't be reached, commands retry three times with a short backoff and report the failure once, without a line per attempt. - `experiments logs` lists entries oldest first, as `--follow` writes them, with local times to the second. - `experiments get`, `metrics` and `logs` name a missing experiment instead of "Resource not found: UNKNOWN". --- crates/tracel-cli/src/cli.rs | 14 ++- crates/tracel-cli/src/commands/experiments.rs | 44 ++++++--- crates/tracel-cli/src/commands/init.rs | 87 ++++++++--------- crates/tracel-cli/src/commands/login.rs | 96 ++++++++----------- crates/tracel-cli/src/commands/models.rs | 14 ++- crates/tracel-cli/src/commands/package.rs | 6 +- crates/tracel-cli/src/commands/unlink.rs | 22 ++--- crates/tracel-cli/src/context.rs | 18 +++- .../src/helpers/model_upload/existence.rs | 42 ++++---- crates/tracel-cli/src/tools/target.rs | 11 +-- crates/tracel-cli/src/ui/mod.rs | 4 +- crates/tracel-cli/src/ui/screen.rs | 7 -- crates/tracel-cli/src/ui/stderr.rs | 25 +++-- crates/tracel-cli/src/ui/time.rs | 26 +++-- 14 files changed, 221 insertions(+), 195 deletions(-) diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index 8538e1a..3288f3e 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -8,7 +8,9 @@ use crate::context::{CliContext, environment_name}; use crate::error::{CliError, ErrorKind, ErrorReport}; use crate::helpers::project::{parse_project, resolve_project}; use crate::tools::tracel_config::TracelProject; -use crate::ui::{self, Format, FormatArg, Layout, Outcome, Output, Place, StdoutClosed, Terminal}; +use crate::ui::{ + self, Declined, Format, FormatArg, Layout, Outcome, Output, Place, StdoutClosed, Terminal, +}; #[derive(Parser, Debug)] #[clap(name = "tracel", author, version, about, long_about = None)] @@ -240,11 +242,19 @@ fn resolve_environment( } /// Reports a failed command on both channels and exits with its code. A reader that -/// closed stdout got all it wanted, so that ends the command quietly. +/// closed stdout got all it wanted, so that ends the command quietly, and a declined +/// prompt ends it on what it didn't do. fn fail(error: &anyhow::Error, output: &Output, terminal: &Terminal) -> ! { if error.chain().any(|cause| cause.is::()) { std::process::exit(0); } + if let Some(declined) = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + terminal.outro(declined.outcome()); + std::process::exit(0); + } let report = ErrorReport::new(error); output.error(&report); terminal.error(&report); diff --git a/crates/tracel-cli/src/commands/experiments.rs b/crates/tracel-cli/src/commands/experiments.rs index 5f13b4c..eec6bd0 100644 --- a/crates/tracel-cli/src/commands/experiments.rs +++ b/crates/tracel-cli/src/commands/experiments.rs @@ -18,8 +18,9 @@ use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; use crate::helpers::project::resolve_namespace_project; +use crate::helpers::{Resource, map_resource_error}; use crate::tools::tracel_config::TracelProject; -use crate::ui::{Details, Human, Layout, Outcome, Output, Render, Table, counted, when}; +use crate::ui::{Details, Human, Layout, Outcome, Output, Render, Table, counted, log_time, when}; #[derive(Args, Debug)] pub struct ExperimentsArgs { @@ -332,8 +333,11 @@ impl Render for Option { } impl Render for ExperimentLogQueryResponse { + /// Oldest first, as `--follow` writes them. fn render(&self, out: &mut Human<'_>) -> io::Result<()> { - self.items.iter().try_for_each(|item| write_log(out, item)) + let mut items: Vec<_> = self.items.iter().collect(); + items.sort_by_key(|item| item.seq); + items.into_iter().try_for_each(|item| write_log(out, item)) } fn summary(&self) -> Option { @@ -355,7 +359,7 @@ fn write_log(out: &mut Human<'_>, item: &ExperimentLogItemResponse) -> io::Resul LogLevelResponse::Error => "error", }; let message = item.message.replace(['\r', '\n'], " "); - writeln!(out, "{} {level} {message}", item.timestamp) + writeln!(out, "{} {level} {message}", log_time(&item.timestamp)) } /// An event of `logs --follow`. As JSON, a log item gains `"type": "log"`. @@ -383,8 +387,16 @@ fn follow_logs( output: &Output, ) -> anyhow::Result<()> { loop { - let response = - client.query_experiment_logs(&project.owner, &project.name, num, request.clone())?; + let response = client + .query_experiment_logs(&project.owner, &project.name, num, request.clone()) + .map_err(|error| { + map_resource_error( + error, + &project.owner, + &project.name, + Resource::Experiment(num), + ) + })?; for item in &response.items { output.event(&LogEvent::Log(item))?; } @@ -418,6 +430,8 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res let client = get_client_and_login_if_needed(&context)?; let project = resolve_namespace_project(&context)?.project; let (owner, name) = (&project.owner, &project.name); + let missing = + |num| move |error| map_resource_error(error, owner, name, Resource::Experiment(num)); match args.command { ExperimentsCommands::List(args) => Ok(client .get_project_experiments( @@ -432,7 +446,9 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res .into()), ExperimentsCommands::Get(args) => { let experiment = match args.experiment { - ExperimentSelector::Number(num) => client.get_experiment(owner, name, num)?, + ExperimentSelector::Number(num) => client + .get_experiment(owner, name, num) + .map_err(missing(num))?, ExperimentSelector::Latest => client .get_project_latest_experiment(owner, name)? .ok_or_else(|| { @@ -443,11 +459,15 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res } ExperimentsCommands::Metrics(args) => { let Some(metric) = args.metric else { - return Ok(client.get_metric_metadata(owner, name, args.num)?.into()); + return Ok(client + .get_metric_metadata(owner, name, args.num) + .map_err(missing(args.num))? + .into()); }; if args.summary { Ok(client - .get_metric_summary(owner, name, args.num, MetricSummaryQuery { metric })? + .get_metric_summary(owner, name, args.num, MetricSummaryQuery { metric }) + .map_err(missing(args.num))? .into()) } else { Ok(client @@ -460,7 +480,8 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res max_points: args.max_points, downsampling_factor: args.downsampling, }, - )? + ) + .map_err(missing(args.num))? .into()) } } @@ -476,7 +497,8 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res return Ok(Outcome::streamed()); } Ok(client - .query_experiment_logs(owner, name, num, request)? + .query_experiment_logs(owner, name, num, request) + .map_err(missing(num))? .into()) } } @@ -671,7 +693,7 @@ mod tests { LogEvent::End { running: false }.render(&mut out).unwrap(); assert_eq!( String::from_utf8(text).unwrap(), - "2026-10-07T00:00:00Z warn loss spiked\n" + format!("{} warn loss spiked\n", log_time("2026-10-07T00:00:00Z")) ); } } diff --git a/crates/tracel-cli/src/commands/init.rs b/crates/tracel-cli/src/commands/init.rs index b8d5a46..3745746 100644 --- a/crates/tracel-cli/src/commands/init.rs +++ b/crates/tracel-cli/src/commands/init.rs @@ -4,7 +4,7 @@ use crate::helpers::{can_initialize_project, require_cargo_workspace, require_li use crate::tools::git; use crate::tools::project_context::ProjectContext; use crate::tools::tracel_config::TracelProject; -use crate::ui::{Outcome, Render, Terminal}; +use crate::ui::{Declined, Outcome, Render, Terminal}; use anyhow::Context; use clap::Args; use serde::Serialize; @@ -145,7 +145,7 @@ fn prompt_init( .context("Failed to initialize project metadata")?; terminal.print("Created project metadata"); - let frontend_url = project_url(context, &user.username, &project_owner, &project_name)?; + let frontend_url = project_url(context, &project_owner, owner_name, &project_name)?; terminal.outro(&format!( "Project initialized successfully! You can check out your project at {}", @@ -279,7 +279,7 @@ fn handle_existing_project( name: project.project_name.clone(), }) } else { - Err(anyhow::anyhow!("Project initialization cancelled by user")) + Err(Declined::new("Nothing was linked.").into()) } } @@ -291,13 +291,13 @@ enum ProjectKind { fn project_url( context: &CliContext, - username: &str, owner: &ProjectKind, + namespace: &str, name: &str, ) -> anyhow::Result { let path = match owner { - ProjectKind::User => format!("/users/{username}/projects/{name}"), - ProjectKind::Organization(namespace) => format!("/orgs/{namespace}/projects/{name}"), + ProjectKind::User => format!("/users/{namespace}/projects/{name}"), + ProjectKind::Organization(_) => format!("/orgs/{namespace}/projects/{name}"), }; Ok(context.get_frontend_endpoint().join(&path)?) } @@ -369,7 +369,7 @@ pub fn ensure_git_repo_clean( return Ok(()); } if commit { - return commit_sequence(terminal, true); + return commit_all(terminal); } if allow_dirty && has_commit { return Ok(()); @@ -387,7 +387,14 @@ pub fn ensure_git_repo_clean( terminal.print( "Repository is dirty. Tracel Console needs a valid commit hash to associate your code with your repository.", ); - commit_sequence(terminal, false).context("Failed to make initial commit") + if !terminal.confirm( + "Do you want to automatically commit all files?", + "commit", + false, + )? { + return Err(Declined::new("Nothing was linked.").into()); + } + commit_all(terminal).context("Failed to make initial commit") } fn commit_required() -> CliError { @@ -395,44 +402,34 @@ fn commit_required() -> CliError { .with_hint("Commit your changes, or pass --commit or --allow-dirty") } -pub fn commit_sequence(terminal: &Terminal, commit: bool) -> anyhow::Result<()> { - if commit - || terminal.confirm( - "Do you want to automatically commit all files?", - "commit", - false, - )? - { - let commit_message = "Automatic commit by Tracel Console CLI"; - let status = std::process::Command::new("git") - .args(["add", "--all"]) - .stdin(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .status() - .context("Failed to run `git add .`")?; - if !status.success() { - return Err(anyhow::anyhow!("Failed to add files to git")); - } - let mut command = std::process::Command::new("git"); - command.args(["commit", "-m", commit_message]); - if git::get_last_commit_hash().is_err() { - command.arg("--allow-empty"); - } - let status = command - .stdin(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .status() - .context("Failed to run `git commit -m`")?; - if !status.success() { - return Err(anyhow::anyhow!("Failed to commit files to git")); - } - terminal.print_success("Committed all files to git."); - } else { - return Err(commit_required().into()); +/// Commit everything in the working tree, making the first commit if there is none. +pub fn commit_all(terminal: &Terminal) -> anyhow::Result<()> { + let commit_message = "Automatic commit by Tracel Console CLI"; + let status = std::process::Command::new("git") + .args(["add", "--all"]) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .status() + .context("Failed to run `git add .`")?; + if !status.success() { + return Err(anyhow::anyhow!("Failed to add files to git")); } - + let mut command = std::process::Command::new("git"); + command.args(["commit", "-m", commit_message]); + if git::get_last_commit_hash().is_err() { + command.arg("--allow-empty"); + } + let status = command + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .status() + .context("Failed to run `git commit -m`")?; + if !status.success() { + return Err(anyhow::anyhow!("Failed to commit files to git")); + } + terminal.print_success("Committed all files to git."); Ok(()) } diff --git a/crates/tracel-cli/src/commands/login.rs b/crates/tracel-cli/src/commands/login.rs index 7a45f41..e2d4c51 100644 --- a/crates/tracel-cli/src/commands/login.rs +++ b/crates/tracel-cli/src/commands/login.rs @@ -45,66 +45,54 @@ impl LoginArgs { } } -pub fn get_client_and_login_if_needed(context: &CliContext) -> anyhow::Result { - const MAX_RETRIES: u32 = 3; - let mut attempts = 0; +/// How long to wait before each new attempt to reach the Tracel Console. +const RETRY_DELAYS: [Duration; 3] = [ + Duration::from_millis(250), + Duration::from_millis(500), + Duration::from_secs(1), +]; +pub fn get_client_and_login_if_needed(context: &CliContext) -> anyhow::Result { + let mut delays = RETRY_DELAYS.into_iter(); + let mut logged_in = false; loop { match context.create_client() { - Ok(client) => { - if attempts > 0 { - context.terminal().print_success("Successfully logged in!"); - } - return Ok(client); - } - Err(err) => { - attempts += 1; - match err { - ClientCreationError::InvalidCredentials - | ClientCreationError::NoCredentials => { - if attempts > MAX_RETRIES { - return Err(CliError::new( - ErrorKind::NotAuthenticated, - "Maximum login attempts exceeded", - ) - .into()); - } - let env_msg = environment_suffix(&context.environment()); - if !context.terminal().is_interactive() { - return Err(CliError::new( - ErrorKind::NotAuthenticated, - format!( - "Not logged in{}. Run 'tracel login' or set TRACEL_API_KEY.", - env_msg - ), - ) - .with_hint( - "Run 'tracel login', or 'tracel login --no-wait' then 'tracel login --complete'.", - ) - .into()); - } - context.terminal().print_err(&format!( - "Not logged in{}. Log in below, or press Ctrl+C to exit.", + Ok(client) => return Ok(client), + Err(ClientCreationError::InvalidCredentials | ClientCreationError::NoCredentials) + if !logged_in => + { + let env_msg = environment_suffix(&context.environment()); + if !context.terminal().is_interactive() { + return Err(CliError::new( + ErrorKind::NotAuthenticated, + format!( + "Not logged in{}. Run 'tracel login' or set TRACEL_API_KEY.", env_msg - )); - - log_in(context)?; - } - ClientCreationError::ServerConnectionError(msg) => { - if attempts > MAX_RETRIES { - return Err(CliError::new( - ErrorKind::Unavailable, - format!("Server connection failed after maximum retries: {}", msg), - ) - .into()); - } - context.terminal().print_err(&format!( - "Failed to connect to the server: {msg}. Retrying..." - )); - } - err => return Err(err.into()), + ), + ) + .with_hint( + "Run 'tracel login', or 'tracel login --no-wait' then 'tracel login --complete'.", + ) + .into()); } + context.terminal().print_err(&format!( + "Not logged in{}. Log in below, or press Ctrl+C to exit.", + env_msg + )); + log_in(context)?; + logged_in = true; } + Err(ClientCreationError::ServerConnectionError(msg)) => match delays.next() { + Some(delay) => std::thread::sleep(delay), + None => { + return Err(CliError::new( + ErrorKind::Unavailable, + format!("Server connection failed after maximum retries: {}", msg), + ) + .into()); + } + }, + Err(err) => return Err(err.into()), } } } diff --git a/crates/tracel-cli/src/commands/models.rs b/crates/tracel-cli/src/commands/models.rs index 32b6018..c793555 100644 --- a/crates/tracel-cli/src/commands/models.rs +++ b/crates/tracel-cli/src/commands/models.rs @@ -25,7 +25,7 @@ use crate::helpers::{ }; use crate::tools::fs::{build_file_specs, collect_files}; use crate::tools::tracel_config::TracelProject; -use crate::ui::{Details, Human, Layout, Outcome, Render, Table, counted, when}; +use crate::ui::{Declined, Details, Human, Layout, Outcome, Render, Table, counted, when}; #[derive(Args, Debug)] pub struct ModelsArgs { @@ -572,7 +572,7 @@ fn push( .terminal() .print(&format!("Uploading to {namespace}/{name}")); - ensure_model_exists( + if !ensure_model_exists( context, &client, namespace, @@ -580,7 +580,9 @@ fn push( &args.model_name, args.auto_create, args.description.clone(), - )?; + )? { + return Err(Declined::new("Nothing was uploaded.").into()); + } let spinner = context.terminal().spinner(); spinner.start("Computing checksums..."); @@ -675,7 +677,7 @@ fn promote( args.experiment, )?; let artifact = &artifacts.items[index]; - ensure_model_exists( + if !ensure_model_exists( context, &client, namespace, @@ -683,7 +685,9 @@ fn promote( &args.model, args.auto_create, args.description, - )?; + )? { + return Err(Declined::new("Nothing was promoted.").into()); + } let map_error = |error| map_resource_error(error, namespace, name, Resource::Model(&args.model)); let mut version = client diff --git a/crates/tracel-cli/src/commands/package.rs b/crates/tracel-cli/src/commands/package.rs index 07ad033..dfe3a46 100644 --- a/crates/tracel-cli/src/commands/package.rs +++ b/crates/tracel-cli/src/commands/package.rs @@ -11,7 +11,7 @@ use tracel_client::console::project::request::{ PublishSourceRequest, }; -use crate::commands::init::commit_sequence; +use crate::commands::init::commit_all; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; @@ -118,7 +118,7 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< } if args.commit { if !has_commit || git::is_repo_dirty()? { - commit_sequence(context.terminal(), true)?; + commit_all(context.terminal())?; } } else if git::is_repo_dirty()? && !args.allow_dirty { context @@ -135,7 +135,7 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< .terminal() .confirm("Commit changes before packaging?", "commit", true)? { - commit_sequence(context.terminal(), false)?; + commit_all(context.terminal())?; } } diff --git a/crates/tracel-cli/src/commands/unlink.rs b/crates/tracel-cli/src/commands/unlink.rs index 8ec05a1..38a540d 100644 --- a/crates/tracel-cli/src/commands/unlink.rs +++ b/crates/tracel-cli/src/commands/unlink.rs @@ -6,7 +6,7 @@ use crate::{ context::CliContext, helpers::require_linked_project, tools::project_context::ProjectContext, - ui::{Outcome, Render}, + ui::{Declined, Outcome, Render}, }; #[derive(Args, Debug)] @@ -16,14 +16,12 @@ pub struct UnlinkArgs { pub yes: bool, } -/// `namespace` and `name` are the project that was unlinked, absent when cancelled. +/// The project that was unlinked. #[derive(Serialize)] struct Unlinked { unlinked: bool, - #[serde(skip_serializing_if = "Option::is_none")] - namespace: Option, - #[serde(skip_serializing_if = "Option::is_none")] - name: Option, + namespace: String, + name: String, } impl Render for Unlinked {} @@ -38,13 +36,7 @@ pub fn handle_command(args: UnlinkArgs, context: CliContext) -> anyhow::Result anyhow::Result url::Url { - // We can't know easily the url depending on the environment, so let's just serve - // production url - Url::parse("https://console.tracel.ai/").expect("Frontend endpoint should be valid") + /// The web Console of the environment. + pub fn get_frontend_endpoint(&self) -> Url { + web_console(&self.environment) } pub fn terminal(&self) -> &Terminal { @@ -119,6 +118,17 @@ impl CliContext { } } +/// The web Console of an environment: production and staging serve it beside their +/// API, and a development server serves it on port 9000. +fn web_console(environment: &Env) -> Url { + let url = match environment { + Env::Production => "https://console.tracel.ai/".to_string(), + Env::Staging(version) => format!("https://s{version}-console.tracel.ai/"), + Env::Development => "http://localhost:9000/".to_string(), + }; + Url::parse(&url).expect("a web Console URL is valid") +} + /// The name of an environment, as `TRACEL_ENV` and JSON data write it. pub fn environment_name(environment: &Env) -> String { match environment { diff --git a/crates/tracel-cli/src/helpers/model_upload/existence.rs b/crates/tracel-cli/src/helpers/model_upload/existence.rs index 66ef679..10df822 100644 --- a/crates/tracel-cli/src/helpers/model_upload/existence.rs +++ b/crates/tracel-cli/src/helpers/model_upload/existence.rs @@ -5,6 +5,8 @@ use tracel_client::console::model::request::CreateModelRequest; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; +/// Make sure `model_name` exists, creating it when `auto_create` says so or the user +/// agrees. False when the user declined to create it. pub fn ensure_model_exists( context: &CliContext, client: &Client, @@ -13,14 +15,31 @@ pub fn ensure_model_exists( model_name: &str, auto_create: Option, description: Option, -) -> anyhow::Result<()> { +) -> anyhow::Result { match client.get_model(namespace, project, model_name) { - Ok(_) => return Ok(()), + Ok(_) => return Ok(true), Err(e) if e.is_not_found() => {} Err(e) => return Err(e).with_context(|| format!("Failed to check model '{model_name}'")), } - if !context.terminal().is_interactive() && auto_create != Some(true) { + let create = match auto_create { + Some(create) => create, + None if context.terminal().is_interactive() => { + if !context.terminal().confirm( + &format!( + "Model '{model_name}' does not exist in {namespace}/{project}. Create it now?" + ), + "auto-create", + false, + )? { + return Ok(false); + } + true + } + None => false, + }; + + if !create { return Err(CliError::new( ErrorKind::Usage, format!("Model '{model_name}' does not exist in {namespace}/{project}."), @@ -29,21 +48,6 @@ pub fn ensure_model_exists( .into()); } - let create = match auto_create { - Some(create) => create, - None => context.terminal().confirm( - &format!( - "Model '{model_name}' does not exist in {namespace}/{project}. Create it now?" - ), - "auto-create", - false, - )?, - }; - - if !create { - anyhow::bail!("Model upload cancelled: model '{model_name}' does not exist."); - } - let description = match description { Some(description) => Some(description), None if auto_create.is_none() => { @@ -69,5 +73,5 @@ pub fn ensure_model_exists( "Created model '{model_name}' in {namespace}/{project}." )); - Ok(()) + Ok(true) } diff --git a/crates/tracel-cli/src/tools/target.rs b/crates/tracel-cli/src/tools/target.rs index 60c77d9..3474e68 100644 --- a/crates/tracel-cli/src/tools/target.rs +++ b/crates/tracel-cli/src/tools/target.rs @@ -8,7 +8,7 @@ use colored::Colorize; use tracel_client::console::project::request::{Arch, Os}; use crate::error::{CliError, ErrorKind}; -use crate::ui::Terminal; +use crate::ui::{Declined, Terminal}; /// Every (os, arch) target we offer to build for, in canonical display order. /// The host is surfaced separately and pulled to the front by `prompt_targets`. @@ -221,14 +221,7 @@ pub fn install_missing_target( add_target(triple)?; } } else { - let cmds = missing - .iter() - .map(|triple| format!("rustup target add {triple}")) - .collect::>() - .join("\n "); - anyhow::bail!( - "Cannot build without the selected targets installed. Install them with:\n {cmds}" - ); + return Err(Declined::new("Nothing was packaged.").into()); } } Ok(()) diff --git a/crates/tracel-cli/src/ui/mod.rs b/crates/tracel-cli/src/ui/mod.rs index 446337c..d14955a 100644 --- a/crates/tracel-cli/src/ui/mod.rs +++ b/crates/tracel-cli/src/ui/mod.rs @@ -18,9 +18,9 @@ use std::sync::Arc; pub use format::{Format, FormatArg}; pub use human::{Details, Human, Table, counted}; pub use layout::{Layout, Place}; -pub use stderr::Terminal; +pub use stderr::{Declined, Terminal}; pub use stdout::{Outcome, Output, Render, StdoutClosed}; -pub use time::{Timestamp, when}; +pub use time::{Timestamp, log_time, when}; /// Stdout and stderr for one command, drawing on the same screen. A flow's session /// opens on the terminal now, saying what it does and where. diff --git a/crates/tracel-cli/src/ui/screen.rs b/crates/tracel-cli/src/ui/screen.rs index 97f3534..b49feee 100644 --- a/crates/tracel-cli/src/ui/screen.rs +++ b/crates/tracel-cli/src/ui/screen.rs @@ -164,13 +164,6 @@ impl Screen { } } - /// Ends a flow's session on an outcome that stops short of what it set out to do. - pub fn close_short(&self, outcome: &str) { - if self.session.swap(false, Ordering::Relaxed) { - self.draw(|| cliclack::outro_cancel(outcome)); - } - } - /// Ends a flow's session with the commands to run next, one per line. pub fn close_with_next_steps(&self, steps: &str) { if self.session.swap(false, Ordering::Relaxed) { diff --git a/crates/tracel-cli/src/ui/stderr.rs b/crates/tracel-cli/src/ui/stderr.rs index e8ca1b6..84d343a 100644 --- a/crates/tracel-cli/src/ui/stderr.rs +++ b/crates/tracel-cli/src/ui/stderr.rs @@ -8,6 +8,22 @@ use super::Format; use super::screen::{Ending, Screen}; use crate::error::{CliError, ErrorKind, ErrorReport}; +/// A prompt the user answered no. The command stops there and ends on what it didn't +/// do, such as "Nothing was linked.", as a normal outcome. +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub struct Declined(String); + +impl Declined { + pub fn new(outcome: impl Into) -> Self { + Self(outcome.into()) + } + + pub fn outcome(&self) -> &str { + &self.0 + } +} + /// Stderr, for the person running a command: what it is doing, its progress, and /// questions for them. With JSON output it carries only errors and the instructions /// a person must follow, as plain lines. @@ -271,15 +287,6 @@ impl Terminal { } } - /// Ends a flow's session on an outcome that stops short of what it set out to do. - pub fn outro_cancel(&self, message: &str) { - if self.screen.session_open() { - self.screen.close_short(message); - } else { - self.print_err(message); - } - } - /// Whether a person can answer prompts: they read from stdin and draw on stderr. pub fn is_interactive(&self) -> bool { let ci = std::env::var_os("CI"); diff --git a/crates/tracel-cli/src/ui/time.rs b/crates/tracel-cli/src/ui/time.rs index 05b3ddd..ad17f3b 100644 --- a/crates/tracel-cli/src/ui/time.rs +++ b/crates/tracel-cli/src/ui/time.rs @@ -23,18 +23,20 @@ impl Serialize for Timestamp { /// A server's RFC 3339 time as a person reads it: local, to the minute. Text that is /// not such a time is kept as it is. pub fn when(text: &str) -> String { - when_in(text, &Local) + local(text, &Local, "%Y-%m-%d %H:%M") } -fn when_in(text: &str, zone: &Tz) -> String +/// A log entry's RFC 3339 time as a person reads it: local, to the second. +pub fn log_time(text: &str) -> String { + local(text, &Local, "%Y-%m-%d %H:%M:%S") +} + +fn local(text: &str, zone: &Tz, format: &str) -> String where Tz::Offset: Display, { match DateTime::parse_from_rfc3339(text) { - Ok(time) => time - .with_timezone(zone) - .format("%Y-%m-%d %H:%M") - .to_string(), + Ok(time) => time.with_timezone(zone).format(format).to_string(), Err(_) => text.to_string(), } } @@ -48,13 +50,17 @@ mod tests { #[test] fn server_times_read_in_the_local_zone() { let zone = chrono::FixedOffset::west_opt(4 * 3600).unwrap(); + let minute = "%Y-%m-%d %H:%M"; assert_eq!( - when_in("2026-10-09T20:21:06.075Z", &zone), + local("2026-10-09T20:21:06.075Z", &zone, minute), "2026-10-09 16:21" ); - assert_eq!(when_in("2026-10-09T20:21:06Z", &Utc), "2026-10-09 20:21"); - assert_eq!(when_in("yesterday", &Utc), "yesterday"); - assert_eq!(when_in("", &Utc), ""); + assert_eq!( + local("2026-10-09T20:21:06Z", &Utc, minute), + "2026-10-09 20:21" + ); + assert_eq!(local("yesterday", &Utc, minute), "yesterday"); + assert_eq!(local("", &Utc, minute), ""); } #[test] From a5c9fb4ae56f62bb4e903d8ceab4b76459786891 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 13 Oct 2026 14:23:47 -0400 Subject: [PATCH 23/32] refactor: messages follow one style Outcomes, errors, hints and warnings are sentences; steps, spinners, prompts and help are fragments. Names the user chose are in single quotes and commands in backticks. - A flow ends on one outcome line, which carries the id worth keeping, such as "Uploaded code version 9e24f16700f2." Steps that only said they were done clear instead: builds, uploads, downloads, checksums. A spinner and a progress bar are one type, which can clear. - A failure is its message and hint, then a bare corner, without a second line saying what didn't happen. - Prompts are short questions or labels: "Targets", "Owner", "Project name", "Unlink test/demo?". When nobody can answer, the error names what needed the answer and the flag that gives it: "Linking test/demo needs confirmation." with "Pass --yes to link it without asking." - Errors are sentences ending in a period, and contexts start with "Can't". A value that isn't one of a set is said once: "No target 'x'. Targets: ...". TRACEL_API_KEY, connection and login errors name what to do without repeating the environment. - Terminals show 12-character digests, sizes in KB, MB and GB, numbers to about four significant digits, and no UUIDs or internal ids. Box rows no longer repeat the box's title, and experiment statuses read as words. - The packager's steps say what it does, and its tracing calls, which nothing recorded, are gone. - Help is one line per command and flag, and every positional argument has one. The top-level help says what tracel is for. - One word per concept: the Tracel Console, the workspace on disk, the project on the Console, a code version, logging in. JSON success data is unchanged. Error envelopes carry the new messages. --- crates/tracel-cli/src/cli.rs | 67 +++-- crates/tracel-cli/src/commands/artifacts.rs | 42 ++- crates/tracel-cli/src/commands/auth.rs | 41 ++- crates/tracel-cli/src/commands/datasets.rs | 30 +-- crates/tracel-cli/src/commands/experiments.rs | 139 +++++----- crates/tracel-cli/src/commands/init.rs | 165 ++++++------ crates/tracel-cli/src/commands/login.rs | 112 +++----- .../tracel-cli/src/commands/login/pending.rs | 18 +- crates/tracel-cli/src/commands/logout.rs | 12 +- crates/tracel-cli/src/commands/me.rs | 8 +- crates/tracel-cli/src/commands/models.rs | 214 +++++++-------- crates/tracel-cli/src/commands/package.rs | 173 ++++++------- crates/tracel-cli/src/commands/project.rs | 29 +-- crates/tracel-cli/src/commands/training.rs | 12 +- crates/tracel-cli/src/commands/unlink.rs | 19 +- crates/tracel-cli/src/context.rs | 14 +- crates/tracel-cli/src/error.rs | 17 +- crates/tracel-cli/src/helpers/download.rs | 30 +-- .../src/helpers/model_upload/existence.rs | 32 ++- .../src/helpers/model_upload/upload.rs | 40 +-- crates/tracel-cli/src/helpers/project.rs | 87 ++++--- crates/tracel-cli/src/helpers/resources.rs | 30 +-- crates/tracel-cli/src/tools/build_driver.rs | 16 +- crates/tracel-cli/src/tools/cargo.rs | 34 +-- crates/tracel-cli/src/tools/fs.rs | 10 +- crates/tracel-cli/src/tools/git.rs | 4 +- crates/tracel-cli/src/tools/linker.rs | 8 +- crates/tracel-cli/src/tools/packager.rs | 96 ++----- .../tracel-cli/src/tools/project_context.rs | 8 +- crates/tracel-cli/src/tools/target.rs | 46 ++-- crates/tracel-cli/src/tools/workspace.rs | 21 +- crates/tracel-cli/src/ui/format.rs | 2 +- crates/tracel-cli/src/ui/human.rs | 56 +++- crates/tracel-cli/src/ui/layout.rs | 19 +- crates/tracel-cli/src/ui/mod.rs | 4 +- crates/tracel-cli/src/ui/screen.rs | 85 +++--- crates/tracel-cli/src/ui/stderr.rs | 243 ++++++------------ 37 files changed, 884 insertions(+), 1099 deletions(-) diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index 3288f3e..66ef2d7 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -13,12 +13,18 @@ use crate::ui::{ }; #[derive(Parser, Debug)] -#[clap(name = "tracel", author, version, about, long_about = None)] +#[clap( + name = "tracel", + author, + version, + about = "Train, track and share Burn models with the Tracel Console", + long_about = None +)] pub struct CliArgs { #[command(subcommand)] pub command: Option, - /// Output format (default: auto) + /// Choose the output format #[arg(short, long, global = true, value_enum, conflicts_with = "json")] pub output: Option, @@ -26,11 +32,11 @@ pub struct CliArgs { #[arg(long, global = true, conflicts_with = "output")] pub json: bool, - /// Never prompt; fail when input is needed + /// Fail instead of asking for input #[arg(long, global = true)] pub no_input: bool, - /// Tracel Console project to use + /// Use this Tracel Console project #[arg(long, global = true, value_name = "NAMESPACE/NAME", value_parser = parse_project)] pub project: Option, @@ -38,11 +44,11 @@ pub struct CliArgs { #[arg(short = 'C', global = true, value_name = "DIR")] pub working_directory: Option, - /// Use development environment (localhost:9001) with separate dev credentials + /// Use the development environment #[arg(long, global = true, action = clap::ArgAction::SetTrue, hide = true, conflicts_with = "staging")] pub dev: bool, - /// Use staging environment (specify version: 1, 2, etc.) + /// Use this staging environment #[arg( long, global = true, @@ -55,33 +61,32 @@ pub struct CliArgs { #[derive(Subcommand, Debug)] pub enum Commands { - /// Run your project locally via `cargo run` (forwards args after `--`). + /// Run the workspace locally with `cargo run` Train(commands::training::TrainingArgs), - - /// Package your project for running on a remote machine. + /// Upload a code version of the workspace Package(commands::package::PackageArgs), - /// Log in to the Tracel server. + /// Log in to the Tracel Console Login(commands::login::LoginArgs), - /// Log out of the Tracel server. + /// Log out of the Tracel Console Logout, - /// Show which credential commands use, or print an access token for scripts. + /// Show your login, or print an access token Auth(commands::auth::AuthArgs), - /// Browse project experiments, metrics, and logs. + /// Browse experiments, their metrics and logs #[command(visible_alias = "exp")] Experiments(commands::experiments::ExperimentsArgs), - /// Initialize a new project or reinitialize an existing one. + /// Link the workspace to a Tracel Console project Init(commands::init::InitArgs), - /// Unlink the Tracel Console project from this repository. + /// Unlink the workspace from its project Unlink(commands::unlink::UnlinkArgs), - /// Display current user information. + /// Show who you are logged in as Me, - /// Display the current project, or list projects. + /// Show the current project, or list projects Project(commands::project::ProjectArgs), - /// Browse and manage models in the model registry. + /// Browse and manage models Models(commands::models::ModelsArgs), - /// Browse and download experiment artifacts. + /// Browse and download experiment artifacts Artifacts(commands::artifacts::ArtifactsArgs), - /// Browse datasets in the selected project. + /// Browse the project's datasets Datasets(commands::datasets::DatasetsArgs), } @@ -92,20 +97,12 @@ impl Commands { match self { Self::Train(_) => Layout::Bare, Self::Auth(args) => args.layout(), - Self::Package(_) => { - Layout::flow("Package the project", "The project was not packaged.") - } + Self::Package(_) => Layout::flow("Package a code version"), Self::Login(args) => args.layout(), - Self::Logout => Layout::flow("Log out of the Tracel Console", "Logout failed."), + Self::Logout => Layout::flow("Log out of the Tracel Console"), Self::Experiments(args) => args.layout(), - Self::Init(_) => Layout::flow( - "Link this workspace to a Tracel Console project", - "The workspace was not linked.", - ), - Self::Unlink(_) => Layout::flow( - "Unlink this workspace from its Tracel Console project", - "The workspace is still linked.", - ), + Self::Init(_) => Layout::flow("Link this workspace to a Tracel Console project"), + Self::Unlink(_) => Layout::flow("Unlink this workspace from its project"), Self::Me | Self::Project(_) | Self::Datasets(_) => Layout::Read, Self::Models(args) => args.layout(), Self::Artifacts(args) => args.layout(), @@ -173,7 +170,7 @@ pub fn cli_main() { let error = CliError::new( ErrorKind::Usage, format!( - "Cannot change directory to '{}': {error}", + "Can't change to directory '{}': {error}", directory.display() ), ); @@ -235,7 +232,9 @@ fn resolve_environment( .ok_or_else(|| { CliError::new( ErrorKind::Usage, - format!("Invalid TRACEL_ENV value '{value}'."), + format!( + "Unknown TRACEL_ENV '{value}'. Environments: production, development, staging-." + ), ) }), } diff --git a/crates/tracel-cli/src/commands/artifacts.rs b/crates/tracel-cli/src/commands/artifacts.rs index 700822b..e00d081 100644 --- a/crates/tracel-cli/src/commands/artifacts.rs +++ b/crates/tracel-cli/src/commands/artifacts.rs @@ -13,7 +13,7 @@ use crate::helpers::{ DownloadFile, DownloadResult, Resource, download_files, map_resource_error, resolve_namespace_project, select_artifact, validate_rel_path, }; -use crate::ui::{Human, Layout, Outcome, Render, Table, counted, when}; +use crate::ui::{Human, Layout, Outcome, Render, Table, list_summary, size, when}; #[derive(Args, Debug)] pub struct ArtifactsArgs { @@ -25,47 +25,44 @@ impl ArtifactsArgs { pub fn layout(&self) -> Layout { match &self.command { ArtifactsCommands::List(_) => Layout::Read, - ArtifactsCommands::Download(args) => Layout::flow( - format!( - "Download artifact '{}' of experiment {}", - args.artifact, args.experiment - ), - format!("Artifact '{}' was not downloaded.", args.artifact), - ), + ArtifactsCommands::Download(args) => Layout::flow(format!( + "Download artifact '{}' of experiment {}", + args.artifact, args.experiment + )), } } } #[derive(Subcommand, Debug)] pub enum ArtifactsCommands { - /// List artifacts in an experiment. + /// List an experiment's artifacts List(ListArgs), - /// Download an artifact selected by name or id. + /// Download an artifact by name or id Download(DownloadArgs), } #[derive(Args, Debug)] pub struct ListArgs { - /// Project-scoped experiment number. + /// Experiment number #[arg(value_parser = clap::value_parser!(i32).range(1..))] pub experiment: i32, - /// Show only artifacts whose name contains this text. + /// Show only artifacts whose name contains this text #[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())] pub name: Option, } #[derive(Args, Debug)] pub struct DownloadArgs { - /// Project-scoped experiment number. + /// Experiment number #[arg(value_parser = clap::value_parser!(i32).range(1..))] pub experiment: i32, - /// Artifact name or id in the experiment. + /// Artifact name or id #[arg(value_name = "NAME|ID", value_parser = clap::builder::NonEmptyStringValueParser::new())] pub artifact: String, - /// Destination directory (default: ./). + /// Download into this directory #[arg(short, long, value_name = "DIR")] pub directory: Option, - /// Overwrite existing destination files. + /// Overwrite existing files #[arg(long)] pub force: bool, } @@ -115,12 +112,11 @@ impl Render for ArtifactDownloaded {} impl Render for ArtifactListResponse { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { - Table::new(["NAME", "KIND", "ID", "FILES", "CREATED AT"]) + Table::new(["NAME", "KIND", "FILES", "CREATED AT"]) .rows(self.items.iter().map(|artifact| { [ artifact.name.clone(), artifact.kind.clone(), - artifact.id.clone(), manifest_files(&artifact.manifest) .map(|files| files.len().to_string()) .unwrap_or_default(), @@ -131,7 +127,7 @@ impl Render for ArtifactListResponse { } fn summary(&self) -> Option { - Some(counted( + Some(list_summary( self.items.len(), self.total as u64, ["artifact", "artifacts"], @@ -147,7 +143,7 @@ pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Resul if directory.as_os_str().is_empty() || (directory.exists() && !directory.is_dir()) { return Err(CliError::new( ErrorKind::Usage, - format!("Invalid destination directory '{}'.", directory.display()), + format!("'{}' isn't a directory.", directory.display()), ) .into()); } @@ -215,14 +211,16 @@ pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Resul .collect(); let files = download_files(&client, context.terminal(), &directory, &files, args.force)?; + let bytes = files.iter().map(|file| file.bytes).sum(); context.terminal().outro(&format!( - "Downloaded artifact '{}' to {}.", + "Downloaded artifact '{}' ({}) to {}.", artifact.name, + size(bytes), directory.display() )); Ok(ArtifactDownloaded { experiment: args.experiment, - bytes: files.iter().map(|file| file.bytes).sum(), + bytes, artifact, directory, files, diff --git a/crates/tracel-cli/src/commands/auth.rs b/crates/tracel-cli/src/commands/auth.rs index c22bd88..51f2ff4 100644 --- a/crates/tracel-cli/src/commands/auth.rs +++ b/crates/tracel-cli/src/commands/auth.rs @@ -6,7 +6,7 @@ use serde::Serialize; use tracel_client::ClientError; use tracel_client::console::TracelCredentials; -use crate::commands::login::environment_suffix; +use crate::commands::login::LOG_IN; use crate::context::{CliContext, ClientCreationError}; use crate::error::{CliError, ErrorKind}; use crate::ui::{Details, Human, Layout, Outcome, Render, Timestamp}; @@ -19,10 +19,9 @@ pub struct AuthArgs { #[derive(Subcommand, Debug)] pub enum AuthCommands { - /// Print an access token of your login, renewed if needed, for scripts. - /// TRACEL_API_KEY is ignored. + /// Print an access token of your login for scripts Token, - /// Show which credential commands use, the user they act as, and when your login ends. + /// Show the credential in use and when your login ends Status, } @@ -93,14 +92,12 @@ impl Render for AuthStatus { Details::new() .field("Credential", credential) .field("Login", login) - .field("User", &self.user.username) .field("Namespace", &self.user.namespace) - .field("Environment", &self.environment) .write(out) } fn summary(&self) -> Option { - Some(format!("Commands act as {}.", self.user.username)) + Some(format!("Logged in as {}.", self.user.username)) } } @@ -110,14 +107,11 @@ fn access_token(context: &CliContext) -> anyhow::Result { .access_token() .map_err(|e| -> anyhow::Error { match e { - ClientError::AppSessionEnded => CliError::new( - ErrorKind::NotAuthenticated, - format!( - "Not logged in{}. Run 'tracel login' first.", - environment_suffix(&context.environment()) - ), - ) - .into(), + ClientError::AppSessionEnded => { + CliError::new(ErrorKind::NotAuthenticated, "Not logged in.") + .with_hint("Run `tracel login`.") + .into() + } e => e.into(), } })?; @@ -147,16 +141,13 @@ fn status(context: &CliContext) -> anyhow::Result { }, environment: context.environment_name(), }), - Err(ClientCreationError::NoCredentials | ClientCreationError::InvalidCredentials) => { - Err(CliError::new( - ErrorKind::NotAuthenticated, - format!( - "Not logged in{}. Run 'tracel login' or set TRACEL_API_KEY.", - environment_suffix(&context.environment()) - ), - ) - .into()) - } + Err( + error @ (ClientCreationError::NoCredentials | ClientCreationError::InvalidCredentials), + ) => Err( + CliError::new(ErrorKind::NotAuthenticated, error.to_string()) + .with_hint(LOG_IN) + .into(), + ), Err(e) => Err(e.into()), } } diff --git a/crates/tracel-cli/src/commands/datasets.rs b/crates/tracel-cli/src/commands/datasets.rs index ce36641..edd5d7a 100644 --- a/crates/tracel-cli/src/commands/datasets.rs +++ b/crates/tracel-cli/src/commands/datasets.rs @@ -9,7 +9,7 @@ use tracel_client::console::dataset::response::{ use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::helpers::{Resource, map_resource_error, resolve_namespace_project}; -use crate::ui::{Details, Human, Outcome, Render, Table, counted, when}; +use crate::ui::{Details, Human, Outcome, Render, Table, list_summary, when}; #[derive(Args, Debug)] pub struct DatasetsArgs { @@ -19,38 +19,40 @@ pub struct DatasetsArgs { #[derive(Subcommand, Debug)] pub enum DatasetsCommands { - /// List datasets in the selected project. + /// List the project's datasets List(ListArgs), - /// Show a dataset and its metadata. + /// Show a dataset and its metadata Get(GetArgs), - /// List published dataset versions. + /// List a dataset's published versions Versions(VersionsArgs), } #[derive(Args, Debug)] pub struct ListArgs { - /// Page number (zero-based). + /// Show this page, counting from 0 #[arg(long, value_name = "N")] pub page: Option, - /// Maximum datasets per page. + /// Show this many datasets per page #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))] pub per_page: Option, } #[derive(Args, Debug)] pub struct GetArgs { + /// Dataset name #[arg(value_parser = clap::builder::NonEmptyStringValueParser::new())] pub dataset: String, } #[derive(Args, Debug)] pub struct VersionsArgs { + /// Dataset name #[arg(value_parser = clap::builder::NonEmptyStringValueParser::new())] pub dataset: String, - /// Page number (zero-based). + /// Show this page, counting from 0 #[arg(long, value_name = "N")] pub page: Option, - /// Maximum versions per page. + /// Show this many versions per page #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))] pub per_page: Option, } @@ -104,20 +106,19 @@ pub fn handle_command(args: DatasetsArgs, context: CliContext) -> anyhow::Result impl Render for DatasetListResponse { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { - Table::new(["NAME", "DESCRIPTION", "ID"]) + Table::new(["NAME", "DESCRIPTION"]) .shrink("DESCRIPTION") .rows(self.items.iter().map(|dataset| { [ dataset.name.clone(), dataset.description.clone().unwrap_or_default(), - dataset.id.clone(), ] })) .write(out) } fn summary(&self) -> Option { - Some(counted( + Some(list_summary( self.items.len(), self.total_count, ["dataset", "datasets"], @@ -130,7 +131,6 @@ impl Render for DatasetResponse { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Details::new() .field("Name", &self.name) - .field("ID", &self.id) .optional("Description", self.description.as_ref()) .section("Metadata", &self.metadata) .write(out) @@ -142,8 +142,8 @@ impl Render for DatasetVersionListResponse { Table::new(["VERSION", "ITEMS", "SOURCE", "CREATED AT"]) .rows(self.items.iter().map(|version| { let source = match version.source_kind { - SourceKindResponse::AnnotationSet => "annotation_set", - SourceKindResponse::DirectUpload => "direct_upload", + SourceKindResponse::AnnotationSet => "annotation set", + SourceKindResponse::DirectUpload => "direct upload", }; [ version.version.to_string(), @@ -156,7 +156,7 @@ impl Render for DatasetVersionListResponse { } fn summary(&self) -> Option { - Some(counted( + Some(list_summary( self.items.len(), self.total_count, ["version", "versions"], diff --git a/crates/tracel-cli/src/commands/experiments.rs b/crates/tracel-cli/src/commands/experiments.rs index eec6bd0..a66b55a 100644 --- a/crates/tracel-cli/src/commands/experiments.rs +++ b/crates/tracel-cli/src/commands/experiments.rs @@ -20,7 +20,10 @@ use crate::error::{CliError, ErrorKind}; use crate::helpers::project::resolve_namespace_project; use crate::helpers::{Resource, map_resource_error}; use crate::tools::tracel_config::TracelProject; -use crate::ui::{Details, Human, Layout, Outcome, Output, Render, Table, counted, log_time, when}; +use crate::ui::{ + Details, Human, Layout, Outcome, Output, Render, Table, counted, list_summary, log_time, + number, when, +}; #[derive(Args, Debug)] pub struct ExperimentsArgs { @@ -31,10 +34,9 @@ pub struct ExperimentsArgs { impl ExperimentsArgs { pub fn layout(&self) -> Layout { match &self.command { - ExperimentsCommands::Logs(args) if args.follow => Layout::flow( - format!("Follow the logs of experiment {}", args.num), - format!("Stopped following experiment {}.", args.num), - ), + ExperimentsCommands::Logs(args) if args.follow => { + Layout::flow(format!("Follow the logs of experiment {}", args.num)) + } _ => Layout::Read, } } @@ -42,25 +44,25 @@ impl ExperimentsArgs { #[derive(Subcommand, Debug)] pub enum ExperimentsCommands { - /// List experiments in the selected project. + /// List the project's experiments List(ListArgs), - /// Show an experiment by its project-scoped number, or latest. + /// Show an experiment Get(GetArgs), - /// List metric definitions, or read a metric series or summary. + /// List metrics, or show one metric's series Metrics(MetricsArgs), - /// Read experiment logs, optionally following until the experiment finishes. + /// Show an experiment's logs Logs(LogsArgs), } #[derive(Args, Debug)] pub struct ListArgs { - /// Page number (zero-based). - #[arg(long)] + /// Show this page, counting from 0 + #[arg(long, value_name = "N")] pub page: Option, - /// Maximum experiments per page. - #[arg(long, value_parser = clap::value_parser!(u32).range(1..))] + /// Show at most this many experiments + #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))] pub limit: Option, - /// Server sort: field, field,asc, or field,desc; repeat for multiple fields. + /// Sort by a field, asc or desc; repeat for more #[arg(long, value_name = "FIELD[,asc|desc]")] pub sort: Vec, } @@ -77,33 +79,33 @@ fn parse_experiment(value: &str) -> Result { } match value.parse::() { Ok(number) if number > 0 => Ok(ExperimentSelector::Number(number)), - _ => Err("Expected a positive project-scoped experiment number or 'latest'.".into()), + _ => Err("Expected an experiment number or latest.".into()), } } #[derive(Args, Debug)] pub struct GetArgs { - /// Project-scoped experiment number, or latest. + /// Experiment number, or latest #[arg(value_name = "NUM|latest", value_parser = parse_experiment)] pub experiment: ExperimentSelector, } #[derive(Args, Debug)] pub struct MetricsArgs { - /// Project-scoped experiment number. + /// Experiment number #[arg(value_parser = clap::value_parser!(i32).range(1..))] pub num: i32, - /// Metric name; omit to list metric definitions. - #[arg(long)] + /// Show this metric's series + #[arg(long, value_name = "NAME")] pub metric: Option, - /// Return a summary instead of the series. + /// Show the metric's summary instead of its series #[arg(long, requires = "metric")] pub summary: bool, - /// Maximum number of series points. - #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(i64).range(1..))] + /// Show at most this many points + #[arg(long, value_name = "N", default_value_t = 100, value_parser = clap::value_parser!(i64).range(1..))] pub max_points: i64, - /// Series downsampling factor. - #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(i64).range(1..))] + /// Downsample the series by this factor + #[arg(long, value_name = "N", default_value_t = 1, value_parser = clap::value_parser!(i64).range(1..))] pub downsampling: i64, } @@ -131,46 +133,46 @@ impl From for LogLevelRequest { fn parse_metadata_pair(value: &str) -> Result<(String, String), String> { match value.split_once('=') { Some((key, value)) if !key.is_empty() => Ok((key.into(), value.into())), - _ => Err("Expected KEY=VALUE with a nonempty key.".into()), + _ => Err("Expected KEY=VALUE with a key.".into()), } } #[derive(Args, Debug)] pub struct LogsArgs { - /// Project-scoped experiment number. + /// Experiment number #[arg(value_parser = clap::value_parser!(i32).range(1..))] pub num: i32, - /// Maximum entries per request. - #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u32).range(1..))] + /// Show at most this many entries + #[arg(long, value_name = "N", default_value_t = 100, value_parser = clap::value_parser!(u32).range(1..))] pub limit: u32, - /// Include this log level; repeat to include multiple levels. + /// Show only this level; repeat for more #[arg(long = "level", value_enum, value_name = "LEVEL")] pub levels: Vec, - /// Return entries after this sequence, oldest first. - #[arg(long, conflicts_with_all = ["from", "to", "offset"])] + /// Show entries after this sequence number + #[arg(long, value_name = "SEQ", conflicts_with_all = ["from", "to", "offset"])] pub after: Option, - /// Start of the time range, as a timestamp string. - #[arg(long)] + /// Show entries from this time + #[arg(long, value_name = "TIME")] pub from: Option, - /// End of the time range, as a timestamp string. - #[arg(long)] + /// Show entries up to this time + #[arg(long, value_name = "TIME")] pub to: Option, - /// Offset within the time range. - #[arg(long)] + /// Skip this many entries + #[arg(long, value_name = "N")] pub offset: Option, - /// Filter messages by search text. - #[arg(long)] + /// Show entries whose message contains this text + #[arg(long, value_name = "TEXT")] pub search: Option, - /// Require a metadata value; repeat for multiple filters. + /// Show entries with this metadata; repeat for more #[arg(long, value_name = "KEY=VALUE", value_parser = parse_metadata_pair)] pub metadata: Vec<(String, String)>, - /// Exclude a metadata value; repeat for multiple filters. + /// Hide entries with this metadata; repeat for more #[arg(long, value_name = "KEY=VALUE", value_parser = parse_metadata_pair)] pub metadata_not: Vec<(String, String)>, - /// Require a metadata key; repeat for multiple filters. + /// Show entries that have this metadata key #[arg(long, value_name = "KEY", value_parser = clap::builder::NonEmptyStringValueParser::new())] pub metadata_exists: Vec, - /// Poll every 2 seconds until finished; JSON output is NDJSON log and end events. + /// Keep showing new entries until the experiment ends #[arg(long, conflicts_with_all = ["from", "to", "offset"])] pub follow: bool, } @@ -221,7 +223,7 @@ impl Render for ListExperimentsResponse { [ experiment.experiment_num.to_string(), experiment.name.clone().unwrap_or_default(), - experiment.status.clone(), + status_name(&experiment.status).to_string(), when(&experiment.created_at), experiment.created_by.username.clone(), ] @@ -230,7 +232,7 @@ impl Render for ListExperimentsResponse { } fn summary(&self) -> Option { - Some(counted( + Some(list_summary( self.items.len(), self.total, ["experiment", "experiments"], @@ -242,11 +244,7 @@ impl Render for ListExperimentsResponse { impl Render for ExperimentDetailsResponse { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Details::new() - .field("Number", self.experiment_num) - .field("ID", self.id) - .field("Project ID", self.project_id) .optional("Name", self.name.as_ref()) - .field("Status", &self.status) .field("Description", &self.description) .field("Created at", when(&self.created_at)) .field("Created by", &self.created_by.username) @@ -257,10 +255,12 @@ impl Render for ExperimentDetailsResponse { } fn summary(&self) -> Option { - Some(format!( - "Experiment {} is {}.", - self.experiment_num, self.status - )) + let number = self.experiment_num; + Some(match self.status.as_str() { + "completed" => format!("Experiment {number} completed."), + "failed" => format!("Experiment {number} failed."), + status => format!("Experiment {number} is {}.", status_name(status)), + }) } } @@ -278,13 +278,10 @@ impl Render for MetricMetadataResponse { } fn summary(&self) -> Option { - let plural = |count: usize, one: &str, many: &str| { - format!("{count} {}", if count == 1 { one } else { many }) - }; Some(format!( "{} in {}.", - plural(self.metric_types.len(), "metric", "metrics"), - plural(self.groups.len(), "group", "groups") + counted(self.metric_types.len() as u64, ["metric", "metrics"]), + counted(self.groups.len() as u64, ["group", "groups"]) )) } } @@ -303,9 +300,9 @@ impl Render for Option { group.name.clone(), entry.epoch.to_string(), entry.iteration.to_string(), - entry.value.to_string(), - entry.low.to_string(), - entry.high.to_string(), + number(entry.value), + number(entry.low), + number(entry.high), ] }) })) @@ -324,7 +321,7 @@ impl Render for Option { .rows(summary.groups.iter().map(|group| { [ group.group.clone(), - group.optimal_value.to_string(), + number(group.optimal_value), group.epoch.to_string(), ] })) @@ -341,7 +338,7 @@ impl Render for ExperimentLogQueryResponse { } fn summary(&self) -> Option { - Some(counted( + Some(list_summary( self.items.len(), self.total, ["log entry", "log entries"], @@ -350,6 +347,14 @@ impl Render for ExperimentLogQueryResponse { } } +/// An experiment status as a word: the server's `cancel_requested` reads "stopping". +fn status_name(status: &str) -> &str { + match status { + "cancel_requested" => "stopping", + status => status, + } +} + fn write_log(out: &mut Human<'_>, item: &ExperimentLogItemResponse) -> io::Result<()> { let level = match item.log_level { LogLevelResponse::Trace => "trace", @@ -413,11 +418,7 @@ fn follow_logs( return output.event(&LogEvent::End { running: false }); } if response.has_more && cursor == previous { - return Err(CliError::new( - ErrorKind::Internal, - "Log response has more entries but no advancing sequence.", - ) - .into()); + anyhow::bail!("The Tracel Console sent more log entries without a new sequence."); } // Drain a backlog right away; wait only once caught up. if !response.has_more { @@ -452,7 +453,7 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res ExperimentSelector::Latest => client .get_project_latest_experiment(owner, name)? .ok_or_else(|| { - CliError::new(ErrorKind::NotFound, "No experiments found in this project.") + CliError::new(ErrorKind::NotFound, "No experiments in this project yet.") })?, }; Ok(experiment.into()) diff --git a/crates/tracel-cli/src/commands/init.rs b/crates/tracel-cli/src/commands/init.rs index 3745746..252930c 100644 --- a/crates/tracel-cli/src/commands/init.rs +++ b/crates/tracel-cli/src/commands/init.rs @@ -4,7 +4,7 @@ use crate::helpers::{can_initialize_project, require_cargo_workspace, require_li use crate::tools::git; use crate::tools::project_context::ProjectContext; use crate::tools::tracel_config::TracelProject; -use crate::ui::{Declined, Outcome, Render, Terminal}; +use crate::ui::{Declined, Outcome, Render, Terminal, Unanswered}; use anyhow::Context; use clap::Args; use serde::Serialize; @@ -14,30 +14,30 @@ use tracel_client::console::project::response::ProjectResponse; #[derive(Args, Debug)] pub struct InitArgs { - /// Force reinitialization of the project + /// Relink a workspace that is already linked #[arg(long, short = 'f')] pub force: bool, - /// Namespace of your user or one of your organizations + /// Own the project as yourself or an organization #[arg(long, value_name = "NAMESPACE")] pub owner: Option, - /// Project name (alphanumeric characters, underscores, and hyphens) + /// Use this project name #[arg(long, value_name = "PROJECT")] pub name: Option, - /// Description for a new project (use an empty string for none) + /// Describe a new project #[arg(long, value_name = "TEXT")] pub description: Option, - /// Link an existing project without asking for confirmation + /// Link an existing project without asking #[arg(long, short = 'y')] pub yes: bool, - /// Commit all current changes, creating the first commit if needed + /// Commit all current changes first #[arg(long, conflicts_with = "allow_dirty")] pub commit: bool, - /// Continue with uncommitted changes + /// Link with uncommitted changes #[arg(long)] pub allow_dirty: bool, } -/// The project the repository is linked to. `url` is null when it already was. +/// The project the workspace is linked to. `url` is null when it already was. #[derive(Serialize)] struct ProjectLinked { namespace: String, @@ -48,11 +48,11 @@ struct ProjectLinked { impl Render for ProjectLinked {} +const NOTHING_LINKED: &str = "Nothing was linked."; + pub fn handle_command(args: InitArgs, context: CliContext) -> anyhow::Result { if let Some(name) = &args.name { - validate_project_name(name).map_err(|message| { - CliError::new(ErrorKind::Usage, message).with_hint("Pass --name .") - })?; + validate_project_name(name).map_err(|message| CliError::new(ErrorKind::Usage, message))?; } if !can_initialize_project(args.force)? { let project = require_linked_project()?; @@ -63,16 +63,16 @@ pub fn handle_command(args: InitArgs, context: CliContext) -> anyhow::Result anyhow::Result return Err(e).context("Failed to check for an existing project"), + Err(e) => { + return Err(e).with_context(|| format!("Can't look up {owner_name}/{project_name}")); + } }; - ProjectContext::init(project_info.clone(), &workspace_info.get_manifest_path()) - .context("Failed to initialize project metadata")?; - terminal.print("Created project metadata"); + ProjectContext::init(project_info.clone(), &workspace_info.get_manifest_path())?; let frontend_url = project_url(context, &project_owner, owner_name, &project_name)?; - - terminal.outro(&format!( - "Project initialized successfully! You can check out your project at {}", - context.terminal().format_url(&frontend_url) - )); + let project = format!("{}/{}", project_info.owner, project_info.name); + let url = terminal.format_url(&frontend_url); + terminal.outro(&if created { + format!("Created and linked {project} at {url}.") + } else { + format!("Linked to {project} at {url}.") + }); Ok(ProjectLinked { namespace: project_info.owner, @@ -183,20 +184,22 @@ fn prompt_owner_name( ProjectKind::Organization(owner.to_string()) }); } - let mut namespaces = vec![(ProjectKind::User, format!("[user] {user_name}"), "")]; + let mut namespaces = vec![(ProjectKind::User, format!("{user_name} (you)"), "")]; namespaces.extend(organizations.organizations.iter().map(|org| { ( ProjectKind::Organization(org.namespace.clone()), - format!("[org] {}", org.name), + org.name.clone(), "", ) })); terminal.select( - "Select the owner of the project", - "owner", + "Owner", &namespaces, Some(ProjectKind::User), - &valid_values, + Unanswered::new( + "Linking needs an owner.", + format!("Pass --owner with {}.", valid_values.join(" or ")), + ), ) } @@ -207,38 +210,31 @@ fn validate_owner(owner: &str, valid_values: &[&str]) -> Result<(), CliError> { Err(CliError::new( ErrorKind::Usage, format!( - "Invalid --owner '{owner}'. Valid values: {}.", + "Unknown owner '{owner}'. Owners: {}.", valid_values.join(", ") ), - ) - .with_hint("Pass --owner using one of the valid values.")) + )) } } fn validate_project_name(name: &str) -> Result<(), String> { if name.is_empty() { - Err("Project name cannot be empty.".to_string()) + Err("A project name can't be empty.".to_string()) } else if name .chars() .all(|c| c.is_alphanumeric() || c == '_' || c == '-') { Ok(()) } else { - Err( - "Project name must contain only alphanumeric characters, underscores, or hyphens." - .to_string(), - ) + Err("Project names use only letters, digits, '_' and '-'.".to_string()) } } pub fn prompt_project_name(workspace_name: &str, terminal: &Terminal) -> anyhow::Result { let input = terminal.input_validated( - &format!( - "Enter the project name (default: {}) ", - console::style(workspace_name).bold() - ), - "name", + "Project name", workspace_name, + Unanswered::new("Linking needs a project name.", "Pass --name ."), |input| { if input.is_empty() { Ok(()) @@ -263,14 +259,15 @@ fn handle_existing_project( terminal: &Terminal, yes: bool, ) -> anyhow::Result { + let name = format!("{}/{}", project.namespace_name, project.project_name); let confirmed = yes || terminal.confirm( - &format!( - "Project \"{}\" already exists under owner \"{}\". Do you want to link it?", - project.project_name, project.namespace_name - ), - "yes", + &format!("Link existing project {name}?"), false, + Unanswered::new( + format!("Linking {name} needs confirmation."), + "Pass --yes to link it without asking.", + ), )?; if confirmed { @@ -279,7 +276,7 @@ fn handle_existing_project( name: project.project_name.clone(), }) } else { - Err(Declined::new("Nothing was linked.").into()) + Err(Declined::new(NOTHING_LINKED).into()) } } @@ -305,6 +302,7 @@ fn project_url( fn create_new_project( client: &Client, project_kind: ProjectKind, + namespace: &str, name: &str, description: Option, terminal: &Terminal, @@ -312,8 +310,11 @@ fn create_new_project( let description = match description { Some(description) => description, None if terminal.is_interactive() => terminal.input( - "Enter the project description (default empty)", - "description", + "Description (optional)", + Unanswered::new( + format!("Creating {namespace}/{name} needs a description."), + "Pass --description .", + ), )?, None => String::new(), }; @@ -333,7 +334,8 @@ fn create_new_project( ), }; - let project = created_project_path.context("Failed to create project")?; + let project = + created_project_path.with_context(|| format!("Can't create project {namespace}/{name}"))?; Ok(TracelProject { owner: project.namespace_name, name: project.project_name, @@ -345,11 +347,8 @@ pub fn ensure_git_repo_initialized( terminal: &Terminal, ) -> anyhow::Result<()> { if !git::is_repo_initialized() { - let repo = git::init_repo(ws_root)?; - terminal.step(&format!( - "No git repository found. Initialized new git repository at: {}", - repo.path().display() - )); + git::init_repo(ws_root)?; + terminal.step("Created a git repository"); } Ok(()) } @@ -363,7 +362,7 @@ pub fn ensure_git_repo_clean( let dirty = match git::is_repo_dirty() { Ok(dirty) => dirty, Err(_) if !has_commit => true, - Err(error) => return Err(error).context("Failed to check if the repository is dirty"), + Err(error) => return Err(error).context("Can't read the git status"), }; if !dirty && has_commit { return Ok(()); @@ -375,31 +374,35 @@ pub fn ensure_git_repo_clean( return Ok(()); } if allow_dirty { - return Err( - CliError::new(ErrorKind::Usage, "The repository needs an initial commit.") - .with_hint("Pass --commit to create the first commit.") - .into(), - ); + return Err(no_commit() + .with_hint("Pass --commit to make the first one.") + .into()); } if !terminal.is_interactive() { return Err(commit_required().into()); } - terminal.print( - "Repository is dirty. Tracel Console needs a valid commit hash to associate your code with your repository.", - ); + terminal.print_warning(if has_commit { + "The workspace has uncommitted changes." + } else { + "The workspace has no commit yet." + }); if !terminal.confirm( - "Do you want to automatically commit all files?", - "commit", + "Commit all files?", false, + Unanswered::new("Linking needs a commit.", "Pass --commit or --allow-dirty."), )? { - return Err(Declined::new("Nothing was linked.").into()); + return Err(Declined::new(NOTHING_LINKED).into()); } - commit_all(terminal).context("Failed to make initial commit") + commit_all(terminal) +} + +fn no_commit() -> CliError { + CliError::new(ErrorKind::Usage, "The workspace has no commit yet.") } fn commit_required() -> CliError { - CliError::new(ErrorKind::Usage, "The repository has uncommitted changes.") - .with_hint("Commit your changes, or pass --commit or --allow-dirty") + CliError::new(ErrorKind::Usage, "The workspace has uncommitted changes.") + .with_hint("Commit them, or pass --commit or --allow-dirty.") } /// Commit everything in the working tree, making the first commit if there is none. @@ -411,9 +414,9 @@ pub fn commit_all(terminal: &Terminal) -> anyhow::Result<()> { .stderr(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .status() - .context("Failed to run `git add .`")?; + .context("Can't run `git add --all`")?; if !status.success() { - return Err(anyhow::anyhow!("Failed to add files to git")); + anyhow::bail!("`git add --all` failed."); } let mut command = std::process::Command::new("git"); command.args(["commit", "-m", commit_message]); @@ -425,11 +428,11 @@ pub fn commit_all(terminal: &Terminal) -> anyhow::Result<()> { .stderr(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .status() - .context("Failed to run `git commit -m`")?; + .context("Can't run `git commit`")?; if !status.success() { - return Err(anyhow::anyhow!("Failed to commit files to git")); + anyhow::bail!("`git commit` failed."); } - terminal.print_success("Committed all files to git."); + terminal.step("Committed all files"); Ok(()) } @@ -459,7 +462,7 @@ mod tests { assert!( validate_project_name("project.name") .unwrap_err() - .contains("hyphens") + .contains("'_' and '-'") ); } } diff --git a/crates/tracel-cli/src/commands/login.rs b/crates/tracel-cli/src/commands/login.rs index e2d4c51..d2dbfac 100644 --- a/crates/tracel-cli/src/commands/login.rs +++ b/crates/tracel-cli/src/commands/login.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant, SystemTime}; use clap::Args; use serde::Serialize; use tracel_client::console::auth::{DeviceAuthClient, DeviceFlowError, DevicePollOutcome}; -use tracel_client::console::{Client, Env, TracelCredentials}; +use tracel_client::console::{Client, TracelCredentials}; use url::Url; use self::pending::{PendingLogin, PendingLoginStore}; @@ -16,13 +16,13 @@ use crate::ui::{Layout, Outcome, Render, Terminal, Timestamp}; #[derive(Args, Debug)] pub struct LoginArgs { - /// Start a login and save it for later completion. + /// Start a login and finish it later with --complete #[arg(long, conflicts_with = "complete")] pub no_wait: bool, - /// Finish the pending login. + /// Finish a login started with --no-wait #[arg(long)] pub complete: bool, - /// Wait at most this many seconds, keeping the pending login on timeout. + /// Stop waiting after this many seconds #[arg(long, requires = "complete", value_name = "SECONDS")] pub timeout: Option, } @@ -30,17 +30,11 @@ pub struct LoginArgs { impl LoginArgs { pub fn layout(&self) -> Layout { if self.no_wait { - Layout::flow( - "Start a login to the Tracel Console", - "No login was started.", - ) + Layout::flow("Start a login to the Tracel Console") } else if self.complete { - Layout::flow( - "Finish the pending login to the Tracel Console", - "Login failed.", - ) + Layout::flow("Finish the pending login to the Tracel Console") } else { - Layout::flow("Log in to the Tracel Console", "Login failed.") + Layout::flow("Log in to the Tracel Console") } } } @@ -58,52 +52,32 @@ pub fn get_client_and_login_if_needed(context: &CliContext) -> anyhow::Result return Ok(client), - Err(ClientCreationError::InvalidCredentials | ClientCreationError::NoCredentials) - if !logged_in => - { - let env_msg = environment_suffix(&context.environment()); + Err( + error @ (ClientCreationError::InvalidCredentials + | ClientCreationError::NoCredentials), + ) if !logged_in => { if !context.terminal().is_interactive() { - return Err(CliError::new( - ErrorKind::NotAuthenticated, - format!( - "Not logged in{}. Run 'tracel login' or set TRACEL_API_KEY.", - env_msg - ), - ) - .with_hint( - "Run 'tracel login', or 'tracel login --no-wait' then 'tracel login --complete'.", - ) - .into()); + return Err( + CliError::new(ErrorKind::NotAuthenticated, error.to_string()) + .with_hint(LOG_IN) + .into(), + ); } - context.terminal().print_err(&format!( - "Not logged in{}. Log in below, or press Ctrl+C to exit.", - env_msg - )); + context.terminal().print_warning(&error.to_string()); log_in(context)?; logged_in = true; } - Err(ClientCreationError::ServerConnectionError(msg)) => match delays.next() { + Err(error @ ClientCreationError::ServerConnectionError(_)) => match delays.next() { Some(delay) => std::thread::sleep(delay), - None => { - return Err(CliError::new( - ErrorKind::Unavailable, - format!("Server connection failed after maximum retries: {}", msg), - ) - .into()); - } + None => return Err(error.into()), }, - Err(err) => return Err(err.into()), + Err(error) => return Err(error.into()), } } } -pub fn environment_suffix(environment: &Env) -> String { - match environment { - Env::Development => " (development environment)".to_string(), - Env::Staging(version) => format!(" (staging environment v{})", version), - Env::Production => String::new(), - } -} +/// The hint for a command that needs a login. +pub const LOG_IN: &str = "Run `tracel login`, or set TRACEL_API_KEY."; fn log_in(context: &CliContext) -> anyhow::Result<()> { let terminal = context.terminal(); @@ -119,12 +93,9 @@ fn log_in(context: &CliContext) -> anyhow::Result<()> { terminal.instruct(&instructions); offer_browser(terminal, &approval_page)?; - let spinner = terminal.spinner(); - spinner.start("Waiting for approval... Press Ctrl+C to cancel."); - let issued = device_auth - .wait_for_approval(&authorization) - .inspect_err(|_| spinner.error("Login failed."))?; - spinner.stop("Login approved."); + let spinner = terminal.spinner("Waiting for approval"); + let issued = device_auth.wait_for_approval(&authorization)?; + spinner.clear(); context.app_session()?.sign_in(issued)?; @@ -138,7 +109,7 @@ fn offer_browser(terminal: &Terminal, approval_page: &Url) -> anyhow::Result<()> } if let Err(error) = webbrowser::open(approval_page.as_str()) { terminal.print_warning(&format!( - "Could not open a browser ({error}). Open the link above instead." + "Can't open a browser ({error}), so open the link yourself." )); } Ok(()) @@ -177,10 +148,7 @@ fn start_pending_login(context: &CliContext) -> anyhow::Result { )?), console::style(&pending.authorization.user_code).bold() )); - terminal.next_steps( - "The login waits for your approval.", - &["tracel login --complete".to_string()], - ); + terminal.outro("Once you approve it, run `tracel login --complete`."); Ok(LoginPending { verification_uri_complete: pending.authorization.verification_uri_complete, @@ -202,9 +170,9 @@ impl PollDeadline { ErrorKind::NotAuthenticated, "The pending login expired before it was approved.", ) - .with_hint("Start again with 'tracel login --no-wait'."), + .with_hint("Start a new one with `tracel login --no-wait`."), Self::Timeout => CliError::new(ErrorKind::Timeout, "The login is still pending.") - .with_hint("Run 'tracel login --complete' again."), + .with_hint("Run `tracel login --complete` again."), } } } @@ -246,16 +214,16 @@ fn poll_with_deadline( Ok(Ok(outcome)) => Ok(outcome), Err(RecvTimeoutError::Timeout) => Err(deadline.error().into()), Ok(Err(DeviceFlowError::Client(error))) => { - Err(anyhow::Error::new(error).context("Failed to check the pending login")) + Err(anyhow::Error::new(error).context("Can't check the pending login")) } Ok(Err(error)) => Err( CliError::new(ErrorKind::NotAuthenticated, error.to_string()) - .with_hint("Start again with 'tracel login --no-wait'.") + .with_hint("Start a new one with `tracel login --no-wait`.") .into(), ), - Err(RecvTimeoutError::Disconnected) => Err(anyhow::anyhow!( - "The pending login check stopped unexpectedly" - )), + Err(RecvTimeoutError::Disconnected) => { + Err(anyhow::anyhow!("The pending login check stopped.")) + } } } @@ -263,8 +231,8 @@ fn complete_pending_login(context: &CliContext, timeout: Option) -> anyhow: let started = Instant::now(); let store = PendingLoginStore::for_server(&context.environment().get_url())?; let mut pending = store.load()?.ok_or_else(|| { - CliError::new(ErrorKind::NotFound, "No pending login found.") - .with_hint("Run 'tracel login --no-wait' first.") + CliError::new(ErrorKind::NotFound, "No pending login.") + .with_hint("Start one with `tracel login --no-wait`.") })?; let timeout = timeout.map(Duration::from_secs); let expires_at = pending.expires_at; @@ -343,11 +311,9 @@ pub fn handle_command(args: LoginArgs, context: CliContext) -> anyhow::Result anyhow::Result { let expires_at = now .checked_add(authorization.expires_in()) - .context("Invalid device authorization expiry")?; + .context("The login's expiry is out of range")?; Ok(Self { authorization, expires_at, @@ -46,9 +46,9 @@ impl PendingLoginStore { let bytes = match fs::read(&self.path) { Ok(bytes) => bytes, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error).context("Could not read pending login"), + Err(error) => return Err(error).context("Can't read the pending login"), }; - let pending = serde_json::from_slice(&bytes).context("Could not decode pending login")?; + let pending = serde_json::from_slice(&bytes).context("Can't read the pending login")?; Ok(Some(pending)) } @@ -57,7 +57,7 @@ impl PendingLoginStore { let directory = self .path .parent() - .context("Missing pending login directory")?; + .context("The pending login has no directory")?; let mut builder = fs::DirBuilder::new(); builder.recursive(true); #[cfg(unix)] @@ -67,12 +67,12 @@ impl PendingLoginStore { } builder .create(directory) - .context("Could not create pending login directory")?; + .context("Can't create the pending login's directory")?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(directory, fs::Permissions::from_mode(0o700)) - .context("Could not protect pending login directory")?; + .context("Can't protect the pending login's directory")?; } let (temporary, mut file) = self.create_temporary()?; @@ -85,14 +85,14 @@ impl PendingLoginStore { if result.is_err() { let _ = fs::remove_file(&temporary); } - result.context("Could not save pending login") + result.context("Can't save the pending login") } pub fn clear(&self) -> anyhow::Result<()> { match fs::remove_file(&self.path) { Ok(()) => Ok(()), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error).context("Could not remove pending login"), + Err(error) => Err(error).context("Can't remove the pending login"), } } @@ -108,7 +108,7 @@ impl PendingLoginStore { match create_owner_only(&temporary) { Ok(file) => return Ok((temporary, file)), Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error).context("Could not create pending login file"), + Err(error) => return Err(error).context("Can't create the pending login file"), } } } diff --git a/crates/tracel-cli/src/commands/logout.rs b/crates/tracel-cli/src/commands/logout.rs index ca2497b..2e09866 100644 --- a/crates/tracel-cli/src/commands/logout.rs +++ b/crates/tracel-cli/src/commands/logout.rs @@ -1,6 +1,5 @@ use serde::Serialize; -use crate::commands::login::environment_suffix; use crate::context::CliContext; use crate::ui::{Outcome, Render}; @@ -16,14 +15,11 @@ pub fn handle_command(context: CliContext) -> anyhow::Result { let was_logged_in = app_session.stored()?.is_some(); app_session.sign_out()?; - let env_msg = environment_suffix(&context.environment()); - if was_logged_in { - context.terminal().outro(&format!("Logged out{}.", env_msg)); + context.terminal().outro(if was_logged_in { + "Logged out." } else { - context - .terminal() - .outro(&format!("Nobody was logged in{}.", env_msg)); - } + "Already logged out." + }); Ok(LoggedOut { logged_out: was_logged_in, diff --git a/crates/tracel-cli/src/commands/me.rs b/crates/tracel-cli/src/commands/me.rs index e32cd59..4d93dc0 100644 --- a/crates/tracel-cli/src/commands/me.rs +++ b/crates/tracel-cli/src/commands/me.rs @@ -19,24 +19,20 @@ struct CurrentUser { impl Render for CurrentUser { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Details::new() - .field("Username", &self.username) .optional("Email", self.email.as_ref()) .field("Namespace", &self.namespace) - .field("Environment", &self.environment) .field("API URL", &self.api_url) .write(out) } fn summary(&self) -> Option { - Some(format!("Signed in as {}.", self.username)) + Some(format!("Logged in as {}.", self.username)) } } pub fn handle_command(context: CliContext) -> anyhow::Result { let client = get_client_and_login_if_needed(&context)?; - let user = client - .get_current_user() - .context("Failed to retrieve user information")?; + let user = client.get_current_user().context("Can't read your user")?; Ok(CurrentUser { username: user.username, diff --git a/crates/tracel-cli/src/commands/models.rs b/crates/tracel-cli/src/commands/models.rs index c793555..fd96a51 100644 --- a/crates/tracel-cli/src/commands/models.rs +++ b/crates/tracel-cli/src/commands/models.rs @@ -25,7 +25,10 @@ use crate::helpers::{ }; use crate::tools::fs::{build_file_specs, collect_files}; use crate::tools::tracel_config::TracelProject; -use crate::ui::{Declined, Details, Human, Layout, Outcome, Render, Table, counted, when}; +use crate::ui::{ + Declined, Details, Human, Layout, Outcome, Render, Table, counted, list_summary, short_digest, + size, when, +}; #[derive(Args, Debug)] pub struct ModelsArgs { @@ -36,36 +39,29 @@ pub struct ModelsArgs { impl ModelsArgs { pub fn layout(&self) -> Layout { match &self.command { - ModelsCommands::Pull(args) => Layout::flow( - format!("Download model '{}' {}", args.model, args.version), - format!("Model '{}' was not downloaded.", args.model), - ), - ModelsCommands::Push(args) => Layout::flow( - format!("Upload a version of model '{}'", args.model_name), - format!("No version of model '{}' was uploaded.", args.model_name), - ), - ModelsCommands::Promote(args) => Layout::flow( - format!( - "Promote artifact '{}' of experiment {} to model '{}'", - args.artifact, args.experiment, args.model - ), - format!("Artifact '{}' was not promoted.", args.artifact), - ), + ModelsCommands::Pull(args) => Layout::flow(format!( + "Download model '{}' ({})", + args.model, args.version + )), + ModelsCommands::Push(args) => { + Layout::flow(format!("Upload a version of model '{}'", args.model_name)) + } + ModelsCommands::Promote(args) => Layout::flow(format!( + "Promote artifact '{}' of experiment {} to model '{}'", + args.artifact, args.experiment, args.model + )), ModelsCommands::Alias(AliasArgs { command: AliasCommands::Set(args), - }) => Layout::flow( - format!( - "Point alias '{}' of model '{}' at v{}", - args.alias, args.model, args.version - ), - format!("Alias '{}' was not changed.", args.alias), - ), + }) => Layout::flow(format!( + "Point alias '{}' of model '{}' at v{}", + args.alias, args.model, args.version + )), ModelsCommands::Alias(AliasArgs { command: AliasCommands::Remove(args), - }) => Layout::flow( - format!("Remove alias '{}' of model '{}'", args.alias, args.model), - format!("Alias '{}' was not removed.", args.alias), - ), + }) => Layout::flow(format!( + "Remove alias '{}' of model '{}'", + args.alias, args.model + )), _ => Layout::Read, } } @@ -73,94 +69,98 @@ impl ModelsArgs { #[derive(Subcommand, Debug)] pub enum ModelsCommands { - /// List models in the selected project. + /// List the project's models List, - /// Show a model or a version selected by reference. + /// Show a model, or one of its versions Get(GetArgs), - /// List ready versions, or versions in all states. + /// List a model's versions Versions(VersionsArgs), - /// Download a model version. + /// Download a model version Pull(PullArgs), - /// Upload a local directory of files as a new model version. + /// Upload a directory as a new model version Push(PushArgs), - /// Promote an experiment artifact into a new model version. + /// Make an experiment artifact a new model version Promote(PromoteArgs), - /// List, set, or remove model aliases. + /// List, set or remove model aliases Alias(AliasArgs), } #[derive(Args, Debug)] pub struct GetArgs { + /// Model name #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] pub model: String, - /// Version reference: latest, 7, v7, or an alias. + /// Show this version: latest, 7, v7 or an alias #[arg(long, value_name = "REF", value_parser = clap::builder::NonEmptyStringValueParser::new())] pub version: Option, } #[derive(Args, Debug)] pub struct VersionsArgs { + /// Model name #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] pub model: String, - /// Include pending, failed, and deleted versions. + /// Include pending, failed and deleted versions #[arg(long)] pub all: bool, } #[derive(Args, Debug)] pub struct PullArgs { + /// Model name #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] pub model: String, - /// Version reference: latest, 7, v7, or an alias. + /// Download this version: latest, 7, v7 or an alias #[arg(long, value_name = "REF", default_value = "latest", value_parser = clap::builder::NonEmptyStringValueParser::new())] pub version: String, - /// Destination directory (default: ./-v). + /// Download into this directory #[arg(short, long, value_name = "DIR")] pub directory: Option, - /// Overwrite existing destination files. + /// Overwrite existing files #[arg(long)] pub force: bool, } #[derive(Args, Debug)] pub struct PushArgs { - /// Name of the model to upload a version to. + /// Model name #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] pub model_name: String, - /// Local directory containing the files to upload. + /// Upload the files in this directory #[arg(short, long, value_name = "DIR")] pub directory: PathBuf, - /// Create a missing model (true/false); otherwise ask when interactive. - #[arg(long, short)] + /// Create the model if it's missing (true or false) + #[arg(long, short, value_name = "BOOL")] pub auto_create: Option, - /// Description to use when auto-creating the model. Requires --auto-create true. + /// Describe a model it creates #[arg(long, requires = "auto_create", value_name = "TEXT")] pub description: Option, - /// Metadata for the new version, as a JSON object. + /// Attach this JSON object to the new version #[arg(long, value_name = "JSON", value_parser = parse_metadata)] pub metadata: Option, } #[derive(Args, Debug)] pub struct PromoteArgs { + /// Model name #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] pub model: String, - /// Project-scoped experiment number. + /// Promote from this experiment number #[arg(long, value_name = "NUM", value_parser = clap::value_parser!(i32).range(1..))] pub experiment: i32, - /// Artifact name or id in the experiment. + /// Promote this artifact, by name or id #[arg(long, value_name = "NAME|ID", value_parser = clap::builder::NonEmptyStringValueParser::new())] pub artifact: String, - /// Point this alias at the new version. + /// Point this alias at the new version #[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())] pub alias: Option, - /// Metadata for the new version, as a JSON object. + /// Attach this JSON object to the new version #[arg(long, value_name = "JSON", value_parser = parse_metadata)] pub metadata: Option, - /// Create a missing model (true/false); otherwise ask when interactive. - #[arg(long)] + /// Create the model if it's missing (true or false) + #[arg(long, value_name = "BOOL")] pub auto_create: Option, - /// Description to use when auto-creating the model. Requires --auto-create true. + /// Describe a model it creates #[arg(long, requires = "auto_create", value_name = "TEXT")] pub description: Option, } @@ -173,37 +173,43 @@ pub struct AliasArgs { #[derive(Subcommand, Debug)] pub enum AliasCommands { - /// List aliases for a model. + /// List a model's aliases List(AliasListArgs), - /// Create or move an alias to a ready version. + /// Point an alias at a ready version Set(AliasSetArgs), - /// Remove an alias. + /// Remove an alias Remove(AliasRemoveArgs), } #[derive(Args, Debug)] pub struct AliasListArgs { + /// Model name #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] pub model: String, } #[derive(Args, Debug)] pub struct AliasSetArgs { + /// Model name #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] pub model: String, + /// Alias name #[arg(value_parser = clap::builder::NonEmptyStringValueParser::new())] pub alias: String, + /// Version number #[arg(value_parser = clap::value_parser!(u32).range(1..))] pub version: u32, - /// Move the alias only if it currently points at this version. + /// Move it only if it points at this version now #[arg(long, value_name = "VERSION", value_parser = clap::value_parser!(u32).range(1..))] pub expect: Option, } #[derive(Args, Debug)] pub struct AliasRemoveArgs { + /// Model name #[arg(value_name = "MODEL", value_parser = clap::builder::NonEmptyStringValueParser::new())] pub model: String, + /// Alias name #[arg(value_parser = clap::builder::NonEmptyStringValueParser::new())] pub alias: String, } @@ -258,7 +264,7 @@ impl Render for ModelListResponse { } fn summary(&self) -> Option { - Some(counted( + Some(list_summary( self.items.len(), self.total as u64, ["model", "models"], @@ -269,14 +275,15 @@ impl Render for ModelListResponse { impl Render for ModelResponse { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { - Details::new() - .field("Name", &self.name) - .field("ID", &self.id) - .field("Project ID", self.project_id) - .field("Display name", &self.display_name) + let details = Details::new(); + let details = if self.display_name == self.name { + details + } else { + details.field("Display name", &self.display_name) + }; + details .optional("Description", self.description.as_ref()) .field("Versions", self.version_count) - .field("Latest", latest_version(self)) .field("Aliases", alias_summary(&self.aliases)) .field("Created at", when(&self.created_at)) .field("Created by", &self.created_by.username) @@ -293,10 +300,7 @@ impl Render for ModelResponse { impl Render for ModelVersionResponse { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { - Details::new() - .field("Version", format!("v{}", self.version)) - .field("ID", &self.id) - .field("State", state_name(self.state)) + let details = Details::new() .field("Source", source_name(self.source_kind)) .optional( "Experiment", @@ -304,21 +308,24 @@ impl Render for ModelVersionResponse { .as_ref() .map(|experiment| experiment.experiment_num), ) - .field("Size", self.size) - .field("Digest", &self.digest) + .field("Size", size(self.size)) + .field("Digest", short_digest(&self.digest)) .field("Aliases", self.aliases.join(", ")) .field("Created at", when(&self.created_at)) - .field("Created by", &self.created_by.username) - .optional("Failure reason", self.failure_reason.as_ref()) - .write(out)?; + .field("Created by", &self.created_by.username); + match &self.failure_reason { + Some(reason) => details.field("Failure reason", reason), + None => details, + } + .write(out)?; out.heading("Files")?; - Table::new(["REL PATH", "SIZE", "CHECKSUM"]) + Table::new(["PATH", "SIZE", "CHECKSUM"]) .empty("No files.") .rows(self.manifest.files.iter().map(|file| { [ file.rel_path.clone(), - file.size_bytes.to_string(), - file.checksum.clone(), + size(file.size_bytes), + short_digest(&file.checksum).to_string(), ] })) .write(out)?; @@ -360,7 +367,7 @@ impl Render for ModelVersionListResponse { format!("v{}", version.version), state_name(version.state).into(), source, - version.size.to_string(), + size(version.size), version.aliases.join(", "), when(&version.created_at), ] @@ -369,7 +376,7 @@ impl Render for ModelVersionListResponse { } fn summary(&self) -> Option { - Some(counted( + Some(list_summary( self.items.len(), self.total as u64, ["version", "versions"], @@ -390,7 +397,7 @@ impl Render for ModelAliasListResponse { } fn summary(&self) -> Option { - Some(counted( + Some(list_summary( self.items.len(), self.total as u64, ["alias", "aliases"], @@ -435,7 +442,7 @@ fn validate_download_directory(directory: Option<&Path>, model: &str) -> anyhow: if directory.as_os_str().is_empty() || (directory.exists() && !directory.is_dir()) { return Err(CliError::new( ErrorKind::Usage, - format!("Invalid destination directory '{}'.", directory.display()), + format!("'{}' isn't a directory.", directory.display()), ) .into()); } @@ -544,14 +551,16 @@ fn pull( }) .collect(); let files = download_files(&client, context.terminal(), &directory, &files, args.force)?; + let bytes = files.iter().map(|file| file.bytes).sum(); context.terminal().outro(&format!( - "Downloaded '{}' v{} to {}.", + "Downloaded '{}' v{} ({}) to {}.", args.model, version.version, + size(bytes), directory.display() )); Ok(ModelPulled { - bytes: files.iter().map(|file| file.bytes).sum(), + bytes, model: args.model, version, directory, @@ -568,10 +577,6 @@ fn push( validate_auto_create(args.auto_create, args.description.as_deref())?; let files = collect_push_files(&args.directory, context)?; let client = get_client_and_login_if_needed(context)?; - context - .terminal() - .print(&format!("Uploading to {namespace}/{name}")); - if !ensure_model_exists( context, &client, @@ -584,20 +589,19 @@ fn push( return Err(Declined::new("Nothing was uploaded.").into()); } - let spinner = context.terminal().spinner(); - spinner.start("Computing checksums..."); - let file_specs = build_file_specs(&files).inspect_err(|_e| { - spinner.error("Failed to compute checksums."); - })?; - spinner.stop("Checksums computed."); + let spinner = context.terminal().spinner(format!( + "Reading {}", + counted(files.len() as u64, ["file", "files"]) + )); + let file_specs = build_file_specs(&files)?; + spinner.clear(); let file_sizes: BTreeMap = file_specs .iter() .map(|f| (f.rel_path.clone(), f.size_bytes)) .collect(); - let spinner = context.terminal().spinner(); - spinner.start("Requesting upload URLs..."); + let spinner = context.terminal().spinner("Starting the upload"); let upload_request = RequestModelVersionUploadRequest { files: file_specs .into_iter() @@ -611,11 +615,8 @@ fn push( }; let upload = client .request_model_version_upload(namespace, name, &args.model_name, upload_request) - .map_err(|e| { - spinner.error("Failed to request upload URLs."); - map_resource_error(e, namespace, name, Resource::Model(&args.model_name)) - })?; - spinner.stop(format!("Allocated model version {}.", upload.version)); + .map_err(|e| map_resource_error(e, namespace, name, Resource::Model(&args.model_name)))?; + spinner.clear(); let tasks = build_part_tasks(&files, &file_sizes, &upload.files)?; upload_parts(&client, tasks, context.terminal())?; @@ -630,8 +631,10 @@ fn push( .map_err(map_error)?; context.terminal().outro(&format!( - "Uploaded model '{}' v{} to {namespace}/{name}.", - args.model_name, upload.version + "Uploaded '{}' v{} ({}).", + args.model_name, + upload.version, + size(version.size) )); Ok(NewVersion(version)) } @@ -640,13 +643,10 @@ fn collect_push_files( directory: &Path, context: &CliContext, ) -> anyhow::Result> { - let spinner = context.terminal().spinner(); - spinner.start("Collecting files..."); - let files = collect_files(directory).map_err(|error| { - spinner.error("Failed to collect files."); - CliError::new(ErrorKind::Usage, format!("{error:#}")) - })?; - spinner.stop(format!("Found {} file(s).", files.len())); + let spinner = context.terminal().spinner("Looking for files"); + let files = collect_files(directory) + .map_err(|error| CliError::new(ErrorKind::Usage, format!("{error:#}")))?; + spinner.clear(); Ok(files) } diff --git a/crates/tracel-cli/src/commands/package.rs b/crates/tracel-cli/src/commands/package.rs index dfe3a46..d669a29 100644 --- a/crates/tracel-cli/src/commands/package.rs +++ b/crates/tracel-cli/src/commands/package.rs @@ -22,20 +22,20 @@ use crate::tools::build_driver::{self, BuildDriver}; use crate::tools::packager::{PackageEvent, package_workspace}; use crate::tools::project_context::ProjectContext; use crate::tools::{cargo, git, target}; -use crate::ui::{Outcome, Render}; +use crate::ui::{Outcome, Render, Unanswered, short_digest}; #[derive(Args, Debug)] pub struct PackageArgs { - /// Package even if the git repository has uncommitted changes (skips the commit prompt). + /// Package with uncommitted changes #[arg(long, action)] pub allow_dirty: bool, - /// Package a compiled binary or source (required without prompts) + /// Package a compiled binary or the source #[arg(long, value_enum)] pub mode: Option, - /// Rust target triple to build (repeatable; binary mode only) + /// Build for this target triple; repeat for more #[arg(long = "target", value_name = "TRIPLE")] pub targets: Vec, - /// Name of the binary to upload when several are built + /// Upload this binary when several are built #[arg(long, value_name = "NAME")] pub bin: Option, /// Install missing Rust targets without asking @@ -78,12 +78,9 @@ impl Render for Packaged {} pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result { if args.mode.is_none() && !context.terminal().is_interactive() { - return Err(CliError::new( - ErrorKind::Usage, - "Missing --mode. Valid values: binary, source.", - ) - .with_hint("Pass --mode binary or --mode source.") - .into()); + return Err(CliError::new(ErrorKind::Usage, MODE_NEEDED) + .with_hint(MODE_HINT) + .into()); } if args.mode == Some(Mode::Source) && !args.targets.is_empty() { return Err(CliError::new( @@ -96,65 +93,59 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< target::parse_target(triple)?; } - // 0. Require a workspace and a project that exists on the server. let workspace_info = require_cargo_workspace()?; let resolved = resolve_namespace_project(&context)?; + let client = get_client_and_login_if_needed(&context)?; + validate_project_exists_on_server(&resolved.project, &resolved.source, &client)?; let project = ProjectContext { workspace_info, project: resolved.project, }; - let client = get_client_and_login_if_needed(&context)?; - validate_project_exists_on_server(&project, &client)?; - // 1. Dirty check — warn and offer to commit, but allow proceeding. let has_commit = git::get_last_commit_hash().is_ok(); if !has_commit && !args.commit && !context.terminal().is_interactive() { - return Err(CliError::new( - ErrorKind::Usage, - "The repository needs at least one commit to package.", - ) - .with_hint("Commit your changes, or pass --commit to create the first commit.") - .into()); + return Err( + CliError::new(ErrorKind::Usage, "The workspace has no commit yet.") + .with_hint("Commit your changes, or pass --commit.") + .into(), + ); } if args.commit { if !has_commit || git::is_repo_dirty()? { commit_all(context.terminal())?; } } else if git::is_repo_dirty()? && !args.allow_dirty { - context - .terminal() - .print_warning("Your repository has uncommitted changes."); + let dirty = "The workspace has uncommitted changes."; if !context.terminal().is_interactive() { - return Err( - CliError::new(ErrorKind::Usage, "The repository has uncommitted changes.") - .with_hint("Commit your changes, or pass --commit or --allow-dirty") - .into(), - ); + return Err(CliError::new(ErrorKind::Usage, dirty) + .with_hint("Commit them, or pass --commit or --allow-dirty.") + .into()); } - if context - .terminal() - .confirm("Commit changes before packaging?", "commit", true)? - { + context.terminal().print_warning(dirty); + if context.terminal().confirm( + "Commit them first?", + true, + Unanswered::new( + "Packaging needs a commit.", + "Pass --commit or --allow-dirty.", + ), + )? { commit_all(context.terminal())?; } } - // 2. The code version is identified by the current commit hash. - let digest = git::get_last_commit_hash().context( - "Failed to read the current git commit. The repository needs at least one commit to package.", - )?; + let digest = git::get_last_commit_hash().context("Can't read the current commit")?; if git::is_repo_dirty()? { context.terminal().print_warning(&format!( - "Proceeding with uncommitted changes — they will not be part of code version {digest}." + "Uncommitted changes are not part of code version {}.", + short_digest(&digest) )); } - // 3. Choose how to package. let mode = match args.mode { Some(mode) => mode, None => context.terminal().select( - "How would you like to package your code?", - "mode", + "Mode", &[ ( Mode::Binary, @@ -168,7 +159,7 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< ), ], None, - &["binary", "source"], + Unanswered::new(MODE_NEEDED, MODE_HINT), )?, }; if mode == Mode::Source && !args.targets.is_empty() { @@ -184,7 +175,6 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< Mode::Binary => build_binary_artifact(&context, &project, &args)?, }; - // 4. Upload. Ok(upload(&context, &client, &project, &digest, mode, artifact)?.into()) } @@ -192,20 +182,14 @@ fn build_source_artifact( context: &CliContext, project: &ProjectContext, ) -> anyhow::Result { - let spinner = context.terminal().spinner(); - spinner.start("Packaging workspace..."); - let spinner_clone = spinner.clone(); + let spinner = context.terminal().spinner("Packaging the workspace"); + let reporter = spinner.clone(); let result = package_workspace( project.get_workspace_name(), - Arc::new(move |msg: PackageEvent| { - spinner_clone.set_message(msg.message); - }), + Arc::new(move |event: PackageEvent| reporter.set_message(event.message)), ) - .map_err(|e| { - spinner.error("Packaging failed."); - anyhow::anyhow!("Failed to package workspace: {e}") - })?; - spinner.stop("Workspace packaged."); + .context("Can't package the workspace")?; + spinner.clear(); Ok(PreparedArtifact { request: PublishArtifactRequest::Source { @@ -254,7 +238,8 @@ fn build_binary_artifact( let linker = if is_host { context.terminal().print_warning(&format!( - "Building for this machine ({triple}). It will only run on compute providers with the same OS and architecture." + "The binary runs only on {} compute providers.", + target::pretty_name(os, arch) )); None } else { @@ -297,13 +282,10 @@ fn build_release_binary( linker: Option<&str>, bin: Option<&str>, ) -> anyhow::Result { - let mut cmd_label = match target { + let cmd_label = match target { Some(triple) => format!("{} --release --target {triple}", driver.label()), None => format!("{} --release", driver.label()), }; - if let Some(linker) = linker { - cmd_label.push_str(&format!(" (linker {linker})")); - } let mut command = cargo::command(); for arg in driver.subcommand_args() { command.arg(arg); @@ -317,15 +299,15 @@ fn build_release_binary( .arg(format!("target.{triple}.linker=\"{linker}\"")); } } - let spinner = context.terminal().spinner(); - spinner.start(format!("Building release binary ({cmd_label})...")); + let building = match target { + Some(triple) => format!("Building for {triple}"), + None => "Building the binary".to_string(), + }; + let spinner = context.terminal().spinner(&building); let built = cargo::build_quietly(command, &cmd_label, |status| { - spinner.set_status(format!("Building release binary: {status}")); + spinner.set_message(format!("{building}: {status}")); }); - match &built { - Ok(build) if build.succeeded() => spinner.stop("Built release binary."), - _ => spinner.error("Could not build the release binary."), - } + spinner.clear(); let built = built?; if !built.succeeded() { built.show_output(); @@ -341,14 +323,13 @@ fn build_release_binary( .ok_or_else(|| { CliError::new( ErrorKind::Usage, - format!("Invalid --bin '{bin}'. Valid values: {}.", names.join(", ")), + format!("No binary '{bin}'. Binaries: {}.", names.join(", ")), ) - .with_hint("Pass --bin using one of the valid values.") .into() }); } match executables.len() { - 0 => anyhow::bail!("The build did not produce any binary target."), + 0 => anyhow::bail!("The build made no binary."), 1 => Ok(executables[0].1.clone()), _ => { let items: Vec<(PathBuf, String, &str)> = executables @@ -364,19 +345,23 @@ fn build_release_binary( }) .collect(); context.terminal().select( - "Multiple binaries were built. Select which to upload", - "bin", + "Binary", &items, None, - &names, + Unanswered::new( + format!("Several binaries were built: {}.", names.join(", ")), + "Pass --bin .", + ), ) } } } +const MODE_NEEDED: &str = "Packaging needs a mode."; +const MODE_HINT: &str = "Pass --mode binary or --mode source."; + fn sha256_and_size(path: &Path) -> anyhow::Result<(String, u64)> { - let bytes = std::fs::read(path) - .with_context(|| format!("Failed to read binary at {}", path.display()))?; + let bytes = std::fs::read(path).with_context(|| format!("Can't read '{}'", path.display()))?; let checksum = format!("{:x}", Sha256::digest(&bytes)); Ok((checksum, bytes.len() as u64)) } @@ -402,7 +387,7 @@ fn upload( ) .with_context(|| { format!( - "Failed to request upload URLs for {}/{}", + "Can't start the upload to {}/{}", bc_project.owner, bc_project.name ) })?; @@ -417,42 +402,38 @@ fn upload( uploaded, }; let Some(urls) = response.urls else { - context.terminal().print_success(&format!( - "This commit ({digest}) is already packaged (version {}).", - response.id + context.terminal().outro(&format!( + "Code version {} is already uploaded.", + short_digest(digest) )); - context.terminal().outro("Nothing to upload."); return Ok(packaged(false)); }; - let spinner = context.terminal().spinner(); - spinner.start("Uploading artifacts..."); + let spinner = context.terminal().spinner("Uploading the code version"); for (key, path) in prepared.uploads { - let url = urls.get(&key).ok_or_else(|| { - spinner.error("Upload failed."); - anyhow::anyhow!("Server did not return an upload URL for `{key}`") - })?; + let url = urls + .get(&key) + .with_context(|| format!("The Tracel Console sent no upload URL for '{key}'"))?; let bytes = - std::fs::read(&path).with_context(|| format!("Failed to read {}", path.display()))?; - client.upload_bytes_to_url(url, bytes).map_err(|e| { - spinner.error("Upload failed."); - anyhow::Error::new(e).context(format!("Failed to upload `{key}`")) - })?; + std::fs::read(&path).with_context(|| format!("Can't read '{}'", path.display()))?; + client + .upload_bytes_to_url(url, bytes) + .with_context(|| format!("Can't upload '{key}'"))?; } - spinner.stop("Artifacts uploaded."); + spinner.clear(); client .complete_project_version_upload(&bc_project.owner, &bc_project.name, &response.id) .with_context(|| { format!( - "Failed to finalize upload for {}/{}", + "Can't finish the upload to {}/{}", bc_project.owner, bc_project.name ) })?; - context - .terminal() - .print_success(&format!("New code version uploaded: {}", response.digest)); - context.terminal().outro("Project packaged successfully."); + context.terminal().outro(&format!( + "Uploaded code version {}.", + short_digest(&response.digest) + )); Ok(packaged(true)) } diff --git a/crates/tracel-cli/src/commands/project.rs b/crates/tracel-cli/src/commands/project.rs index 9663f11..7fb5574 100644 --- a/crates/tracel-cli/src/commands/project.rs +++ b/crates/tracel-cli/src/commands/project.rs @@ -9,9 +9,9 @@ use tracel_client::console::project::response::ProjectResponse; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; -use crate::helpers::project::ProjectSource; +use crate::helpers::project::{ProjectSource, missing_project}; use crate::helpers::resolve_namespace_project; -use crate::ui::{Details, Human, Outcome, Render, Table, counted, when}; +use crate::ui::{Details, Human, Outcome, Render, Table, list_summary, when}; #[derive(Args, Debug)] pub struct ProjectArgs { @@ -21,13 +21,13 @@ pub struct ProjectArgs { #[derive(Subcommand, Debug)] pub enum ProjectCommands { - /// List your projects and your organizations' projects. + /// List your and your organizations' projects List(ListArgs), } #[derive(Args, Debug)] pub struct ListArgs { - /// Your namespace or one of your organizations; omit to list all. + /// List only this namespace's projects #[arg(value_parser = clap::builder::NonEmptyStringValueParser::new())] pub namespace: Option, } @@ -52,7 +52,7 @@ impl Render for Projects { } fn summary(&self) -> Option { - Some(counted( + Some(list_summary( self.0.len(), self.0.len() as u64, ["project", "projects"], @@ -74,8 +74,6 @@ struct ProjectInfo { impl Render for ProjectInfo { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Details::new() - .field("Project", &self.name) - .field("Namespace", &self.namespace) .field("Description", &self.description) .field("Visibility", visibility_name(&self.visibility)) .field("Created by", &self.created_by) @@ -134,11 +132,8 @@ fn list_projects(args: ListArgs, context: CliContext) -> anyhow::Result`." - )) .into()); } projects.extend(client.list_organization_projects(&namespace)?); @@ -158,20 +153,12 @@ fn show_project(context: CliContext) -> anyhow::Result { let project = match client.get_project(&bc_project.owner, &bc_project.name) { Ok(project) => project, Err(e) if e.is_not_found() => { - return Err(CliError::new( - ErrorKind::NotFound, - format!( - "Project {}/{} not found on Tracel Console.", - bc_project.owner, bc_project.name - ), - ) - .with_hint("Run 'tracel init --force' to link another project.") - .into()); + return Err(missing_project(bc_project, &resolved.source).into()); } Err(e) => { return Err(e).with_context(|| { format!( - "Failed to retrieve project {}/{}", + "Can't read project {}/{}", bc_project.owner, bc_project.name ) }); diff --git a/crates/tracel-cli/src/commands/training.rs b/crates/tracel-cli/src/commands/training.rs index a264059..a177482 100644 --- a/crates/tracel-cli/src/commands/training.rs +++ b/crates/tracel-cli/src/commands/training.rs @@ -7,8 +7,12 @@ use crate::{context::CliContext, tools::cargo, ui::Outcome}; #[derive(Parser, Debug, Default)] pub struct TrainingArgs { - /// Arguments forwarded to `cargo run` (everything after `--`). - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + /// Arguments for the program, after -- + #[arg( + value_name = "ARGS", + trailing_var_arg = true, + allow_hyphen_values = true + )] forwarded: Vec, } @@ -45,13 +49,13 @@ pub fn run_cargo(forwarded: &[String], context: CliContext) -> anyhow::Result<() fn hand_over(mut cmd: std::process::Command) -> anyhow::Result<()> { use std::os::unix::process::CommandExt; - Err(cmd.exec()).context("Failed to run `cargo run`") + Err(cmd.exec()).context("Can't run `cargo run`") } /// Windows cannot replace a process, so wait for the child and mirror its exit code. #[cfg(not(unix))] fn hand_over(mut cmd: std::process::Command) -> anyhow::Result<()> { - let status = cmd.status().context("Failed to run `cargo run`")?; + let status = cmd.status().context("Can't run `cargo run`")?; if !status.success() { std::process::exit(status.code().unwrap_or(1)); } diff --git a/crates/tracel-cli/src/commands/unlink.rs b/crates/tracel-cli/src/commands/unlink.rs index 38a540d..e590761 100644 --- a/crates/tracel-cli/src/commands/unlink.rs +++ b/crates/tracel-cli/src/commands/unlink.rs @@ -1,4 +1,3 @@ -use anyhow::Context; use clap::Args; use serde::Serialize; @@ -6,12 +5,12 @@ use crate::{ context::CliContext, helpers::require_linked_project, tools::project_context::ProjectContext, - ui::{Declined, Outcome, Render}, + ui::{Declined, Outcome, Render, Unanswered}, }; #[derive(Args, Debug)] pub struct UnlinkArgs { - /// Unlink without asking for confirmation + /// Unlink without asking #[arg(long, short = 'y')] pub yes: bool, } @@ -28,22 +27,26 @@ impl Render for Unlinked {} pub fn handle_command(args: UnlinkArgs, context: CliContext) -> anyhow::Result { let project = require_linked_project()?; + let linked = project.get_project(); + let name = format!("{}/{}", linked.owner, linked.name); if !args.yes { let confirmed = context.terminal().confirm( - "Are you sure you want to unlink the Tracel Console project from this repository?", - "yes", + &format!("Unlink {name}?"), false, + Unanswered::new( + format!("Unlinking {name} needs confirmation."), + "Pass --yes to unlink without asking.", + ), )?; if !confirmed { return Err(Declined::new("Nothing was unlinked.").into()); } } - ProjectContext::unlink(&project.get_manifest_path()).context("Failed to unlink project")?; - context.terminal().outro("Project unlinked successfully"); + ProjectContext::unlink(&project.get_manifest_path())?; + context.terminal().outro(&format!("Unlinked {name}.")); - let linked = project.get_project(); Ok(Unlinked { unlinked: true, namespace: linked.owner.clone(), diff --git a/crates/tracel-cli/src/context.rs b/crates/tracel-cli/src/context.rs index 1071322..8e4e568 100644 --- a/crates/tracel-cli/src/context.rs +++ b/crates/tracel-cli/src/context.rs @@ -11,15 +11,15 @@ const CLIENT_ID: &str = "tracel-cli"; #[derive(thiserror::Error, Debug)] pub enum ClientCreationError { - #[error("No credentials found")] + #[error("Not logged in.")] NoCredentials, - #[error("Invalid credentials")] + #[error("Your login has ended.")] InvalidCredentials, - #[error("The API key in TRACEL_API_KEY was refused: {0}")] - ApiKeyRefused(ClientError), + #[error("TRACEL_API_KEY was rejected: it's invalid, expired or revoked.")] + ApiKeyRefused, #[error(transparent)] SessionStore(ClientError), - #[error("Server connection error: {0}")] + #[error("Can't reach the Tracel Console at {0}.")] ServerConnectionError(String), } @@ -78,12 +78,12 @@ impl CliContext { Client::connect(self.environment(), &credentials).map_err(|e| match e { ClientError::SessionStore(_) => ClientCreationError::SessionStore(e), ClientError::Unauthenticated | ClientError::CredentialNotAllowed if uses_api_key => { - ClientCreationError::ApiKeyRefused(e) + ClientCreationError::ApiKeyRefused } ClientError::Unauthenticated | ClientError::AppSessionEnded => { ClientCreationError::InvalidCredentials } - _ => ClientCreationError::ServerConnectionError(e.to_string()), + _ => ClientCreationError::ServerConnectionError(self.get_api_endpoint()), }) } diff --git a/crates/tracel-cli/src/error.rs b/crates/tracel-cli/src/error.rs index 3ea7bc7..3a3c26a 100644 --- a/crates/tracel-cli/src/error.rs +++ b/crates/tracel-cli/src/error.rs @@ -73,13 +73,24 @@ impl<'a> ErrorReport<'a> { let hint = explicit_error(error).and_then(|error| error.hint.as_deref()); Self { code, - message: format!("{error:#}"), + message: sentence(format!("{error:#}")), hint, exit_code: code.exit_code(), } } } +/// `text` ending as a sentence does, as an error chain such as "Can't read 'a': No +/// such file or directory (os error 2)" does not. +fn sentence(text: String) -> String { + let text = text.trim_end(); + if text.ends_with(['.', '!', '?']) { + text.to_string() + } else { + format!("{text}.") + } +} + pub fn classify(error: &anyhow::Error) -> ErrorKind { if let Some(error) = explicit_error(error) { return error.kind; @@ -90,7 +101,7 @@ pub fn classify(error: &anyhow::Error) -> ErrorKind { return match error { ClientCreationError::NoCredentials | ClientCreationError::InvalidCredentials - | ClientCreationError::ApiKeyRefused(_) => ErrorKind::NotAuthenticated, + | ClientCreationError::ApiKeyRefused => ErrorKind::NotAuthenticated, ClientCreationError::ServerConnectionError(_) => ErrorKind::Unavailable, ClientCreationError::SessionStore(error) => classify_client(error), }; @@ -263,7 +274,7 @@ mod tests { ErrorKind::NotAuthenticated, ), ( - ClientCreationError::ApiKeyRefused(ClientError::CredentialNotAllowed), + ClientCreationError::ApiKeyRefused, ErrorKind::NotAuthenticated, ), ( diff --git a/crates/tracel-cli/src/helpers/download.rs b/crates/tracel-cli/src/helpers/download.rs index 81aa2ec..bf8a0b7 100644 --- a/crates/tracel-cli/src/helpers/download.rs +++ b/crates/tracel-cli/src/helpers/download.rs @@ -8,7 +8,7 @@ use sha2::{Digest, Sha256}; use tracel_client::console::Client; use crate::error::{CliError, ErrorKind}; -use crate::ui::Terminal; +use crate::ui::{Terminal, counted, short_digest}; pub struct DownloadFile { pub rel_path: String, @@ -98,8 +98,10 @@ impl HashingWriter { return Err(CliError::new( ErrorKind::Internal, format!( - "Checksum mismatch for '{}': expected {checksum}, received {actual}.", - file.rel_path + "Checksum mismatch for '{}': expected {}, received {}.", + file.rel_path, + short_digest(checksum), + short_digest(&actual) ), )); } @@ -129,15 +131,12 @@ pub fn download_files( force: bool, ) -> anyhow::Result> { check_destinations(directory, files, force)?; - let spinner = terminal.spinner(); - spinner.start(format!("Downloading {} file(s)...", files.len())); - let results = download_each(client, directory, files) - .inspect_err(|_| spinner.error("Download failed."))?; - let bytes: u64 = results.iter().map(|result| result.bytes).sum(); - spinner.stop(format!( - "Downloaded {} file(s), {bytes} bytes.", - results.len() + let spinner = terminal.spinner(format!( + "Downloading {}", + counted(files.len() as u64, ["file", "files"]) )); + let results = download_each(client, directory, files)?; + spinner.clear(); Ok(results) } @@ -151,15 +150,15 @@ fn download_each( let path = directory.join(&file.rel_path); let parent = path.parent().expect("Validated relative file has a parent"); fs::create_dir_all(parent) - .with_context(|| format!("Failed to create '{}'.", parent.display()))?; + .with_context(|| format!("Can't create '{}'", parent.display()))?; let mut name = path .file_name() .expect("Validated relative file has a name") .to_os_string(); name.push(".part"); let part = path.with_file_name(name); - let output = File::create(&part) - .with_context(|| format!("Failed to create '{}'.", part.display()))?; + let output = + File::create(&part).with_context(|| format!("Can't create '{}'", part.display()))?; let result = (|| -> anyhow::Result { let mut writer = HashingWriter::new(output); client.download_from_url(&file.url, &mut writer)?; @@ -167,8 +166,7 @@ fn download_each( writer.verify(file)?; let bytes = writer.bytes; drop(writer); - fs::rename(&part, &path) - .with_context(|| format!("Failed to save '{}'.", path.display()))?; + fs::rename(&part, &path).with_context(|| format!("Can't save '{}'", path.display()))?; Ok(bytes) })(); let bytes = result.inspect_err(|_| { diff --git a/crates/tracel-cli/src/helpers/model_upload/existence.rs b/crates/tracel-cli/src/helpers/model_upload/existence.rs index 10df822..a2e6634 100644 --- a/crates/tracel-cli/src/helpers/model_upload/existence.rs +++ b/crates/tracel-cli/src/helpers/model_upload/existence.rs @@ -4,6 +4,7 @@ use tracel_client::console::model::request::CreateModelRequest; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; +use crate::ui::Unanswered; /// Make sure `model_name` exists, creating it when `auto_create` says so or the user /// agrees. False when the user declined to create it. @@ -19,18 +20,19 @@ pub fn ensure_model_exists( match client.get_model(namespace, project, model_name) { Ok(_) => return Ok(true), Err(e) if e.is_not_found() => {} - Err(e) => return Err(e).with_context(|| format!("Failed to check model '{model_name}'")), + Err(e) => return Err(e).with_context(|| format!("Can't look up model '{model_name}'")), } let create = match auto_create { Some(create) => create, None if context.terminal().is_interactive() => { if !context.terminal().confirm( - &format!( - "Model '{model_name}' does not exist in {namespace}/{project}. Create it now?" - ), - "auto-create", + &format!("Create model '{model_name}'?"), false, + Unanswered::new( + format!("Creating model '{model_name}' needs confirmation."), + "Pass --auto-create true to create it.", + ), )? { return Ok(false); } @@ -42,18 +44,22 @@ pub fn ensure_model_exists( if !create { return Err(CliError::new( ErrorKind::Usage, - format!("Model '{model_name}' does not exist in {namespace}/{project}."), + format!("No model '{model_name}' in {namespace}/{project}."), ) - .with_hint("Pass --auto-create true to create it") + .with_hint("Pass --auto-create true to create it.") .into()); } let description = match description { Some(description) => Some(description), None if auto_create.is_none() => { - let description = context - .terminal() - .input("Enter model description (optional)", "description")?; + let description = context.terminal().input( + "Description (optional)", + Unanswered::new( + format!("Creating model '{model_name}' needs a description."), + "Pass --description .", + ), + )?; let description = description.trim().to_string(); (!description.is_empty()).then_some(description) } @@ -69,9 +75,9 @@ pub fn ensure_model_exists( }, )?; - context.terminal().print_success(&format!( - "Created model '{model_name}' in {namespace}/{project}." - )); + context + .terminal() + .step(&format!("Created model '{model_name}'")); Ok(true) } diff --git a/crates/tracel-cli/src/helpers/model_upload/upload.rs b/crates/tracel-cli/src/helpers/model_upload/upload.rs index 405a1b6..2310d0e 100644 --- a/crates/tracel-cli/src/helpers/model_upload/upload.rs +++ b/crates/tracel-cli/src/helpers/model_upload/upload.rs @@ -1,4 +1,5 @@ use anyhow::Context; +use std::collections::BTreeSet; use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; @@ -6,7 +7,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::Duration; use tracel_client::ClientError; -use crate::ui::Terminal; +use crate::ui::{Terminal, counted}; use super::parts::PartUploadTask; @@ -23,14 +24,13 @@ impl PartUploader for tracel_client::console::Client { } fn read_part_bytes(path: &Path, offset: u64, size: u64) -> anyhow::Result> { - let mut file = - File::open(path).with_context(|| format!("Failed to open file '{}'.", path.display()))?; + let mut file = File::open(path).with_context(|| format!("Can't open '{}'", path.display()))?; file.seek(SeekFrom::Start(offset)) - .with_context(|| format!("Failed to seek in file '{}'.", path.display()))?; + .with_context(|| format!("Can't seek in '{}'", path.display()))?; let mut buffer = vec![0u8; size as usize]; file.read_exact(&mut buffer) - .with_context(|| format!("Failed to read part from file '{}'.", path.display()))?; + .with_context(|| format!("Can't read '{}'", path.display()))?; Ok(buffer) } @@ -83,7 +83,10 @@ fn upload_chunk( } Err(e) => { cancelled.store(true, Ordering::Relaxed); - return Err(e.context(format!("{} (part {})", task.rel_path, task.part))); + return Err(e.context(format!( + "Can't upload '{}' (part {})", + task.rel_path, task.part + ))); } } } @@ -97,18 +100,20 @@ pub fn upload_parts( terminal: &Terminal, ) -> anyhow::Result<()> { let total_parts = tasks.len(); + let files: BTreeSet<&str> = tasks.iter().map(|task| task.rel_path.as_str()).collect(); + let progress = terminal.progress( + format!( + "Uploading {}", + counted(files.len() as u64, ["file", "files"]) + ), + total_parts as u64, + ); let worker_count = UPLOAD_WORKER_COUNT.min(total_parts.max(1)); let chunks = chunk_tasks(tasks, worker_count); let cancelled = AtomicBool::new(false); let completed = AtomicU64::new(0); - let progress = terminal.progress( - &format!("Uploading {total_parts} part(s) with {worker_count} worker(s)"), - total_parts as u64, - ); - progress.set(0, format!("Uploading (0/{total_parts})")); - let failure = std::thread::scope(|scope| { let handles: Vec<_> = chunks .into_iter() @@ -123,7 +128,7 @@ pub fn upload_parts( while handles.iter().any(|handle| !handle.is_finished()) { let done = completed.load(Ordering::Relaxed); if done > reported { - progress.set(done, format!("Uploading ({done}/{total_parts})")); + progress.set_position(done); reported = done; } std::thread::sleep(Duration::from_millis(50)); @@ -137,18 +142,15 @@ pub fn upload_parts( .filter_map(|handle| match handle.join() { Ok(Ok(())) => None, Ok(Err(e)) => Some(e), - Err(_) => Some(anyhow::anyhow!("An upload worker thread panicked.")), + Err(_) => Some(anyhow::anyhow!("An upload worker stopped unexpectedly.")), }) .next() }); match failure { - Some(e) => { - progress.error("Upload failed."); - Err(e.context("Failed to upload model file part")) - } + Some(error) => Err(error), None => { - progress.stop(format!("Uploaded {total_parts}/{total_parts}")); + progress.clear(); Ok(()) } } diff --git a/crates/tracel-cli/src/helpers/project.rs b/crates/tracel-cli/src/helpers/project.rs index bb8e56a..3e3a25f 100644 --- a/crates/tracel-cli/src/helpers/project.rs +++ b/crates/tracel-cli/src/helpers/project.rs @@ -17,12 +17,9 @@ use tracel_client::console::Client; pub fn find_manifest() -> anyhow::Result { try_locate_manifest().ok_or_else(|| { - CliError::new( - CliErrorKind::Usage, - "Could not locate a Cargo.toml manifest.", - ) - .with_hint("Run this command inside a Burn project directory.") - .into() + CliError::new(CliErrorKind::Usage, "Not in a Cargo workspace.") + .with_hint("Run it in your project's directory, or pass -C .") + .into() }) } @@ -40,18 +37,18 @@ fn explain_project_context_error(e: ProjectContextError) -> anyhow::Error { let (kind, hint) = match e.kind() { ErrorKind::ManifestNotFound => ( CliErrorKind::Usage, - "Run this command from a Rust project directory.", + Some("Run it in your project's directory, or pass -C ."), ), - ErrorKind::ProjectNotLinked => (CliErrorKind::NotFound, "Run 'tracel init' to link it."), - ErrorKind::Parsing => ( - CliErrorKind::Usage, - "Check that Cargo.toml and tracel.toml are valid.", + ErrorKind::ProjectNotLinked => ( + CliErrorKind::NotFound, + Some("Run `tracel init` to link it."), ), + ErrorKind::Parsing => (CliErrorKind::Usage, None), ErrorKind::ProjectInitialization => ( CliErrorKind::Internal, - "Re-link the project with 'tracel init --force'.", + Some("Link it again with `tracel init --force`."), ), - ErrorKind::Unexpected => (CliErrorKind::Internal, "Check your project setup."), + ErrorKind::Unexpected => (CliErrorKind::Internal, None), }; let mut message = e.to_string(); @@ -61,9 +58,11 @@ fn explain_project_context_error(e: ProjectContextError) -> anyhow::Error { cause = err.source(); } - CliError::new(kind, format!("{message}.")) - .with_hint(hint) - .into() + let error = CliError::new(kind, message); + match hint { + Some(hint) => error.with_hint(hint).into(), + None => error.into(), + } } /// Require a linked Tracel Console project. @@ -84,7 +83,7 @@ pub fn parse_project(value: &str) -> Result { Err(CliError::new( CliErrorKind::Usage, - format!("Invalid --project value '{value}': expected /."), + format!("--project needs /, not '{value}'."), )) } @@ -123,11 +122,28 @@ struct TracelTomlConfig { } fn project_not_found() -> CliError { - CliError::new( + CliError::new(CliErrorKind::NotFound, "No project here.") + .with_hint("Run `tracel init`, or pass --project /.") +} + +/// The error for a project the Tracel Console doesn't have, with a hint that fits how +/// it was selected. +pub fn missing_project(project: &TracelProject, source: &ProjectSource) -> CliError { + let error = CliError::new( CliErrorKind::NotFound, - "No Tracel Console project resolved.", - ) - .with_hint("Run 'tracel init' or pass --project /.") + format!( + "No project {}/{} on the Tracel Console.", + project.owner, project.name + ), + ); + match source { + ProjectSource::TracelToml => { + error.with_hint("Run `tracel init --force` to link another project.") + } + ProjectSource::Flag | ProjectSource::Env => { + error.with_hint("List your projects with `tracel project list`.") + } + } } /// Resolve project identity from flags, environment variables, or tracel.toml. @@ -153,7 +169,7 @@ pub fn resolve_project(flag: Option<&TracelProject>) -> anyhow::Result { return Err(CliError::new( CliErrorKind::Usage, - format!("Failed to read '{}': {error}", path.display()), + format!("Can't read '{}': {error}", path.display()), ) .into()); } @@ -161,7 +177,7 @@ pub fn resolve_project(flag: Option<&TracelProject>) -> anyhow::Result anyhow::Result { Ok(force || !is_tracel_project_linked()) } -/// Validate that the linked project exists on Tracel Console server +/// Fail unless the Tracel Console has `project`, selected by `source`. pub fn validate_project_exists_on_server( - project: &ProjectContext, + project: &TracelProject, + source: &ProjectSource, client: &Client, ) -> anyhow::Result<()> { - let bc_project = project.get_project(); - - match client.get_project(&bc_project.owner, &bc_project.name) { + match client.get_project(&project.owner, &project.name) { Ok(_) => Ok(()), - Err(e) if e.is_not_found() => Err(CliError::new( - CliErrorKind::NotFound, - format!( - "Project {}/{} not found on Tracel Console. It may have been deleted or renamed.", - bc_project.owner, bc_project.name - ), - ) - .with_hint("Run 'tracel init --force' to link another project.") - .into()), - Err(e) => Err(e).context("Failed to verify project exists on server"), + Err(e) if e.is_not_found() => Err(missing_project(project, source).into()), + Err(e) => { + Err(e).with_context(|| format!("Can't look up {}/{}", project.owner, project.name)) + } } } @@ -358,7 +367,7 @@ mod tests { assert_eq!(report.exit_code, 5); assert_eq!( report.hint, - Some("Run 'tracel init' or pass --project /.") + Some("Run `tracel init`, or pass --project /.") ); } for (source, value) in [ diff --git a/crates/tracel-cli/src/helpers/resources.rs b/crates/tracel-cli/src/helpers/resources.rs index fe9a41a..9249925 100644 --- a/crates/tracel-cli/src/helpers/resources.rs +++ b/crates/tracel-cli/src/helpers/resources.rs @@ -80,10 +80,7 @@ pub fn map_resource_error( pub fn parse_metadata(value: &str) -> Result { let metadata: Value = serde_json::from_str(value).map_err(|error| { - CliError::new( - ErrorKind::Usage, - format!("Invalid --metadata JSON: {error}"), - ) + CliError::new(ErrorKind::Usage, format!("--metadata isn't JSON: {error}")) })?; if !metadata.is_object() { return Err(CliError::new( @@ -101,7 +98,7 @@ pub fn validate_auto_create( if description.is_some() && auto_create != Some(true) { return Err(CliError::new( ErrorKind::Usage, - "--description can only be used together with --auto-create true.", + "--description needs --auto-create true.", )); } Ok(()) @@ -132,16 +129,16 @@ pub fn select_artifact<'a>( [(index, _)] => Ok(*index), _ => Err(CliError::new( ErrorKind::Conflict, - format!( - "Several artifacts named '{value}' in experiment {experiment}: {}.", - matches - .iter() - .map(|(_, id)| *id) - .collect::>() - .join(", ") - ), + format!("Several artifacts in experiment {experiment} are named '{value}'."), ) - .with_hint("Pass the artifact id instead.")), + .with_hint(format!( + "Pass one of their ids: {}.", + matches + .iter() + .map(|(_, id)| *id) + .collect::>() + .join(", ") + ))), } } @@ -361,7 +358,10 @@ mod tests { assert_eq!(select_artifact(items, "logs", 7).unwrap(), 3); let error = select_artifact(items, "weights", 7).unwrap_err(); assert_eq!(error.kind, ErrorKind::Conflict); - assert!(error.to_string().contains("a, b")); + assert_eq!( + ErrorReport::new(&error.into()).hint, + Some("Pass one of their ids: a, b.") + ); let error = select_artifact(items, "missing", 7).unwrap_err(); assert_eq!(error.kind, ErrorKind::NotFound); assert_eq!(error.to_string(), "No artifact 'missing' in experiment 7."); diff --git a/crates/tracel-cli/src/tools/build_driver.rs b/crates/tracel-cli/src/tools/build_driver.rs index 3920cf9..467913d 100644 --- a/crates/tracel-cli/src/tools/build_driver.rs +++ b/crates/tracel-cli/src/tools/build_driver.rs @@ -102,11 +102,9 @@ pub fn choose(host: (Os, Arch), target: (Os, Arch), avail: &AvailableDrivers) -> /// plain `cargo build` (which will almost certainly fail at link). pub fn install_hint(target: (Os, Arch)) -> &'static str { match target.0 { - Os::Windows => { - "install cargo-xwin (`cargo install --locked cargo-xwin`); it also needs LLVM/lld" - } + Os::Windows => "Install it with `cargo install --locked cargo-xwin`, and LLVM.", Os::Linux | Os::Macos => { - "install cargo-zigbuild (`cargo install --locked cargo-zigbuild`) and Zig; macOS also needs the Apple SDK" + "Install it with `cargo install --locked cargo-zigbuild`, and Zig." } } } @@ -127,27 +125,27 @@ pub fn cross_preflight( // Same-OS cross-arch: a `target..linker` setting (Linux) is enough. let linker = linker::resolve_linker(terminal, root, host, target); terminal.print_warning(&format!( - "Cross-building {triple} with `cargo build`. It may fail at link time if the cross toolchain is missing." + "Building {triple} with plain cargo, which may fail to link." )); Ok(linker) } BuildDriver::Cargo => { // Cross-OS with no suitable driver installed — plain cargo build won't link. terminal.print_warning(&format!( - "No cross-build driver found for {triple}; plain `cargo build` will almost certainly fail at link — {hint}.", - hint = install_hint(target) + "No cross-build tool for {triple}, so the build will likely fail.\n{}", + install_hint(target) )); Ok(None) } BuildDriver::Zigbuild => { terminal.print(&format!( - "Cross-building {triple} with `cargo zigbuild` (requires Zig; macOS targets also need the Apple SDK)." + "Building {triple} with cargo zigbuild, which needs Zig." )); Ok(None) } BuildDriver::Xwin => { terminal.print(&format!( - "Cross-building {triple} with `cargo xwin build` (downloads the MSVC CRT/SDK on first use; needs LLVM/lld)." + "Building {triple} with cargo xwin, which needs LLVM." )); Ok(None) } diff --git a/crates/tracel-cli/src/tools/cargo.rs b/crates/tracel-cli/src/tools/cargo.rs index 96999aa..da019b6 100644 --- a/crates/tracel-cli/src/tools/cargo.rs +++ b/crates/tracel-cli/src/tools/cargo.rs @@ -5,30 +5,18 @@ use std::process::Stdio; use anyhow::Context; -pub fn try_locate_manifest() -> Option { +/// The manifest of the Cargo workspace around the current directory, if any. +pub fn try_locate_manifest() -> Option { let output = command() .arg("locate-project") .arg("--workspace") .output() - .expect("Failed to run cargo locate-project"); + .ok()?; if !output.status.success() { return None; } - - let output_str = String::from_utf8(output.stdout).expect("Failed to parse output"); - if output_str.trim().is_empty() { - return None; - } - let parsed_output: serde_json::Value = - serde_json::from_str(&output_str).expect("Failed to parse output"); - - let manifest_path_str = parsed_output["root"] - .as_str() - .expect("Failed to parse output") - .to_owned(); - - let manifest_path = std::path::PathBuf::from(manifest_path_str); - Some(manifest_path) + let location: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; + location["root"].as_str().map(PathBuf::from) } /// Retrieve the command to run cargo as define by the CARGO environment variable or default to "cargo" @@ -57,7 +45,7 @@ impl QuietBuild { /// The executables the build made, as `(target name, path)` pairs, or why it failed. pub fn executables(self) -> anyhow::Result> { if !self.succeeded { - anyhow::bail!("`{}` failed", self.label); + anyhow::bail!("`{}` failed.", self.label); } executables(&self.messages) } @@ -79,7 +67,7 @@ pub fn build_quietly( .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() - .with_context(|| format!("Failed to run `{label}`"))?; + .with_context(|| format!("Can't run `{label}`"))?; let mut stdout = child.stdout.take().context("cargo's stdout is not piped")?; let messages = std::thread::spawn(move || { let mut messages = Vec::new(); @@ -97,7 +85,7 @@ pub fn build_quietly( } let messages = messages .join() - .map_err(|_| anyhow::anyhow!("Failed to read the output of `{label}`"))??; + .map_err(|_| anyhow::anyhow!("Can't read the output of `{label}`."))??; Ok(QuietBuild { label: label.to_string(), succeeded: child.wait()?.success(), @@ -143,9 +131,9 @@ fn executables(messages: &[u8]) -> anyhow::Result> { let Some(executable) = message["executable"].as_str() else { continue; }; - let name = message["target"]["name"].as_str().ok_or_else(|| { - anyhow::anyhow!("Cargo did not return a name for binary {executable}") - })?; + let name = message["target"]["name"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Cargo sent no name for the binary {executable}."))?; executables.push((name.to_string(), PathBuf::from(executable))); } Ok(executables) diff --git a/crates/tracel-cli/src/tools/fs.rs b/crates/tracel-cli/src/tools/fs.rs index f90b4c7..aa59178 100644 --- a/crates/tracel-cli/src/tools/fs.rs +++ b/crates/tracel-cli/src/tools/fs.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; pub fn collect_files(directory: &Path) -> anyhow::Result> { let base_dir = std::fs::canonicalize(directory) - .with_context(|| format!("Failed to resolve directory '{}'", directory.display()))?; + .with_context(|| format!("Can't find directory '{}'", directory.display()))?; if !base_dir.is_dir() { anyhow::bail!("'{}' is not a directory.", base_dir.display()); @@ -16,7 +16,7 @@ pub fn collect_files(directory: &Path) -> anyhow::Result anyhow::Result<(String, u64)> { - let mut file = std::fs::File::open(path) - .with_context(|| format!("Failed to open file '{}'", path.display()))?; + let mut file = + std::fs::File::open(path).with_context(|| format!("Can't open '{}'", path.display()))?; let mut hasher = Sha256::new(); let mut buffer = [0u8; 1024 * 1024]; @@ -56,7 +56,7 @@ fn file_sha256_and_size(path: &Path) -> anyhow::Result<(String, u64)> { loop { let read = file .read(&mut buffer) - .with_context(|| format!("Failed reading file '{}'", path.display()))?; + .with_context(|| format!("Can't read '{}'", path.display()))?; if read == 0 { break; } diff --git a/crates/tracel-cli/src/tools/git.rs b/crates/tracel-cli/src/tools/git.rs index a7597ac..7a1674e 100644 --- a/crates/tracel-cli/src/tools/git.rs +++ b/crates/tracel-cli/src/tools/git.rs @@ -25,7 +25,7 @@ pub fn get_first_commit_hash() -> anyhow::Result { .sorting(Sorting::ByCommitTime(CommitTimeOrder::OldestFirst)); let revs = platform.all()?; - let last_hash = revs.last().context("No commits found in the repository.")?; + let last_hash = revs.last().context("The repository has no commit")?; let last_hash = last_hash?.id(); Ok(last_hash.to_string()) @@ -37,7 +37,7 @@ pub fn is_repo_initialized() -> bool { pub fn init_repo(dir: &Path) -> anyhow::Result { if is_repo_initialized() { - return Err(anyhow::anyhow!("Repository already initialized.")); + anyhow::bail!("The git repository already exists."); } let repo = gix::init(dir)?; diff --git a/crates/tracel-cli/src/tools/linker.rs b/crates/tracel-cli/src/tools/linker.rs index 3dee09f..29a078d 100644 --- a/crates/tracel-cli/src/tools/linker.rs +++ b/crates/tracel-cli/src/tools/linker.rs @@ -43,11 +43,11 @@ pub fn linker_need(host: (Os, Arch), target: (Os, Arch)) -> LinkerNeed { Os::Linux => match target_arch { Arch::Arm64 => LinkerNeed::ConfigEntry { linker: "aarch64-linux-gnu-gcc", - install_hint: "install the cross-linker, e.g. `apt install gcc-aarch64-linux-gnu` (Debian/Ubuntu) or `pacman -S aarch64-linux-gnu-gcc` (Arch)", + install_hint: "Install it with `apt install gcc-aarch64-linux-gnu`.", }, Arch::X86_64 => LinkerNeed::ConfigEntry { linker: "x86_64-linux-gnu-gcc", - install_hint: "install the cross-linker, e.g. `apt install gcc-x86-64-linux-gnu` (Debian/Ubuntu)", + install_hint: "Install it with `apt install gcc-x86-64-linux-gnu`.", }, }, // macOS (clang) and Windows (MSVC) toolchains build either arch natively. @@ -133,9 +133,7 @@ pub fn resolve_linker( return None; } if !linker_on_path(linker_bin) { - terminal.print_warning(&format!( - "Linker `{linker_bin}` was not found on PATH — {install_hint}." - )); + terminal.print_warning(&format!("No linker {linker_bin} on PATH.\n{install_hint}")); } Some(linker_bin) } diff --git a/crates/tracel-cli/src/tools/packager.rs b/crates/tracel-cli/src/tools/packager.rs index 72e7fef..91aeb08 100644 --- a/crates/tracel-cli/src/tools/packager.rs +++ b/crates/tracel-cli/src/tools/packager.rs @@ -10,11 +10,12 @@ use std::{ sync::Arc, }; -use colored::Colorize; +use anyhow::Context; use sha2::Digest as _; use sha2::Sha256; use crate::tools::event::Reporter; +use crate::ui::{counted, size}; #[derive(Debug)] pub struct ArchiveMetadata { @@ -36,40 +37,22 @@ pub fn package_workspace( workspace_name: &str, event_reporter: Arc, ) -> anyhow::Result { - event_reporter.report_event(PackageEvent { - message: "Initializing workspace packaging".to_string(), - }); - - // Get workspace metadata - let cmd = cargo_metadata::MetadataCommand::new(); - let metadata = cmd.exec()?; - + let metadata = cargo_metadata::MetadataCommand::new().exec()?; let workspace_root = metadata .workspace_root .canonicalize() - .map_err(|e| anyhow::anyhow!("Failed to canonicalize workspace root: {}", e))?; - - tracing::info!( - "Packaging workspace at: {}", - workspace_root.display().to_string().bold() - ); + .context("Can't find the workspace root")?; - // List all files in the workspace respecting gitignore event_reporter.report_event(PackageEvent { - message: "Discovering files (respecting .gitignore)".to_string(), + message: "Listing files".to_string(), }); - let files = list_workspace_files(&workspace_root)?; - tracing::info!("Found {} files to package", files.len()); - event_reporter.report_event(PackageEvent { - message: format!("Discovered {} files", files.len()), - }); - - // Create the archive - event_reporter.report_event(PackageEvent { - message: "Creating compressed archive".to_string(), + message: format!( + "Compressing {}", + counted(files.len() as u64, ["file", "files"]) + ), }); // Write the archive under the cargo target directory, the idiomatic home for @@ -91,50 +74,20 @@ pub fn package_workspace( create_workspace_archive(&workspace_root, &files, &archive_file, workspace_name)?; event_reporter.report_event(PackageEvent { - message: format!( - "Archive created: {}", - human_readable_bytes(uncompressed_size) - ), - }); - - // Calculate checksum - event_reporter.report_event(PackageEvent { - message: "Computing checksum".to_string(), + message: format!("Computing the checksum of {}", size(uncompressed_size)), }); - let archive_data = std::fs::read(&archive_path)?; - let checksum = format!("{:x}", Sha256::digest(&archive_data)); - let size = archive_data.len() as u64; - - // Build metadata from cargo_metadata - event_reporter.report_event(PackageEvent { - message: "Extracting package metadata".to_string(), - }); - - let archive_data = ArchiveMetadata { + Ok(ArchiveMetadata { path: archive_path, - checksum: checksum.clone(), - size, - }; - - event_reporter.report_event(PackageEvent { - message: "Workspace packaging completed successfully".to_string(), - }); - - Ok(archive_data) + checksum: format!("{:x}", Sha256::digest(&archive_data)), + size: archive_data.len() as u64, + }) } /// Lists all files in the workspace that should be packaged, respecting gitignore rules. fn list_workspace_files(workspace_root: &Path) -> anyhow::Result> { let git_repo = discover_gix_repo(workspace_root)?; - if let Some(ref repo) = git_repo { - tracing::info!( - "Git repository found at {}", - repo.path().display().to_string().bold() - ); - } - // Build gitignore matcher let mut exclude_builder = ignore::gitignore::GitignoreBuilder::new(workspace_root); @@ -192,7 +145,7 @@ fn create_workspace_archive( for file_path in files { let relative_path = file_path .strip_prefix(workspace_root) - .map_err(|e| anyhow::anyhow!("Failed to strip workspace root prefix: {}", e))?; + .context("A packaged file is outside the workspace")?; if file_path.is_file() { let mut file = File::open(file_path)?; @@ -223,10 +176,7 @@ fn discover_gix_repo(root: &Path) -> anyhow::Result> { }; let repo_root = repo.workdir().ok_or_else(|| { - anyhow::format_err!( - "Did not expect repo at {} to be bare", - repo.path().display() - ) + anyhow::format_err!("The git repository at {} is bare.", repo.path().display()) })?; // Verify the repository contains the workspace root @@ -326,17 +276,3 @@ fn list_files_walk( Ok(()) } - -/// Formats a byte count into a human-readable string. -fn human_readable_bytes(bytes: u64) -> String { - const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; - let mut size = bytes as f64; - let mut unit_idx = 0; - - while size >= 1024.0 && unit_idx < UNITS.len() - 1 { - size /= 1024.0; - unit_idx += 1; - } - - format!("{:.2} {}", size, UNITS[unit_idx]) -} diff --git a/crates/tracel-cli/src/tools/project_context.rs b/crates/tracel-cli/src/tools/project_context.rs index 450c907..14b64eb 100644 --- a/crates/tracel-cli/src/tools/project_context.rs +++ b/crates/tracel-cli/src/tools/project_context.rs @@ -55,14 +55,14 @@ impl ProjectContext { let project = TracelProject::load(&workspace_info.workspace_root) .map_err(|e| { ProjectContextError::new( - "Failed to read tracel.toml".to_string(), + "Can't read tracel.toml".to_string(), ErrorKind::Parsing, Some(e.into()), ) })? .ok_or_else(|| { ProjectContextError::new( - "No Tracel Console project linked to this repository".to_string(), + "This workspace isn't linked to a project".to_string(), ErrorKind::ProjectNotLinked, None, ) @@ -79,7 +79,7 @@ impl ProjectContext { project.save(&workspace_info.workspace_root).map_err(|e| { ProjectContextError::new( - "Failed to write tracel.toml".to_string(), + "Can't write tracel.toml".to_string(), ErrorKind::ProjectInitialization, Some(e.into()), ) @@ -96,7 +96,7 @@ impl ProjectContext { TracelProject::remove(&workspace_info.workspace_root).map_err(|e| { ProjectContextError::new( - "Failed to remove tracel.toml".to_string(), + "Can't remove tracel.toml".to_string(), ErrorKind::Unexpected, Some(e.into()), ) diff --git a/crates/tracel-cli/src/tools/target.rs b/crates/tracel-cli/src/tools/target.rs index 3474e68..47f2377 100644 --- a/crates/tracel-cli/src/tools/target.rs +++ b/crates/tracel-cli/src/tools/target.rs @@ -4,11 +4,10 @@ use std::collections::HashSet; use std::process::{Command, Stdio}; use anyhow::Context; -use colored::Colorize; use tracel_client::console::project::request::{Arch, Os}; use crate::error::{CliError, ErrorKind}; -use crate::ui::{Declined, Terminal}; +use crate::ui::{Declined, Terminal, Unanswered}; /// Every (os, arch) target we offer to build for, in canonical display order. /// The host is surfaced separately and pulled to the front by `prompt_targets`. @@ -47,9 +46,8 @@ pub fn parse_target(triple: &str) -> Result<(Os, Arch), CliError> { .join(", "); CliError::new( ErrorKind::Usage, - format!("Invalid --target '{triple}'. Valid values: {valid}."), + format!("No target '{triple}'. Targets: {valid}."), ) - .with_hint("Pass --target using one of the valid values.") }) } @@ -80,7 +78,7 @@ pub fn select_targets( } /// Human-friendly name for an (os, arch) pair, e.g. "Linux x86_64". -fn pretty_name(os: Os, arch: Arch) -> &'static str { +pub fn pretty_name(os: Os, arch: Arch) -> &'static str { match (os, arch) { (Os::Linux, Arch::X86_64) => "Linux x86_64", (Os::Linux, Arch::Arm64) => "Linux arm64", @@ -97,15 +95,15 @@ pub fn host_target() -> anyhow::Result<(Os, Arch)> { "windows" => Os::Windows, "linux" => Os::Linux, "macos" => Os::Macos, - other => anyhow::bail!("Unsupported host operating system for packaging: `{other}`"), + other => anyhow::bail!("Packaging doesn't support this operating system ({other})."), }; let arch = match std::env::consts::ARCH { "x86_64" => Arch::X86_64, "aarch64" | "arm64" => Arch::Arm64, - other => anyhow::bail!("Unsupported host architecture for packaging: `{other}`"), + other => anyhow::bail!("Packaging doesn't support this architecture ({other})."), }; if (os, arch) == (Os::Macos, Arch::X86_64) { - anyhow::bail!("macOS on x86_64 (Intel) is not supported for packaging."); + anyhow::bail!("Packaging doesn't support macOS on Intel."); } Ok((os, arch)) } @@ -143,11 +141,9 @@ pub fn add_target(triple: &str) -> anyhow::Result<()> { .stdin(Stdio::null()) .stdout(Stdio::from(std::io::stderr())) .status() - .with_context(|| { - format!("Failed to run `rustup target add {triple}` (is rustup installed?)") - })?; + .with_context(|| format!("Can't run `rustup target add {triple}`"))?; if !status.success() { - anyhow::bail!("`rustup target add {triple}` failed"); + anyhow::bail!("`rustup target add {triple}` failed."); } Ok(()) } @@ -174,30 +170,29 @@ pub fn prompt_targets( ( (os, arch), format!("{name} ({triple}) (this machine)"), - "builds natively; no extra toolchain needed".to_string(), + "builds natively".to_string(), ) } else if installed.contains(triple) { ((os, arch), format!("{name} ({triple})"), String::new()) } else { - // Not installed: dim the whole label (stays greyed even under the - // cursor) and keep a visible textual marker so the distinction - // survives in terminals without color, plus an actionable hint. + // Dim the whole label, which stays dim under the cursor, and keep a + // marker that survives without colors. ( (os, arch), - format!("{name} ({triple}) - not installed") - .dimmed() + console::style(format!("{name} ({triple}) not installed")) + .dim() .to_string(), - format!("run: rustup target add {triple}"), + "installed with rustup if chosen".to_string(), ) } }) .collect(); terminal.multiselect( - "Select the target(s) to build for (space to toggle, enter to confirm)", - "target", + "Targets", &items, vec![host], + Unanswered::new("Packaging needs targets.", "Pass --target ."), ) } @@ -210,11 +205,12 @@ pub fn install_missing_target( let list = missing.join(", "); if install_targets || terminal.confirm( - &format!( - "These targets are not installed: {list}. Run `rustup target add` for them now?" - ), - "install-targets", + &format!("Install {list} with rustup?"), true, + Unanswered::new( + format!("Installing {list} needs confirmation."), + "Pass --install-targets to install them without asking.", + ), )? { for triple in &missing { diff --git a/crates/tracel-cli/src/tools/workspace.rs b/crates/tracel-cli/src/tools/workspace.rs index 79d39f8..61fc76c 100644 --- a/crates/tracel-cli/src/tools/workspace.rs +++ b/crates/tracel-cli/src/tools/workspace.rs @@ -12,10 +12,7 @@ impl WorkspaceInfo { pub fn load_from_path(manifest_path: &Path) -> Result { if !manifest_path.is_file() { return Err(ProjectContextError::new( - format!( - "Cargo.toml not found at specified path '{}'", - manifest_path.display() - ), + format!("No Cargo.toml at '{}'", manifest_path.display()), ErrorKind::ManifestNotFound, None, )); @@ -27,9 +24,8 @@ impl WorkspaceInfo { .map_err(|e| { ProjectContextError::new( format!( - "Failed to load cargo metadata for manifest at '{}': {}", - manifest_path.display(), - e + "Can't read the cargo metadata of '{}'", + manifest_path.display() ), ErrorKind::Parsing, Some(anyhow::anyhow!(e)), @@ -42,10 +38,7 @@ impl WorkspaceInfo { let workspace_toml_path = workspace_root.join("Cargo.toml"); if !workspace_toml_path.exists() { return Err(ProjectContextError::new( - format!( - "Cargo.toml not found at workspace root '{}'. This is not a valid cargo project.", - workspace_toml_path.display() - ), + format!("No Cargo.toml at '{}'", workspace_toml_path.display()), ErrorKind::ManifestNotFound, None, )); @@ -53,11 +46,7 @@ impl WorkspaceInfo { let toml_str = std::fs::read_to_string(&workspace_toml_path).map_err(|e| { ProjectContextError::new( - format!( - "Failed to read Cargo.toml at '{}': {}", - workspace_toml_path.display(), - e - ), + format!("Can't read '{}'", workspace_toml_path.display()), ErrorKind::Parsing, Some(anyhow::anyhow!(e)), ) diff --git a/crates/tracel-cli/src/ui/format.rs b/crates/tracel-cli/src/ui/format.rs index b9259bd..4b3a9a7 100644 --- a/crates/tracel-cli/src/ui/format.rs +++ b/crates/tracel-cli/src/ui/format.rs @@ -38,7 +38,7 @@ impl Format { Some(value) => { return Err(CliError::new( ErrorKind::Usage, - format!("Invalid TRACEL_OUTPUT value '{value}': expected human or json."), + format!("Unknown TRACEL_OUTPUT '{value}'. Formats: human, json."), )); } }, diff --git a/crates/tracel-cli/src/ui/human.rs b/crates/tracel-cli/src/ui/human.rs index 67a81a6..1e47488 100644 --- a/crates/tracel-cli/src/ui/human.rs +++ b/crates/tracel-cli/src/ui/human.rs @@ -282,15 +282,58 @@ impl Details { } } +/// A count of things, such as "1 file" or "3 files". +pub fn counted(count: u64, [one, many]: [&str; 2]) -> String { + match count { + 1 => format!("1 {one}"), + count => format!("{count} {many}"), + } +} + /// The summary of a list: "Showing 10 of 16 experiments." when some are left out, /// "16 experiments." when not, and `none` when there are none. -pub fn counted(shown: usize, total: u64, [one, many]: [&str; 2], none: &str) -> String { +pub fn list_summary(shown: usize, total: u64, forms: [&str; 2], none: &str) -> String { let shown = shown as u64; match total.max(shown) { 0 => none.to_string(), - total if shown < total => format!("Showing {shown} of {total} {many}."), - 1 => format!("1 {one}."), - total => format!("{total} {many}."), + total if shown < total => format!("Showing {shown} of {total} {}.", forms[1]), + total => format!("{}.", counted(total, forms)), + } +} + +/// The first 12 characters of a digest, enough to tell code and model versions apart. +pub fn short_digest(digest: &str) -> &str { + digest.get(..12).unwrap_or(digest) +} + +/// A size in bytes, as "512 B", "6.9 KB" or "713.7 MB". +pub fn size(bytes: u64) -> String { + const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"]; + if bytes < 1000 { + return format!("{bytes} B"); + } + let mut value = bytes as f64 / 1000.0; + let mut unit = 0; + while value >= 1000.0 && unit + 1 < UNITS.len() { + value /= 1000.0; + unit += 1; + } + format!("{} {}", number(value), UNITS[unit]) +} + +/// A number to about 4 significant digits, as "0.75" for 0.7499999999999999. Large +/// numbers keep their integer digits. +pub fn number(value: f64) -> String { + if value == 0.0 || !value.is_finite() { + return value.to_string(); + } + let magnitude = value.abs().log10().floor() as i32; + let decimals = (3 - magnitude).max(0) as usize; + let text = format!("{value:.decimals$}"); + if text.contains('.') { + text.trim_end_matches('0').trim_end_matches('.').to_string() + } else { + text } } @@ -308,6 +351,9 @@ fn flatten(prefix: &str, value: &Value, fields: &mut Vec<(String, String)>) { } Value::Object(_) if prefix.is_empty() => {} Value::String(text) => fields.push((prefix.into(), text.clone())), + Value::Number(value) if value.is_f64() => { + fields.push((prefix.into(), number(value.as_f64().unwrap_or_default()))) + } other => fields.push((prefix.into(), other.to_string())), } } @@ -493,7 +539,7 @@ mod tests { #[test] fn counts_mention_what_was_left_out() { - let count = |shown, total| counted(shown, total, ["model", "models"], "No models."); + let count = |shown, total| list_summary(shown, total, ["model", "models"], "No models."); assert_eq!(count(0, 0), "No models."); assert_eq!(count(1, 1), "1 model."); assert_eq!(count(3, 3), "3 models."); diff --git a/crates/tracel-cli/src/ui/layout.rs b/crates/tracel-cli/src/ui/layout.rs index e1649d0..9b4b3b8 100644 --- a/crates/tracel-cli/src/ui/layout.rs +++ b/crates/tracel-cli/src/ui/layout.rs @@ -4,28 +4,19 @@ use std::fmt::{self, Display}; pub enum Layout { /// A command that reads: its result in one box, under its summary. Read, - /// A command that does work: a session that first says what it does and where. - Flow(Flow), + /// A command that does work: a session that first says what it does, such as + /// "Run job 'train'", and where. + Flow(String), /// Nothing drawn: the output is a bare value, or another program's. Bare, } impl Layout { - pub fn flow(what: impl Into, failure: impl Into) -> Self { - Self::Flow(Flow { - what: what.into(), - failure: failure.into(), - }) + pub fn flow(what: impl Into) -> Self { + Self::Flow(what.into()) } } -/// What a flow does, such as "Run job 'train'", and the outcome that closes it when it -/// fails, such as "Job 'train' did not complete." -pub struct Flow { - pub what: String, - pub failure: String, -} - /// Where a command works: the project it uses, and its environment unless that is /// production. #[derive(Default)] diff --git a/crates/tracel-cli/src/ui/mod.rs b/crates/tracel-cli/src/ui/mod.rs index d14955a..ed3f567 100644 --- a/crates/tracel-cli/src/ui/mod.rs +++ b/crates/tracel-cli/src/ui/mod.rs @@ -16,9 +16,9 @@ mod time; use std::sync::Arc; pub use format::{Format, FormatArg}; -pub use human::{Details, Human, Table, counted}; +pub use human::{Details, Human, Table, counted, list_summary, number, short_digest, size}; pub use layout::{Layout, Place}; -pub use stderr::{Declined, Terminal}; +pub use stderr::{Declined, Terminal, Unanswered}; pub use stdout::{Outcome, Output, Render, StdoutClosed}; pub use time::{Timestamp, log_time, when}; diff --git a/crates/tracel-cli/src/ui/screen.rs b/crates/tracel-cli/src/ui/screen.rs index b49feee..c5b7b75 100644 --- a/crates/tracel-cli/src/ui/screen.rs +++ b/crates/tracel-cli/src/ui/screen.rs @@ -2,7 +2,7 @@ use std::io::{self, IsTerminal}; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use cliclack::{MultiProgress, ProgressBar}; +use cliclack::ProgressBar; use super::Format; use super::layout::{Layout, Place}; @@ -30,14 +30,14 @@ pub struct Screen { struct Live { id: u64, bar: ProgressBar, - group: Option, message: String, } +/// How a live widget ends: as a line with its message, or with nothing left. #[derive(Clone, Copy)] pub enum Ending { Done, - Failed, + Cleared, } /// How a result reaches the person. @@ -102,13 +102,13 @@ impl Screen { /// Opens a flow's session: the tracel badge, then what the flow does and where. pub fn begin(&self) { - let Layout::Flow(flow) = &self.layout else { + let Layout::Flow(what) = &self.layout else { return; }; if !self.styled { return; } - let mut what = flow.what.clone(); + let mut what = what.clone(); if let Some(project) = &self.place.project { what.push_str(&format!( " in {}", @@ -164,23 +164,10 @@ impl Screen { } } - /// Ends a flow's session with the commands to run next, one per line. - pub fn close_with_next_steps(&self, steps: &str) { - if self.session.swap(false, Ordering::Relaxed) { - self.draw(|| cliclack::outro_note("Next steps", steps)); - } - } - - /// Closes what a failure was just reported in: a flow's session with its failure, or - /// a bare corner. + /// Closes the frame a failure was just reported in with a bare corner. pub fn close_failed(&self) { - let outcome = match &self.layout { - Layout::Flow(flow) if self.session.swap(false, Ordering::Relaxed) => { - flow.failure.as_str() - } - _ => "", - }; - self.draw(|| cliclack::outro_cancel(outcome)); + self.session.store(false, Ordering::Relaxed); + self.draw(|| cliclack::outro_cancel("")); } pub fn next_id(&self) -> u64 { @@ -193,44 +180,41 @@ impl Screen { let _ = draw(); } - /// Settles the live widget, then draws the one `draw` makes as live widget `id`. - pub fn show( - &self, - id: u64, - message: String, - draw: impl FnOnce() -> (ProgressBar, Option), - ) { + /// Settles the live widget, then starts `bar` as live widget `id`. + pub fn show(&self, id: u64, bar: ProgressBar, message: String) { let mut live = self.lock(); if let Some(previous) = live.take() { previous.end(Ending::Done, None); } - let (bar, group) = draw(); bar.start(&message); - *live = Some(Live { - id, - bar, - group, - message, - }); + *live = Some(Live { id, bar, message }); } - /// Changes the live widget `id`; nothing when it was settled. - pub fn update(&self, id: u64, message: String, position: Option) { + /// Changes the message of live widget `id`; nothing when it was settled. + pub fn update(&self, id: u64, message: String) { if let Some(live) = self.lock().as_mut().filter(|live| live.id == id) { - if let Some(position) = position { - live.bar.set_position(position); - } live.bar.set_message(&message); live.message = message; } } - /// Finishes the live widget `id`. False when something else settled it first. - pub fn finish(&self, id: u64, ending: Ending, message: &str) -> bool { + /// Moves the bar of live widget `id` to `position`; nothing when it was settled. + pub fn update_position(&self, id: u64, position: u64) { + if let Some(live) = self.lock().as_ref().filter(|live| live.id == id) { + live.bar.set_position(position); + } + } + + /// Clears live widget `id`, leaving no line. + pub fn clear(&self, id: u64) { + self.end(id, Ending::Cleared, None); + } + + fn end(&self, id: u64, ending: Ending, message: Option<&str>) -> bool { let mut live = self.lock(); match live.take() { Some(widget) if widget.id == id => { - widget.end(ending, Some(message)); + widget.end(ending, message); true } other => { @@ -285,20 +269,9 @@ fn columns(terminal: &console::Term) -> Option { impl Live { fn end(self, ending: Ending, message: Option<&str>) { - let message = message.unwrap_or(&self.message); match ending { - Ending::Done => { - self.bar.stop(message); - if let Some(group) = &self.group { - group.stop(); - } - } - Ending::Failed => { - self.bar.error(message); - if let Some(group) = &self.group { - group.error(message); - } - } + Ending::Done => self.bar.stop(message.unwrap_or(&self.message)), + Ending::Cleared => self.bar.clear(), } } } diff --git a/crates/tracel-cli/src/ui/stderr.rs b/crates/tracel-cli/src/ui/stderr.rs index 84d343a..218e4a5 100644 --- a/crates/tracel-cli/src/ui/stderr.rs +++ b/crates/tracel-cli/src/ui/stderr.rs @@ -24,6 +24,27 @@ impl Declined { } } +/// What a prompt says when nobody can answer it: a message such as "Unlinking +/// test/demo needs confirmation." and a hint naming the flag that answers it, such as +/// "Pass --yes to unlink without asking." +pub struct Unanswered { + message: String, + hint: String, +} + +impl Unanswered { + pub fn new(message: impl Into, hint: impl Into) -> Self { + Self { + message: message.into(), + hint: hint.into(), + } + } + + fn into_error(self, kind: ErrorKind) -> CliError { + CliError::new(kind, self.message).with_hint(self.hint) + } +} + /// Stderr, for the person running a command: what it is doing, its progress, and /// questions for them. With JSON output it carries only errors and the instructions /// a person must follow, as plain lines. @@ -71,14 +92,6 @@ impl Terminal { } } - pub fn print_err(&self, message: &str) { - if self.is_styled() { - self.screen.draw(|| cliclack::log::error(message)); - } else if self.is_human() { - self.line(format_args!("error: {message}")); - } - } - pub fn print_success(&self, message: &str) { if self.is_styled() { self.screen.draw(|| cliclack::log::success(message)); @@ -106,10 +119,10 @@ impl Terminal { } /// Reports a failed command: the message and hint here, the envelope on stdout - /// with JSON output. On a terminal both are one error, then a flow's session ends - /// on its failure, and anything else on a bare corner. + /// with JSON output. On a terminal both are one error, then a bare corner closes + /// the frame. pub fn error(&self, report: &ErrorReport<'_>) { - self.screen.settle(Ending::Failed); + self.screen.settle(Ending::Cleared); if !self.is_human() { self.line(format_args!("error: {}", one_line(&report.message))); } else if self.is_styled() { @@ -127,26 +140,24 @@ impl Terminal { } } - pub fn spinner(&self) -> TerminalSpinner { - TerminalSpinner { - terminal: self.clone(), - id: self.screen.next_id(), - } + /// A spinner for one step, drawn from now until it ends. + pub fn spinner(&self, message: impl Display) -> Progress { + self.live(message.to_string(), cliclack::spinner()) + } + + /// A bar that counts `total` steps of one task, drawn from now until it ends. + pub fn progress(&self, message: impl Display, total: u64) -> Progress { + self.live(message.to_string(), cliclack::progress_bar(total)) } - /// A bar that counts `total` steps under `title`. - pub fn progress(&self, title: &str, total: u64) -> TerminalProgress { + fn live(&self, message: String, bar: ProgressBar) -> Progress { let id = self.screen.next_id(); if self.is_styled() { - self.screen.show(id, String::new(), || { - let group = cliclack::multi_progress(title); - let bar = group.add(ProgressBar::new(total).with_download_template()); - (bar, Some(group)) - }); + self.screen.show(id, bar, message); } else { - self.print(title); + self.print(&message); } - TerminalProgress { + Progress { terminal: self.clone(), id, } @@ -166,34 +177,24 @@ impl Terminal { } } - fn require_input(&self, message: &str, flag: &str, values: &[&str]) -> anyhow::Result<()> { - self.require_answer(ErrorKind::Usage, message, flag, values) - } - - /// Fails when nobody can answer, and otherwise clears the screen for a prompt. - fn require_answer( - &self, - kind: ErrorKind, - message: &str, - flag: &str, - values: &[&str], - ) -> anyhow::Result<()> { + /// Fails with `kind` when nobody can answer a prompt, and otherwise clears the + /// screen for it. + fn require_answer(&self, kind: ErrorKind, unanswered: Unanswered) -> anyhow::Result<()> { if !self.is_interactive() { - let mut message = format!("Input needed: {}", message.trim()); - if !values.is_empty() { - message.push_str(&format!(" Valid values: {}.", values.join(", "))); - } - return Err(CliError::new(kind, message) - .with_hint(format!("Pass --{flag} to answer without a prompt.")) - .into()); + return Err(unanswered.into_error(kind).into()); } self.screen.settle(Ending::Done); Ok(()) } - pub fn confirm(&self, message: &str, flag: &str, initial: bool) -> anyhow::Result { - self.require_answer(ErrorKind::ConfirmationRequired, message, flag, &[])?; - cliclack::confirm(message) + pub fn confirm( + &self, + question: &str, + initial: bool, + unanswered: Unanswered, + ) -> anyhow::Result { + self.require_answer(ErrorKind::ConfirmationRequired, unanswered)?; + cliclack::confirm(question) .initial_value(initial) .interact() .map_err(anyhow::Error::from) @@ -201,31 +202,34 @@ impl Terminal { /// Offers a shortcut with a question answered yes by default. When nobody can answer, the /// question is not asked and the answer is no. - pub fn offer(&self, message: &str) -> anyhow::Result { + pub fn offer(&self, question: &str) -> anyhow::Result { if !self.is_interactive() { return Ok(false); } self.screen.settle(Ending::Done); - cliclack::confirm(message) + cliclack::confirm(question) .initial_value(true) .interact() .map_err(anyhow::Error::from) } - pub fn input(&self, message: &str, flag: &str) -> anyhow::Result { - self.input_validated(message, flag, "", |_| Ok(())) + /// Asks for optional text. + pub fn input(&self, question: &str, unanswered: Unanswered) -> anyhow::Result { + self.input_validated(question, "", unanswered, |_| Ok(())) } + /// Asks for text that `validate` accepts, `default` when the answer is empty. pub fn input_validated( &self, - message: &str, - flag: &str, - placeholder: &str, + question: &str, + default: &str, + unanswered: Unanswered, validate: impl Fn(&String) -> Result<(), String> + 'static, ) -> anyhow::Result { - self.require_input(message, flag, &[])?; - cliclack::input(message) - .placeholder(placeholder) + self.require_answer(ErrorKind::Usage, unanswered)?; + cliclack::input(question) + .default_input(default) + .placeholder(default) .required(false) .validate(validate) .interact() @@ -234,14 +238,13 @@ impl Terminal { pub fn select( &self, - message: &str, - flag: &str, + question: &str, items: &[(T, impl Display, impl Display)], initial: Option, - values: &[&str], + unanswered: Unanswered, ) -> anyhow::Result { - self.require_input(message, flag, values)?; - let mut prompt = cliclack::select(message).items(items); + self.require_answer(ErrorKind::Usage, unanswered)?; + let mut prompt = cliclack::select(question).items(items); if let Some(initial) = initial { prompt = prompt.initial_value(initial); } @@ -250,13 +253,13 @@ impl Terminal { pub fn multiselect( &self, - message: &str, - flag: &str, + question: &str, items: &[(T, impl Display, impl Display)], initial: Vec, + unanswered: Unanswered, ) -> anyhow::Result> { - self.require_input(message, flag, &[])?; - cliclack::multiselect(message) + self.require_answer(ErrorKind::Usage, unanswered)?; + cliclack::multiselect(question) .items(items) .initial_values(initial) .required(true) @@ -273,20 +276,6 @@ impl Terminal { } } - /// Ends a flow's session with what it did, then the commands to run next. - pub fn next_steps(&self, message: &str, steps: &[String]) { - if self.screen.session_open() { - self.print_success(message); - self.screen.close_with_next_steps(&steps.join("\n")); - } else if self.is_human() { - self.print_success(message); - self.line("Next steps:"); - for step in steps { - self.line(format_args!(" {step}")); - } - } - } - /// Whether a person can answer prompts: they read from stdin and draw on stderr. pub fn is_interactive(&self) -> bool { let ci = std::env::var_os("CI"); @@ -327,99 +316,29 @@ fn hyperlink(url: &url::Url, text: &str) -> String { format!("\u{1b}]8;;{url}\u{1b}\\{text}\u{1b}]8;;\u{1b}\\") } -/// A spinner for one step. It is drawn when started, and stops early when something -/// else draws before it ends; its last message then appears as a plain line. +/// A spinner or bar for one step, drawn from its start until it ends. When something +/// else draws before it ends, it stops early with its last message. #[derive(Clone)] -pub struct TerminalSpinner { +pub struct Progress { terminal: Terminal, id: u64, } -impl TerminalSpinner { - pub fn start(&self, message: impl Display) { - if self.terminal.is_styled() { - self.terminal - .screen - .show(self.id, message.to_string(), || (cliclack::spinner(), None)); - } else { - self.terminal.print(&message.to_string()); - } - } - +impl Progress { /// A passing detail of the step, such as what cargo compiles: shown on a terminal - /// only, where it replaces the spinner's message. - pub fn set_status(&self, message: impl Display) { - if self.terminal.is_styled() { - self.terminal - .screen - .update(self.id, message.to_string(), None); - } - } - + /// only, where it replaces the message. pub fn set_message(&self, message: impl Display) { - if self.terminal.is_styled() { - self.terminal - .screen - .update(self.id, message.to_string(), None); - } else { - self.terminal.print(&message.to_string()); - } + self.terminal.screen.update(self.id, message.to_string()); } - pub fn stop(&self, message: impl Display) { - let message = message.to_string(); - if !self.terminal.is_styled() - || !self.terminal.screen.finish(self.id, Ending::Done, &message) - { - self.terminal.print_success(&message); - } + /// How many of a bar's steps are done. + pub fn set_position(&self, done: u64) { + self.terminal.screen.update_position(self.id, done); } - pub fn error(&self, message: impl Display) { - let message = message.to_string(); - if !self.terminal.is_styled() - || !self - .terminal - .screen - .finish(self.id, Ending::Failed, &message) - { - self.terminal.print_err(&message); - } - } -} - -/// A bar counting the steps of one task, drawn from creation until it ends. -pub struct TerminalProgress { - terminal: Terminal, - id: u64, -} - -impl TerminalProgress { - pub fn set(&self, done: u64, message: impl Display) { - self.terminal - .screen - .update(self.id, message.to_string(), Some(done)); - } - - pub fn stop(&self, message: impl Display) { - let message = message.to_string(); - if !self.terminal.is_styled() - || !self.terminal.screen.finish(self.id, Ending::Done, &message) - { - self.terminal.print_success(&message); - } - } - - pub fn error(&self, message: impl Display) { - let message = message.to_string(); - if !self.terminal.is_styled() - || !self - .terminal - .screen - .finish(self.id, Ending::Failed, &message) - { - self.terminal.print_err(&message); - } + /// Ends the step without a line, for a step whose outcome says what it did. + pub fn clear(&self) { + self.terminal.screen.clear(self.id); } } From 933828a97d66f5564d7f9c03ea9672004e012a2e Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 13 Oct 2026 14:38:40 -0400 Subject: [PATCH 24/32] refactor: plainer doc comments and less duplication - Doc comments open with a plain sentence: a verb for functions and a noun phrase for types, in two lines or fewer. Narrating line comments are gone, and the box, session and flow vocabulary stays in ui. - The environment has one module: how `--dev`, `--staging` and TRACEL_ENV choose it, its name in JSON and on the terminal, its value for the SDK, and its web Console. - The ui speaks of intro and outro, settling and clearing. Stderr's methods are info, warning, step and error, and a progress handle no longer needs a terminal when cliclack doesn't draw. - A result for JSON only is `Outcome::data(value)`, which replaces the empty `Render` impls and the transparent newtypes; `render` is now required of every result. - The project field `owner` is `namespace`, the name tracel.toml, JSON and the Console use; `init --owner` keeps its flag. init chooses a `Namespace` that knows its Console path and how to create a project. - One credential lookup for `auth status` and every client, one login error, one approval message, and one deadline check per poll step. - Helper modules are private behind curated exports, items used only in their file are private, internal errors without a hint are plain anyhow errors, and the colored, directories and tracing dependencies and an unused packager parameter are gone. --- Cargo.lock | 132 +------- Cargo.toml | 2 - crates/tracel-cli/Cargo.toml | 3 - crates/tracel-cli/src/cli.rs | 114 +------ crates/tracel-cli/src/commands/artifacts.rs | 9 +- crates/tracel-cli/src/commands/auth.rs | 49 ++- crates/tracel-cli/src/commands/datasets.rs | 10 +- crates/tracel-cli/src/commands/experiments.rs | 28 +- crates/tracel-cli/src/commands/init.rs | 307 ++++++++---------- crates/tracel-cli/src/commands/login.rs | 116 ++++--- crates/tracel-cli/src/commands/logout.rs | 9 +- crates/tracel-cli/src/commands/me.rs | 2 +- crates/tracel-cli/src/commands/models.rs | 48 +-- crates/tracel-cli/src/commands/package.rs | 70 ++-- crates/tracel-cli/src/commands/project.rs | 10 +- crates/tracel-cli/src/commands/training.rs | 28 +- crates/tracel-cli/src/commands/unlink.rs | 13 +- crates/tracel-cli/src/context.rs | 85 +++-- crates/tracel-cli/src/environment.rs | 119 +++++++ crates/tracel-cli/src/error.rs | 8 +- crates/tracel-cli/src/helpers/download.rs | 50 ++- crates/tracel-cli/src/helpers/mod.rs | 17 +- .../src/helpers/model_upload/mod.rs | 4 +- .../src/helpers/model_upload/parts.rs | 4 + .../src/helpers/model_upload/upload.rs | 14 +- crates/tracel-cli/src/helpers/project.rs | 48 +-- crates/tracel-cli/src/helpers/resources.rs | 14 +- crates/tracel-cli/src/lib.rs | 1 + crates/tracel-cli/src/tools/build_driver.rs | 40 +-- crates/tracel-cli/src/tools/cargo.rs | 20 +- crates/tracel-cli/src/tools/event.rs | 2 +- crates/tracel-cli/src/tools/fs.rs | 3 + crates/tracel-cli/src/tools/git.rs | 2 +- crates/tracel-cli/src/tools/linker.rs | 50 ++- crates/tracel-cli/src/tools/packager.rs | 43 +-- .../tracel-cli/src/tools/project_context.rs | 3 + crates/tracel-cli/src/tools/target.rs | 33 +- crates/tracel-cli/src/tools/tracel_config.rs | 19 +- crates/tracel-cli/src/tools/workspace.rs | 6 +- crates/tracel-cli/src/ui/format.rs | 6 +- crates/tracel-cli/src/ui/human.rs | 76 +++-- crates/tracel-cli/src/ui/layout.rs | 14 +- crates/tracel-cli/src/ui/mod.rs | 20 +- crates/tracel-cli/src/ui/screen.rs | 190 +++++------ crates/tracel-cli/src/ui/stderr.rs | 146 +++++---- crates/tracel-cli/src/ui/stdout.rs | 74 +++-- crates/tracel-cli/src/ui/time.rs | 6 +- 47 files changed, 948 insertions(+), 1119 deletions(-) create mode 100644 crates/tracel-cli/src/environment.rs diff --git a/Cargo.lock b/Cargo.lock index 332e1e6..8b0fa47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -380,15 +380,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "colored" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "combine" version = "4.6.7" @@ -698,34 +689,13 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "directories" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" -dependencies = [ - "dirs-sys 0.4.1", -] - [[package]] name = "directories" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" dependencies = [ - "dirs-sys 0.5.0", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", + "dirs-sys", ] [[package]] @@ -736,7 +706,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.3", + "redox_users", "windows-sys 0.61.2", ] @@ -2766,17 +2736,6 @@ dependencies = [ "bitflags 2.13.1", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - [[package]] name = "redox_users" version = "0.5.3" @@ -3679,9 +3638,7 @@ dependencies = [ "chrono", "clap", "cliclack", - "colored", "console", - "directories 5.0.1", "flate2", "gix", "ignore", @@ -3694,7 +3651,6 @@ dependencies = [ "toml", "toml_edit", "tracel-client", - "tracing", "url", "walkdir", "webbrowser", @@ -3705,7 +3661,7 @@ name = "tracel-client" version = "0.21.0" source = "git+https://github.com/tracel-ai/burn-central-client?rev=1f0095dae2933a70f3a07eaf02138ca664d1708c#1f0095dae2933a70f3a07eaf02138ca664d1708c" dependencies = [ - "directories 6.0.0", + "directories", "reqwest", "rustls", "serde", @@ -4203,22 +4159,13 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4230,67 +4177,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4303,48 +4217,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 03e1f6a..679ed06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,6 @@ anyhow = "1.0.104" toml = "0.8.23" toml_edit = "0.22.27" clap = { version = "4.6.4", features = ["derive", "cargo"] } -colored = "3.1.1" derive-new = { version = "0.7.0", default-features = false } tracing = "0.1.44" serde = { version = "1.0.229", default-features = false, features = ["derive"] } @@ -34,7 +33,6 @@ console = "0.16.4" cliclack = "0.5.6" supports-hyperlinks = "3.2.0" webbrowser = "1.2.4" -directories = "5.0" url = "2.5.8" cargo_metadata = { version = "0.20.0", features = ["builder"] } flate2 = { version = "1.1.9", default-features = false, features = ["zlib"] } diff --git a/crates/tracel-cli/Cargo.toml b/crates/tracel-cli/Cargo.toml index 0957bb4..420208d 100644 --- a/crates/tracel-cli/Cargo.toml +++ b/crates/tracel-cli/Cargo.toml @@ -19,12 +19,10 @@ path = "src/bin/tracel.rs" tracel-client.workspace = true anyhow.workspace = true clap.workspace = true -colored.workspace = true console.workspace = true cliclack.workspace = true supports-hyperlinks.workspace = true webbrowser.workspace = true -directories.workspace = true serde.workspace = true serde_json.workspace = true chrono.workspace = true @@ -34,7 +32,6 @@ toml_edit.workspace = true url.workspace = true toml.workspace = true gix.workspace = true -tracing.workspace = true cargo_metadata.workspace = true ignore.workspace = true flate2.workspace = true diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index 66ef2d7..51a3dbc 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -4,9 +4,10 @@ use clap::{CommandFactory, Parser, Subcommand}; use tracel_client::console::Env; use crate::commands; -use crate::context::{CliContext, environment_name}; +use crate::context::CliContext; +use crate::environment; use crate::error::{CliError, ErrorKind, ErrorReport}; -use crate::helpers::project::{parse_project, resolve_project}; +use crate::helpers::{parse_project, resolve_project}; use crate::tools::tracel_config::TracelProject; use crate::ui::{ self, Declined, Format, FormatArg, Layout, Outcome, Output, Place, StdoutClosed, Terminal, @@ -91,8 +92,7 @@ pub enum Commands { } impl Commands { - /// How the command shows on a terminal: a read as a box, a flow as a session, and - /// nothing where stdout is a bare value or another program's output. + /// How the command draws on a terminal. fn layout(&self) -> Layout { match self { Self::Train(_) => Layout::Bare, @@ -109,7 +109,7 @@ impl Commands { } } - /// Whether the command works in a project, which a terminal then names with it. + /// Whether the command works in a project, which its header then names. fn in_project(&self) -> bool { matches!( self, @@ -121,9 +121,8 @@ impl Commands { ) } - /// What `auto` output means: human on a terminal and JSON otherwise, except where - /// stdout carries plain text: the bare token for `$(tracel auth token)`, or the - /// output of the program `train` runs. + /// Choose what `auto` means: human on a terminal and JSON otherwise, except for + /// `auth token` and `train`, whose stdout is plain text for another program. fn auto_format(&self, stdout_is_terminal: bool) -> Format { match self { Self::Train(_) @@ -187,17 +186,17 @@ pub fn cli_main() { let format = format.unwrap_or_else(|error| fail_early(&error.into(), auto)); let environment_value = std::env::var_os("TRACEL_ENV").map(|value| value.to_string_lossy().into_owned()); - let environment = resolve_environment(args.dev, args.staging, environment_value.as_deref()) + let environment = environment::resolve(args.dev, args.staging, environment_value.as_deref()) .unwrap_or_else(|error| fail_early(&error.into(), format)); let place = Place { project: command .in_project() .then(|| resolve_project(args.project.as_ref()).ok()) .flatten() - .map(|resolved| format!("{}/{}", resolved.project.owner, resolved.project.name)), + .map(|resolved| format!("{}/{}", resolved.project.namespace, resolved.project.name)), environment: match environment { Env::Production => None, - _ => Some(environment_name(&environment)), + _ => Some(environment::name(&environment)), }, }; let (output, terminal) = ui::channels(format, args.no_input, command.layout(), place); @@ -209,40 +208,8 @@ pub fn cli_main() { } } -fn resolve_environment( - dev: bool, - version: Option, - value: Option<&str>, -) -> Result { - if dev { - return Ok(Env::Development); - } - if let Some(version) = version { - return Ok(Env::Staging(version)); - } - match value { - None | Some("Production" | "production") => Ok(Env::Production), - Some("Development" | "development") => Ok(Env::Development), - Some(value) => value - .strip_prefix("Staging(") - .and_then(|number| number.strip_suffix(')')) - .or_else(|| value.strip_prefix("staging-")) - .and_then(|number| number.parse::().ok()) - .map(Env::Staging) - .ok_or_else(|| { - CliError::new( - ErrorKind::Usage, - format!( - "Unknown TRACEL_ENV '{value}'. Environments: production, development, staging-." - ), - ) - }), - } -} - -/// Reports a failed command on both channels and exits with its code. A reader that -/// closed stdout got all it wanted, so that ends the command quietly, and a declined -/// prompt ends it on what it didn't do. +/// Report a command that stopped on `error` and exit with its code. A closed stdout +/// and a declined prompt aren't failures, so they exit 0. fn fail(error: &anyhow::Error, output: &Output, terminal: &Terminal) -> ! { if error.chain().any(|cause| cause.is::()) { std::process::exit(0); @@ -260,13 +227,13 @@ fn fail(error: &anyhow::Error, output: &Output, terminal: &Terminal) -> ! { std::process::exit(report.exit_code) } -/// Fails before the command's channels exist. +/// Fail before the command's channels exist. fn fail_early(error: &anyhow::Error, format: Format) -> ! { let (output, terminal) = ui::channels(format, true, Layout::Bare, Place::default()); fail(error, &output, &terminal) } -/// The format for an error in arguments that did not parse. +/// Choose the format for an error in arguments that did not parse. fn usage_format(arguments: impl IntoIterator) -> Format { let mut arguments = arguments.into_iter(); let mut flag = None; @@ -348,7 +315,7 @@ mod tests { ] { let args = CliArgs::try_parse_from(arguments).unwrap(); let project = args.project.unwrap(); - assert_eq!(project.owner, "alice"); + assert_eq!(project.namespace, "alice"); assert_eq!(project.name, "demo"); assert_eq!( args.working_directory.unwrap(), @@ -410,56 +377,7 @@ mod tests { } #[test] - fn environment_values_match_sdk_and_lowercase_forms() { - for value in [None, Some("Production"), Some("production")] { - assert!(matches!( - resolve_environment(false, None, value), - Ok(Env::Production) - )); - } - for value in ["Development", "development"] { - assert!(matches!( - resolve_environment(false, None, Some(value)), - Ok(Env::Development) - )); - } - for version in [0, 1, 255] { - for value in [format!("Staging({version})"), format!("staging-{version}")] { - assert!(matches!( - resolve_environment(false, None, Some(&value)), - Ok(Env::Staging(number)) if number == version - )); - } - } - for value in [ - "bogus", - "", - "Staging()", - "Staging(1", - "staging-", - "staging--1", - "Staging(256)", - "staging-256", - "Production ", - ] { - let error = resolve_environment(false, None, Some(value)).unwrap_err(); - assert_eq!(error.kind, ErrorKind::Usage); - assert!(error.to_string().contains("TRACEL_ENV")); - } - } - - #[test] - fn environment_flags_override_variable() { - for value in [None, Some("Production"), Some("Development"), Some("bogus")] { - assert!(matches!( - resolve_environment(true, None, value), - Ok(Env::Development) - )); - assert!(matches!( - resolve_environment(false, Some(2), value), - Ok(Env::Staging(2)) - )); - } + fn environment_flags_conflict() { assert_eq!( CliArgs::try_parse_from(["tracel", "--dev", "--staging", "1", "me"]) .unwrap_err() diff --git a/crates/tracel-cli/src/commands/artifacts.rs b/crates/tracel-cli/src/commands/artifacts.rs index e00d081..1fa75d8 100644 --- a/crates/tracel-cli/src/commands/artifacts.rs +++ b/crates/tracel-cli/src/commands/artifacts.rs @@ -108,8 +108,6 @@ struct ArtifactDownloaded { bytes: u64, } -impl Render for ArtifactDownloaded {} - impl Render for ArtifactListResponse { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Table::new(["NAME", "KIND", "FILES", "CREATED AT"]) @@ -150,7 +148,7 @@ pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Resul } } let client = get_client_and_login_if_needed(&context)?; - let namespace = &project.owner; + let namespace = &project.namespace; let name = &project.name; match args.command { ArtifactsCommands::List(args) => Ok(match args.name { @@ -218,14 +216,13 @@ pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Resul size(bytes), directory.display() )); - Ok(ArtifactDownloaded { + Ok(Outcome::data(ArtifactDownloaded { experiment: args.experiment, bytes, artifact, directory, files, - } - .into()) + })) } } } diff --git a/crates/tracel-cli/src/commands/auth.rs b/crates/tracel-cli/src/commands/auth.rs index 51f2ff4..3bce5bd 100644 --- a/crates/tracel-cli/src/commands/auth.rs +++ b/crates/tracel-cli/src/commands/auth.rs @@ -6,7 +6,7 @@ use serde::Serialize; use tracel_client::ClientError; use tracel_client::console::TracelCredentials; -use crate::commands::login::LOG_IN; +use crate::commands::login::login_needed; use crate::context::{CliContext, ClientCreationError}; use crate::error::{CliError, ErrorKind}; use crate::ui::{Details, Human, Layout, Outcome, Render, Timestamp}; @@ -59,7 +59,6 @@ impl Render for AccessToken { enum Credential { ApiKey, Login, - None, } #[derive(Serialize)] @@ -81,7 +80,6 @@ impl Render for AuthStatus { let credential = match self.credential { Credential::ApiKey => "TRACEL_API_KEY", Credential::Login => "login", - Credential::None => "none", }; let now = SystemTime::now(); let login = match self.login_ends_at { @@ -122,34 +120,27 @@ fn access_token(context: &CliContext) -> anyhow::Result { } fn status(context: &CliContext) -> anyhow::Result { - let client = context.create_client(); - let login = context.app_session()?.stored()?; - - let credential = match (TracelCredentials::from_env().is_ok(), &login) { - (true, _) => Credential::ApiKey, - (false, Some(_)) => Credential::Login, - (false, None) => Credential::None, + let client = context.create_client().map_err(|error| match error { + ClientCreationError::NoCredentials | ClientCreationError::InvalidCredentials => { + login_needed(&error).into() + } + error => anyhow::Error::from(error), + })?; + let credential = match context.credentials()? { + Some(TracelCredentials::ApiKey(_)) => Credential::ApiKey, + _ => Credential::Login, }; + let login = context.app_session()?.stored()?; - match client { - Ok(client) => Ok(AuthStatus { - credential, - login_ends_at: login.map(|login| Timestamp(login.refresh_token_expires_at)), - user: User { - username: client.user().username.clone(), - namespace: client.user().namespace.clone(), - }, - environment: context.environment_name(), - }), - Err( - error @ (ClientCreationError::NoCredentials | ClientCreationError::InvalidCredentials), - ) => Err( - CliError::new(ErrorKind::NotAuthenticated, error.to_string()) - .with_hint(LOG_IN) - .into(), - ), - Err(e) => Err(e.into()), - } + Ok(AuthStatus { + credential, + login_ends_at: login.map(|login| Timestamp(login.refresh_token_expires_at)), + user: User { + username: client.user().username.clone(), + namespace: client.user().namespace.clone(), + }, + environment: context.environment_name(), + }) } fn time_left(until: SystemTime, now: SystemTime) -> String { diff --git a/crates/tracel-cli/src/commands/datasets.rs b/crates/tracel-cli/src/commands/datasets.rs index edd5d7a..ce31463 100644 --- a/crates/tracel-cli/src/commands/datasets.rs +++ b/crates/tracel-cli/src/commands/datasets.rs @@ -63,7 +63,7 @@ pub fn handle_command(args: DatasetsArgs, context: CliContext) -> anyhow::Result match args.command { DatasetsCommands::List(args) => Ok(client .query_datasets( - &project.owner, + &project.namespace, &project.name, QueryDatasetsRequest { page: args.page, @@ -72,11 +72,11 @@ pub fn handle_command(args: DatasetsArgs, context: CliContext) -> anyhow::Result )? .into()), DatasetsCommands::Get(args) => Ok(client - .get_dataset(&project.owner, &project.name, &args.dataset) + .get_dataset(&project.namespace, &project.name, &args.dataset) .map_err(|error| { map_resource_error( error, - &project.owner, + &project.namespace, &project.name, Resource::Dataset(&args.dataset), ) @@ -84,7 +84,7 @@ pub fn handle_command(args: DatasetsArgs, context: CliContext) -> anyhow::Result .into()), DatasetsCommands::Versions(args) => Ok(client .query_dataset_versions( - &project.owner, + &project.namespace, &project.name, &args.dataset, QueryDatasetVersionsRequest { @@ -95,7 +95,7 @@ pub fn handle_command(args: DatasetsArgs, context: CliContext) -> anyhow::Result .map_err(|error| { map_resource_error( error, - &project.owner, + &project.namespace, &project.name, Resource::Dataset(&args.dataset), ) diff --git a/crates/tracel-cli/src/commands/experiments.rs b/crates/tracel-cli/src/commands/experiments.rs index a66b55a..7605762 100644 --- a/crates/tracel-cli/src/commands/experiments.rs +++ b/crates/tracel-cli/src/commands/experiments.rs @@ -17,7 +17,7 @@ use tracel_client::console::experiment::response::{ use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; -use crate::helpers::project::resolve_namespace_project; +use crate::helpers::resolve_namespace_project; use crate::helpers::{Resource, map_resource_error}; use crate::tools::tracel_config::TracelProject; use crate::ui::{ @@ -330,7 +330,7 @@ impl Render for Option { } impl Render for ExperimentLogQueryResponse { - /// Oldest first, as `--follow` writes them. + /// Write the entries oldest first, as `--follow` does. fn render(&self, out: &mut Human<'_>) -> io::Result<()> { let mut items: Vec<_> = self.items.iter().collect(); items.sort_by_key(|item| item.seq); @@ -347,7 +347,7 @@ impl Render for ExperimentLogQueryResponse { } } -/// An experiment status as a word: the server's `cancel_requested` reads "stopping". +/// Name an experiment status in a word: "stopping" for the server's `cancel_requested`. fn status_name(status: &str) -> &str { match status { "cancel_requested" => "stopping", @@ -393,11 +393,11 @@ fn follow_logs( ) -> anyhow::Result<()> { loop { let response = client - .query_experiment_logs(&project.owner, &project.name, num, request.clone()) + .query_experiment_logs(&project.namespace, &project.name, num, request.clone()) .map_err(|error| { map_resource_error( error, - &project.owner, + &project.namespace, &project.name, Resource::Experiment(num), ) @@ -430,13 +430,13 @@ fn follow_logs( pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Result { let client = get_client_and_login_if_needed(&context)?; let project = resolve_namespace_project(&context)?.project; - let (owner, name) = (&project.owner, &project.name); + let (namespace, name) = (&project.namespace, &project.name); let missing = - |num| move |error| map_resource_error(error, owner, name, Resource::Experiment(num)); + |num| move |error| map_resource_error(error, namespace, name, Resource::Experiment(num)); match args.command { ExperimentsCommands::List(args) => Ok(client .get_project_experiments( - owner, + namespace, name, ListExperimentsQuery { page: args.page, @@ -448,10 +448,10 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res ExperimentsCommands::Get(args) => { let experiment = match args.experiment { ExperimentSelector::Number(num) => client - .get_experiment(owner, name, num) + .get_experiment(namespace, name, num) .map_err(missing(num))?, ExperimentSelector::Latest => client - .get_project_latest_experiment(owner, name)? + .get_project_latest_experiment(namespace, name)? .ok_or_else(|| { CliError::new(ErrorKind::NotFound, "No experiments in this project yet.") })?, @@ -461,19 +461,19 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res ExperimentsCommands::Metrics(args) => { let Some(metric) = args.metric else { return Ok(client - .get_metric_metadata(owner, name, args.num) + .get_metric_metadata(namespace, name, args.num) .map_err(missing(args.num))? .into()); }; if args.summary { Ok(client - .get_metric_summary(owner, name, args.num, MetricSummaryQuery { metric }) + .get_metric_summary(namespace, name, args.num, MetricSummaryQuery { metric }) .map_err(missing(args.num))? .into()) } else { Ok(client .get_metrics( - owner, + namespace, name, args.num, MetricAggregatedQuery { @@ -498,7 +498,7 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res return Ok(Outcome::streamed()); } Ok(client - .query_experiment_logs(owner, name, num, request) + .query_experiment_logs(namespace, name, num, request) .map_err(missing(num))? .into()) } diff --git a/crates/tracel-cli/src/commands/init.rs b/crates/tracel-cli/src/commands/init.rs index 252930c..8da95db 100644 --- a/crates/tracel-cli/src/commands/init.rs +++ b/crates/tracel-cli/src/commands/init.rs @@ -4,7 +4,7 @@ use crate::helpers::{can_initialize_project, require_cargo_workspace, require_li use crate::tools::git; use crate::tools::project_context::ProjectContext; use crate::tools::tracel_config::TracelProject; -use crate::ui::{Declined, Outcome, Render, Terminal, Unanswered}; +use crate::ui::{Declined, Outcome, Terminal, Unanswered}; use anyhow::Context; use clap::Args; use serde::Serialize; @@ -46,8 +46,6 @@ struct ProjectLinked { url: Option, } -impl Render for ProjectLinked {} - const NOTHING_LINKED: &str = "Nothing was linked."; pub fn handle_command(args: InitArgs, context: CliContext) -> anyhow::Result { @@ -55,166 +53,171 @@ pub fn handle_command(args: InitArgs, context: CliContext) -> anyhow::Result anyhow::Result { +/// End on the project the workspace is linked to, unless the flags ask for another. +fn already_linked(args: &InitArgs, context: &CliContext) -> anyhow::Result { + let project = require_linked_project()?; + let linked = project.get_project(); + let other_namespace = args.owner.as_deref().is_some_and(|o| o != linked.namespace); + let other_name = args.name.as_deref().is_some_and(|n| n != linked.name); + let name = format!("{}/{}", linked.namespace, linked.name); + if other_namespace || other_name { + return Err(CliError::new( + ErrorKind::Conflict, + format!("This workspace is already linked to {name}."), + ) + .with_hint("Pass --force to link another project.") + .into()); + } + context + .terminal() + .info("Pass --force to link another project."); + context + .terminal() + .outro(&format!("Already linked to {name}.")); + Ok(Outcome::data(ProjectLinked { + namespace: linked.namespace.clone(), + name: linked.name.clone(), + created: false, + url: None, + })) +} + +/// Link the workspace to the project the flags or prompts choose, creating it when the +/// Tracel Console has none by that name. +fn link(args: InitArgs, context: &CliContext, client: &Client) -> anyhow::Result { let user = client.get_current_user()?; let workspace_info = require_cargo_workspace()?; - let terminal = context.terminal(); ensure_git_repo_initialized(&workspace_info.get_ws_root(), terminal)?; ensure_git_repo_clean(terminal, args.commit, args.allow_dirty)?; - git::get_first_commit_hash().context("Can't read the first commit")?; - let project_owner = prompt_owner_name( + let namespace = choose_namespace( &user.username, &user.namespace, args.owner.as_deref(), client, terminal, )?; - let project_name = match args.name { + let name = match args.name { Some(name) => name, None => prompt_project_name(&workspace_info.workspace_name, terminal)?, }; - let owner_name = match &project_owner { - ProjectKind::User => user.namespace.as_str(), - ProjectKind::Organization(org_name) => org_name.as_str(), - }; - let (project_info, created) = match client.get_project(owner_name, &project_name) { + let (project, created) = match client.get_project(namespace.name(), &name) { Ok(project) => ( - handle_existing_project(&project, terminal, args.yes)?, + confirm_existing_project(&project, terminal, args.yes)?, false, ), Err(e) if e.is_not_found() => ( - create_new_project( - client, - project_owner.clone(), - owner_name, - &project_name, - args.description, - terminal, - )?, + create_project(client, &namespace, &name, args.description, terminal)?, true, ), Err(e) => { - return Err(e).with_context(|| format!("Can't look up {owner_name}/{project_name}")); + return Err(e).with_context(|| format!("Can't look up {}/{name}", namespace.name())); } }; - ProjectContext::init(project_info.clone(), &workspace_info.get_manifest_path())?; + ProjectContext::init(project.clone(), &workspace_info.get_manifest_path())?; - let frontend_url = project_url(context, &project_owner, owner_name, &project_name)?; - let project = format!("{}/{}", project_info.owner, project_info.name); - let url = terminal.format_url(&frontend_url); + let url = context.web_console().join(&namespace.project_path(&name))?; + let shown = format!("{}/{}", project.namespace, project.name); + let link = terminal.format_url(&url); terminal.outro(&if created { - format!("Created and linked {project} at {url}.") + format!("Created and linked {shown} at {link}.") } else { - format!("Linked to {project} at {url}.") + format!("Linked to {shown} at {link}.") }); Ok(ProjectLinked { - namespace: project_info.owner, - name: project_info.name, + namespace: project.namespace, + name: project.name, created, - url: Some(frontend_url.to_string()), + url: Some(url.to_string()), }) } -fn prompt_owner_name( - user_name: &str, +/// A namespace that can own a project: the user's own or one of their organizations'. +#[derive(Clone, PartialEq, Eq)] +enum Namespace { + User(String), + Organization(String), +} + +impl Namespace { + fn name(&self) -> &str { + match self { + Self::User(name) | Self::Organization(name) => name, + } + } + + /// Build the web Console path of project `name`. + fn project_path(&self, name: &str) -> String { + match self { + Self::User(namespace) => format!("/users/{namespace}/projects/{name}"), + Self::Organization(namespace) => format!("/orgs/{namespace}/projects/{name}"), + } + } +} + +/// Choose the namespace `--owner` names, else ask the user. +fn choose_namespace( + username: &str, user_namespace: &str, owner: Option<&str>, client: &Client, terminal: &Terminal, -) -> anyhow::Result { - let organizations = client.get_user_organizations()?; - let mut valid_values = vec![user_namespace]; - valid_values.extend( - organizations - .organizations - .iter() - .map(|org| org.namespace.as_str()), - ); - if let Some(owner) = owner { - validate_owner(owner, &valid_values)?; - return Ok(if owner == user_namespace { - ProjectKind::User - } else { - ProjectKind::Organization(owner.to_string()) - }); - } - let mut namespaces = vec![(ProjectKind::User, format!("{user_name} (you)"), "")]; - namespaces.extend(organizations.organizations.iter().map(|org| { +) -> anyhow::Result { + let organizations = client.get_user_organizations()?.organizations; + let mut namespaces = vec![( + Namespace::User(user_namespace.to_string()), + format!("{username} (you)"), + "", + )]; + namespaces.extend(organizations.iter().map(|organization| { ( - ProjectKind::Organization(org.namespace.clone()), - org.name.clone(), + Namespace::Organization(organization.namespace.clone()), + organization.name.clone(), "", ) })); + let names: Vec<&str> = namespaces + .iter() + .map(|(namespace, _, _)| namespace.name()) + .collect(); + if let Some(owner) = owner { + return namespaces + .iter() + .find(|(namespace, _, _)| namespace.name() == owner) + .map(|(namespace, _, _)| namespace.clone()) + .ok_or_else(|| unknown_owner(owner, &names).into()); + } terminal.select( "Owner", &namespaces, - Some(ProjectKind::User), + None, Unanswered::new( "Linking needs an owner.", - format!("Pass --owner with {}.", valid_values.join(" or ")), + format!("Pass --owner with {}.", names.join(" or ")), ), ) } -fn validate_owner(owner: &str, valid_values: &[&str]) -> Result<(), CliError> { - if valid_values.contains(&owner) { - Ok(()) - } else { - Err(CliError::new( - ErrorKind::Usage, - format!( - "Unknown owner '{owner}'. Owners: {}.", - valid_values.join(", ") - ), - )) - } +fn unknown_owner(owner: &str, namespaces: &[&str]) -> CliError { + CliError::new( + ErrorKind::Usage, + format!( + "Unknown owner '{owner}'. Owners: {}.", + namespaces.join(", ") + ), + ) } fn validate_project_name(name: &str) -> Result<(), String> { @@ -230,7 +233,8 @@ fn validate_project_name(name: &str) -> Result<(), String> { } } -pub fn prompt_project_name(workspace_name: &str, terminal: &Terminal) -> anyhow::Result { +/// Ask for a project name, `workspace_name` by default. +fn prompt_project_name(workspace_name: &str, terminal: &Terminal) -> anyhow::Result { let input = terminal.input_validated( "Project name", workspace_name, @@ -243,18 +247,17 @@ pub fn prompt_project_name(workspace_name: &str, terminal: &Terminal) -> anyhow: } }, )?; - - let input = if input.is_empty() { + let name = if input.is_empty() { workspace_name.to_string() } else { input }; - - validate_project_name(&input).map_err(|message| CliError::new(ErrorKind::Usage, message))?; - Ok(input) + validate_project_name(&name).map_err(|message| CliError::new(ErrorKind::Usage, message))?; + Ok(name) } -fn handle_existing_project( +/// Link `project`, which already exists, once the user agrees or `yes` says so. +fn confirm_existing_project( project: &ProjectResponse, terminal: &Terminal, yes: bool, @@ -269,40 +272,18 @@ fn handle_existing_project( "Pass --yes to link it without asking.", ), )?; - - if confirmed { - Ok(TracelProject { - owner: project.namespace_name.clone(), - name: project.project_name.clone(), - }) - } else { - Err(Declined::new(NOTHING_LINKED).into()) + if !confirmed { + return Err(Declined::new(NOTHING_LINKED).into()); } + Ok(TracelProject { + namespace: project.namespace_name.clone(), + name: project.project_name.clone(), + }) } -#[derive(Debug, Clone, PartialEq, Eq)] -enum ProjectKind { - User, - Organization(String), -} - -fn project_url( - context: &CliContext, - owner: &ProjectKind, - namespace: &str, - name: &str, -) -> anyhow::Result { - let path = match owner { - ProjectKind::User => format!("/users/{namespace}/projects/{name}"), - ProjectKind::Organization(_) => format!("/orgs/{namespace}/projects/{name}"), - }; - Ok(context.get_frontend_endpoint().join(&path)?) -} - -fn create_new_project( +fn create_project( client: &Client, - project_kind: ProjectKind, - namespace: &str, + namespace: &Namespace, name: &str, description: Option, terminal: &Terminal, @@ -312,37 +293,34 @@ fn create_new_project( None if terminal.is_interactive() => terminal.input( "Description (optional)", Unanswered::new( - format!("Creating {namespace}/{name} needs a description."), + format!("Creating {}/{name} needs a description.", namespace.name()), "Pass --description .", ), )?, None => String::new(), }; - let desc = if description.is_empty() { - None - } else { - Some(description) - }; + let description = (!description.is_empty()).then_some(description); - let created_project_path = match project_kind { - ProjectKind::User => client.create_user_project(name, desc.as_deref(), Visibility::Private), - ProjectKind::Organization(org_name) => client.create_organization_project( - &org_name, + let created = match namespace { + Namespace::User(_) => { + client.create_user_project(name, description.as_deref(), Visibility::Private) + } + Namespace::Organization(organization) => client.create_organization_project( + organization, name, - desc.as_deref(), + description.as_deref(), Visibility::Private, ), }; - let project = - created_project_path.with_context(|| format!("Can't create project {namespace}/{name}"))?; + created.with_context(|| format!("Can't create project {}/{name}", namespace.name()))?; Ok(TracelProject { - owner: project.namespace_name, + namespace: project.namespace_name, name: project.project_name, }) } -pub fn ensure_git_repo_initialized( +fn ensure_git_repo_initialized( ws_root: &std::path::Path, terminal: &Terminal, ) -> anyhow::Result<()> { @@ -353,7 +331,9 @@ pub fn ensure_git_repo_initialized( Ok(()) } -pub fn ensure_git_repo_clean( +/// Make sure the workspace has a commit and no uncommitted changes, committing when +/// `commit` says so or the user agrees, unless `allow_dirty`. +fn ensure_git_repo_clean( terminal: &Terminal, commit: bool, allow_dirty: bool, @@ -381,7 +361,7 @@ pub fn ensure_git_repo_clean( if !terminal.is_interactive() { return Err(commit_required().into()); } - terminal.print_warning(if has_commit { + terminal.warning(if has_commit { "The workspace has uncommitted changes." } else { "The workspace has no commit yet." @@ -440,17 +420,6 @@ pub fn commit_all(terminal: &Terminal) -> anyhow::Result<()> { mod tests { use super::*; - #[test] - fn owner_must_match_a_namespace() { - let valid = ["user-namespace", "org-namespace"]; - for owner in valid { - assert!(validate_owner(owner, &valid).is_ok()); - } - let error = validate_owner("display-name", &valid).unwrap_err(); - assert_eq!(error.kind, ErrorKind::Usage); - assert!(error.to_string().contains("user-namespace, org-namespace")); - } - #[test] fn project_name_allows_alphanumeric_underscores_and_hyphens() { for name in ["project", "Project123", "my_project-1", "modèle"] { diff --git a/crates/tracel-cli/src/commands/login.rs b/crates/tracel-cli/src/commands/login.rs index d2dbfac..8de5447 100644 --- a/crates/tracel-cli/src/commands/login.rs +++ b/crates/tracel-cli/src/commands/login.rs @@ -12,7 +12,7 @@ use url::Url; use self::pending::{PendingLogin, PendingLoginStore}; use crate::context::{CliContext, ClientCreationError}; use crate::error::{CliError, ErrorKind, classify}; -use crate::ui::{Layout, Outcome, Render, Terminal, Timestamp}; +use crate::ui::{Layout, Outcome, Terminal, Timestamp}; #[derive(Args, Debug)] pub struct LoginArgs { @@ -46,6 +46,8 @@ const RETRY_DELAYS: [Duration; 3] = [ Duration::from_secs(1), ]; +/// Connect to the Tracel Console, logging in first when there is no login and the user +/// can answer. pub fn get_client_and_login_if_needed(context: &CliContext) -> anyhow::Result { let mut delays = RETRY_DELAYS.into_iter(); let mut logged_in = false; @@ -57,13 +59,9 @@ pub fn get_client_and_login_if_needed(context: &CliContext) -> anyhow::Result { if !context.terminal().is_interactive() { - return Err( - CliError::new(ErrorKind::NotAuthenticated, error.to_string()) - .with_hint(LOG_IN) - .into(), - ); + return Err(login_needed(&error).into()); } - context.terminal().print_warning(&error.to_string()); + context.terminal().warning(&error.to_string()); log_in(context)?; logged_in = true; } @@ -76,8 +74,11 @@ pub fn get_client_and_login_if_needed(context: &CliContext) -> anyhow::Result CliError { + CliError::new(ErrorKind::NotAuthenticated, error.to_string()) + .with_hint("Run `tracel login`, or set TRACEL_API_KEY.") +} fn log_in(context: &CliContext) -> anyhow::Result<()> { let terminal = context.terminal(); @@ -85,12 +86,11 @@ fn log_in(context: &CliContext) -> anyhow::Result<()> { let authorization = device_auth.start()?; let approval_page = Url::parse(&authorization.verification_uri_complete)?; - let instructions = format!( - "Open {} and check that it shows the code {}.", - terminal.format_url(&approval_page), - console::style(&authorization.user_code).bold() - ); - terminal.instruct(&instructions); + terminal.instruct(&approval_instructions( + terminal, + &approval_page, + &authorization.user_code, + )); offer_browser(terminal, &approval_page)?; let spinner = terminal.spinner("Waiting for approval"); @@ -102,13 +102,23 @@ fn log_in(context: &CliContext) -> anyhow::Result<()> { Ok(()) } -/// Offers to open the approval page in a browser, when one can open it and someone can answer. +/// Ask the user to open `page` and check that it shows `code`. +fn approval_instructions(terminal: &Terminal, page: &Url, code: &str) -> String { + format!( + "Open {} and check that it shows the code {}.", + terminal.format_url(page), + console::style(code).for_stderr().bold() + ) +} + +/// Offer to open the approval page in a browser, when there is one and the user can +/// answer. fn offer_browser(terminal: &Terminal, approval_page: &Url) -> anyhow::Result<()> { if !webbrowser::Browser::is_available() || !terminal.offer("Open it in your browser?")? { return Ok(()); } if let Err(error) = webbrowser::open(approval_page.as_str()) { - terminal.print_warning(&format!( + terminal.warning(&format!( "Can't open a browser ({error}), so open the link yourself." )); } @@ -121,8 +131,6 @@ struct LoggedIn { environment: String, } -impl Render for LoggedIn {} - /// Serialized with `"status": "pending"`. #[derive(Serialize)] #[serde(tag = "status", rename = "pending")] @@ -132,8 +140,6 @@ struct LoginPending { expires_at: Timestamp, } -impl Render for LoginPending {} - fn start_pending_login(context: &CliContext) -> anyhow::Result { let authorization = context.device_auth().start()?; let pending = PendingLogin::new(authorization, SystemTime::now())?; @@ -141,12 +147,11 @@ fn start_pending_login(context: &CliContext) -> anyhow::Result { store.save(&pending)?; let terminal = context.terminal(); - terminal.print(&format!( - "Open {} and check that it shows the code {}.", - terminal.format_url(&Url::parse( - &pending.authorization.verification_uri_complete - )?), - console::style(&pending.authorization.user_code).bold() + let approval_page = Url::parse(&pending.authorization.verification_uri_complete)?; + terminal.info(&approval_instructions( + terminal, + &approval_page, + &pending.authorization.user_code, )); terminal.outro("Once you approve it, run `tracel login --complete`."); @@ -236,49 +241,39 @@ fn complete_pending_login(context: &CliContext, timeout: Option) -> anyhow: })?; let timeout = timeout.map(Duration::from_secs); let expires_at = pending.expires_at; - let window = || poll_window(expires_at, SystemTime::now(), timeout, started.elapsed()); + // The time left before the earlier deadline, or that deadline's error once it passed. + let time_left = || match poll_window(expires_at, SystemTime::now(), timeout, started.elapsed()) + { + (remaining, deadline) if remaining.is_zero() => Err(anyhow::Error::from(deadline.error())), + window => Ok(window), + }; let result = (|| { - let (remaining, deadline) = window(); - if remaining.is_zero() { - return Err(deadline.error().into()); - } let device_auth = context.device_auth(); let mut interval = pending.authorization.interval().max(Duration::from_secs(1)); + let (mut remaining, _) = time_left()?; loop { - let (remaining, deadline) = window(); - if remaining.is_zero() { - return Err(deadline.error().into()); - } std::thread::sleep(interval.min(remaining)); - let (remaining, deadline) = window(); - if remaining.is_zero() { - return Err(deadline.error().into()); - } - - let outcome = poll_with_deadline( + let (left, deadline) = time_left()?; + match poll_with_deadline( &device_auth, &pending.authorization.device_code, - remaining, + left, deadline, - )?; - let (remaining, deadline) = window(); - if remaining.is_zero() { - return Err(deadline.error().into()); - } - match outcome { - DevicePollOutcome::Pending => {} + )? { + DevicePollOutcome::Approved(issued) => { + context.app_session()?.sign_in(issued)?; + return Ok(()); + } DevicePollOutcome::SlowDown => { interval = poll_backoff(interval); pending.authorization.interval = interval.as_secs().try_into().unwrap_or(i64::MAX); store.save(&pending)?; } - DevicePollOutcome::Approved(issued) => { - context.app_session()?.sign_in(issued)?; - return Ok(()); - } + DevicePollOutcome::Pending => {} } + remaining = time_left()?.0; } })(); @@ -295,7 +290,7 @@ fn complete_pending_login(context: &CliContext, timeout: Option) -> anyhow: pub fn handle_command(args: LoginArgs, context: CliContext) -> anyhow::Result { if args.no_wait { - return Ok(start_pending_login(&context)?.into()); + return Ok(Outcome::data(start_pending_login(&context)?)); } if args.complete { complete_pending_login(&context, args.timeout)?; @@ -307,19 +302,18 @@ pub fn handle_command(args: LoginArgs, context: CliContext) -> anyhow::Result anyhow::Result { let app_session = context.app_session()?; let was_logged_in = app_session.stored()?.is_some(); @@ -21,8 +19,7 @@ pub fn handle_command(context: CliContext) -> anyhow::Result { "Already logged out." }); - Ok(LoggedOut { + Ok(Outcome::data(LoggedOut { logged_out: was_logged_in, - } - .into()) + })) } diff --git a/crates/tracel-cli/src/commands/me.rs b/crates/tracel-cli/src/commands/me.rs index 4d93dc0..f12b463 100644 --- a/crates/tracel-cli/src/commands/me.rs +++ b/crates/tracel-cli/src/commands/me.rs @@ -39,7 +39,7 @@ pub fn handle_command(context: CliContext) -> anyhow::Result { email: user.email, namespace: user.namespace, environment: context.environment_name(), - api_url: context.get_api_endpoint(), + api_url: context.api_url().to_string(), } .into()) } diff --git a/crates/tracel-cli/src/commands/models.rs b/crates/tracel-cli/src/commands/models.rs index fd96a51..73b79bc 100644 --- a/crates/tracel-cli/src/commands/models.rs +++ b/crates/tracel-cli/src/commands/models.rs @@ -214,13 +214,6 @@ pub struct AliasRemoveArgs { pub alias: String, } -/// A version made by `push` or `promote`. -#[derive(Serialize)] -#[serde(transparent)] -struct NewVersion(ModelVersionResponse); - -impl Render for NewVersion {} - #[derive(Serialize)] struct ModelPulled { model: String, @@ -230,14 +223,6 @@ struct ModelPulled { bytes: u64, } -impl Render for ModelPulled {} - -#[derive(Serialize)] -#[serde(transparent)] -struct AliasSet(ModelAliasResponse); - -impl Render for AliasSet {} - #[derive(Serialize)] struct AliasRemoved { model: String, @@ -245,8 +230,6 @@ struct AliasRemoved { removed: bool, } -impl Render for AliasRemoved {} - impl Render for ModelListResponse { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { Table::new(["NAME", "VERSIONS", "LATEST", "ALIASES", "CREATED AT"]) @@ -459,7 +442,7 @@ fn validate_download_directory(directory: Option<&Path>, model: &str) -> anyhow: pub fn handle_command(args: ModelsArgs, context: CliContext) -> anyhow::Result { let project = resolve_namespace_project(&context)?.project; - let (namespace, name) = (&project.owner, &project.name); + let (namespace, name) = (&project.namespace, &project.name); let client = || get_client_and_login_if_needed(&context); match args.command { ModelsCommands::List => Ok(client()?.list_models(namespace, name)?.into()), @@ -505,9 +488,9 @@ pub fn handle_command(args: ModelsArgs, context: CliContext) -> anyhow::Result Ok(pull(args, &context, &project)?.into()), - ModelsCommands::Push(args) => Ok(push(args, &context, &project)?.into()), - ModelsCommands::Promote(args) => Ok(promote(args, &context, &project)?.into()), + ModelsCommands::Pull(args) => Ok(Outcome::data(pull(args, &context, &project)?)), + ModelsCommands::Push(args) => Ok(Outcome::data(push(args, &context, &project)?)), + ModelsCommands::Promote(args) => Ok(Outcome::data(promote(args, &context, &project)?)), ModelsCommands::Alias(args) => alias(args.command, &context, &project), } } @@ -517,7 +500,7 @@ fn pull( context: &CliContext, project: &TracelProject, ) -> anyhow::Result { - let (namespace, name) = (&project.owner, &project.name); + let (namespace, name) = (&project.namespace, &project.name); validate_download_directory(args.directory.as_deref(), &args.model)?; let client = get_client_and_login_if_needed(context)?; let map_error = |error| { @@ -572,8 +555,8 @@ fn push( args: PushArgs, context: &CliContext, project: &TracelProject, -) -> anyhow::Result { - let (namespace, name) = (&project.owner, &project.name); +) -> anyhow::Result { + let (namespace, name) = (&project.namespace, &project.name); validate_auto_create(args.auto_create, args.description.as_deref())?; let files = collect_push_files(&args.directory, context)?; let client = get_client_and_login_if_needed(context)?; @@ -636,7 +619,7 @@ fn push( upload.version, size(version.size) )); - Ok(NewVersion(version)) + Ok(version) } fn collect_push_files( @@ -654,8 +637,8 @@ fn promote( args: PromoteArgs, context: &CliContext, project: &TracelProject, -) -> anyhow::Result { - let (namespace, name) = (&project.owner, &project.name); +) -> anyhow::Result { + let (namespace, name) = (&project.namespace, &project.name); validate_auto_create(args.auto_create, args.description.as_deref())?; let client = get_client_and_login_if_needed(context)?; let artifacts = client @@ -723,7 +706,7 @@ fn promote( "Promoted artifact '{}' to '{}' v{}.", artifact.name, args.model, version.version )); - Ok(NewVersion(version)) + Ok(version) } fn alias( @@ -731,7 +714,7 @@ fn alias( context: &CliContext, project: &TracelProject, ) -> anyhow::Result { - let (namespace, name) = (&project.owner, &project.name); + let (namespace, name) = (&project.namespace, &project.name); match command { AliasCommands::List(args) => Ok(get_client_and_login_if_needed(context)? .list_model_aliases(namespace, name, &args.model) @@ -766,7 +749,7 @@ fn alias( "Alias '{}' of '{}' points to v{}.", alias.alias, args.model, alias.version )); - Ok(AliasSet(alias).into()) + Ok(Outcome::data(alias)) } AliasCommands::Remove(args) => { get_client_and_login_if_needed(context)? @@ -786,12 +769,11 @@ fn alias( "Removed alias '{}' from '{}'.", args.alias, args.model )); - Ok(AliasRemoved { + Ok(Outcome::data(AliasRemoved { model: args.model, alias: args.alias, removed: true, - } - .into()) + })) } } } diff --git a/crates/tracel-cli/src/commands/package.rs b/crates/tracel-cli/src/commands/package.rs index d669a29..ebf00a2 100644 --- a/crates/tracel-cli/src/commands/package.rs +++ b/crates/tracel-cli/src/commands/package.rs @@ -22,7 +22,7 @@ use crate::tools::build_driver::{self, BuildDriver}; use crate::tools::packager::{PackageEvent, package_workspace}; use crate::tools::project_context::ProjectContext; use crate::tools::{cargo, git, target}; -use crate::ui::{Outcome, Render, Unanswered, short_digest}; +use crate::ui::{Outcome, Unanswered, short_digest}; #[derive(Args, Debug)] pub struct PackageArgs { @@ -53,16 +53,15 @@ pub enum Mode { Source, } -/// An artifact prepared for upload: the publish request describing it, plus the -/// `(upload-url key, file path)` pairs whose bytes must be PUT to the presigned -/// URLs the server returns. -struct PreparedArtifact { +/// A code version ready to upload: its publish request, each file by the key of its +/// upload URL, and the targets it was built for. +struct PreparedCodeVersion { request: PublishArtifactRequest, uploads: Vec<(String, PathBuf)>, targets: Vec, } -/// A code version of the project. `uploaded` is false when it was already packaged. +/// The code version `package` made. `uploaded` is false when it already existed. #[derive(Serialize)] struct Packaged { namespace: String, @@ -74,8 +73,6 @@ struct Packaged { uploaded: bool, } -impl Render for Packaged {} - pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result { if args.mode.is_none() && !context.terminal().is_interactive() { return Err(CliError::new(ErrorKind::Usage, MODE_NEEDED) @@ -121,7 +118,7 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< .with_hint("Commit them, or pass --commit or --allow-dirty.") .into()); } - context.terminal().print_warning(dirty); + context.terminal().warning(dirty); if context.terminal().confirm( "Commit them first?", true, @@ -136,7 +133,7 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< let digest = git::get_last_commit_hash().context("Can't read the current commit")?; if git::is_repo_dirty()? { - context.terminal().print_warning(&format!( + context.terminal().warning(&format!( "Uncommitted changes are not part of code version {}.", short_digest(&digest) )); @@ -170,18 +167,25 @@ pub fn handle_command(args: PackageArgs, context: CliContext) -> anyhow::Result< .into()); } - let artifact = match mode { - Mode::Source => build_source_artifact(&context, &project)?, - Mode::Binary => build_binary_artifact(&context, &project, &args)?, + let code_version = match mode { + Mode::Source => prepare_source(&context, &project)?, + Mode::Binary => prepare_binaries(&context, &project, &args)?, }; - Ok(upload(&context, &client, &project, &digest, mode, artifact)?.into()) + Ok(Outcome::data(upload( + &context, + &client, + &project, + &digest, + mode, + code_version, + )?)) } -fn build_source_artifact( +fn prepare_source( context: &CliContext, project: &ProjectContext, -) -> anyhow::Result { +) -> anyhow::Result { let spinner = context.terminal().spinner("Packaging the workspace"); let reporter = spinner.clone(); let result = package_workspace( @@ -191,7 +195,7 @@ fn build_source_artifact( .context("Can't package the workspace")?; spinner.clear(); - Ok(PreparedArtifact { + Ok(PreparedCodeVersion { request: PublishArtifactRequest::Source { source: PublishSourceRequest { checksum: result.checksum, @@ -203,17 +207,17 @@ fn build_source_artifact( }) } -fn build_binary_artifact( +fn prepare_binaries( context: &CliContext, project: &ProjectContext, args: &PackageArgs, -) -> anyhow::Result { +) -> anyhow::Result { let host = target::host_target()?; let installed = target::installed_targets(); let selected = target::select_targets(context.terminal(), &args.targets, host, &installed)?; - // rustup preflight: offer to install any selected cross target whose std is missing. + // Offer to install the chosen cross targets whose standard library is missing. let missing: Vec<&str> = selected .iter() .filter(|&&(os, arch)| (os, arch) != host) @@ -237,7 +241,7 @@ fn build_binary_artifact( }; let linker = if is_host { - context.terminal().print_warning(&format!( + context.terminal().warning(&format!( "The binary runs only on {} compute providers.", target::pretty_name(os, arch) )); @@ -263,7 +267,7 @@ fn build_binary_artifact( uploads.push((triple.to_string(), path)); } - Ok(PreparedArtifact { + Ok(PreparedCodeVersion { request: PublishArtifactRequest::Binaries { binaries }, uploads, targets: selected @@ -273,8 +277,8 @@ fn build_binary_artifact( }) } -/// Run the release build with `driver` (optionally for a cross `--target`) and return -/// the path to the produced executable (prompting if the build produced more than one). +/// Build the release binary with `driver`, for `target` when cross-building, and return +/// its path, asking which one when the build makes several. fn build_release_binary( context: &CliContext, target: Option<&str>, @@ -372,14 +376,14 @@ fn upload( project: &ProjectContext, digest: &str, mode: Mode, - prepared: PreparedArtifact, + prepared: PreparedCodeVersion, ) -> anyhow::Result { - let bc_project = project.get_project(); + let linked = project.get_project(); let response = client .publish_project_version_urls( - &bc_project.owner, - &bc_project.name, + &linked.namespace, + &linked.name, PublishProjectVersionRequest { digest: digest.to_string(), artifact: prepared.request, @@ -388,13 +392,13 @@ fn upload( .with_context(|| { format!( "Can't start the upload to {}/{}", - bc_project.owner, bc_project.name + linked.namespace, linked.name ) })?; let packaged = |uploaded| Packaged { - namespace: bc_project.owner.clone(), - project: bc_project.name.clone(), + namespace: linked.namespace.clone(), + project: linked.name.clone(), digest: digest.to_string(), version_id: response.id.clone(), mode, @@ -423,11 +427,11 @@ fn upload( spinner.clear(); client - .complete_project_version_upload(&bc_project.owner, &bc_project.name, &response.id) + .complete_project_version_upload(&linked.namespace, &linked.name, &response.id) .with_context(|| { format!( "Can't finish the upload to {}/{}", - bc_project.owner, bc_project.name + linked.namespace, linked.name ) })?; diff --git a/crates/tracel-cli/src/commands/project.rs b/crates/tracel-cli/src/commands/project.rs index 7fb5574..eddc7f8 100644 --- a/crates/tracel-cli/src/commands/project.rs +++ b/crates/tracel-cli/src/commands/project.rs @@ -9,8 +9,8 @@ use tracel_client::console::project::response::ProjectResponse; use crate::commands::login::get_client_and_login_if_needed; use crate::context::CliContext; use crate::error::{CliError, ErrorKind}; -use crate::helpers::project::{ProjectSource, missing_project}; use crate::helpers::resolve_namespace_project; +use crate::helpers::{ProjectSource, missing_project}; use crate::ui::{Details, Human, Outcome, Render, Table, list_summary, when}; #[derive(Args, Debug)] @@ -149,17 +149,17 @@ fn show_project(context: CliContext) -> anyhow::Result { let resolved = resolve_namespace_project(&context)?; let client = get_client_and_login_if_needed(&context)?; - let bc_project = &resolved.project; - let project = match client.get_project(&bc_project.owner, &bc_project.name) { + let selected = &resolved.project; + let project = match client.get_project(&selected.namespace, &selected.name) { Ok(project) => project, Err(e) if e.is_not_found() => { - return Err(missing_project(bc_project, &resolved.source).into()); + return Err(missing_project(selected, &resolved.source).into()); } Err(e) => { return Err(e).with_context(|| { format!( "Can't read project {}/{}", - bc_project.owner, bc_project.name + selected.namespace, selected.name ) }); } diff --git a/crates/tracel-cli/src/commands/training.rs b/crates/tracel-cli/src/commands/training.rs index a177482..0a013c8 100644 --- a/crates/tracel-cli/src/commands/training.rs +++ b/crates/tracel-cli/src/commands/training.rs @@ -1,9 +1,7 @@ use anyhow::Context; use clap::Parser; -use tracel_client::console::Env; - -use crate::{context::CliContext, tools::cargo, ui::Outcome}; +use crate::{context::CliContext, environment, tools::cargo, ui::Outcome}; #[derive(Parser, Debug, Default)] pub struct TrainingArgs { @@ -21,17 +19,15 @@ pub fn handle_command(args: TrainingArgs, context: CliContext) -> anyhow::Result Ok(Outcome::streamed()) } -/// Run `cargo run` in the current directory, forwarding `forwarded` after `--`. -/// -/// `tracel train -- entrypoint` is equivalent to `cargo run -- entrypoint`: stdio is -/// inherited and the program's exit code is the command's. -pub fn run_cargo(forwarded: &[String], context: CliContext) -> anyhow::Result<()> { +/// Run `cargo run` in this directory with `forwarded` after `--`. The program gets +/// tracel's stdio, and its exit code is tracel's. +fn run_cargo(forwarded: &[String], context: CliContext) -> anyhow::Result<()> { let mut cmd = cargo::command(); cmd.arg("run"); - cmd.env("TRACEL_ENV", tracel_env_value(&context.environment())); + cmd.env("TRACEL_ENV", environment::sdk_value(&context.environment())); if let Some(project) = context.project() { - cmd.env("TRACEL_NAMESPACE", &project.owner); + cmd.env("TRACEL_NAMESPACE", &project.namespace); cmd.env("TRACEL_PROJECT", &project.name); } @@ -62,15 +58,3 @@ fn hand_over(mut cmd: std::process::Command) -> anyhow::Result<()> { Ok(()) } - -/// The value the SDK expects in the `TRACEL_ENV` environment variable. -/// -/// The SDK parses this string with an explicit match (see `discover_env` in the -/// `tracel-core` cloud backend). This needs to match. -fn tracel_env_value(env: &Env) -> String { - match env { - Env::Production => "Production".to_string(), - Env::Development => "Development".to_string(), - Env::Staging(version) => format!("Staging({version})"), - } -} diff --git a/crates/tracel-cli/src/commands/unlink.rs b/crates/tracel-cli/src/commands/unlink.rs index e590761..45e489f 100644 --- a/crates/tracel-cli/src/commands/unlink.rs +++ b/crates/tracel-cli/src/commands/unlink.rs @@ -5,7 +5,7 @@ use crate::{ context::CliContext, helpers::require_linked_project, tools::project_context::ProjectContext, - ui::{Declined, Outcome, Render, Unanswered}, + ui::{Declined, Outcome, Unanswered}, }; #[derive(Args, Debug)] @@ -23,12 +23,10 @@ struct Unlinked { name: String, } -impl Render for Unlinked {} - pub fn handle_command(args: UnlinkArgs, context: CliContext) -> anyhow::Result { let project = require_linked_project()?; let linked = project.get_project(); - let name = format!("{}/{}", linked.owner, linked.name); + let name = format!("{}/{}", linked.namespace, linked.name); if !args.yes { let confirmed = context.terminal().confirm( @@ -47,10 +45,9 @@ pub fn handle_command(args: UnlinkArgs, context: CliContext) -> anyhow::Result Result { let store = FileSessionStore::for_server(&self.environment.get_url())?; Ok(AppSession::new(Arc::new(store), self.device_auth())) } - /// `TRACEL_API_KEY` if set, else the login stored for this environment's server. - fn credentials(&self) -> Result, ClientError> { - if let Ok(credentials) = TracelCredentials::from_env() { - return Ok(Some(credentials)); - } - - let app_session = self.app_session()?; - Ok(app_session - .stored()? - .map(|_| TracelCredentials::app_session(app_session))) + /// Find the credential commands use: `TRACEL_API_KEY` when it is set, else the + /// stored login. + pub fn credentials(&self) -> Result, ClientError> { + first_credentials(TracelCredentials::from_env().ok(), || { + let app_session = self.app_session()?; + Ok(app_session + .stored()? + .map(|_| TracelCredentials::app_session(app_session))) + }) } + /// Connect to the Tracel Console with the credential commands use. pub fn create_client(&self) -> Result { let credentials = self .credentials() @@ -83,57 +87,48 @@ impl CliContext { ClientError::Unauthenticated | ClientError::AppSessionEnded => { ClientCreationError::InvalidCredentials } - _ => ClientCreationError::ServerConnectionError(self.get_api_endpoint()), + _ => ClientCreationError::ServerConnectionError(self.environment.get_url()), }) } - /// The web Console of the environment. - pub fn get_frontend_endpoint(&self) -> Url { - web_console(&self.environment) + pub fn web_console(&self) -> Url { + environment::web_console(&self.environment) } pub fn terminal(&self) -> &Terminal { &self.terminal } - pub fn environment(&self) -> Env { - self.environment.clone() - } - - /// Stdout, for commands that stream their output while they run. + /// Return stdout, for commands that stream their output while they run. pub fn output(&self) -> &Output { &self.output } - pub fn project(&self) -> Option<&TracelProject> { - self.project.as_ref() + pub fn environment(&self) -> Env { + self.environment.clone() } pub fn environment_name(&self) -> String { - environment_name(&self.environment) + environment::name(&self.environment) } - pub fn get_api_endpoint(&self) -> String { - self.environment.get_url().to_string() + /// Return the project `--project` names, if any. + pub fn project(&self) -> Option<&TracelProject> { + self.project.as_ref() } -} -/// The web Console of an environment: production and staging serve it beside their -/// API, and a development server serves it on port 9000. -fn web_console(environment: &Env) -> Url { - let url = match environment { - Env::Production => "https://console.tracel.ai/".to_string(), - Env::Staging(version) => format!("https://s{version}-console.tracel.ai/"), - Env::Development => "http://localhost:9000/".to_string(), - }; - Url::parse(&url).expect("a web Console URL is valid") + pub fn api_url(&self) -> Url { + self.environment.get_url() + } } -/// The name of an environment, as `TRACEL_ENV` and JSON data write it. -pub fn environment_name(environment: &Env) -> String { - match environment { - Env::Production => "production".to_string(), - Env::Development => "development".to_string(), - Env::Staging(version) => format!("staging-{version}"), +/// Prefer `api_key`, and read the stored login only without one. +fn first_credentials( + api_key: Option, + stored_login: impl FnOnce() -> Result, ClientError>, +) -> Result, ClientError> { + match api_key { + Some(api_key) => Ok(Some(api_key)), + None => stored_login(), } } diff --git a/crates/tracel-cli/src/environment.rs b/crates/tracel-cli/src/environment.rs new file mode 100644 index 0000000..bc992f2 --- /dev/null +++ b/crates/tracel-cli/src/environment.rs @@ -0,0 +1,119 @@ +//! The Tracel Console environments a command can target, and how each one is written. + +use tracel_client::console::Env; +use url::Url; + +use crate::error::{CliError, ErrorKind}; + +/// Choose the environment `--dev` or `--staging` selects, else the one `TRACEL_ENV` +/// names, else production. +pub fn resolve(dev: bool, staging: Option, variable: Option<&str>) -> Result { + if dev { + return Ok(Env::Development); + } + if let Some(version) = staging { + return Ok(Env::Staging(version)); + } + match variable { + None => Ok(Env::Production), + Some(value) => parse(value).ok_or_else(|| { + CliError::new(ErrorKind::Usage, format!("Unknown TRACEL_ENV '{value}'.")) + }), + } +} + +/// Name an environment as JSON data and the terminal show it: "production", +/// "development" or "staging-N". +pub fn name(environment: &Env) -> String { + match environment { + Env::Production => "production".to_string(), + Env::Development => "development".to_string(), + Env::Staging(version) => format!("staging-{version}"), + } +} + +/// Write an environment as the SDK reads `TRACEL_ENV`: "Production", "Development" or +/// "Staging(N)". +pub fn sdk_value(environment: &Env) -> String { + match environment { + Env::Production => "Production".to_string(), + Env::Development => "Development".to_string(), + Env::Staging(version) => format!("Staging({version})"), + } +} + +/// Locate an environment's web Console. A development server serves it on port 9000, +/// beside its API on 9001. +pub fn web_console(environment: &Env) -> Url { + let url = match environment { + Env::Production => "https://console.tracel.ai/".to_string(), + Env::Staging(version) => format!("https://s{version}-console.tracel.ai/"), + Env::Development => "http://localhost:9000/".to_string(), + }; + Url::parse(&url).expect("a web Console URL is valid") +} + +/// Read an environment written as `name` or `sdk_value` writes it. +fn parse(value: &str) -> Option { + match value { + "production" | "Production" => Some(Env::Production), + "development" | "Development" => Some(Env::Development), + _ => value + .strip_prefix("staging-") + .or_else(|| value.strip_prefix("Staging(")?.strip_suffix(')')) + .and_then(|version| version.parse().ok()) + .map(Env::Staging), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn environment_values_match_sdk_and_lowercase_forms() { + for value in [None, Some("Production"), Some("production")] { + assert!(matches!(resolve(false, None, value), Ok(Env::Production))); + } + for value in ["Development", "development"] { + assert!(matches!( + resolve(false, None, Some(value)), + Ok(Env::Development) + )); + } + for version in [0, 1, 255] { + for value in [format!("Staging({version})"), format!("staging-{version}")] { + assert!(matches!( + resolve(false, None, Some(&value)), + Ok(Env::Staging(number)) if number == version + )); + } + } + for value in [ + "bogus", + "", + "Staging()", + "Staging(1", + "staging-", + "staging--1", + "Staging(256)", + "staging-256", + "Production ", + ] { + let error = resolve(false, None, Some(value)).unwrap_err(); + assert_eq!(error.kind, ErrorKind::Usage); + assert!(error.to_string().contains("TRACEL_ENV")); + } + } + + #[test] + fn environment_flags_override_variable() { + for value in [None, Some("Production"), Some("Development"), Some("bogus")] { + assert!(matches!(resolve(true, None, value), Ok(Env::Development))); + assert!(matches!( + resolve(false, Some(2), value), + Ok(Env::Staging(2)) + )); + } + } +} diff --git a/crates/tracel-cli/src/error.rs b/crates/tracel-cli/src/error.rs index 3a3c26a..3032ff7 100644 --- a/crates/tracel-cli/src/error.rs +++ b/crates/tracel-cli/src/error.rs @@ -80,8 +80,8 @@ impl<'a> ErrorReport<'a> { } } -/// `text` ending as a sentence does, as an error chain such as "Can't read 'a': No -/// such file or directory (os error 2)" does not. +/// End `text` with a period unless it already ends a sentence. An error chain such as +/// "Can't read 'a': No such file or directory (os error 2)" ends without one. fn sentence(text: String) -> String { let text = text.trim_end(); if text.ends_with(['.', '!', '?']) { @@ -278,7 +278,9 @@ mod tests { ErrorKind::NotAuthenticated, ), ( - ClientCreationError::ServerConnectionError("offline".into()), + ClientCreationError::ServerConnectionError( + url::Url::parse("http://localhost:9001/").unwrap(), + ), ErrorKind::Unavailable, ), ( diff --git a/crates/tracel-cli/src/helpers/download.rs b/crates/tracel-cli/src/helpers/download.rs index bf8a0b7..ecc02c6 100644 --- a/crates/tracel-cli/src/helpers/download.rs +++ b/crates/tracel-cli/src/helpers/download.rs @@ -10,6 +10,7 @@ use tracel_client::console::Client; use crate::error::{CliError, ErrorKind}; use crate::ui::{Terminal, counted, short_digest}; +/// A file to download into a directory, with the size and checksum to check it against. pub struct DownloadFile { pub rel_path: String, pub url: String, @@ -17,6 +18,7 @@ pub struct DownloadFile { pub checksum: Option, } +/// A downloaded file: where it went and how many bytes it has. #[derive(Debug, Serialize)] pub struct DownloadResult { pub rel_path: String, @@ -24,7 +26,8 @@ pub struct DownloadResult { pub bytes: u64, } -pub fn validate_rel_path(rel_path: &str) -> Result<(), CliError> { +/// Fail unless `rel_path` is a plain relative path that stays inside its directory. +pub fn validate_rel_path(rel_path: &str) -> anyhow::Result<()> { let path = Path::new(rel_path); let windows_prefix = rel_path.as_bytes().get(1) == Some(&b':'); if rel_path.is_empty() @@ -35,10 +38,7 @@ pub fn validate_rel_path(rel_path: &str) -> Result<(), CliError> { .components() .all(|part| matches!(part, Component::Normal(_))) { - return Err(CliError::new( - ErrorKind::Internal, - format!("Unsafe download path '{rel_path}'."), - )); + anyhow::bail!("Unsafe download path '{rel_path}'."); } Ok(()) } @@ -76,16 +76,14 @@ impl HashingWriter { } } - fn verify(&self, file: &DownloadFile) -> Result<(), CliError> { + fn verify(&self, file: &DownloadFile) -> anyhow::Result<()> { if let Some(size) = file.size_bytes.filter(|size| *size != 0) { if self.bytes != size { - return Err(CliError::new( - ErrorKind::Internal, - format!( - "Size mismatch for '{}': expected {size} bytes, received {}.", - file.rel_path, self.bytes - ), - )); + anyhow::bail!( + "Size mismatch for '{}': expected {size} bytes, received {}.", + file.rel_path, + self.bytes + ); } } if let Some(checksum) = file @@ -95,15 +93,12 @@ impl HashingWriter { { let actual = format!("{:x}", self.hasher.clone().finalize()); if !actual.eq_ignore_ascii_case(checksum) { - return Err(CliError::new( - ErrorKind::Internal, - format!( - "Checksum mismatch for '{}': expected {}, received {}.", - file.rel_path, - short_digest(checksum), - short_digest(&actual) - ), - )); + anyhow::bail!( + "Checksum mismatch for '{}': expected {}, received {}.", + file.rel_path, + short_digest(checksum), + short_digest(&actual) + ); } } Ok(()) @@ -123,6 +118,8 @@ impl Write for HashingWriter { } } +/// Download `files` into `directory` under a spinner, after checking every path and, +/// unless `force`, that none of them exists. pub fn download_files( client: &Client, terminal: &Terminal, @@ -207,7 +204,7 @@ mod tests { "a\0b", ] { let error = validate_rel_path(path).unwrap_err(); - assert_eq!(error.kind, ErrorKind::Internal); + assert_eq!(crate::error::classify(&error), ErrorKind::Internal); assert!(error.to_string().contains(path)); } } @@ -247,14 +244,11 @@ mod tests { assert!(writer.verify(&file(Some(0), Some(String::new()))).is_ok()); assert!(writer.verify(&file(None, None)).is_ok()); assert_eq!( - writer.verify(&file(Some(10), None)).unwrap_err().kind, + crate::error::classify(&writer.verify(&file(Some(10), None)).unwrap_err()), ErrorKind::Internal ); assert_eq!( - writer - .verify(&file(None, Some("bad".into()))) - .unwrap_err() - .kind, + crate::error::classify(&writer.verify(&file(None, Some("bad".into()))).unwrap_err()), ErrorKind::Internal ); } diff --git a/crates/tracel-cli/src/helpers/mod.rs b/crates/tracel-cli/src/helpers/mod.rs index 3c7814a..cbca47d 100644 --- a/crates/tracel-cli/src/helpers/mod.rs +++ b/crates/tracel-cli/src/helpers/mod.rs @@ -1,16 +1,15 @@ -pub mod model_upload; -pub mod project; - -pub use model_upload::{build_part_tasks, ensure_model_exists, upload_parts}; -pub use project::{ - can_initialize_project, require_cargo_workspace, require_linked_project, - resolve_namespace_project, validate_project_exists_on_server, -}; - mod download; +mod model_upload; +mod project; mod resources; pub use download::{DownloadFile, DownloadResult, download_files, validate_rel_path}; +pub use model_upload::{build_part_tasks, ensure_model_exists, upload_parts}; +pub use project::{ + ProjectSource, can_initialize_project, missing_project, parse_project, require_cargo_workspace, + require_linked_project, resolve_namespace_project, resolve_project, + validate_project_exists_on_server, +}; pub use resources::{ Resource, map_resource_error, parse_metadata, select_artifact, validate_auto_create, }; diff --git a/crates/tracel-cli/src/helpers/model_upload/mod.rs b/crates/tracel-cli/src/helpers/model_upload/mod.rs index 6397bec..c6c1b56 100644 --- a/crates/tracel-cli/src/helpers/model_upload/mod.rs +++ b/crates/tracel-cli/src/helpers/model_upload/mod.rs @@ -1,5 +1,5 @@ -//! Helpers for `tracel models push`: model existence check, and -//! multi-threaded fail-fast multipart part upload. +//! Uploading a model version: making sure the model exists, and uploading its parts in +//! parallel. mod existence; mod parts; diff --git a/crates/tracel-cli/src/helpers/model_upload/parts.rs b/crates/tracel-cli/src/helpers/model_upload/parts.rs index 8268842..df3e1a6 100644 --- a/crates/tracel-cli/src/helpers/model_upload/parts.rs +++ b/crates/tracel-cli/src/helpers/model_upload/parts.rs @@ -3,6 +3,8 @@ use std::path::PathBuf; use tracel_client::console::artifact::response::PresignedUploadUrlResponse; use tracel_client::console::model::response::PresignedModelFileUploadUrlsResponse; +/// One part of a file to upload: where its bytes are in the file, and the URL to upload +/// them to. #[derive(Clone, Debug)] pub struct PartUploadTask { pub rel_path: String, @@ -43,6 +45,8 @@ fn validate_part_total_size( Ok(()) } +/// Split each file of an upload response into its parts, checking that they number from +/// 1 and cover the file. pub fn build_part_tasks( files: &BTreeMap, file_sizes: &BTreeMap, diff --git a/crates/tracel-cli/src/helpers/model_upload/upload.rs b/crates/tracel-cli/src/helpers/model_upload/upload.rs index 2310d0e..3ef4445 100644 --- a/crates/tracel-cli/src/helpers/model_upload/upload.rs +++ b/crates/tracel-cli/src/helpers/model_upload/upload.rs @@ -13,6 +13,7 @@ use super::parts::PartUploadTask; const UPLOAD_WORKER_COUNT: usize = 8; +/// Uploads one part's bytes to its presigned URL. pub trait PartUploader: Send + Sync { fn upload(&self, url: &str, bytes: Vec) -> Result<(), ClientError>; } @@ -35,7 +36,7 @@ fn read_part_bytes(path: &Path, offset: u64, size: u64) -> anyhow::Result, worker_count: usize) -> Vec> { if tasks.is_empty() { return Vec::new(); @@ -56,9 +57,8 @@ fn chunk_tasks(tasks: Vec, worker_count: usize) -> Vec, uploader: &impl PartUploader, @@ -94,6 +94,8 @@ fn upload_chunk( Ok(()) } +/// Upload `tasks` with up to 8 workers under one progress bar, stopping at the first +/// failure. pub fn upload_parts( uploader: &impl PartUploader, tasks: Vec, @@ -134,9 +136,7 @@ pub fn upload_parts( std::thread::sleep(Duration::from_millis(50)); } - // Each worker thread's Result is joined explicitly here (rather than relying on - // thread::scope's implicit join-on-exit), so a worker panic is turned into a clean - // anyhow::Error instead of unwinding past the error-reporting below. + // Join each worker here, so a panic becomes an error instead of unwinding. handles .into_iter() .filter_map(|handle| match handle.join() { diff --git a/crates/tracel-cli/src/helpers/project.rs b/crates/tracel-cli/src/helpers/project.rs index 3e3a25f..1b7aa23 100644 --- a/crates/tracel-cli/src/helpers/project.rs +++ b/crates/tracel-cli/src/helpers/project.rs @@ -1,4 +1,4 @@ -//! Project helpers for CLI operations +//! Finding the workspace and the project a command works on. use anyhow::Context; use serde::{Deserialize, Serialize}; @@ -15,7 +15,7 @@ use crate::{ }; use tracel_client::console::Client; -pub fn find_manifest() -> anyhow::Result { +fn find_manifest() -> anyhow::Result { try_locate_manifest().ok_or_else(|| { CliError::new(CliErrorKind::Usage, "Not in a Cargo workspace.") .with_hint("Run it in your project's directory, or pass -C .") @@ -23,8 +23,7 @@ pub fn find_manifest() -> anyhow::Result { }) } -/// Check if current directory has a linked Tracel Console project -pub fn is_tracel_project_linked() -> bool { +fn is_tracel_project_linked() -> bool { let manifest_path = find_manifest(); match manifest_path { Err(_) => false, @@ -32,7 +31,7 @@ pub fn is_tracel_project_linked() -> bool { } } -/// One error that says what went wrong, why, and what to do next. +/// Turn a workspace error into one that says what to do next. fn explain_project_context_error(e: ProjectContextError) -> anyhow::Error { let (kind, hint) = match e.kind() { ErrorKind::ManifestNotFound => ( @@ -48,7 +47,7 @@ fn explain_project_context_error(e: ProjectContextError) -> anyhow::Error { CliErrorKind::Internal, Some("Link it again with `tracel init --force`."), ), - ErrorKind::Unexpected => (CliErrorKind::Internal, None), + ErrorKind::Unexpected => return e.into(), }; let mut message = e.to_string(); @@ -65,7 +64,7 @@ fn explain_project_context_error(e: ProjectContextError) -> anyhow::Error { } } -/// Require a linked Tracel Console project. +/// Load the workspace and the project it is linked to. pub fn require_linked_project() -> anyhow::Result { let manifest_path = find_manifest()?; ProjectContext::load(&manifest_path).map_err(explain_project_context_error) @@ -75,7 +74,7 @@ pub fn parse_project(value: &str) -> Result { if let Some((namespace, project)) = value.split_once('/') { if !namespace.is_empty() && !project.is_empty() && !project.contains('/') { return Ok(TracelProject { - owner: namespace.to_owned(), + namespace: namespace.to_owned(), name: project.to_owned(), }); } @@ -126,14 +125,14 @@ fn project_not_found() -> CliError { .with_hint("Run `tracel init`, or pass --project /.") } -/// The error for a project the Tracel Console doesn't have, with a hint that fits how -/// it was selected. +/// Build the error for a project the Tracel Console doesn't have, with a hint that fits +/// how it was selected. pub fn missing_project(project: &TracelProject, source: &ProjectSource) -> CliError { let error = CliError::new( CliErrorKind::NotFound, format!( "No project {}/{} on the Tracel Console.", - project.owner, project.name + project.namespace, project.name ), ); match source { @@ -146,12 +145,13 @@ pub fn missing_project(project: &TracelProject, source: &ProjectSource) -> CliEr } } -/// Resolve project identity from flags, environment variables, or tracel.toml. +/// Find the project a command works on. pub fn resolve_namespace_project(context: &CliContext) -> anyhow::Result { resolve_project(context.project()) } -/// The project `flag` names, or else the one the environment or `tracel.toml` names. +/// Find the project `flag` names, else the one TRACEL_NAMESPACE and TRACEL_PROJECT +/// name, else `tracel.toml`'s. The variables each override their key. pub fn resolve_project(flag: Option<&TracelProject>) -> anyhow::Result { resolve_project_from( flag, @@ -211,7 +211,7 @@ fn resolve_project_from( Ok(ResolvedProject { project: TracelProject { - owner: namespace + namespace: namespace .or(config.namespace) .ok_or_else(project_not_found)?, name: project.or(config.project).ok_or_else(project_not_found)?, @@ -220,14 +220,14 @@ fn resolve_project_from( }) } -/// Require a Cargo workspace (with or without Tracel Console linkage) +/// Load the Cargo workspace around the current directory, linked or not. pub fn require_cargo_workspace() -> anyhow::Result { let manifest_path = find_manifest()?; ProjectContext::load_workspace_info(&manifest_path).map_err(explain_project_context_error) } -/// Whether `tracel init` should go ahead: fails outside a Rust project, and is -/// false when the project is already linked and `force` is off. +/// Decide whether `tracel init` links: not when the workspace is linked and `force` is +/// off. Fails outside a workspace. pub fn can_initialize_project(force: bool) -> anyhow::Result { find_manifest()?; Ok(force || !is_tracel_project_linked()) @@ -239,11 +239,11 @@ pub fn validate_project_exists_on_server( source: &ProjectSource, client: &Client, ) -> anyhow::Result<()> { - match client.get_project(&project.owner, &project.name) { + match client.get_project(&project.namespace, &project.name) { Ok(_) => Ok(()), Err(e) if e.is_not_found() => Err(missing_project(project, source).into()), Err(e) => { - Err(e).with_context(|| format!("Can't look up {}/{}", project.owner, project.name)) + Err(e).with_context(|| format!("Can't look up {}/{}", project.namespace, project.name)) } } } @@ -255,7 +255,7 @@ mod tests { #[test] fn project_flag_requires_two_nonempty_parts() { let project = parse_project("alice/demo").unwrap(); - assert_eq!(project.owner, "alice"); + assert_eq!(project.namespace, "alice"); assert_eq!(project.name, "demo"); for value in [ "", @@ -282,7 +282,7 @@ mod tests { || panic!("File discovery must be skipped"), ) .unwrap(); - assert_eq!(resolved.project.owner, "alice"); + assert_eq!(resolved.project.namespace, "alice"); assert_eq!(resolved.project.name, "demo"); assert_eq!(resolved.source, ProjectSource::Flag); @@ -291,7 +291,7 @@ mod tests { panic!("File discovery must be skipped") }) .unwrap(); - assert_eq!(resolved.project.owner, "alice"); + assert_eq!(resolved.project.namespace, "alice"); assert_eq!(resolved.project.name, "demo"); assert_eq!(resolved.source, ProjectSource::Env); } @@ -327,7 +327,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(resolved.project.owner, expected_namespace); + assert_eq!(resolved.project.namespace, expected_namespace); assert_eq!(resolved.project.name, expected_project); assert_eq!(resolved.source, source); } @@ -347,7 +347,7 @@ mod tests { || Ok(toml::from_str(contents).unwrap()), ) .unwrap(); - assert_eq!(resolved.project.owner, "alice"); + assert_eq!(resolved.project.namespace, "alice"); assert_eq!(resolved.project.name, "demo"); } } diff --git a/crates/tracel-cli/src/helpers/resources.rs b/crates/tracel-cli/src/helpers/resources.rs index 9249925..8b09423 100644 --- a/crates/tracel-cli/src/helpers/resources.rs +++ b/crates/tracel-cli/src/helpers/resources.rs @@ -3,6 +3,7 @@ use tracel_client::{ApiErrorCode, ClientError}; use crate::error::{CliError, ErrorKind}; +/// What a command looked for on the Tracel Console, to name it when it is missing. #[derive(Clone, Copy)] pub enum Resource<'a> { Model(&'a str), @@ -13,6 +14,8 @@ pub enum Resource<'a> { Dataset(&'a str), } +/// Turn the Tracel Console's "not found" for `resource` into an error that names it and +/// how to list what exists. Other errors stay as they are. pub fn map_resource_error( error: ClientError, namespace: &str, @@ -26,11 +29,8 @@ pub fn map_resource_error( | Resource::ModelVersionRef { model, .. } | Resource::ModelVersion { model, .. } | Resource::ModelAlias { model, .. }, - ) => ( - format!("No model '{model}' in {namespace}/{project}."), - "List models with `tracel models list`.".to_string(), - ), - ( + ) + | ( ClientError::NotFound | ClientError::NotFoundWithCode(ApiErrorCode::Unknown), Resource::Model(model), ) => ( @@ -78,6 +78,7 @@ pub fn map_resource_error( .into() } +/// Read `--metadata`, which must be a JSON object. pub fn parse_metadata(value: &str) -> Result { let metadata: Value = serde_json::from_str(value).map_err(|error| { CliError::new(ErrorKind::Usage, format!("--metadata isn't JSON: {error}")) @@ -91,6 +92,7 @@ pub fn parse_metadata(value: &str) -> Result { Ok(metadata) } +/// Fail when `--description` comes without `--auto-create true`. pub fn validate_auto_create( auto_create: Option, description: Option<&str>, @@ -104,6 +106,8 @@ pub fn validate_auto_create( Ok(()) } +/// Find the artifact `value` names among `(id, name)` pairs: by id first, else by a +/// name only one artifact has. pub fn select_artifact<'a>( items: impl IntoIterator, value: &str, diff --git a/crates/tracel-cli/src/lib.rs b/crates/tracel-cli/src/lib.rs index b9c3c86..2a4176d 100644 --- a/crates/tracel-cli/src/lib.rs +++ b/crates/tracel-cli/src/lib.rs @@ -1,6 +1,7 @@ mod cli; mod commands; mod context; +mod environment; mod error; mod helpers; mod tools; diff --git a/crates/tracel-cli/src/tools/build_driver.rs b/crates/tracel-cli/src/tools/build_driver.rs index 467913d..058c43a 100644 --- a/crates/tracel-cli/src/tools/build_driver.rs +++ b/crates/tracel-cli/src/tools/build_driver.rs @@ -17,16 +17,16 @@ use crate::ui::Terminal; /// How to drive the build of a given target. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum BuildDriver { - /// Plain `cargo build` — host builds and same-OS cross-arch builds. + /// `cargo build`, for the host and for another architecture of its OS. Cargo, - /// `cargo zigbuild` — cross-OS Linux/macOS via Zig as the C compiler/linker. + /// `cargo zigbuild`, for Linux and macOS from another OS, with Zig as the linker. Zigbuild, - /// `cargo xwin build` — cross-OS Windows-MSVC. + /// `cargo xwin build`, for Windows MSVC from another OS. Xwin, } impl BuildDriver { - /// Human-readable command name, used in progress and error messages. + /// The command the driver runs, as messages show it. pub fn label(self) -> &'static str { match self { BuildDriver::Cargo => "cargo build", @@ -35,7 +35,7 @@ impl BuildDriver { } } - /// The subcommand arguments appended to `cargo` (before `--release`). + /// The arguments after `cargo` that start the build. pub fn subcommand_args(self) -> &'static [&'static str] { match self { BuildDriver::Cargo => &["build"], @@ -59,7 +59,7 @@ pub fn detect() -> AvailableDrivers { } } -/// Whether `bin --version` runs successfully (the binary exists and is executable). +/// Whether `bin --version` runs. fn subcommand_installed(bin: &str) -> bool { Command::new(bin) .arg("--version") @@ -70,12 +70,8 @@ fn subcommand_installed(bin: &str) -> bool { .unwrap_or(false) } -/// Pick the build driver for `target` when building on `host`. -/// -/// Same-OS builds (host, or cross-arch within the same OS) use plain `cargo build`. -/// Cross-OS builds need a specialized driver: Windows-MSVC uses `cargo-xwin` only -/// (Zig cannot target the MSVC ABI), Linux/macOS use `cargo-zigbuild`. When the -/// needed driver is absent we fall back to `Cargo`, which will surface a clear error. +/// Pick the driver that builds `target` on `host`: cargo within one OS, and cargo-xwin +/// or cargo-zigbuild across, else cargo, whose build then fails to link. pub fn choose(host: (Os, Arch), target: (Os, Arch), avail: &AvailableDrivers) -> BuildDriver { if host.0 == target.0 { return BuildDriver::Cargo; @@ -98,8 +94,7 @@ pub fn choose(host: (Os, Arch), target: (Os, Arch), avail: &AvailableDrivers) -> } } -/// Actionable hint when no suitable cross-OS driver is installed and we fall back to -/// plain `cargo build` (which will almost certainly fail at link). +/// The hint to install the cross-build tool `target` needs. pub fn install_hint(target: (Os, Arch)) -> &'static str { match target.0 { Os::Windows => "Install it with `cargo install --locked cargo-xwin`, and LLVM.", @@ -109,9 +104,8 @@ pub fn install_hint(target: (Os, Arch)) -> &'static str { } } -/// Prepare to cross-build `target` with `driver`, returning the cross-linker the caller -/// should inject into the build command (same-OS cargo builds only), or `None` when no -/// per-build linker is needed. Also prints which driver/toolchain a cross-OS build relies on. +/// Prepare to build `target` with `driver`, saying what the build relies on, and return +/// the linker a cross build within one OS needs. pub fn cross_preflight( terminal: &Terminal, root: &Path, @@ -124,27 +118,27 @@ pub fn cross_preflight( BuildDriver::Cargo if host.0 == target.0 => { // Same-OS cross-arch: a `target..linker` setting (Linux) is enough. let linker = linker::resolve_linker(terminal, root, host, target); - terminal.print_warning(&format!( + terminal.warning(&format!( "Building {triple} with plain cargo, which may fail to link." )); Ok(linker) } BuildDriver::Cargo => { - // Cross-OS with no suitable driver installed — plain cargo build won't link. - terminal.print_warning(&format!( + // Across operating systems without a driver, plain cargo won't link. + terminal.warning(&format!( "No cross-build tool for {triple}, so the build will likely fail.\n{}", install_hint(target) )); Ok(None) } BuildDriver::Zigbuild => { - terminal.print(&format!( + terminal.info(&format!( "Building {triple} with cargo zigbuild, which needs Zig." )); Ok(None) } BuildDriver::Xwin => { - terminal.print(&format!( + terminal.info(&format!( "Building {triple} with cargo xwin, which needs LLVM." )); Ok(None) @@ -184,7 +178,7 @@ mod tests { #[test] fn cross_os_windows_uses_xwin_only() { assert_eq!(choose(LINUX_X86, WIN_X86, &both()), BuildDriver::Xwin); - // Zig can't target MSVC, so without xwin we fall back to cargo (not zigbuild). + // Zig can't target MSVC, so without xwin it falls back to cargo, not zigbuild. let only_zig = AvailableDrivers { zigbuild: true, xwin: false, diff --git a/crates/tracel-cli/src/tools/cargo.rs b/crates/tracel-cli/src/tools/cargo.rs index da019b6..0c238df 100644 --- a/crates/tracel-cli/src/tools/cargo.rs +++ b/crates/tracel-cli/src/tools/cargo.rs @@ -5,7 +5,7 @@ use std::process::Stdio; use anyhow::Context; -/// The manifest of the Cargo workspace around the current directory, if any. +/// Find the manifest of the Cargo workspace around the current directory. pub fn try_locate_manifest() -> Option { let output = command() .arg("locate-project") @@ -19,12 +19,12 @@ pub fn try_locate_manifest() -> Option { location["root"].as_str().map(PathBuf::from) } -/// Retrieve the command to run cargo as define by the CARGO environment variable or default to "cargo" +/// Start a cargo command, with the cargo `CARGO` names when it is set. pub fn command() -> std::process::Command { std::process::Command::new(std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"))) } -/// A cargo build that ran with cargo's own output kept back, as [`build_quietly`] runs it. +/// A finished cargo build whose own output was kept back. pub struct QuietBuild { label: String, succeeded: bool, @@ -37,12 +37,13 @@ impl QuietBuild { self.succeeded } - /// Prints cargo's output on stderr, as it would have printed it. + /// Print cargo's output on stderr, as cargo would have. pub fn show_output(&self) { let _ = io::stderr().write_all(&self.output); } - /// The executables the build made, as `(target name, path)` pairs, or why it failed. + /// List the executables the build made as `(target name, path)` pairs, or fail if + /// the build did. pub fn executables(self) -> anyhow::Result> { if !self.succeeded { anyhow::bail!("`{}` failed.", self.label); @@ -51,9 +52,8 @@ impl QuietBuild { } } -/// Run `command`, a cargo build named `label` in messages, keeping cargo's own output back: -/// each of its status lines, such as `Compiling trainer v0.1.0`, goes to `status` as the -/// build runs. +/// Run `command`, a cargo build named `label` in errors, keeping cargo's output back. +/// Each status line, such as `Compiling trainer v0.1.0`, goes to `status` as it comes. pub fn build_quietly( mut command: std::process::Command, label: &str, @@ -94,7 +94,7 @@ pub fn build_quietly( }) } -/// A line of cargo's output that says what it is doing, such as `Compiling trainer v0.1.0`, +/// Find what cargo says it is doing in `line`, such as `Compiling trainer v0.1.0`, /// without the path it may end with. fn status_line(line: &str) -> Option { const STATUSES: [&str; 6] = [ @@ -118,7 +118,7 @@ fn status_line(line: &str) -> Option { Some(line.to_string()) } -/// The executables in cargo's JSON build messages, as `(target name, path)` pairs. +/// List the executables in cargo's JSON build messages. fn executables(messages: &[u8]) -> anyhow::Result> { let mut executables = Vec::new(); for line in messages.split(|&b| b == b'\n') { diff --git a/crates/tracel-cli/src/tools/event.rs b/crates/tracel-cli/src/tools/event.rs index 8e5246f..d019a75 100644 --- a/crates/tracel-cli/src/tools/event.rs +++ b/crates/tracel-cli/src/tools/event.rs @@ -1,4 +1,4 @@ -/// Trait for reporting events of type `E`. +/// A receiver of events of type `E`. pub trait Reporter: Send + Sync { fn report_event(&self, event: E); } diff --git a/crates/tracel-cli/src/tools/fs.rs b/crates/tracel-cli/src/tools/fs.rs index aa59178..2d445f5 100644 --- a/crates/tracel-cli/src/tools/fs.rs +++ b/crates/tracel-cli/src/tools/fs.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use std::io::Read; use std::path::{Path, PathBuf}; +/// List the files under `directory` by their relative path, written with `/`. pub fn collect_files(directory: &Path) -> anyhow::Result> { let base_dir = std::fs::canonicalize(directory) .with_context(|| format!("Can't find directory '{}'", directory.display()))?; @@ -39,6 +40,7 @@ pub fn collect_files(directory: &Path) -> anyhow::Result anyhow::Result<(String, u64)> { Ok((format!("{:x}", hasher.finalize()), size)) } +/// Measure and hash each of `files`. pub fn build_file_specs(files: &BTreeMap) -> anyhow::Result> { let mut specs = Vec::with_capacity(files.len()); diff --git a/crates/tracel-cli/src/tools/git.rs b/crates/tracel-cli/src/tools/git.rs index 7a1674e..23b573f 100644 --- a/crates/tracel-cli/src/tools/git.rs +++ b/crates/tracel-cli/src/tools/git.rs @@ -9,7 +9,7 @@ pub fn is_repo_dirty() -> anyhow::Result { Ok(repo.is_dirty()?) } -/// The hash of the current HEAD commit. Used as the code-version digest. +/// Read the hash of the HEAD commit, which identifies a code version. pub fn get_last_commit_hash() -> anyhow::Result { let repo = gix::discover(".")?; let last_commit = repo.head()?.peel_to_commit()?.id(); diff --git a/crates/tracel-cli/src/tools/linker.rs b/crates/tracel-cli/src/tools/linker.rs index 29a078d..55f773b 100644 --- a/crates/tracel-cli/src/tools/linker.rs +++ b/crates/tracel-cli/src/tools/linker.rs @@ -1,11 +1,7 @@ -//! Cross-linker helpers for binary upload. -//! -//! `rustup target add` installs only the prebuilt std, not a linker. For the -//! predictable Linux same-OS cross-arch case we resolve a cross-linker and inject it -//! into the single build command via cargo's `--config target..linker` flag, -//! leaving the project's `.cargo/config.toml` untouched. -//! Cross-OS targets (Windows/macOS from Linux, etc.) need a whole toolchain, not just -//! a linker entry — those are handled by a build driver (see `tools::build_driver`). +//! The linker of a Linux build for another architecture. `rustup target add` installs a +//! target's standard library but no linker, so the build gets one through cargo's +//! `--config target..linker`, and the project's cargo config stays as it is. +//! A build for another OS needs a whole toolchain, which a build driver supplies. use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -16,12 +12,11 @@ use tracel_client::console::project::request::{Arch, Os}; use crate::tools::target::target_triple; use crate::ui::Terminal; -/// What (if anything) is needed to link `target` while running on `host`. -pub enum LinkerNeed { - /// Native, a same-OS cross-arch the toolchain handles itself (macOS/Windows), or a - /// cross-OS target (whose toolchain is supplied by the build driver, not a linker entry). +/// What linking `target` on `host` needs. +enum LinkerNeed { + /// Nothing: the toolchain or the build driver links it. None, - /// Linux same-OS cross-arch: a `target..linker` setting we inject per-build. + /// A `target..linker` setting for the build, on Linux. ConfigEntry { linker: &'static str, install_hint: &'static str, @@ -29,7 +24,7 @@ pub enum LinkerNeed { } /// Classify what linker setup `target` needs when building on `host`. -pub fn linker_need(host: (Os, Arch), target: (Os, Arch)) -> LinkerNeed { +fn linker_need(host: (Os, Arch), target: (Os, Arch)) -> LinkerNeed { if host == target { return LinkerNeed::None; } @@ -55,13 +50,12 @@ pub fn linker_need(host: (Os, Arch), target: (Os, Arch)) -> LinkerNeed { }; } - // Different OS: a linker entry isn't enough — a build driver (cargo-xwin / - // cargo-zigbuild) supplies the toolchain instead. See `tools::build_driver`. + // For another OS, a build driver supplies the toolchain. LinkerNeed::None } -/// Read the project's cargo config, preferring `.cargo/config.toml` over the legacy -/// `.cargo/config`. Returns the path it was read from and its contents. +/// Read the project's cargo config, `.cargo/config.toml` before the older +/// `.cargo/config`, as its path and contents. fn read_config(root: &Path) -> Option<(PathBuf, String)> { for name in [".cargo/config.toml", ".cargo/config"] { let path = root.join(name); @@ -81,10 +75,9 @@ fn linker_env_var(triple: &str) -> String { ) } -/// Whether a linker for `triple` is already configured — either via a -/// `target..linker` entry in the project's cargo config, or the -/// `CARGO_TARGET__LINKER` environment variable. -pub fn linker_configured(root: &Path, triple: &str) -> bool { +/// Whether `triple` already has a linker, from the project's cargo config or +/// `CARGO_TARGET__LINKER`. +fn linker_configured(root: &Path, triple: &str) -> bool { if std::env::var_os(linker_env_var(triple)).is_some() { return true; } @@ -100,8 +93,8 @@ pub fn linker_configured(root: &Path, triple: &str) -> bool { false } -/// Whether `bin` can be executed (used to decide whether to also print an install hint). -pub fn linker_on_path(bin: &str) -> bool { +/// Whether `bin` runs from PATH. +fn linker_on_path(bin: &str) -> bool { Command::new(bin) .arg("--version") .stdout(Stdio::null()) @@ -111,9 +104,8 @@ pub fn linker_on_path(bin: &str) -> bool { .unwrap_or(false) } -/// Resolve the cross-linker to inject for a same-OS cross-arch build that needs one -/// (Linux), or `None` when the toolchain handles it natively or the user has already -/// configured a linker for `target`. +/// Choose the linker to give a Linux build for another architecture, unless its +/// toolchain needs none or the user set one. pub fn resolve_linker( terminal: &Terminal, root: &Path, @@ -127,13 +119,11 @@ pub fn resolve_linker( linker: linker_bin, install_hint, } => { - // The user already set a linker for this target (cargo config or env var); - // respect their choice and don't override it. if linker_configured(root, triple) { return None; } if !linker_on_path(linker_bin) { - terminal.print_warning(&format!("No linker {linker_bin} on PATH.\n{install_hint}")); + terminal.warning(&format!("No linker {linker_bin} on PATH.\n{install_hint}")); } Some(linker_bin) } diff --git a/crates/tracel-cli/src/tools/packager.rs b/crates/tracel-cli/src/tools/packager.rs index 91aeb08..e963919 100644 --- a/crates/tracel-cli/src/tools/packager.rs +++ b/crates/tracel-cli/src/tools/packager.rs @@ -1,8 +1,4 @@ -//! Workspace packaging functionality for Tracel Console -//! -//! This module provides functionality to package an entire workspace as a single compressed archive, -//! respecting gitignore rules. This is used to upload workspace projects to Tracel Console while -//! maintaining backwards compatibility with the single-crate API. +//! Packaging the workspace's source as one compressed archive, without what git ignores. use std::{ fs::File, @@ -17,6 +13,7 @@ use sha2::Sha256; use crate::tools::event::Reporter; use crate::ui::{counted, size}; +/// A packaged archive: where it is, its SHA-256 checksum and its size. #[derive(Debug)] pub struct ArchiveMetadata { pub path: PathBuf, @@ -24,15 +21,14 @@ pub struct ArchiveMetadata { pub size: u64, } +/// A step of packaging, shown as it happens. pub struct PackageEvent { pub message: String, } type PackageEventReporter = dyn Reporter; -/// Package the entire workspace as a single compressed archive with gitignore applied. -/// -/// Returns a `PackageResult` containing the packaged workspace data and digest +/// Package the workspace as one compressed archive, without what git ignores. pub fn package_workspace( workspace_name: &str, event_reporter: Arc, @@ -55,8 +51,7 @@ pub fn package_workspace( ), }); - // Write the archive under the cargo target directory, the idiomatic home for - // build artifacts (kept out of the archive itself by the `target/` exclusion). + // The archive goes in the target directory, which it leaves out. let output_dir = metadata .target_directory .as_std_path() @@ -66,10 +61,8 @@ pub fn package_workspace( std::fs::create_dir_all(&output_dir)?; - // Create archive at tarcel/package/{workspace_name}.tar.gz let archive_file = File::create(&archive_path)?; - // Organize files inside the archive (when uncompressed) it will be inside a directory named `{workspace_name}/` to match the standard cargo crate format. let uncompressed_size = create_workspace_archive(&workspace_root, &files, &archive_file, workspace_name)?; @@ -84,14 +77,13 @@ pub fn package_workspace( }) } -/// Lists all files in the workspace that should be packaged, respecting gitignore rules. +/// List the workspace files to package, without what git ignores. fn list_workspace_files(workspace_root: &Path) -> anyhow::Result> { let git_repo = discover_gix_repo(workspace_root)?; - // Build gitignore matcher let mut exclude_builder = ignore::gitignore::GitignoreBuilder::new(workspace_root); - // Add default excludes if not using git + // Without git, leave out hidden files. if git_repo.is_none() { exclude_builder.add_line(None, ".*")?; } @@ -112,25 +104,22 @@ fn list_workspace_files(workspace_root: &Path) -> anyhow::Result> { } } - // Check gitignore rules !ignore_exclude .matched_path_or_any_parents(relative_path, is_dir) .is_ignore() }; - // Use git if available, otherwise walk the filesystem if let Some(repo) = git_repo { list_files_gix(workspace_root, &repo, &filter) } else { let mut files = Vec::new(); - list_files_walk(workspace_root, &mut files, true, &filter)?; + list_files_walk(workspace_root, &mut files, &filter)?; Ok(files) } } -/// Creates a compressed tar.gz archive of the workspace files. -/// -/// All files are prefixed with `{package_prefix}/` to match the standard cargo crate format. +/// Write `files` to `dst` as a tar.gz archive, each under `{package_prefix}/` as in +/// cargo's crate archives, and return their total size. fn create_workspace_archive( workspace_root: &Path, files: &[PathBuf], @@ -155,7 +144,6 @@ fn create_workspace_archive( header.set_metadata_in_mode(&metadata, tar::HeaderMode::Deterministic); header.set_cksum(); - // Prefix all paths with {name}-{version}/ to match cargo crate format let prefixed_path = PathBuf::from(package_prefix).join(relative_path); ar.append_data(&mut header, &prefixed_path, &mut file)?; uncompressed_size += metadata.len(); @@ -168,7 +156,7 @@ fn create_workspace_archive( Ok(uncompressed_size) } -/// Discovers a git repository starting from the given path. +/// Find the git repository whose work tree holds `root`, if any. fn discover_gix_repo(root: &Path) -> anyhow::Result> { let repo = match gix::ThreadSafeRepository::discover(root) { Ok(repo) => repo.to_thread_local(), @@ -179,7 +167,6 @@ fn discover_gix_repo(root: &Path) -> anyhow::Result> { anyhow::format_err!("The git repository at {} is bare.", repo.path().display()) })?; - // Verify the repository contains the workspace root let canon_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); let canon_repo_root = repo_root .canonicalize() @@ -192,7 +179,7 @@ fn discover_gix_repo(root: &Path) -> anyhow::Result> { } } -/// Lists files using git to respect .gitignore rules. +/// List the files git tracks or doesn't ignore, through nested repositories. fn list_files_gix( workspace_root: &Path, repo: &gix::Repository, @@ -219,13 +206,12 @@ fn list_files_gix( if !is_dir { files.push(file_path); } else { - // Recursively walk directories match gix::open(&file_path) { Ok(sub_repo) => { files.extend(list_files_gix(workspace_root, &sub_repo, filter)?); } Err(_) => { - list_files_walk(&file_path, &mut files, false, filter)?; + list_files_walk(&file_path, &mut files, filter)?; } } } @@ -235,11 +221,10 @@ fn list_files_gix( Ok(files) } -/// Lists files by walking the filesystem (fallback when git is not available). +/// List the files under `path` that `filter` keeps, without git. fn list_files_walk( path: &Path, files: &mut Vec, - _is_root: bool, filter: &impl Fn(&Path, bool) -> bool, ) -> anyhow::Result<()> { if !path.is_dir() { diff --git a/crates/tracel-cli/src/tools/project_context.rs b/crates/tracel-cli/src/tools/project_context.rs index 14b64eb..b895654 100644 --- a/crates/tracel-cli/src/tools/project_context.rs +++ b/crates/tracel-cli/src/tools/project_context.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use crate::tools::{tracel_config::TracelProject, workspace::WorkspaceInfo}; +/// What went wrong with a workspace or its link. #[derive(Debug)] pub enum ErrorKind { ManifestNotFound, @@ -11,6 +12,7 @@ pub enum ErrorKind { Unexpected, } +/// An error about a workspace or its link. #[derive(thiserror::Error, Debug)] pub struct ProjectContextError { message: String, @@ -39,6 +41,7 @@ impl std::fmt::Display for ProjectContextError { } } +/// A workspace and the project it is linked to. pub struct ProjectContext { pub workspace_info: WorkspaceInfo, pub project: TracelProject, diff --git a/crates/tracel-cli/src/tools/target.rs b/crates/tracel-cli/src/tools/target.rs index 47f2377..1d46201 100644 --- a/crates/tracel-cli/src/tools/target.rs +++ b/crates/tracel-cli/src/tools/target.rs @@ -9,10 +9,8 @@ use tracel_client::console::project::request::{Arch, Os}; use crate::error::{CliError, ErrorKind}; use crate::ui::{Declined, Terminal, Unanswered}; -/// Every (os, arch) target we offer to build for, in canonical display order. -/// The host is surfaced separately and pulled to the front by `prompt_targets`. -/// macOS x86_64 (Intel) is intentionally omitted — we don't support it. -pub const ALL_TARGETS: [(Os, Arch); 5] = [ +/// The targets packaging builds for, in display order. macOS on Intel isn't one. +const ALL_TARGETS: [(Os, Arch); 5] = [ (Os::Linux, Arch::X86_64), (Os::Linux, Arch::Arm64), (Os::Macos, Arch::Arm64), @@ -33,6 +31,7 @@ pub fn target_triple(os: Os, arch: Arch) -> &'static str { } } +/// Read a `--target` triple. pub fn parse_target(triple: &str) -> Result<(Os, Arch), CliError> { ALL_TARGETS .iter() @@ -65,6 +64,8 @@ fn resolve_targets(host: (Os, Arch), requested: &[String]) -> Result &'static str { match (os, arch) { (Os::Linux, Arch::X86_64) => "Linux x86_64", @@ -108,11 +109,8 @@ pub fn host_target() -> anyhow::Result<(Os, Arch)> { Ok((os, arch)) } -/// Triples reported by `rustup target list --installed`. -/// -/// Returns an empty set on any failure (rustup missing, non-zero exit, bad UTF-8), -/// which conservatively makes every *cross* target appear "not installed". The host -/// target never depends on this set — a host build needs no rustup target. +/// List the targets `rustup target list --installed` reports, none when rustup fails, +/// so that every cross target then counts as missing. pub fn installed_targets() -> HashSet { let output = Command::new("rustup") .arg("target") @@ -131,9 +129,9 @@ pub fn installed_targets() -> HashSet { } } -/// Install a target's prebuilt std with `rustup target add `, streaming -/// output to the user's terminal. -pub fn add_target(triple: &str) -> anyhow::Result<()> { +/// Install a target's standard library with `rustup target add`, showing its output +/// on stderr. +fn add_target(triple: &str) -> anyhow::Result<()> { let status = Command::new("rustup") .arg("target") .arg("add") @@ -148,10 +146,9 @@ pub fn add_target(triple: &str) -> anyhow::Result<()> { Ok(()) } -/// Prompt the user to select one or more targets to build for. The host is listed -/// first and labelled "(this machine)"; cross targets not installed via rustup are -/// dimmed and annotated with the `rustup target add` command to install them. -pub fn prompt_targets( +/// Ask which targets to build, the host first. Targets rustup hasn't installed are +/// dimmed. +fn prompt_targets( terminal: &Terminal, host: (Os, Arch), installed: &HashSet, @@ -196,6 +193,8 @@ pub fn prompt_targets( ) } +/// Install the `missing` targets with rustup when `install_targets` says so or the user +/// agrees. pub fn install_missing_target( terminal: &Terminal, missing: Vec<&str>, diff --git a/crates/tracel-cli/src/tools/tracel_config.rs b/crates/tracel-cli/src/tools/tracel_config.rs index 8b2b6e6..6fe18f8 100644 --- a/crates/tracel-cli/src/tools/tracel_config.rs +++ b/crates/tracel-cli/src/tools/tracel_config.rs @@ -2,23 +2,21 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::{fs, io}; -/// Project identity persisted to `tracel.toml` at the workspace root. -/// -/// The on-disk keys are `namespace`/`project` to match the Tracel SDK's reader; -/// `owner`/`name` are accepted as aliases for backwards compatibility. The Rust -/// field names stay `owner`/`name` so call sites read `.owner` / `.name`. +/// A project on the Tracel Console, as `tracel.toml` at the workspace root names it. +/// The file's keys are `namespace` and `project`, as the SDK reads them, and the older +/// `owner` and `name` still read. #[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct TracelProject { - #[serde(rename = "namespace", alias = "owner")] - pub owner: String, + #[serde(alias = "owner")] + pub namespace: String, #[serde(rename = "project", alias = "name")] pub name: String, } impl TracelProject { - pub const FILENAME: &'static str = "tracel.toml"; + const FILENAME: &'static str = "tracel.toml"; - /// Path to the `tracel.toml` for the given workspace root. + /// Locate `tracel.toml` in `workspace_root`. pub fn path(workspace_root: &Path) -> PathBuf { workspace_root.join(Self::FILENAME) } @@ -29,8 +27,7 @@ impl TracelProject { fs::write(Self::path(workspace_root), contents) } - /// Load the project from `workspace_root/tracel.toml`, or `None` if the file - /// does not exist. + /// Load `tracel.toml` from `workspace_root`, or `None` when there is none. pub fn load(workspace_root: &Path) -> io::Result> { let path = Self::path(workspace_root); if !path.exists() { diff --git a/crates/tracel-cli/src/tools/workspace.rs b/crates/tracel-cli/src/tools/workspace.rs index 61fc76c..fe3c0a8 100644 --- a/crates/tracel-cli/src/tools/workspace.rs +++ b/crates/tracel-cli/src/tools/workspace.rs @@ -34,7 +34,6 @@ impl WorkspaceInfo { let workspace_root = metadata.workspace_root.clone().into_std_path_buf(); - // Determine workspace name from workspace Cargo.toml let workspace_toml_path = workspace_root.join("Cargo.toml"); if !workspace_toml_path.exists() { return Err(ProjectContextError::new( @@ -76,10 +75,7 @@ impl WorkspaceInfo { } } -/// Extract workspace name from TOML content -/// Returns None if: -/// - TOML content cannot be parsed -/// - Neither workspace.package.name nor package.name exists +/// Read the name in `[workspace.package]`, else in `[package]`. fn extract_workspace_name_from_toml(toml_content: &str) -> Option { let workspace_document = toml_content.parse::().ok()?; diff --git a/crates/tracel-cli/src/ui/format.rs b/crates/tracel-cli/src/ui/format.rs index 4b3a9a7..c96381b 100644 --- a/crates/tracel-cli/src/ui/format.rs +++ b/crates/tracel-cli/src/ui/format.rs @@ -18,8 +18,8 @@ pub enum Format { } impl Format { - /// `--json`, then `--output`, then `TRACEL_OUTPUT`. `auto` and no choice at all - /// mean `auto`, which the command decides. + /// Choose the format from `--json`, then `--output`, then `TRACEL_OUTPUT`. `auto`, or + /// no choice, leaves it to the command. pub fn resolve( flag: Option, json: bool, @@ -50,7 +50,7 @@ impl Format { }) } - /// Human output for a person at a terminal, JSON for a program reading stdout. + /// Choose human output for a terminal and JSON for a program reading stdout. pub fn for_stdout(stdout_is_terminal: bool) -> Self { if stdout_is_terminal { Self::Human diff --git a/crates/tracel-cli/src/ui/human.rs b/crates/tracel-cli/src/ui/human.rs index 1e47488..1e32d88 100644 --- a/crates/tracel-cli/src/ui/human.rs +++ b/crates/tracel-cli/src/ui/human.rs @@ -7,9 +7,9 @@ use serde_json::Value; /// The narrowest a shrinkable table column gets, ellipsis included. const MIN_COLUMN_WIDTH: usize = 4; -/// A result as text for a person: plain, as stdout carries it, or styled for the box a -/// terminal shows it in. Cards, headings and tables are written through `Details`, -/// `heading` and `Table`; other text with `write!`. +/// A writer of a result's text: plain on stdout, or styled for its box on a terminal. +/// Fields, headings and tables go through `Details`, `heading` and `Table`, and other +/// text through `write!`. pub struct Human<'a> { out: &'a mut dyn Write, width: Option, @@ -17,7 +17,7 @@ pub struct Human<'a> { } impl<'a> Human<'a> { - /// Plain text on `out`. Tables fit in `width` columns, when there is one. + /// Write plain text on `out`, with tables fitted to `width` columns when given. pub fn plain(out: &'a mut dyn Write, width: Option) -> Self { Self { out, @@ -26,8 +26,8 @@ impl<'a> Human<'a> { } } - /// Text for a box on the terminal, styled unless stderr shows no colors. - pub fn boxed(out: &'a mut dyn Write, width: Option) -> Self { + /// Write text for a box on the terminal, styled unless stderr shows no colors. + pub fn for_box(out: &'a mut dyn Write, width: Option) -> Self { Self { out, width, @@ -57,25 +57,25 @@ impl Human<'_> { self.styled(text, console::Style::new().dim()) } - /// Names what follows: a title ending in a colon. + /// Write a title for what follows, ending in a colon. pub fn heading(&mut self, text: impl Display) -> io::Result<()> { writeln!(self, "{text}:") } - /// One field of a card, its value aligned after labels `width` wide. + /// Write one field, its value aligned after labels `width` wide. fn field(&mut self, label: &str, value: &str, width: usize) -> io::Result<()> { let padding = " ".repeat(width - label.len()); let label = self.dim(format!("{label}:")); writeln!(self, "{label}{padding} {value}") } - /// Lines under a heading, indented. + /// Write lines indented under a heading. fn section(&mut self, title: &str, lines: &[String]) -> io::Result<()> { self.heading(title)?; lines.iter().try_for_each(|line| writeln!(self, " {line}")) } - /// Lines that belong together, such as a table. + /// Write lines that belong together, such as a table. fn block(&mut self, text: &str) -> io::Result<()> { self.write_all(text.as_bytes()) } @@ -91,10 +91,9 @@ impl Write for Human<'_> { } } -/// Columns aligned under their headers, one row per line. When the width is too -/// narrow for it, the columns marked with `shrink` are shortened to fit. Without -/// rows it writes nothing, or its `empty` text: a list says it has none in its -/// summary. +/// Columns aligned under their headers, one row per line. Columns marked with `shrink` +/// shorten to fit a narrow width. Without rows it writes its `empty` text, or nothing +/// when a list's summary says it has none. pub struct Table { headers: [&'static str; N], rows: Vec<[String; N]>, @@ -112,7 +111,7 @@ impl Table { } } - /// Stands in for the rows when there are none, for a table inside other text. + /// Show `text` when there are no rows, for a table inside other text. pub fn empty(mut self, text: &'static str) -> Self { self.empty = Some(text); self @@ -123,9 +122,8 @@ impl Table { self } - /// Lets the column with this header be shortened, for free text such as a - /// description. Columns holding what other commands take, such as names and - /// ids, stay whole. + /// Let the column with this header shorten, for free text such as a description. + /// Columns of values that other commands take, such as names, stay whole. pub fn shrink(mut self, header: &'static str) -> Self { let column = self.headers.iter().position(|&name| name == header); debug_assert!(column.is_some(), "no column named {header}"); @@ -162,19 +160,19 @@ impl Table { } let header = - std::array::from_fn(|column| out.bold(fitted(self.headers[column], widths[column]))); + std::array::from_fn(|column| out.bold(truncate(self.headers[column], widths[column]))); let mut text = line(&header, &widths); for row in &rows { let cells = std::array::from_fn(|column| match row[column].as_str() { "-" => out.dim("-"), - cell => fitted(cell, widths[column]), + cell => truncate(cell, widths[column]), }); text.push_str(&line(&cells, &widths)); } out.block(&text) } - /// Narrows the widest shrinkable columns until the table fits in `available` + /// Narrow the widest shrinkable columns until the table fits in `available` /// columns or nothing can shrink further. fn fit(&self, widths: &mut [usize; N], available: usize) { let gaps = 2 * N.saturating_sub(1); @@ -190,7 +188,7 @@ impl Table { } } -/// One row of a table: each cell but the last padded to its column, then a gap. +/// A row of a table: each cell but the last padded to its column, then a gap. fn line(cells: &[String; N], widths: &[usize; N]) -> String { let mut line = String::new(); for (column, cell) in cells.iter().enumerate() { @@ -204,7 +202,7 @@ fn line(cells: &[String; N], widths: &[usize; N]) -> String { line } -/// Labelled values, aligned. Sections list the fields of a JSON value under a +/// Labelled values, aligned, and sections that list the fields of a JSON value under a /// heading. #[derive(Default)] pub struct Details { @@ -233,8 +231,8 @@ impl Details { } } - /// The fields of `value` under `title`, nested keys joined with dots. An empty or - /// null value adds nothing. + /// Add the fields of `value` under `title`, nested keys joined with dots. An empty + /// or null value adds nothing. pub fn section(mut self, title: &'static str, value: &impl Serialize) -> Self { let value = serde_json::to_value(value).unwrap_or(Value::Null); let mut fields = Vec::new(); @@ -262,7 +260,7 @@ impl Details { for entry in &self.entries { match entry { Entry::Field(label, value) => { - let value = shown(out, value); + let value = styled_cell(out, value); out.field(label, &value, width)?; } Entry::Section(title, fields) => { @@ -271,7 +269,7 @@ impl Details { .iter() .map(|(key, value)| { let padding = " ".repeat(width - key.len()); - format!("{}{padding} {}", out.dim(key), shown(out, value)) + format!("{}{padding} {}", out.dim(key), styled_cell(out, value)) }) .collect(); out.section(title, &lines)?; @@ -282,7 +280,7 @@ impl Details { } } -/// A count of things, such as "1 file" or "3 files". +/// Count things in words: "1 file" or "3 files". pub fn counted(count: u64, [one, many]: [&str; 2]) -> String { match count { 1 => format!("1 {one}"), @@ -290,7 +288,7 @@ pub fn counted(count: u64, [one, many]: [&str; 2]) -> String { } } -/// The summary of a list: "Showing 10 of 16 experiments." when some are left out, +/// Sum up a list: "Showing 10 of 16 experiments." when some are left out, /// "16 experiments." when not, and `none` when there are none. pub fn list_summary(shown: usize, total: u64, forms: [&str; 2], none: &str) -> String { let shown = shown as u64; @@ -301,12 +299,12 @@ pub fn list_summary(shown: usize, total: u64, forms: [&str; 2], none: &str) -> S } } -/// The first 12 characters of a digest, enough to tell code and model versions apart. +/// Shorten a digest to 12 characters, enough to tell versions apart. pub fn short_digest(digest: &str) -> &str { digest.get(..12).unwrap_or(digest) } -/// A size in bytes, as "512 B", "6.9 KB" or "713.7 MB". +/// Write a size in bytes as "512 B", "6.9 KB" or "713.7 MB". pub fn size(bytes: u64) -> String { const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"]; if bytes < 1000 { @@ -321,8 +319,8 @@ pub fn size(bytes: u64) -> String { format!("{} {}", number(value), UNITS[unit]) } -/// A number to about 4 significant digits, as "0.75" for 0.7499999999999999. Large -/// numbers keep their integer digits. +/// Round a number to about 4 significant digits, as "0.75" for 0.7499999999999999, +/// keeping all the integer digits of a large one. pub fn number(value: f64) -> String { if value == 0.0 || !value.is_finite() { return value.to_string(); @@ -358,16 +356,16 @@ fn flatten(prefix: &str, value: &Value, fields: &mut Vec<(String, String)>) { } } -/// A value on one line, with a dim "-" standing in for nothing. -fn shown(out: &Human<'_>, value: &str) -> String { +/// Put a value on one line, with a dim "-" for nothing. +fn styled_cell(out: &Human<'_>, value: &str) -> String { match cell_text(value) { text if text == "-" => out.dim(text), text => text, } } -/// `text` cut to `width` columns with an ellipsis, when it is wider. -fn fitted(text: &str, width: usize) -> String { +/// Cut `text` to `width` columns with an ellipsis, when it is wider. +fn truncate(text: &str, width: usize) -> String { if console::measure_text_width(text) <= width { return text.to_string(); } @@ -375,7 +373,7 @@ fn fitted(text: &str, width: usize) -> String { format!("{}…", kept.trim_end()) } -/// A value on one line, with "-" standing in for nothing. +/// Put a value on one line, with "-" for nothing. fn cell_text(value: &str) -> String { let text = value.split_whitespace().collect::>().join(" "); if text.is_empty() { "-".into() } else { text } @@ -399,7 +397,7 @@ mod tests { /// Text for a box, styled. fn boxed(width: Option, write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> String { let mut out = Vec::new(); - let mut human = Human::boxed(&mut out, width); + let mut human = Human::for_box(&mut out, width); human.styled = true; write(&mut human).unwrap(); String::from_utf8(out).unwrap() diff --git a/crates/tracel-cli/src/ui/layout.rs b/crates/tracel-cli/src/ui/layout.rs index 9b4b3b8..e4d0027 100644 --- a/crates/tracel-cli/src/ui/layout.rs +++ b/crates/tracel-cli/src/ui/layout.rs @@ -1,13 +1,14 @@ use std::fmt::{self, Display}; -/// How a command shows on a terminal: a read is a box, a flow is a session. +/// How a command shows on a terminal: a read in a box, a flow in a session. pub enum Layout { - /// A command that reads: its result in one box, under its summary. + /// A command that reads, its result in one box under its summary. Read, - /// A command that does work: a session that first says what it does, such as - /// "Run job 'train'", and where. + /// A command that does work, in a session that first says what it does, such as + /// "Upload a version of model 'weights'", and where. Flow(String), - /// Nothing drawn: the output is a bare value, or another program's. + /// A command whose output is a bare value or another program's, with nothing + /// drawn. Bare, } @@ -17,8 +18,7 @@ impl Layout { } } -/// Where a command works: the project it uses, and its environment unless that is -/// production. +/// Where a command works: its project, and its environment unless that is production. #[derive(Default)] pub struct Place { pub project: Option, diff --git a/crates/tracel-cli/src/ui/mod.rs b/crates/tracel-cli/src/ui/mod.rs index ed3f567..b50c29e 100644 --- a/crates/tracel-cli/src/ui/mod.rs +++ b/crates/tracel-cli/src/ui/mod.rs @@ -1,9 +1,7 @@ -//! What the person running a command sees. On a terminal, a read draws its result in -//! one box, and a flow, a command that does work, draws a session: what it does and -//! where, its progress and prompts through `Terminal`, and its outcome. What a command -//! streams is written as it comes. Otherwise stdout carries the result and nothing -//! else, so it can be piped or captured: an envelope with JSON output, or the plain -//! text of its `Render` impl. +//! What a command shows the user. Results go to stdout: a JSON envelope, or the text +//! of their `Render` impl. Progress, prompts and errors go to stderr. On a terminal, +//! cliclack draws them: a read's result in one box, and a flow, a command that does +//! work, in a session that says what it does and ends on its outcome. mod format; mod human; @@ -15,6 +13,8 @@ mod time; use std::sync::Arc; +use screen::{Screen, Terminals}; + pub use format::{Format, FormatArg}; pub use human::{Details, Human, Table, counted, list_summary, number, short_digest, size}; pub use layout::{Layout, Place}; @@ -22,16 +22,16 @@ pub use stderr::{Declined, Terminal, Unanswered}; pub use stdout::{Outcome, Output, Render, StdoutClosed}; pub use time::{Timestamp, log_time, when}; -/// Stdout and stderr for one command, drawing on the same screen. A flow's session -/// opens on the terminal now, saying what it does and where. +/// Open stdout and stderr for one command, drawing on the same screen. A flow's session +/// opens now, saying what it does and where. pub fn channels( format: Format, no_input: bool, layout: Layout, place: Place, ) -> (Output, Terminal) { - let screen = Arc::new(screen::Screen::new(format, layout, place)); - screen.begin(); + let screen = Arc::new(Screen::new(format, layout, place, Terminals::detect())); + screen.intro(); ( Output::new(Arc::clone(&screen)), Terminal::new(screen, no_input), diff --git a/crates/tracel-cli/src/ui/screen.rs b/crates/tracel-cli/src/ui/screen.rs index c5b7b75..c6f640b 100644 --- a/crates/tracel-cli/src/ui/screen.rs +++ b/crates/tracel-cli/src/ui/screen.rs @@ -1,6 +1,6 @@ use std::io::{self, IsTerminal}; -use std::sync::Mutex; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Mutex, MutexGuard}; use cliclack::ProgressBar; @@ -11,10 +11,36 @@ use super::layout::{Layout, Place}; /// left, two spaces and its bar on the right. pub const BOX_PADDING: u16 = 6; -/// The terminal a command draws on. With human output on a styled stderr, cliclack -/// draws there: a flow's session and widgets, and a read's result when stdout is the -/// same terminal. Only one thing draws at a time: anything new settles the live widget -/// first. +/// Which of stderr and stdout are terminals. +#[derive(Clone, Copy)] +pub struct Terminals { + pub stderr: bool, + pub stdout: bool, +} + +impl Terminals { + pub fn detect() -> Self { + Self { + stderr: io::stderr().is_terminal(), + stdout: io::stdout().is_terminal(), + } + } +} + +/// Where a result goes. +#[derive(Debug, PartialEq, Eq)] +pub enum Delivery { + /// One box on the terminal, its text fitted to the columns inside the box. + Box(Option), + /// Stdout, which carries nothing else: an envelope, or plain text ending with the + /// summary. + Stdout, +} + +/// The terminal a command draws on, shared by its stdout and stderr. With human output +/// on a terminal, cliclack draws on stderr: a flow's session with its steps and +/// prompts, and a read's box when stdout is the same terminal. One thing draws at a +/// time, so anything new first settles the live spinner or bar. pub struct Screen { format: Format, styled: bool, @@ -26,35 +52,19 @@ pub struct Screen { next_id: AtomicU64, } -/// A spinner or progress bar that redraws itself until it is finished. +/// A spinner or bar that redraws itself until it ends. struct Live { id: u64, bar: ProgressBar, message: String, } -/// How a live widget ends: as a line with its message, or with nothing left. -#[derive(Clone, Copy)] -pub enum Ending { - Done, - Cleared, -} - -/// How a result reaches the person. -pub enum Delivery { - /// In one box on the terminal, its text fitted to the columns inside the box. - Box(Option), - /// On stdout, which carries nothing else: an envelope, or plain text ending with - /// its summary. - Stdout, -} - impl Screen { - pub fn new(format: Format, layout: Layout, place: Place) -> Self { + pub fn new(format: Format, layout: Layout, place: Place, terminals: Terminals) -> Self { Self { format, - styled: format == Format::Human && io::stderr().is_terminal(), - stdout_is_terminal: io::stdout().is_terminal(), + styled: format == Format::Human && terminals.stderr, + stdout_is_terminal: terminals.stdout, layout, place, session: AtomicBool::new(false), @@ -67,7 +77,7 @@ impl Screen { self.format } - /// Whether stderr draws with cliclack. + /// Whether cliclack draws on stderr. pub fn is_styled(&self) -> bool { self.styled } @@ -91,7 +101,7 @@ impl Screen { } } - /// The columns of the terminal stdout is, if it is one. + /// Measure stdout's width, when it is a terminal. pub fn stdout_width(&self) -> Option { if self.stdout_is_terminal { columns(&console::Term::stdout()) @@ -100,32 +110,38 @@ impl Screen { } } - /// Opens a flow's session: the tracel badge, then what the flow does and where. - pub fn begin(&self) { + /// Build the line under the tracel badge that opens a flow's session: what the flow + /// does and where. None when no session opens. + fn header(&self) -> Option { let Layout::Flow(what) = &self.layout else { - return; + return None; }; if !self.styled { - return; + return None; } - let mut what = what.clone(); + let mut header = what.clone(); if let Some(project) = &self.place.project { - what.push_str(&format!( - " in {}", - console::style(project).for_stderr().bold() - )); + let project = console::style(project).for_stderr().bold(); + header.push_str(&format!(" in {project}")); } if let Some(environment) = &self.place.environment { let environment = format!(" · {environment}"); - what.push_str(&console::style(environment).for_stderr().dim().to_string()); + header.push_str(&console::style(environment).for_stderr().dim().to_string()); } - let badge = console::style(" tracel ").for_stderr().black().on_green(); - let _ = cliclack::intro(badge); - let _ = cliclack::log::remark(what); - self.session.store(true, Ordering::Relaxed); + Some(header) } - /// Shows a read's result: `body` in one box under `summary` and the place, or the + /// Open a flow's session: the tracel badge, then what the flow does and where. + pub fn intro(&self) { + if let Some(header) = self.header() { + let badge = console::style(" tracel ").for_stderr().black().on_green(); + let _ = cliclack::intro(badge); + let _ = cliclack::log::remark(header); + self.session.store(true, Ordering::Relaxed); + } + } + + /// Show a read's result: `body` in one box under `summary` and the place, or the /// summary alone when there is no body. pub fn show_result(&self, summary: Option<&str>, body: &str) { let place = self.place.to_string(); @@ -157,105 +173,106 @@ impl Screen { }); } - /// Ends a flow's session with its outcome. - pub fn close(&self, outcome: &str) { + /// End a flow's session on its outcome. Nothing when no session is open. + pub fn outro(&self, outcome: &str) { if self.session.swap(false, Ordering::Relaxed) { self.draw(|| cliclack::outro(outcome)); } } - /// Closes the frame a failure was just reported in with a bare corner. - pub fn close_failed(&self) { + /// Close the frame an error was just drawn in with a bare corner. + pub fn close_frame(&self) { self.session.store(false, Ordering::Relaxed); - self.draw(|| cliclack::outro_cancel("")); + self.draw(|| cliclack::outro("")); } - pub fn next_id(&self) -> u64 { - self.next_id.fetch_add(1, Ordering::Relaxed) - } - - /// Settles the live widget, then draws with cliclack. + /// Settle the live widget, then draw with cliclack. pub fn draw(&self, draw: impl FnOnce() -> io::Result<()>) { - self.settle(Ending::Done); + self.settle(); let _ = draw(); } - /// Settles the live widget, then starts `bar` as live widget `id`. - pub fn show(&self, id: u64, bar: ProgressBar, message: String) { + /// Settle the live widget, then start `bar` as the live one. Returns its id. + pub fn start(&self, bar: ProgressBar, message: String) -> u64 { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); let mut live = self.lock(); if let Some(previous) = live.take() { - previous.end(Ending::Done, None); + previous.stop(); } bar.start(&message); *live = Some(Live { id, bar, message }); + id } - /// Changes the message of live widget `id`; nothing when it was settled. - pub fn update(&self, id: u64, message: String) { + /// Change the message of live widget `id`, unless it has ended. + pub fn set_message(&self, id: u64, message: String) { if let Some(live) = self.lock().as_mut().filter(|live| live.id == id) { live.bar.set_message(&message); live.message = message; } } - /// Moves the bar of live widget `id` to `position`; nothing when it was settled. - pub fn update_position(&self, id: u64, position: u64) { + /// Move the bar of live widget `id` to `position`, unless it has ended. + pub fn set_position(&self, id: u64, position: u64) { if let Some(live) = self.lock().as_ref().filter(|live| live.id == id) { live.bar.set_position(position); } } - /// Clears live widget `id`, leaving no line. + /// End live widget `id` without leaving a line, unless it has ended. pub fn clear(&self, id: u64) { - self.end(id, Ending::Cleared, None); - } - - fn end(&self, id: u64, ending: Ending, message: Option<&str>) -> bool { let mut live = self.lock(); - match live.take() { - Some(widget) if widget.id == id => { - widget.end(ending, message); - true - } - other => { - *live = other; - false + if live.as_ref().is_some_and(|live| live.id == id) { + if let Some(widget) = live.take() { + widget.bar.clear(); } } } - /// Finishes the live widget, if any, with its last message. - pub fn settle(&self, ending: Ending) { + /// End the live widget, if any, with its last message as a line. + pub fn settle(&self) { if let Some(widget) = self.lock().take() { - widget.end(ending, None); + widget.stop(); + } + } + + /// End the live widget, if any, without leaving a line. + pub fn clear_live(&self) { + if let Some(widget) = self.lock().take() { + widget.bar.clear(); } } /// Stdout, once nothing else draws where it lands. pub fn stdout(&self) -> io::StdoutLock<'static> { if self.shares_stdout() { - self.settle(Ending::Done); + self.settle(); } io::stdout().lock() } - fn lock(&self) -> std::sync::MutexGuard<'_, Option> { + fn lock(&self) -> MutexGuard<'_, Option> { self.live .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } } -/// cliclack's theme while a result's box is drawn. cliclack dims the text of a box, as -/// it dims a prompt's answer; a result keeps its own styles instead. tracel sets no -/// other theme, so drawing the box goes back to cliclack's own after it. +impl Live { + fn stop(self) { + self.bar.stop(&self.message); + } +} + +/// cliclack's theme while a result's box is drawn. cliclack dims a box's text as it +/// dims a prompt's answer, and a result keeps its own styles instead. tracel sets no +/// other theme, so the box goes back to cliclack's own after it. struct ResultTheme; impl cliclack::Theme for ResultTheme { fn input_style(&self, state: &cliclack::ThemeState) -> console::Style { match state { cliclack::ThemeState::Cancel => console::Style::new().dim().strikethrough(), - cliclack::ThemeState::Submit => console::Style::new(), _ => console::Style::new(), } } @@ -266,12 +283,3 @@ fn columns(terminal: &console::Term) -> Option { .size_checked() .map(|(_, columns)| usize::from(columns)) } - -impl Live { - fn end(self, ending: Ending, message: Option<&str>) { - match ending { - Ending::Done => self.bar.stop(message.unwrap_or(&self.message)), - Ending::Cleared => self.bar.clear(), - } - } -} diff --git a/crates/tracel-cli/src/ui/stderr.rs b/crates/tracel-cli/src/ui/stderr.rs index 218e4a5..81aec92 100644 --- a/crates/tracel-cli/src/ui/stderr.rs +++ b/crates/tracel-cli/src/ui/stderr.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use cliclack::ProgressBar; use super::Format; -use super::screen::{Ending, Screen}; +use super::screen::Screen; use crate::error::{CliError, ErrorKind, ErrorReport}; /// A prompt the user answered no. The command stops there and ends on what it didn't @@ -24,7 +24,7 @@ impl Declined { } } -/// What a prompt says when nobody can answer it: a message such as "Unlinking +/// What a prompt says when the user can't answer it: a message such as "Unlinking /// test/demo needs confirmation." and a hint naming the flag that answers it, such as /// "Pass --yes to unlink without asking." pub struct Unanswered { @@ -45,9 +45,9 @@ impl Unanswered { } } -/// Stderr, for the person running a command: what it is doing, its progress, and -/// questions for them. With JSON output it carries only errors and the instructions -/// a person must follow, as plain lines. +/// Stderr, where a command tells the user what it does, shows its progress and asks +/// its questions. With JSON output it carries only errors and the instructions the +/// user must follow, as plain lines. #[derive(Clone)] pub struct Terminal { screen: Arc, @@ -67,7 +67,7 @@ impl Terminal { self.screen.is_styled() } - /// A plain line, for stderr that is not a styled terminal. + /// Write a plain line, for stderr that cliclack doesn't draw on. fn line(&self, text: impl Display) { let _ = writeln!( io::stderr(), @@ -76,15 +76,8 @@ impl Terminal { ); } - pub fn print_warning(&self, message: &str) { - if self.is_styled() { - self.screen.draw(|| cliclack::log::warning(message)); - } else if self.is_human() { - self.line(format_args!("warning: {message}")); - } - } - - pub fn print(&self, message: &str) { + /// Say something the user should know, as a sentence. + pub fn info(&self, message: &str) { if self.is_styled() { self.screen.draw(|| cliclack::log::info(message)); } else if self.is_human() { @@ -92,37 +85,48 @@ impl Terminal { } } - pub fn print_success(&self, message: &str) { + pub fn warning(&self, message: &str) { if self.is_styled() { - self.screen.draw(|| cliclack::log::success(message)); + self.screen.draw(|| cliclack::log::warning(message)); } else if self.is_human() { - self.line(message); + self.line(format_args!("warning: {message}")); } } + /// Record a step that left a fact the user needs later, such as "Created model + /// 'weights'". pub fn step(&self, message: &str) { if self.is_styled() { self.screen.draw(|| cliclack::log::step(message)); - } else { - self.print(message); + } else if self.is_human() { + self.line(message); } } - /// Instructions a person must follow before the command can go on, shown in - /// every format. + /// Show instructions the user must follow before the command can go on, in every + /// format. pub fn instruct(&self, message: &str) { if self.is_human() { - self.print(message); + self.info(message); } else { self.line(message); } } - /// Reports a failed command: the message and hint here, the envelope on stdout - /// with JSON output. On a terminal both are one error, then a bare corner closes - /// the frame. + /// End the command on its outcome: the corner of a flow's session, or a line. + pub fn outro(&self, outcome: &str) { + if self.screen.session_open() { + self.screen.outro(outcome); + } else { + self.step(outcome); + } + } + + /// Report a failed command: its message and hint here, and its envelope on stdout + /// with JSON output. On a terminal both are one error, then a bare corner closes the + /// frame. pub fn error(&self, report: &ErrorReport<'_>) { - self.screen.settle(Ending::Cleared); + self.screen.clear_live(); if !self.is_human() { self.line(format_args!("error: {}", one_line(&report.message))); } else if self.is_styled() { @@ -131,7 +135,7 @@ impl Terminal { None => report.message.clone(), }; self.screen.draw(|| cliclack::log::error(text)); - self.screen.close_failed(); + self.screen.close_frame(); } else { self.line(format_args!("error: {}", report.message)); if let Some(hint) = report.hint { @@ -140,31 +144,31 @@ impl Terminal { } } - /// A spinner for one step, drawn from now until it ends. + /// Start a spinner for one step. pub fn spinner(&self, message: impl Display) -> Progress { - self.live(message.to_string(), cliclack::spinner()) + self.start(cliclack::spinner(), message.to_string()) } - /// A bar that counts `total` steps of one task, drawn from now until it ends. + /// Start a bar that counts the `total` steps of one task. pub fn progress(&self, message: impl Display, total: u64) -> Progress { - self.live(message.to_string(), cliclack::progress_bar(total)) + self.start(cliclack::progress_bar(total), message.to_string()) } - fn live(&self, message: String, bar: ProgressBar) -> Progress { - let id = self.screen.next_id(); - if self.is_styled() { - self.screen.show(id, bar, message); + fn start(&self, bar: ProgressBar, message: String) -> Progress { + let id = if self.is_styled() { + Some(self.screen.start(bar, message)) } else { - self.print(&message); - } + self.info(&message); + None + }; Progress { - terminal: self.clone(), + screen: Arc::clone(&self.screen), id, } } - /// A URL as cliclack shows it: styled unless `NO_COLOR` is set and, in terminals that - /// render hyperlinks, one, so a click opens it. Plain when stderr is not a terminal. + /// A URL as cliclack shows it: styled unless `NO_COLOR` is set, and a hyperlink a + /// click opens in terminals that render them. Plain when cliclack doesn't draw. pub fn format_url(&self, url: &url::Url) -> String { if !self.is_styled() { return url.to_string(); @@ -177,13 +181,13 @@ impl Terminal { } } - /// Fails with `kind` when nobody can answer a prompt, and otherwise clears the - /// screen for it. + /// Fail with `kind` when the user can't answer a prompt, and otherwise settle the screen + /// for it. fn require_answer(&self, kind: ErrorKind, unanswered: Unanswered) -> anyhow::Result<()> { if !self.is_interactive() { return Err(unanswered.into_error(kind).into()); } - self.screen.settle(Ending::Done); + self.screen.settle(); Ok(()) } @@ -200,25 +204,25 @@ impl Terminal { .map_err(anyhow::Error::from) } - /// Offers a shortcut with a question answered yes by default. When nobody can answer, the - /// question is not asked and the answer is no. + /// Offer a shortcut, yes by default. When the user can't answer, it isn't asked and the + /// answer is no. pub fn offer(&self, question: &str) -> anyhow::Result { if !self.is_interactive() { return Ok(false); } - self.screen.settle(Ending::Done); + self.screen.settle(); cliclack::confirm(question) .initial_value(true) .interact() .map_err(anyhow::Error::from) } - /// Asks for optional text. + /// Ask for optional text. pub fn input(&self, question: &str, unanswered: Unanswered) -> anyhow::Result { self.input_validated(question, "", unanswered, |_| Ok(())) } - /// Asks for text that `validate` accepts, `default` when the answer is empty. + /// Ask for text that `validate` accepts, `default` when the answer is empty. pub fn input_validated( &self, question: &str, @@ -267,16 +271,7 @@ impl Terminal { .map_err(anyhow::Error::from) } - /// Ends a flow's session with what it did. - pub fn outro(&self, message: &str) { - if self.screen.session_open() { - self.screen.close(message); - } else { - self.print_success(message); - } - } - - /// Whether a person can answer prompts: they read from stdin and draw on stderr. + /// Whether the user can answer prompts: they type on stdin and read stderr. pub fn is_interactive(&self) -> bool { let ci = std::env::var_os("CI"); allows_input( @@ -310,35 +305,42 @@ fn one_line(message: &str) -> String { .join(" ") } -/// `text` as a terminal hyperlink to `url` (OSC 8): terminals that render hyperlinks open -/// `url` on a click on `text`. +/// `text` as a terminal hyperlink to `url` (OSC 8), which terminals that render them +/// open on a click. fn hyperlink(url: &url::Url, text: &str) -> String { format!("\u{1b}]8;;{url}\u{1b}\\{text}\u{1b}]8;;\u{1b}\\") } -/// A spinner or bar for one step, drawn from its start until it ends. When something -/// else draws before it ends, it stops early with its last message. +/// A spinner or bar for one step, drawn until it ends. When something else draws +/// first, it ends early as a line with its last message. #[derive(Clone)] pub struct Progress { - terminal: Terminal, - id: u64, + screen: Arc, + /// The live widget on the screen, when cliclack draws. + id: Option, } impl Progress { - /// A passing detail of the step, such as what cargo compiles: shown on a terminal - /// only, where it replaces the message. + /// Replace the message with a passing detail, such as what cargo compiles. Only a + /// terminal shows it. pub fn set_message(&self, message: impl Display) { - self.terminal.screen.update(self.id, message.to_string()); + if let Some(id) = self.id { + self.screen.set_message(id, message.to_string()); + } } - /// How many of a bar's steps are done. + /// Set how many of a bar's steps are done. pub fn set_position(&self, done: u64) { - self.terminal.screen.update_position(self.id, done); + if let Some(id) = self.id { + self.screen.set_position(id, done); + } } - /// Ends the step without a line, for a step whose outcome says what it did. + /// End the step without a line, for a step whose outcome says what it did. pub fn clear(&self) { - self.terminal.screen.clear(self.id); + if let Some(id) = self.id { + self.screen.clear(id); + } } } diff --git a/crates/tracel-cli/src/ui/stdout.rs b/crates/tracel-cli/src/ui/stdout.rs index 42dfb5a..1768925 100644 --- a/crates/tracel-cli/src/ui/stdout.rs +++ b/crates/tracel-cli/src/ui/stdout.rs @@ -8,14 +8,10 @@ use super::human::Human; use super::screen::{Delivery, Screen}; use crate::error::ErrorReport; -/// A command result: serialized for JSON output, written for people. +/// A command's result: serialized for JSON output, written as text otherwise. pub trait Render: Serialize { - /// What a person reads. The default writes nothing, for actions that report - /// what they did on stderr as they go. - fn render(&self, out: &mut Human<'_>) -> io::Result<()> { - let _ = out; - Ok(()) - } + /// Write the result as text for the user. + fn render(&self, out: &mut Human<'_>) -> io::Result<()>; /// One line that sums the result up: the title of its box on a terminal, the /// outcome that ends a session, and the last line of plain text. @@ -28,10 +24,16 @@ pub trait Render: Serialize { pub struct Outcome(Option>); impl Outcome { - /// The command wrote to stdout while it ran: streamed events, or a program it ran. + /// Nothing more: the command wrote to stdout as it ran, such as streamed events or + /// the output of a program it ran. pub fn streamed() -> Self { Self(None) } + + /// A result for JSON output only, from a command that says what it did on stderr. + pub fn data(value: impl Serialize + 'static) -> Self { + Self(Some(Box::new(Data(value)))) + } } impl From for Outcome { @@ -40,6 +42,16 @@ impl From for Outcome { } } +#[derive(Serialize)] +#[serde(transparent)] +struct Data(T); + +impl Render for Data { + fn render(&self, _: &mut Human<'_>) -> io::Result<()> { + Ok(()) + } +} + trait Report { fn write_json(&self, out: &mut dyn Write) -> serde_json::Result<()>; fn write_text(&self, out: &mut Human<'_>) -> io::Result<()>; @@ -83,8 +95,8 @@ struct Failure<'a> { #[error("stdout was closed")] pub struct StdoutClosed; -/// Where command results go, in the format the user chose: a read's in one box on a -/// terminal, and otherwise on stdout, which carries nothing else. +/// Stdout, where results go in the format the user chose. On a terminal a read's +/// result goes in one box on stderr instead. #[derive(Clone)] pub struct Output { screen: Arc, @@ -95,28 +107,29 @@ impl Output { Self { screen } } - /// Writes the result of a finished command, and ends the session it ran in with - /// its summary. + /// Write the result of a finished command, then end its session on the summary. pub fn finish(&self, outcome: Outcome) -> anyhow::Result<()> { let summary = outcome.0.as_ref().and_then(|report| report.summary()); if let Some(report) = outcome.0 { - let (format, width) = (self.screen.format(), self.screen.stdout_width()); match self.screen.delivery() { Delivery::Box(width) => { - let body = boxed(report.as_ref(), width)?; + let body = box_text(report.as_ref(), width)?; self.screen.show_result(summary.as_deref(), &body); } - Delivery::Stdout => self.write_stdout(|out| { - write_result(format, report.as_ref(), summary.as_deref(), width, out) - })?, + Delivery::Stdout => { + let (format, width) = (self.screen.format(), self.screen.stdout_width()); + self.write_stdout(|out| { + write_result(format, report.as_ref(), summary.as_deref(), width, out) + })? + } } } - self.screen.close(summary.as_deref().unwrap_or_default()); + self.screen.outro(summary.as_deref().unwrap_or_default()); Ok(()) } - /// Writes one event of a stream as it happens: an NDJSON line, or its plain text, - /// which stays the program's own output on a terminal too. + /// Write one event of a stream as it happens: an NDJSON line, or its plain text, + /// which a terminal shows as it is. pub fn event(&self, event: &impl Render) -> anyhow::Result<()> { let (format, width) = (self.screen.format(), self.screen.stdout_width()); self.write_stdout(|out| { @@ -125,7 +138,8 @@ impl Output { }) } - /// Writes the error envelope of a failed command; text errors go to stderr. + /// Write the error envelope of a failed command with JSON output. Text errors go + /// to stderr. pub fn error(&self, report: &ErrorReport<'_>) { if self.screen.format() == Format::Json { let _ = self.write_stdout(|out| write_failure(report, out)); @@ -143,14 +157,14 @@ impl Output { } } -/// The text of a result for its box, fitted to `width`. -fn boxed(report: &dyn Report, width: Option) -> io::Result { +/// Write a result's text for its box, fitted to `width`. +fn box_text(report: &dyn Report, width: Option) -> io::Result { let mut text = Vec::new(); - report.write_text(&mut Human::boxed(&mut text, width))?; + report.write_text(&mut Human::for_box(&mut text, width))?; Ok(String::from_utf8_lossy(&text).into_owned()) } -/// A result on stdout: its envelope, or its plain text ending with `summary`. +/// Write a result on stdout: its envelope, or its plain text ending with `summary`. fn write_result( format: Format, report: &dyn Report, @@ -223,7 +237,11 @@ mod tests { #[derive(Serialize)] struct Done {} - impl Render for Done {} + impl Render for Done { + fn render(&self, _: &mut Human<'_>) -> io::Result<()> { + Ok(()) + } + } fn written(write: impl FnOnce(&mut dyn Write) -> io::Result<()>) -> String { let mut out = Vec::new(); @@ -262,10 +280,10 @@ mod tests { #[test] fn a_box_holds_the_text_of_a_result() { assert_eq!( - boxed(&Greeting { name: "Ada" }, None).unwrap(), + box_text(&Greeting { name: "Ada" }, None).unwrap(), "Hello, Ada.\n" ); - assert_eq!(boxed(&Done {}, None).unwrap(), ""); + assert_eq!(box_text(&Done {}, None).unwrap(), ""); } #[test] diff --git a/crates/tracel-cli/src/ui/time.rs b/crates/tracel-cli/src/ui/time.rs index ad17f3b..674cb8d 100644 --- a/crates/tracel-cli/src/ui/time.rs +++ b/crates/tracel-cli/src/ui/time.rs @@ -20,13 +20,13 @@ impl Serialize for Timestamp { } } -/// A server's RFC 3339 time as a person reads it: local, to the minute. Text that is -/// not such a time is kept as it is. +/// Show a server's RFC 3339 time in the local zone, to the minute. Other text stays as +/// it is. pub fn when(text: &str) -> String { local(text, &Local, "%Y-%m-%d %H:%M") } -/// A log entry's RFC 3339 time as a person reads it: local, to the second. +/// Show a log entry's RFC 3339 time in the local zone, to the second. pub fn log_time(text: &str) -> String { local(text, &Local, "%Y-%m-%d %H:%M:%S") } From 3159703a4bfa9906c9b0e417d12bf407b2e377a4 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 13 Oct 2026 14:47:50 -0400 Subject: [PATCH 25/32] test: one convention, and tests for what matters Tests live beside the code they exercise and are named as one present-tense sentence about one behaviour. Three or more cases are one table, and a shared test_support module parses arguments through `CliArgs::try_parse_args`, reports an error's kind, exit code and hint, writes a result's plain text, and gives a terminal that never draws or asks. Temporary directories come from tempfile. - Gone: tests that restate clap's derive (flags setting fields, declared defaults and ranges, conflicts), all but one `debug_assert`, tests of removed flags and commands, and a test of an empty project's linker. - Error tests assert the kind, exit code, hint and the offending value instead of whole sentences. Classification is one table, as are the workspace name, linker, build driver, part layout, download path and project resolution cases. - New: where a result goes on a terminal and when a session opens, a closed stdout and a declined prompt exiting 0, the format of an error in arguments that did not parse, a confirmation that can't be asked, TRACEL_ENV reading back what the CLI and the SDK write, TRACEL_API_KEY coming before a stored login, logs oldest first, and the formatting of numbers, sizes and digests. To make them testable, how a stopped command ends, the format of a parse error, and what a broken pipe means are now functions of their inputs. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/tracel-cli/Cargo.toml | 3 + crates/tracel-cli/src/cli.rs | 282 ++++++++-------- crates/tracel-cli/src/commands/artifacts.rs | 99 ++---- crates/tracel-cli/src/commands/auth.rs | 22 ++ crates/tracel-cli/src/commands/datasets.rs | 76 ----- crates/tracel-cli/src/commands/experiments.rs | 217 ++++++------- crates/tracel-cli/src/commands/init.rs | 34 +- crates/tracel-cli/src/commands/login.rs | 38 ++- .../tracel-cli/src/commands/login/pending.rs | 81 ++--- crates/tracel-cli/src/commands/models.rs | 300 ++---------------- crates/tracel-cli/src/commands/project.rs | 31 -- crates/tracel-cli/src/context.rs | 14 + crates/tracel-cli/src/environment.rs | 75 +++-- crates/tracel-cli/src/error.rs | 218 +++++++------ crates/tracel-cli/src/helpers/download.rs | 166 +++++----- .../src/helpers/model_upload/parts.rs | 174 +++------- .../src/helpers/model_upload/upload.rs | 121 +++---- crates/tracel-cli/src/helpers/project.rs | 184 ++++++----- crates/tracel-cli/src/helpers/resources.rs | 213 ++++++------- crates/tracel-cli/src/lib.rs | 2 + crates/tracel-cli/src/test_support.rs | 53 ++++ crates/tracel-cli/src/tools/build_driver.rs | 59 ++-- crates/tracel-cli/src/tools/fs.rs | 61 ++-- crates/tracel-cli/src/tools/git.rs | 22 +- crates/tracel-cli/src/tools/linker.rs | 82 ++--- crates/tracel-cli/src/tools/target.rs | 28 +- crates/tracel-cli/src/tools/workspace.rs | 83 ++--- crates/tracel-cli/src/ui/format.rs | 93 +++--- crates/tracel-cli/src/ui/human.rs | 176 ++++++---- crates/tracel-cli/src/ui/layout.rs | 28 +- crates/tracel-cli/src/ui/screen.rs | 65 ++++ crates/tracel-cli/src/ui/stderr.rs | 57 +++- crates/tracel-cli/src/ui/stdout.rs | 127 ++++---- crates/tracel-cli/src/ui/time.rs | 49 +-- 36 files changed, 1513 insertions(+), 1822 deletions(-) create mode 100644 crates/tracel-cli/src/test_support.rs diff --git a/Cargo.lock b/Cargo.lock index 8b0fa47..8cfe56e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3647,6 +3647,7 @@ dependencies = [ "sha2", "supports-hyperlinks", "tar", + "tempfile", "thiserror 2.0.20", "toml", "toml_edit", diff --git a/Cargo.toml b/Cargo.toml index 679ed06..1aaeca1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ gix = { version = "0.77.0", default-features = false, features = [ ] } chrono = "0.4.45" strum = { version = "0.27.2", features = ["derive"] } +tempfile = "3.27.0" ## External Crate diff --git a/crates/tracel-cli/Cargo.toml b/crates/tracel-cli/Cargo.toml index 420208d..72b8414 100644 --- a/crates/tracel-cli/Cargo.toml +++ b/crates/tracel-cli/Cargo.toml @@ -37,3 +37,6 @@ ignore.workspace = true flate2.workspace = true tar.workspace = true walkdir.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index 51a3dbc..fd3245a 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -1,3 +1,4 @@ +use std::ffi::OsString; use std::io::IsTerminal; use clap::{CommandFactory, Parser, Subcommand}; @@ -60,6 +61,17 @@ pub struct CliArgs { pub staging: Option, } +impl CliArgs { + /// Parse the arguments `tracel` was started with. + pub fn try_parse_args(args: I) -> Result + where + I: IntoIterator, + T: Into + Clone, + { + Self::try_parse_from(args) + } +} + #[derive(Subcommand, Debug)] pub enum Commands { /// Run the workspace locally with `cargo run` @@ -135,20 +147,19 @@ impl Commands { } pub fn cli_main() { - let args = match CliArgs::try_parse() { + let args = match CliArgs::try_parse_args(std::env::args_os()) { Ok(args) => args, Err(error) if !error.use_stderr() => error.exit(), Err(error) => { - let format = usage_format(std::env::args_os().skip(1)); + let format = usage_format( + std::env::args_os().skip(1), + std::env::var("TRACEL_OUTPUT").ok().as_deref(), + std::io::stdout().is_terminal(), + ); if format == Format::Human { error.exit(); } - let message = error.to_string(); - let message = message - .trim() - .strip_prefix("error: ") - .unwrap_or(message.trim()); - fail_early(&CliError::new(ErrorKind::Usage, message).into(), format); + fail_early(&usage_error(&error).into(), format); } }; @@ -208,23 +219,50 @@ pub fn cli_main() { } } -/// Report a command that stopped on `error` and exit with its code. A closed stdout -/// and a declined prompt aren't failures, so they exit 0. -fn fail(error: &anyhow::Error, output: &Output, terminal: &Terminal) -> ! { - if error.chain().any(|cause| cause.is::()) { - std::process::exit(0); +/// How a command that stopped on an error ends. +enum Stop<'a> { + /// Its reader closed stdout, having got all it wanted. + Closed, + /// The user answered a prompt no, and the command ends on what it didn't do. + Declined(&'a str), + Failed(ErrorReport<'a>), +} + +impl<'a> Stop<'a> { + fn of(error: &'a anyhow::Error) -> Self { + if error.chain().any(|cause| cause.is::()) { + return Self::Closed; + } + match error + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + Some(declined) => Self::Declined(declined.outcome()), + None => Self::Failed(ErrorReport::new(error)), + } } - if let Some(declined) = error - .chain() - .find_map(|cause| cause.downcast_ref::()) - { - terminal.outro(declined.outcome()); - std::process::exit(0); + + fn exit_code(&self) -> i32 { + match self { + Self::Closed | Self::Declined(_) => 0, + Self::Failed(report) => report.exit_code, + } } - let report = ErrorReport::new(error); - output.error(&report); - terminal.error(&report); - std::process::exit(report.exit_code) +} + +/// End a command that stopped on `error`: report a failure on both channels, and exit +/// with its code. +fn fail(error: &anyhow::Error, output: &Output, terminal: &Terminal) -> ! { + let stop = Stop::of(error); + match &stop { + Stop::Closed => {} + Stop::Declined(outcome) => terminal.outro(outcome), + Stop::Failed(report) => { + output.error(report); + terminal.error(report); + } + } + std::process::exit(stop.exit_code()) } /// Fail before the command's channels exist. @@ -233,8 +271,24 @@ fn fail_early(error: &anyhow::Error, format: Format) -> ! { fail(error, &output, &terminal) } -/// Choose the format for an error in arguments that did not parse. -fn usage_format(arguments: impl IntoIterator) -> Format { +/// Turn arguments that did not parse into a usage error, without clap's "error: " +/// prefix. +fn usage_error(error: &clap::Error) -> CliError { + let message = error.to_string(); + let message = message.trim(); + CliError::new( + ErrorKind::Usage, + message.strip_prefix("error: ").unwrap_or(message), + ) +} + +/// Choose the format for an error in arguments that did not parse, from the output +/// flags among them and `environment`, the value of TRACEL_OUTPUT. +fn usage_format( + arguments: impl IntoIterator, + environment: Option<&str>, + stdout_is_terminal: bool, +) -> Format { let mut arguments = arguments.into_iter(); let mut flag = None; let mut json = false; @@ -265,14 +319,8 @@ fn usage_format(arguments: impl IntoIterator) -> Form } } } - let auto = Format::for_stdout(std::io::stdout().is_terminal()); - Format::resolve( - flag, - json, - std::env::var("TRACEL_OUTPUT").ok().as_deref(), - auto, - ) - .unwrap_or(auto) + let auto = Format::for_stdout(stdout_is_terminal); + Format::resolve(flag, json, environment, auto).unwrap_or(auto) } fn handle_command(command: Commands, context: CliContext) -> anyhow::Result { @@ -298,137 +346,93 @@ fn handle_command(command: Commands, context: CliContext) -> anyhow::Result anyhow::Resul #[cfg(test)] mod tests { - use clap::{CommandFactory, Parser}; use serde_json::json; use super::*; - use crate::cli::{CliArgs, Commands}; - - fn parse_command(arguments: &[&str]) -> ArtifactsArgs { - let args = CliArgs::try_parse_from(arguments).unwrap(); - let Some(Commands::Artifacts(args)) = args.command else { - panic!("Expected artifacts command"); - }; - args - } + use crate::test_support::report; #[test] - fn definitions_and_arguments_are_valid() { - CliArgs::command().debug_assert(); - let args = parse_command(&["tracel", "artifacts", "list", "7", "--name", "weights"]); - let ArtifactsCommands::List(args) = args.command else { - panic!("Expected list"); - }; - assert_eq!(args.experiment, 7); - assert_eq!(args.name.as_deref(), Some("weights")); - let args = parse_command(&[ - "tracel", - "artifacts", - "download", - "7", - "artifact-id", - "-d", - "weights", - "--force", - "-o", - "json", - ]); - let ArtifactsCommands::Download(args) = args.command else { - panic!("Expected download"); - }; - assert_eq!(args.experiment, 7); - assert_eq!(args.artifact, "artifact-id"); - assert_eq!(args.directory.unwrap(), Path::new("weights")); - assert!(args.force); - let args = parse_command(&["tracel", "artifacts", "download", "1", "weights"]); - let ArtifactsCommands::Download(args) = args.command else { - panic!("Expected download"); - }; - assert!(args.directory.is_none()); - assert!(!args.force); - for number in ["0", "-1", "2147483648", "latest"] { - assert!(CliArgs::try_parse_from(["tracel", "artifacts", "list", number]).is_err()); + fn an_artifact_downloads_into_a_directory_named_after_it() { + for (name, expected) in [ + ("weights", Some("./weights")), + ("", None), + (".", None), + ("..", None), + ("../weights", None), + ("weights/nested", None), + ("/weights", None), + ("C:\\weights", None), + ] { + match default_directory(name) { + Ok(directory) => assert_eq!(Some(directory.as_path()), expected.map(Path::new)), + Err(error) => { + assert_eq!(expected, None, "{name:?}"); + let error = anyhow::Error::new(error); + assert_eq!(report(&error), (ErrorKind::Usage, 2, true), "{name:?}"); + } + } } } #[test] - fn default_directory_and_integrity_use_manifest_fields() { - assert_eq!( - default_directory("weights").unwrap(), - Path::new("./weights") - ); - for name in [ - "", - ".", - "..", - "../weights", - "weights/nested", - "/weights", - "C:\\weights", + fn a_file_is_checked_against_its_manifest_entry() { + let manifest = json!({"files": [ + {"rel_path": "a", "size_bytes": 7, "checksum": "abc"}, + {"rel_path": "b"}, + ]}); + for (rel_path, expected) in [ + ("a", (Some(7), Some("abc".to_string()))), + ("b", (None, None)), + ("missing", (None, None)), ] { - assert_eq!(default_directory(name).unwrap_err().kind, ErrorKind::Usage); + assert_eq!(file_integrity(&manifest, rel_path), expected, "{rel_path}"); } - let manifest = json!({"files": [{"rel_path": "a", "size_bytes": 7, "checksum": "abc"}, {"rel_path": "b"}]}); - assert_eq!(manifest_files(&manifest).unwrap().len(), 2); - assert_eq!( - file_integrity(&manifest, "a"), - (Some(7), Some("abc".into())) - ); - assert_eq!(file_integrity(&manifest, "b"), (None, None)); - assert_eq!(file_integrity(&manifest, "missing"), (None, None)); - assert!(manifest_files(&Value::Null).is_none()); + assert_eq!(file_integrity(&Value::Null, "a"), (None, None)); } } diff --git a/crates/tracel-cli/src/commands/auth.rs b/crates/tracel-cli/src/commands/auth.rs index 3bce5bd..f8874e2 100644 --- a/crates/tracel-cli/src/commands/auth.rs +++ b/crates/tracel-cli/src/commands/auth.rs @@ -154,3 +154,25 @@ fn time_left(until: SystemTime, now: SystemTime) -> String { format!("{minutes}m") } } + +#[cfg(test)] +mod tests { + use std::time::{Duration, UNIX_EPOCH}; + + use super::*; + + #[test] + fn the_time_left_on_a_login_reads_in_its_two_largest_units() { + let now = UNIX_EPOCH + Duration::from_secs(1_900_000_000); + for (seconds, expected) in [ + (0, "0m"), + (59, "0m"), + (45 * 60, "45m"), + (3 * 3600 + 5 * 60, "3h 5m"), + (6 * 86400 + 23 * 3600 + 59 * 60, "6d 23h"), + ] { + let until = now + Duration::from_secs(seconds); + assert_eq!(time_left(until, now), expected, "{seconds}"); + } + } +} diff --git a/crates/tracel-cli/src/commands/datasets.rs b/crates/tracel-cli/src/commands/datasets.rs index ce31463..b7b503b 100644 --- a/crates/tracel-cli/src/commands/datasets.rs +++ b/crates/tracel-cli/src/commands/datasets.rs @@ -164,79 +164,3 @@ impl Render for DatasetVersionListResponse { )) } } - -#[cfg(test)] -mod tests { - use clap::{CommandFactory, Parser}; - - use super::*; - use crate::cli::{CliArgs, Commands}; - - fn parse_command(arguments: &[&str]) -> DatasetsArgs { - let args = CliArgs::try_parse_from(arguments).unwrap(); - let Some(Commands::Datasets(args)) = args.command else { - panic!("Expected datasets command"); - }; - args - } - - #[test] - fn definitions_and_pagination_are_valid() { - CliArgs::command().debug_assert(); - let args = parse_command(&[ - "tracel", - "datasets", - "list", - "--page", - "0", - "--per-page", - "25", - ]); - let DatasetsCommands::List(args) = args.command else { - panic!("Expected list"); - }; - assert_eq!(args.page, Some(0)); - assert_eq!(args.per_page, Some(25)); - let args = parse_command(&["tracel", "datasets", "get", "images"]); - let DatasetsCommands::Get(args) = args.command else { - panic!("Expected get"); - }; - assert_eq!(args.dataset, "images"); - let args = parse_command(&[ - "tracel", - "datasets", - "versions", - "images", - "--page", - "2", - "--per-page", - "10", - ]); - let DatasetsCommands::Versions(args) = args.command else { - panic!("Expected versions"); - }; - assert_eq!(args.dataset, "images"); - assert_eq!(args.page, Some(2)); - assert_eq!(args.per_page, Some(10)); - let args = parse_command(&["tracel", "datasets", "list"]); - let DatasetsCommands::List(args) = args.command else { - panic!("Expected list"); - }; - assert!(args.page.is_none()); - assert!(args.per_page.is_none()); - for arguments in [ - vec!["tracel", "datasets", "list", "--page", "-1"], - vec!["tracel", "datasets", "list", "--per-page", "0"], - vec![ - "tracel", - "datasets", - "versions", - "images", - "--per-page", - "0", - ], - ] { - assert!(CliArgs::try_parse_from(arguments).is_err()); - } - } -} diff --git a/crates/tracel-cli/src/commands/experiments.rs b/crates/tracel-cli/src/commands/experiments.rs index 7605762..eeb97e5 100644 --- a/crates/tracel-cli/src/commands/experiments.rs +++ b/crates/tracel-cli/src/commands/experiments.rs @@ -507,84 +507,53 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res #[cfg(test)] mod tests { - use clap::{CommandFactory, Parser}; use serde_json::json; use super::*; - use crate::cli::{CliArgs, Commands}; - - fn parse_command(arguments: &[&str]) -> ExperimentsArgs { - let args = CliArgs::try_parse_from(arguments).unwrap(); - let Some(Commands::Experiments(args)) = args.command else { - panic!("Expected experiments command"); - }; - args + use crate::cli::Commands; + use crate::test_support::{command, plain}; + + fn logs_args(args: &[&str]) -> LogsArgs { + let mut argv = vec!["experiments", "logs", "42"]; + argv.extend(args); + match command(&argv) { + Commands::Experiments(ExperimentsArgs { + command: ExperimentsCommands::Logs(args), + }) => args, + other => panic!("{argv:?} parse as {other:?}"), + } } - #[test] - fn command_definitions_are_valid_and_alias_preserves_sort() { - CliArgs::command().debug_assert(); - let args = parse_command(&[ - "tracel", - "exp", - "list", - "--page", - "0", - "--limit", - "25", - "--sort", - "created_at,desc", - "--sort", - "name,asc", - ]); - let ExperimentsCommands::List(args) = args.command else { - panic!("Expected list command"); - }; - assert_eq!(args.page, Some(0)); - assert_eq!(args.limit, Some(25)); - assert_eq!(args.sort, ["created_at,desc", "name,asc"]); + fn log(seq: u64, message: &str) -> ExperimentLogItemResponse { + ExperimentLogItemResponse { + seq, + log_level: LogLevelResponse::Warn, + timestamp: "2026-10-07T00:00:00Z".into(), + message: message.into(), + metadata: json!({"epoch": 3}), + } } #[test] - fn experiment_selectors_and_metric_defaults_are_validated() { - for (selector, number) in [("latest", None), ("42", Some(42))] { - let args = parse_command(&["tracel", "experiments", "get", selector]); - let ExperimentsCommands::Get(args) = args.command else { - panic!("Expected get command"); - }; - match args.experiment { - ExperimentSelector::Latest => assert!(number.is_none()), - ExperimentSelector::Number(value) => assert_eq!(Some(value), number), - } - } - let args = parse_command(&["tracel", "exp", "metrics", "42", "--metric", "loss"]); - let ExperimentsCommands::Metrics(args) = args.command else { - panic!("Expected metrics command"); - }; - assert_eq!(args.num, 42); - assert_eq!(args.metric.as_deref(), Some("loss")); - assert_eq!(args.max_points, 100); - assert_eq!(args.downsampling, 1); - for arguments in [ - vec!["tracel", "exp", "get", "invalid"], - vec!["tracel", "exp", "get", "0"], - vec!["tracel", "exp", "metrics", "42", "--summary"], - vec!["tracel", "exp", "metrics", "42", "--max-points", "0"], - vec!["tracel", "exp", "metrics", "42", "--downsampling", "0"], - vec!["tracel", "exp", "logs", "42", "--limit", "0"], - vec!["tracel", "exp", "list", "--limit", "0"], + fn an_experiment_is_a_positive_number_or_latest() { + for (value, expected) in [ + ("latest", Some("latest")), + ("42", Some("42")), + ("0", None), + ("-1", None), + ("v7", None), ] { - assert!(CliArgs::try_parse_from(arguments).is_err()); + let parsed = parse_experiment(value).ok().map(|selector| match selector { + ExperimentSelector::Latest => "latest".to_string(), + ExperimentSelector::Number(number) => number.to_string(), + }); + assert_eq!(parsed.as_deref(), expected, "{value}"); } } #[test] - fn log_filters_map_to_the_client_request() { - let args = parse_command(&[ - "tracel", - "exp", - "logs", - "42", + fn log_filters_become_the_client_request() { + let args = logs_args(&[ "--limit", "25", "--level", @@ -606,10 +575,9 @@ mod tests { "--metadata-exists", "epoch", ]); - let ExperimentsCommands::Logs(args) = args.command else { - panic!("Expected logs command"); - }; + let request = args.into_request(); + assert_eq!(request.limit, Some(25)); assert_eq!( request.levels, @@ -631,69 +599,68 @@ mod tests { } #[test] - fn follow_starts_from_a_sequence_cursor() { - for after in [None, Some("12")] { - let mut arguments = vec!["tracel", "exp", "logs", "42", "--follow", "--json"]; - if let Some(after) = after { - arguments.extend(["--after", after]); - } - let args = parse_command(&arguments); - let ExperimentsCommands::Logs(args) = args.command else { - panic!("Expected logs command"); - }; - let request = args.into_request(); - assert_eq!( - request.after, - Some(after.map_or(0, |value| value.parse().unwrap())) - ); - assert_eq!(request.limit, Some(100)); - assert!(request.levels.is_none()); - } - for flag in ["--follow", "--after"] { - for (filter, value) in [("--from", "start"), ("--to", "end"), ("--offset", "5")] { - let mut arguments = vec!["tracel", "exp", "logs", "42", flag]; - if flag == "--after" { - arguments.push("12"); - } - arguments.extend([filter, value]); - assert_eq!( - CliArgs::try_parse_from(arguments).unwrap_err().kind(), - clap::error::ErrorKind::ArgumentConflict - ); - } + fn following_logs_reads_from_a_sequence_cursor() { + for (args, after) in [ + (&["--follow"][..], Some(0)), + (&["--follow", "--after", "12"], Some(12)), + (&["--after", "12"], Some(12)), + (&[], None), + ] { + assert_eq!(logs_args(args).into_request().after, after, "{args:?}"); } } #[test] - fn followed_logs_are_typed_events() { - let item = ExperimentLogItemResponse { - seq: 7, - log_level: LogLevelResponse::Warn, - timestamp: "2026-10-07T00:00:00Z".into(), - message: "loss\nspiked".into(), - metadata: json!({"epoch": 3}), + fn logs_read_oldest_first() { + let logs = ExperimentLogQueryResponse { + running: false, + items: vec![log(9, "second"), log(8, "first")], + has_more: false, + next_cursor: None, + total: 2, }; + + let text = plain(&logs); + + let messages: Vec<&str> = text + .lines() + .map(|line| line.rsplit(' ').next().unwrap()) + .collect(); + assert_eq!(messages, ["first", "second"]); + } + + #[test] + fn a_followed_log_is_an_event_with_its_type() { + let item = log(7, "loss\nspiked"); + for (event, expected) in [ + ( + LogEvent::Log(&item), + json!({ + "type": "log", + "seq": 7, + "log_level": "warn", + "timestamp": "2026-10-07T00:00:00Z", + "message": "loss\nspiked", + "metadata": {"epoch": 3}, + }), + ), + ( + LogEvent::End { running: false }, + json!({"type": "end", "running": false}), + ), + ] { + assert_eq!(serde_json::to_value(&event).unwrap(), expected); + } + } + + #[test] + fn a_log_line_shows_its_local_time_level_and_one_line_message() { + let item = log(7, "loss\nspiked"); + + let text = plain(&LogEvent::Log(&item)) + &plain(&LogEvent::End { running: false }); + assert_eq!( - serde_json::to_value(LogEvent::Log(&item)).unwrap(), - json!({ - "type": "log", - "seq": 7, - "log_level": "warn", - "timestamp": "2026-10-07T00:00:00Z", - "message": "loss\nspiked", - "metadata": {"epoch": 3}, - }) - ); - assert_eq!( - serde_json::to_value(LogEvent::End { running: false }).unwrap(), - json!({"type": "end", "running": false}) - ); - let mut text = Vec::new(); - let mut out = Human::plain(&mut text, None); - LogEvent::Log(&item).render(&mut out).unwrap(); - LogEvent::End { running: false }.render(&mut out).unwrap(); - assert_eq!( - String::from_utf8(text).unwrap(), + text, format!("{} warn loss spiked\n", log_time("2026-10-07T00:00:00Z")) ); } diff --git a/crates/tracel-cli/src/commands/init.rs b/crates/tracel-cli/src/commands/init.rs index 8da95db..1a280ca 100644 --- a/crates/tracel-cli/src/commands/init.rs +++ b/crates/tracel-cli/src/commands/init.rs @@ -421,17 +421,31 @@ mod tests { use super::*; #[test] - fn project_name_allows_alphanumeric_underscores_and_hyphens() { - for name in ["project", "Project123", "my_project-1", "modèle"] { - assert!(validate_project_name(name).is_ok()); + fn a_project_name_has_only_letters_digits_underscores_and_hyphens() { + for (name, valid) in [ + ("project", true), + ("Project123", true), + ("my_project-1", true), + ("modèle", true), + ("", false), + ("my project", false), + ("project.name", false), + ("project/name", false), + ] { + assert_eq!(validate_project_name(name).is_ok(), valid, "{name:?}"); } - for name in ["", "my project", "project.name", "project/name"] { - assert!(validate_project_name(name).is_err()); + } + + #[test] + fn a_project_is_on_the_web_console_under_its_namespace() { + for (namespace, expected) in [ + (Namespace::User("ada".into()), "/users/ada/projects/demo"), + ( + Namespace::Organization("tracel".into()), + "/orgs/tracel/projects/demo", + ), + ] { + assert_eq!(namespace.project_path("demo"), expected); } - assert!( - validate_project_name("project.name") - .unwrap_err() - .contains("'_' and '-'") - ); } } diff --git a/crates/tracel-cli/src/commands/login.rs b/crates/tracel-cli/src/commands/login.rs index 8de5447..f265ecf 100644 --- a/crates/tracel-cli/src/commands/login.rs +++ b/crates/tracel-cli/src/commands/login.rs @@ -342,28 +342,38 @@ mod tests { timeout.map(Duration::from_secs), Duration::from_secs(elapsed) ), - (Duration::from_secs(remaining), deadline) + (Duration::from_secs(remaining), deadline), + "{timeout:?} {elapsed}" ); } + } + + #[test] + fn an_expired_login_stops_polling_whatever_the_timeout() { + let expires_at = UNIX_EPOCH + Duration::from_secs(1_900_000_000); for timeout in [None, Some(Duration::ZERO), Some(Duration::MAX)] { - for current in [expires_at, expires_at + Duration::from_secs(1)] { + for now in [expires_at, expires_at + Duration::from_secs(1)] { assert_eq!( - poll_window(expires_at, current, timeout, Duration::ZERO), - (Duration::ZERO, PollDeadline::Expiry) + poll_window(expires_at, now, timeout, Duration::ZERO), + (Duration::ZERO, PollDeadline::Expiry), + "{timeout:?} {now:?}" ); } } } #[test] - fn a_pending_login_says_so() { + fn a_pending_login_says_so_in_its_json() { let pending = LoginPending { verification_uri_complete: "http://localhost:9001/verify?code=BCDF-GHJK".into(), user_code: "BCDF-GHJK".into(), expires_at: Timestamp(UNIX_EPOCH + Duration::from_secs(1_900_000_000)), }; + + let json = serde_json::to_value(pending).unwrap(); + assert_eq!( - serde_json::to_value(pending).unwrap(), + json, serde_json::json!({ "status": "pending", "verification_uri_complete": "http://localhost:9001/verify?code=BCDF-GHJK", @@ -374,13 +384,13 @@ mod tests { } #[test] - fn slow_down_adds_five_seconds_without_overflow() { - let interval = Duration::from_secs(7); - assert_eq!(poll_backoff(interval), Duration::from_secs(12)); - assert_eq!( - poll_backoff(poll_backoff(interval)), - Duration::from_secs(17) - ); - assert_eq!(poll_backoff(Duration::MAX), Duration::MAX); + fn a_slow_down_adds_five_seconds_to_the_interval() { + for (interval, expected) in [ + (Duration::from_secs(7), Duration::from_secs(12)), + (Duration::from_secs(12), Duration::from_secs(17)), + (Duration::MAX, Duration::MAX), + ] { + assert_eq!(poll_backoff(interval), expected, "{interval:?}"); + } } } diff --git a/crates/tracel-cli/src/commands/login/pending.rs b/crates/tracel-cli/src/commands/login/pending.rs index 6282c8f..35d55d0 100644 --- a/crates/tracel-cli/src/commands/login/pending.rs +++ b/crates/tracel-cli/src/commands/login/pending.rs @@ -131,29 +131,12 @@ mod tests { use super::*; - struct ScratchDirectory(PathBuf); - - impl ScratchDirectory { - fn new() -> Self { - let path = std::env::temp_dir().join(format!( - "tracel-pending-login-{}-{}", - std::process::id(), - TEMPORARY_ID.fetch_add(1, Ordering::Relaxed) - )); - fs::create_dir(&path).unwrap(); - Self(path) - } - - fn store(&self) -> PendingLoginStore { - PendingLoginStore { - path: self.0.join("sessions").join("localhost_9001.pending.json"), - } - } - } - - impl Drop for ScratchDirectory { - fn drop(&mut self) { - fs::remove_dir_all(&self.0).unwrap(); + fn store(directory: &tempfile::TempDir) -> PendingLoginStore { + PendingLoginStore { + path: directory + .path() + .join("sessions") + .join("localhost_9001.pending.json"), } } @@ -169,12 +152,12 @@ mod tests { } #[test] - fn pending_login_round_trip_replace_and_clear() { - let directory = ScratchDirectory::new(); - let store = directory.store(); + fn a_saved_pending_login_loads_back_until_it_is_cleared() { + let directory = tempfile::tempdir().unwrap(); + let store = store(&directory); + let now = UNIX_EPOCH + Duration::from_secs(1_900_000_000); assert!(store.load().unwrap().is_none()); - let now = UNIX_EPOCH + Duration::from_secs(1_900_000_000); for device_code in ["first", "second"] { let pending = PendingLogin::new(authorization(device_code), now).unwrap(); store.save(&pending).unwrap(); @@ -183,63 +166,61 @@ mod tests { serde_json::to_value(&loaded).unwrap(), serde_json::to_value(&pending).unwrap() ); - assert_eq!( - fs::read_dir(store.path.parent().unwrap()).unwrap().count(), - 1 - ); + let entries = fs::read_dir(store.path.parent().unwrap()).unwrap().count(); + assert_eq!(entries, 1, "a save leaves no temporary file"); } - store.clear().unwrap(); store.clear().unwrap(); + assert!(store.load().unwrap().is_none()); } #[cfg(unix)] #[test] - fn pending_login_and_its_directory_are_owner_only() { + fn a_pending_login_is_readable_by_its_owner_only() { use std::os::unix::fs::PermissionsExt; - let directory = ScratchDirectory::new(); - let store = directory.store(); + let directory = tempfile::tempdir().unwrap(); + let store = store(&directory); let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777; let pending = PendingLogin::new(authorization("first"), SystemTime::now()).unwrap(); store.save(&pending).unwrap(); - assert_eq!(mode(&store.path), 0o600); - assert_eq!(mode(store.path.parent().unwrap()), 0o700); - fs::set_permissions(&store.path, fs::Permissions::from_mode(0o644)).unwrap(); - fs::set_permissions( - store.path.parent().unwrap(), - fs::Permissions::from_mode(0o755), - ) - .unwrap(); + let parent = store.path.parent().unwrap(); + fs::set_permissions(parent, fs::Permissions::from_mode(0o755)).unwrap(); + store.save(&pending).unwrap(); + assert_eq!(mode(&store.path), 0o600); - assert_eq!(mode(store.path.parent().unwrap()), 0o700); + assert_eq!(mode(parent), 0o700); } #[test] - fn expiry_uses_the_start_time_and_clamps_negative_lifetimes() { + fn a_pending_login_expires_its_lifetime_after_it_started() { let now = UNIX_EPOCH + Duration::from_secs(1_900_000_000); - for seconds in [-1, 0, 1, 600] { + for (seconds, expected) in [(-1, 0), (0, 0), (1, 1), (600, 600)] { let mut authorization = authorization("device-code"); authorization.expires_in = seconds; let pending = PendingLogin::new(authorization, now).unwrap(); assert_eq!( pending.expires_at, - now + Duration::from_secs(seconds.max(0) as u64) + now + Duration::from_secs(expected), + "{seconds}" ); } } #[test] - fn pending_path_is_a_sibling_of_the_session() { + fn a_pending_login_sits_beside_its_session() { for url in ["https://console.tracel.ai/api/", "http://localhost:9001/"] { let url = Url::parse(url).unwrap(); let session = FileSessionStore::for_server(&url).unwrap(); let pending = PendingLoginStore::for_server(&url).unwrap(); - assert_eq!(pending.path.parent(), session.path().parent()); - assert_eq!(pending.path, session.path().with_extension("pending.json")); + assert_eq!( + pending.path, + session.path().with_extension("pending.json"), + "{url}" + ); } } } diff --git a/crates/tracel-cli/src/commands/models.rs b/crates/tracel-cli/src/commands/models.rs index 73b79bc..4da61c2 100644 --- a/crates/tracel-cli/src/commands/models.rs +++ b/crates/tracel-cli/src/commands/models.rs @@ -780,287 +780,33 @@ fn alias( #[cfg(test)] mod tests { - use clap::{CommandFactory, Parser}; - use serde_json::json; - use super::*; - use crate::cli::{CliArgs, Commands}; - - fn parse_command(arguments: &[&str]) -> ModelsArgs { - let args = CliArgs::try_parse_from(arguments).unwrap(); - let Some(Commands::Models(args)) = args.command else { - panic!("Expected models command"); - }; - args - } - - #[test] - fn definitions_and_read_commands_are_valid() { - CliArgs::command().debug_assert(); - assert!(matches!( - parse_command(&["tracel", "models", "list"]).command, - ModelsCommands::List - )); - let args = parse_command(&["tracel", "models", "get", "weights"]); - let ModelsCommands::Get(args) = args.command else { - panic!("Expected get"); - }; - assert_eq!(args.model, "weights"); - assert!(args.version.is_none()); - for reference in ["latest", "7", "v7", "production"] { - let args = - parse_command(&["tracel", "models", "get", "weights", "--version", reference]); - let ModelsCommands::Get(args) = args.command else { - panic!("Expected get"); - }; - assert_eq!(args.version.as_deref(), Some(reference)); - } - let args = parse_command(&["tracel", "models", "versions", "weights", "--all"]); - let ModelsCommands::Versions(args) = args.command else { - panic!("Expected versions"); - }; - assert!(args.all); - let args = parse_command(&["tracel", "models", "pull", "weights"]); - let ModelsCommands::Pull(args) = args.command else { - panic!("Expected pull"); - }; - assert_eq!(args.version, "latest"); - assert!(args.directory.is_none()); - assert!(!args.force); - let args = parse_command(&[ - "tracel", - "models", - "pull", - "weights", - "--version", - "production", - "-d", - "./out", - "--force", - "-o", - "json", - ]); - let ModelsCommands::Pull(args) = args.command else { - panic!("Expected pull"); - }; - assert_eq!(args.version, "production"); - assert_eq!(args.directory.unwrap(), Path::new("./out")); - assert!(args.force); - } - - #[test] - fn push_and_promote_parse_creation_and_metadata() { - let args = parse_command(&[ - "tracel", - "models", - "push", - "weights", - "-d", - ".", - "-a", - "true", - "--description", - "Weights", - "--metadata", - r#"{"format":"bin"}"#, - ]); - let ModelsCommands::Push(args) = args.command else { - panic!("Expected push"); - }; - assert_eq!(args.model_name, "weights"); - assert_eq!(args.directory, Path::new(".")); - assert_eq!(args.auto_create, Some(true)); - assert_eq!(args.description.as_deref(), Some("Weights")); - assert_eq!(args.metadata.unwrap(), json!({"format": "bin"})); - let args = parse_command(&[ - "tracel", - "models", - "promote", - "weights", - "--experiment", - "7", - "--artifact", - "artifact-id", - "--alias", - "production", - "--auto-create", - "false", - "--metadata", - "{}", - ]); - let ModelsCommands::Promote(args) = args.command else { - panic!("Expected promote"); - }; - assert_eq!(args.experiment, 7); - assert_eq!(args.artifact, "artifact-id"); - assert_eq!(args.alias.as_deref(), Some("production")); - assert_eq!(args.auto_create, Some(false)); - assert_eq!(args.metadata.unwrap(), json!({})); - for value in ["null", "[]", "1", "{invalid"] { - assert!( - CliArgs::try_parse_from([ - "tracel", - "models", - "push", - "weights", - "-d", - ".", - "--metadata", - value - ]) - .is_err() - ); - assert!( - CliArgs::try_parse_from([ - "tracel", - "models", - "promote", - "weights", - "--experiment", - "7", - "--artifact", - "weights", - "--metadata", - value - ]) - .is_err() - ); - } - for arguments in [ - vec!["tracel", "models", "push", "weights"], - vec![ - "tracel", - "models", - "push", - "weights", - "-d", - ".", - "--description", - "text", - ], - vec![ - "tracel", - "models", - "push", - "weights", - "-d", - ".", - "--auto-create", - "maybe", - ], - vec![ - "tracel", - "models", - "promote", - "weights", - "--artifact", - "weights", - ], - vec![ - "tracel", - "models", - "promote", - "weights", - "--experiment", - "0", - "--artifact", - "weights", - ], - ] { - assert!(CliArgs::try_parse_from(arguments).is_err()); - } - } + use crate::test_support::report; #[test] - fn aliases_require_positive_versions_and_parse_the_guard() { - let args = parse_command(&["tracel", "models", "alias", "list", "weights"]); - let ModelsCommands::Alias(args) = args.command else { - panic!("Expected alias"); - }; - assert!( - matches!(args.command, AliasCommands::List(AliasListArgs { model }) if model == "weights") - ); - let args = parse_command(&[ - "tracel", - "models", - "alias", - "set", - "weights", - "production", - "7", - "--expect", - "6", - ]); - let ModelsCommands::Alias(args) = args.command else { - panic!("Expected alias"); - }; - let AliasCommands::Set(args) = args.command else { - panic!("Expected set"); - }; - assert_eq!(args.model, "weights"); - assert_eq!(args.alias, "production"); - assert_eq!(args.version, 7); - assert_eq!(args.expect, Some(6)); - let args = parse_command(&[ - "tracel", - "models", - "alias", - "remove", - "weights", - "production", - ]); - let ModelsCommands::Alias(args) = args.command else { - panic!("Expected alias"); - }; - assert!( - matches!(args.command, AliasCommands::Remove(AliasRemoveArgs { model, alias }) if model == "weights" && alias == "production") - ); - for version in ["0", "-1", "4294967296", "v7"] { - assert!( - CliArgs::try_parse_from([ - "tracel", - "models", - "alias", - "set", - "weights", - "production", - version - ]) - .is_err() - ); - assert!( - CliArgs::try_parse_from([ - "tracel", - "models", - "alias", - "set", - "weights", - "production", - "7", - "--expect", - version - ]) - .is_err() - ); - } - } - - #[test] - fn default_pull_destination_requires_a_single_normal_model_name() { - assert!(validate_download_directory(None, "weights").is_ok()); - for model in [ - "", - ".", - "..", - "/weights", - "../weights", - "nested/weights", - "C:\\weights", + fn a_default_pull_directory_needs_a_plain_model_name() { + for (model, valid) in [ + ("weights", true), + ("", false), + (".", false), + ("..", false), + ("/weights", false), + ("../weights", false), + ("nested/weights", false), + ("C:\\weights", false), ] { - assert_eq!( - crate::error::classify(&validate_download_directory(None, model).unwrap_err()), - ErrorKind::Usage - ); + let checked = validate_download_directory(None, model); + match checked { + Ok(()) => assert!(valid, "{model:?}"), + Err(error) => { + assert!(!valid, "{model:?}"); + assert_eq!(report(&error), (ErrorKind::Usage, 2, true), "{model:?}"); + assert!( + error.to_string().contains(&format!("'{model}'")), + "{model:?}" + ); + } + } assert!(validate_download_directory(Some(Path::new("./out")), model).is_ok()); } } diff --git a/crates/tracel-cli/src/commands/project.rs b/crates/tracel-cli/src/commands/project.rs index eddc7f8..c992a82 100644 --- a/crates/tracel-cli/src/commands/project.rs +++ b/crates/tracel-cli/src/commands/project.rs @@ -174,34 +174,3 @@ fn show_project(context: CliContext) -> anyhow::Result { source: resolved.source, }) } - -#[cfg(test)] -mod tests { - use clap::{CommandFactory, Parser}; - - use super::*; - use crate::cli::{CliArgs, Commands}; - - #[test] - fn bare_project_and_list_parse_without_a_selected_project() { - CliArgs::command().debug_assert(); - let args = CliArgs::try_parse_from(["tracel", "project"]).unwrap(); - let Some(Commands::Project(args)) = args.command else { - panic!("Expected project"); - }; - assert!(args.command.is_none()); - for namespace in [None, Some("alice"), Some("team")] { - let mut arguments = vec!["tracel", "project", "list"]; - arguments.extend(namespace); - let args = CliArgs::try_parse_from(arguments).unwrap(); - assert!(args.project.is_none()); - let Some(Commands::Project(args)) = args.command else { - panic!("Expected project"); - }; - let Some(ProjectCommands::List(args)) = args.command else { - panic!("Expected list"); - }; - assert_eq!(args.namespace.as_deref(), namespace); - } - } -} diff --git a/crates/tracel-cli/src/context.rs b/crates/tracel-cli/src/context.rs index f830ba3..a3a43b5 100644 --- a/crates/tracel-cli/src/context.rs +++ b/crates/tracel-cli/src/context.rs @@ -132,3 +132,17 @@ fn first_credentials( None => stored_login(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracel_api_key_comes_before_a_stored_login() { + let api_key = TracelCredentials::ApiKey("key".into()); + + let chosen = first_credentials(Some(api_key), || panic!("the stored login is read")); + + assert!(matches!(chosen, Ok(Some(TracelCredentials::ApiKey(key))) if key == "key")); + } +} diff --git a/crates/tracel-cli/src/environment.rs b/crates/tracel-cli/src/environment.rs index bc992f2..b3f6086 100644 --- a/crates/tracel-cli/src/environment.rs +++ b/crates/tracel-cli/src/environment.rs @@ -69,26 +69,44 @@ fn parse(value: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::test_support::report; #[test] - fn environment_values_match_sdk_and_lowercase_forms() { - for value in [None, Some("Production"), Some("production")] { - assert!(matches!(resolve(false, None, value), Ok(Env::Production))); - } - for value in ["Development", "development"] { - assert!(matches!( - resolve(false, None, Some(value)), - Ok(Env::Development) - )); + fn flags_choose_the_environment_before_tracel_env() { + for (dev, staging, variable, expected) in [ + (false, None, None, "production"), + (false, None, Some("development"), "development"), + (true, None, Some("production"), "development"), + (true, None, Some("bogus"), "development"), + (false, Some(2), Some("Development"), "staging-2"), + (false, Some(2), Some("bogus"), "staging-2"), + ] { + let environment = resolve(dev, staging, variable).unwrap(); + assert_eq!( + name(&environment), + expected, + "{dev} {staging:?} {variable:?}" + ); } - for version in [0, 1, 255] { - for value in [format!("Staging({version})"), format!("staging-{version}")] { - assert!(matches!( - resolve(false, None, Some(&value)), - Ok(Env::Staging(number)) if number == version - )); + } + + #[test] + fn tracel_env_reads_every_environment_as_the_cli_and_the_sdk_write_it() { + for environment in [ + Env::Production, + Env::Development, + Env::Staging(0), + Env::Staging(255), + ] { + for written in [name(&environment), sdk_value(&environment)] { + let read = resolve(false, None, Some(&written)).unwrap(); + assert_eq!(name(&read), name(&environment), "{written}"); } } + } + + #[test] + fn an_unknown_tracel_env_is_a_usage_error() { for value in [ "bogus", "", @@ -100,20 +118,27 @@ mod tests { "staging-256", "Production ", ] { - let error = resolve(false, None, Some(value)).unwrap_err(); - assert_eq!(error.kind, ErrorKind::Usage); - assert!(error.to_string().contains("TRACEL_ENV")); + let error = anyhow::Error::new(resolve(false, None, Some(value)).unwrap_err()); + assert_eq!(report(&error), (ErrorKind::Usage, 2, false), "{value:?}"); + assert!( + error.to_string().contains(&format!("'{value}'")), + "{value:?}" + ); } } #[test] - fn environment_flags_override_variable() { - for value in [None, Some("Production"), Some("Development"), Some("bogus")] { - assert!(matches!(resolve(true, None, value), Ok(Env::Development))); - assert!(matches!( - resolve(false, Some(2), value), - Ok(Env::Staging(2)) - )); + fn each_environment_has_its_web_console() { + for (environment, expected) in [ + (Env::Production, "https://console.tracel.ai/"), + (Env::Staging(3), "https://s3-console.tracel.ai/"), + (Env::Development, "http://localhost:9000/"), + ] { + assert_eq!( + web_console(&environment).as_str(), + expected, + "{environment:?}" + ); } } } diff --git a/crates/tracel-cli/src/error.rs b/crates/tracel-cli/src/error.rs index 3032ff7..84fb64f 100644 --- a/crates/tracel-cli/src/error.rs +++ b/crates/tracel-cli/src/error.rs @@ -156,7 +156,7 @@ mod tests { use super::*; #[test] - fn exit_codes_and_code_strings_are_stable() { + fn each_kind_keeps_its_documented_code_and_exit_code() { for (kind, code, exit) in [ (ErrorKind::Internal, "INTERNAL", 1), (ErrorKind::Usage, "USAGE", 2), @@ -169,147 +169,161 @@ mod tests { (ErrorKind::Timeout, "TIMEOUT", 10), (ErrorKind::Unavailable, "UNAVAILABLE", 11), ] { - assert_eq!(kind.exit_code(), exit); - assert_eq!(serde_json::to_value(kind).unwrap(), code); + assert_eq!(kind.exit_code(), exit, "{kind:?}"); + assert_eq!(serde_json::to_value(kind).unwrap(), code, "{kind:?}"); + } + } + + fn api_error(status: u16, code: ApiErrorCode) -> ClientError { + ClientError::ApiError { + status: status.try_into().unwrap(), + body: tracel_client::error::ApiErrorBody { + code, + message: "from the server".into(), + }, } } #[test] - fn client_errors_survive_context() { + fn an_error_takes_the_kind_of_its_cause_under_any_context() { + let console = url::Url::parse("http://localhost:9001/").unwrap(); + let unreachable = || ClientError::UnknownError("connection refused".into()); for (error, kind) in [ - (ClientError::Unauthenticated, ErrorKind::NotAuthenticated), - (ClientError::AppSessionEnded, ErrorKind::NotAuthenticated), - (ClientError::CredentialNotAllowed, ErrorKind::Forbidden), - (ClientError::NotFound, ErrorKind::NotFound), ( - ClientError::NotFoundWithCode(tracel_client::ApiErrorCode::Unknown), + ClientError::Unauthenticated.into(), + ErrorKind::NotAuthenticated, + ), + ( + ClientError::AppSessionEnded.into(), + ErrorKind::NotAuthenticated, + ), + ( + ClientError::CredentialNotAllowed.into(), + ErrorKind::Forbidden, + ), + (ClientError::NotFound.into(), ErrorKind::NotFound), + ( + ClientError::NotFoundWithCode(ApiErrorCode::Unknown).into(), ErrorKind::NotFound, ), - (ClientError::InternalServerError, ErrorKind::Unavailable), ( - ClientError::UnknownError("connection refused".into()), + ClientError::InternalServerError.into(), ErrorKind::Unavailable, ), + (unreachable().into(), ErrorKind::Unavailable), ( - ClientError::SessionStore("unreadable".into()), + ClientError::SessionStore("unreadable".into()).into(), ErrorKind::Internal, ), - ] { - assert_eq!( - classify(&anyhow::Error::new(error).context("request failed")), - kind - ); - } - } - - #[test] - fn api_status_determines_kind() { - for (status, kind) in [ - (400, ErrorKind::Usage), - (422, ErrorKind::Usage), - (401, ErrorKind::NotAuthenticated), - (403, ErrorKind::Forbidden), - (404, ErrorKind::NotFound), - (409, ErrorKind::Conflict), - (429, ErrorKind::Unavailable), - (500, ErrorKind::Unavailable), - (503, ErrorKind::Unavailable), - (599, ErrorKind::Unavailable), - (408, ErrorKind::Internal), - ] { - let error = ClientError::ApiError { - status: status.try_into().unwrap(), - body: Default::default(), - }; - assert_eq!( - classify(&anyhow::Error::new(error).context("request failed")), - kind - ); - } - } - - #[test] - fn server_limit_code_is_limit_reached() { - for status in [400, 403] { - let error = ClientError::ApiError { - status: status.try_into().unwrap(), - body: tracel_client::error::ApiErrorBody { - code: ApiErrorCode::LimitReached, - message: "limit".into(), - }, - }; - assert_eq!( - classify(&anyhow::Error::new(error).context("request failed")), - ErrorKind::LimitReached - ); - } - } - - #[test] - fn device_flow_errors_separate_outcomes_from_outages() { - for (error, kind) in [ - (DeviceFlowError::AccessDenied, ErrorKind::NotAuthenticated), - (DeviceFlowError::ExpiredToken, ErrorKind::NotAuthenticated), ( - DeviceFlowError::Client(ClientError::UnknownError("connection refused".into())), + api_error(400, ApiErrorCode::Unknown).into(), + ErrorKind::Usage, + ), + ( + api_error(422, ApiErrorCode::Unknown).into(), + ErrorKind::Usage, + ), + ( + api_error(401, ApiErrorCode::Unknown).into(), + ErrorKind::NotAuthenticated, + ), + ( + api_error(403, ApiErrorCode::Unknown).into(), + ErrorKind::Forbidden, + ), + ( + api_error(404, ApiErrorCode::Unknown).into(), + ErrorKind::NotFound, + ), + ( + api_error(409, ApiErrorCode::Unknown).into(), + ErrorKind::Conflict, + ), + ( + api_error(429, ApiErrorCode::Unknown).into(), + ErrorKind::Unavailable, + ), + ( + api_error(503, ApiErrorCode::Unknown).into(), + ErrorKind::Unavailable, + ), + ( + api_error(408, ApiErrorCode::Unknown).into(), + ErrorKind::Internal, + ), + ( + api_error(400, ApiErrorCode::LimitReached).into(), + ErrorKind::LimitReached, + ), + ( + api_error(403, ApiErrorCode::LimitReached).into(), + ErrorKind::LimitReached, + ), + ( + DeviceFlowError::AccessDenied.into(), + ErrorKind::NotAuthenticated, + ), + ( + DeviceFlowError::ExpiredToken.into(), + ErrorKind::NotAuthenticated, + ), + ( + DeviceFlowError::Client(unreachable()).into(), ErrorKind::Unavailable, ), - ] { - assert_eq!( - classify(&anyhow::Error::new(error).context("login failed")), - kind - ); - } - } - - #[test] - fn creation_errors_keep_their_kind() { - for (error, kind) in [ ( - ClientCreationError::NoCredentials, + ClientCreationError::NoCredentials.into(), ErrorKind::NotAuthenticated, ), ( - ClientCreationError::InvalidCredentials, + ClientCreationError::InvalidCredentials.into(), ErrorKind::NotAuthenticated, ), ( - ClientCreationError::ApiKeyRefused, + ClientCreationError::ApiKeyRefused.into(), ErrorKind::NotAuthenticated, ), ( - ClientCreationError::ServerConnectionError( - url::Url::parse("http://localhost:9001/").unwrap(), - ), + ClientCreationError::ServerConnectionError(console.clone()).into(), ErrorKind::Unavailable, ), ( - ClientCreationError::SessionStore(ClientError::SessionStore("unreadable".into())), + ClientCreationError::SessionStore(ClientError::SessionStore("x".into())).into(), ErrorKind::Internal, ), + (anyhow::anyhow!("unclassified"), ErrorKind::Internal), ] { - assert_eq!( - classify(&anyhow::Error::new(error).context("client failed")), - kind - ); + let error: anyhow::Error = error; + let shown = format!("{error:?}"); + assert_eq!(classify(&error.context("Can't do it")), kind, "{shown}"); } } #[test] - fn explicit_kind_and_hint_survive_context() { + fn a_cli_error_keeps_its_kind_and_hint_under_context() { let error = anyhow::Error::new(ClientError::NotFound) - .context( - CliError::new(ErrorKind::LimitReached, "limit reached") - .with_hint("Try again later."), - ) - .context("operation failed"); + .context(CliError::new(ErrorKind::LimitReached, "Limit reached.").with_hint("Wait.")) + .context("Can't do it"); + let report = ErrorReport::new(&error); + assert_eq!(report.code, ErrorKind::LimitReached); - assert_eq!(report.hint, Some("Try again later.")); + assert_eq!(report.hint, Some("Wait.")); assert_eq!(report.exit_code, 8); - assert_eq!( - classify(&anyhow::anyhow!("unclassified")), - ErrorKind::Internal - ); + } + + #[test] + fn a_reported_message_ends_as_a_sentence() { + for (error, expected) in [ + (anyhow::anyhow!("No model 'w'."), "No model 'w'."), + ( + anyhow::anyhow!("No such file").context("Can't read 'a'"), + "Can't read 'a': No such file.", + ), + (anyhow::anyhow!("Ready?"), "Ready?"), + (anyhow::anyhow!("trailing space "), "trailing space."), + ] { + assert_eq!(ErrorReport::new(&error).message, expected); + } } } diff --git a/crates/tracel-cli/src/helpers/download.rs b/crates/tracel-cli/src/helpers/download.rs index ecc02c6..d4728cc 100644 --- a/crates/tracel-cli/src/helpers/download.rs +++ b/crates/tracel-cli/src/helpers/download.rs @@ -181,110 +181,116 @@ fn download_each( #[cfg(test)] mod tests { use super::*; + use crate::error::classify; #[test] - fn paths_must_contain_only_normal_relative_components() { - for path in ["a", "nested/weights.bin", "a-b/c_d"] { - assert!(validate_rel_path(path).is_ok()); - } - for path in [ - "", - ".", - "./a", - "a/./b", - "..", - "../a", - "a/../b", - "/a", - "C:\\a", - "C:a", - "C:/a", - "\\\\server\\file", - "\\a", - "a\0b", + fn a_download_path_stays_inside_its_directory() { + for (path, safe) in [ + ("a", true), + ("nested/weights.bin", true), + ("a-b/c_d", true), + ("", false), + (".", false), + ("./a", false), + ("a/./b", false), + ("..", false), + ("../a", false), + ("a/../b", false), + ("/a", false), + ("C:\\a", false), + ("C:a", false), + ("C:/a", false), + ("\\\\server\\file", false), + ("\\a", false), + ("a\0b", false), ] { - let error = validate_rel_path(path).unwrap_err(); - assert_eq!(crate::error::classify(&error), ErrorKind::Internal); - assert!(error.to_string().contains(path)); + match validate_rel_path(path) { + Ok(()) => assert!(safe, "{path:?}"), + Err(error) => { + assert!(!safe, "{path:?}"); + assert_eq!(classify(&error), ErrorKind::Internal, "{path:?}"); + assert!(error.to_string().contains(path), "{path:?}"); + } + } } } - fn file(size_bytes: Option, checksum: Option) -> DownloadFile { + fn file(size_bytes: Option, checksum: Option<&str>) -> DownloadFile { DownloadFile { rel_path: "hello".into(), url: String::new(), size_bytes, - checksum, + checksum: checksum.map(str::to_string), } } - #[test] - fn writer_hashes_and_counts_only_written_bytes() { - struct PartialWriter(Vec); - impl Write for PartialWriter { - fn write(&mut self, data: &[u8]) -> std::io::Result { - let count = data.len().min(2); - self.0.extend_from_slice(&data[..count]); - Ok(count) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } + /// A writer that takes two bytes at a time, as a socket may. + struct PartialWriter(Vec); + + impl Write for PartialWriter { + fn write(&mut self, data: &[u8]) -> std::io::Result { + let count = data.len().min(2); + self.0.extend_from_slice(&data[..count]); + Ok(count) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) } + } + + #[test] + fn the_writer_hashes_only_the_bytes_written() { let mut writer = HashingWriter::new(PartialWriter(Vec::new())); + writer.write_all(b"hello world").unwrap(); + assert_eq!(writer.bytes, 11); assert_eq!(writer.inner.0, b"hello world"); - let checksum = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"; - assert!( - writer - .verify(&file(Some(11), Some(checksum.to_uppercase()))) - .is_ok() - ); - assert!(writer.verify(&file(Some(0), Some(String::new()))).is_ok()); - assert!(writer.verify(&file(None, None)).is_ok()); assert_eq!( - crate::error::classify(&writer.verify(&file(Some(10), None)).unwrap_err()), - ErrorKind::Internal - ); - assert_eq!( - crate::error::classify(&writer.verify(&file(None, Some("bad".into()))).unwrap_err()), - ErrorKind::Internal + format!("{:x}", writer.hasher.clone().finalize()), + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" ); } #[test] - fn preflight_validates_all_paths_before_checking_all_conflicts() { - let directory = std::env::temp_dir().join(format!( - "tracel-download-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - fs::create_dir_all(&directory).unwrap(); - fs::write(directory.join("hello"), b"existing").unwrap(); - let mut files = vec![ - DownloadFile { - rel_path: "new".into(), - ..file(None, None) - }, - file(None, None), - ]; - let error = check_destinations(&directory, &files, false).unwrap_err(); - assert_eq!(crate::error::classify(&error), ErrorKind::Conflict); - assert!(error.to_string().contains("hello")); - assert!(!directory.join("new").exists()); - assert!(check_destinations(&directory, &files, true).is_ok()); - files.push(DownloadFile { + fn a_download_must_match_its_declared_size_and_checksum() { + let checksum = "B94D27B9934D3E08A52E52D7DA7DABFAC484EFE37A5380EE9088F7ACE2EFCDE9"; + let mut writer = HashingWriter::new(Vec::new()); + writer.write_all(b"hello world").unwrap(); + for (declared, matches) in [ + (file(Some(11), Some(checksum)), true), + (file(Some(0), Some("")), true), + (file(None, None), true), + (file(Some(10), None), false), + (file(None, Some("bad")), false), + ] { + let case = (declared.size_bytes, declared.checksum.clone()); + assert_eq!(writer.verify(&declared).is_ok(), matches, "{case:?}"); + } + } + + #[test] + fn every_path_is_checked_before_any_existing_file() { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("hello"), b"existing").unwrap(); + let new = || DownloadFile { + rel_path: "new".into(), + ..file(None, None) + }; + let unsafe_path = DownloadFile { rel_path: "../unsafe".into(), ..file(None, None) - }); - assert_eq!( - crate::error::classify(&check_destinations(&directory, &files, false).unwrap_err()), - ErrorKind::Internal - ); - fs::remove_dir_all(directory).unwrap(); + }; + + let existing = check_destinations(directory.path(), &[new(), file(None, None)], false); + let forced = check_destinations(directory.path(), &[new(), file(None, None)], true); + let unsafe_first = + check_destinations(directory.path(), &[file(None, None), unsafe_path], false); + + assert_eq!(classify(&existing.unwrap_err()), ErrorKind::Conflict); + assert!(forced.is_ok()); + assert_eq!(classify(&unsafe_first.unwrap_err()), ErrorKind::Internal); + assert!(!directory.path().join("new").exists()); } } diff --git a/crates/tracel-cli/src/helpers/model_upload/parts.rs b/crates/tracel-cli/src/helpers/model_upload/parts.rs index df3e1a6..cc7d752 100644 --- a/crates/tracel-cli/src/helpers/model_upload/parts.rs +++ b/crates/tracel-cli/src/helpers/model_upload/parts.rs @@ -101,147 +101,59 @@ mod tests { use super::*; use tracel_client::console::artifact::response::MultipartUploadResponse; - #[test] - fn given_multi_part_file_when_build_part_tasks_then_computes_sequential_offsets() { - let mut files = BTreeMap::new(); - files.insert("weights.bin".to_string(), PathBuf::from("/tmp/weights.bin")); - let mut file_sizes = BTreeMap::new(); - file_sizes.insert("weights.bin".to_string(), 300u64); - let upload_files = vec![PresignedModelFileUploadUrlsResponse { - rel_path: "weights.bin".to_string(), - urls: MultipartUploadResponse { - id: "upload-1".to_string(), - parts: vec![ - PresignedUploadUrlResponse { - part: 2, - url: "https://example.com/part2".to_string(), - size_bytes: 100, - }, - PresignedUploadUrlResponse { - part: 1, - url: "https://example.com/part1".to_string(), - size_bytes: 200, - }, - ], - }, - }]; - - let tasks = build_part_tasks(&files, &file_sizes, &upload_files).unwrap(); - - assert_eq!(tasks.len(), 2); - assert_eq!(tasks[0].part, 1); - assert_eq!(tasks[0].offset, 0); - assert_eq!(tasks[0].size_bytes, 200); - assert_eq!(tasks[1].part, 2); - assert_eq!(tasks[1].offset, 200); - assert_eq!(tasks[1].size_bytes, 100); - } - - #[test] - fn given_unknown_rel_path_when_build_part_tasks_then_returns_error() { - let files: BTreeMap = BTreeMap::new(); - let file_sizes: BTreeMap = BTreeMap::new(); - - let upload_files = vec![PresignedModelFileUploadUrlsResponse { - rel_path: "missing.bin".to_string(), - urls: MultipartUploadResponse { - id: "upload-1".to_string(), - parts: vec![PresignedUploadUrlResponse { - part: 1, - url: "https://example.com/part1".to_string(), - size_bytes: 10, - }], - }, - }]; - - let result = build_part_tasks(&files, &file_sizes, &upload_files); - - assert!(result.is_err()); + fn local_file(size: u64) -> (BTreeMap, BTreeMap) { + let files = BTreeMap::from([("weights.bin".to_string(), PathBuf::from("weights.bin"))]); + let sizes = BTreeMap::from([("weights.bin".to_string(), size)]); + (files, sizes) } - #[test] - fn given_parts_summing_to_less_than_local_size_when_build_part_tasks_then_returns_error() { - let mut files = BTreeMap::new(); - files.insert("weights.bin".to_string(), PathBuf::from("/tmp/weights.bin")); - let mut file_sizes = BTreeMap::new(); - file_sizes.insert("weights.bin".to_string(), 500u64); - - let upload_files = vec![PresignedModelFileUploadUrlsResponse { - rel_path: "weights.bin".to_string(), + fn response(rel_path: &str, parts: &[(u32, u64)]) -> Vec { + vec![PresignedModelFileUploadUrlsResponse { + rel_path: rel_path.to_string(), urls: MultipartUploadResponse { id: "upload-1".to_string(), - parts: vec![PresignedUploadUrlResponse { - part: 1, - url: "https://example.com/part1".to_string(), - size_bytes: 300, - }], + parts: parts + .iter() + .map(|&(part, size_bytes)| PresignedUploadUrlResponse { + part, + url: format!("https://example.com/part{part}"), + size_bytes, + }) + .collect(), }, - }]; - - let result = build_part_tasks(&files, &file_sizes, &upload_files); - - assert!(result.is_err()); + }] } #[test] - fn given_duplicate_part_number_when_build_part_tasks_then_returns_error() { - let mut files = BTreeMap::new(); - files.insert("weights.bin".to_string(), PathBuf::from("/tmp/weights.bin")); - let mut file_sizes = BTreeMap::new(); - file_sizes.insert("weights.bin".to_string(), 400u64); - - let upload_files = vec![PresignedModelFileUploadUrlsResponse { - rel_path: "weights.bin".to_string(), - urls: MultipartUploadResponse { - id: "upload-1".to_string(), - parts: vec![ - PresignedUploadUrlResponse { - part: 1, - url: "https://example.com/part1".to_string(), - size_bytes: 200, - }, - PresignedUploadUrlResponse { - part: 1, - url: "https://example.com/part1b".to_string(), - size_bytes: 200, - }, - ], - }, - }]; - - let result = build_part_tasks(&files, &file_sizes, &upload_files); - - assert!(result.is_err()); + fn parts_get_sequential_offsets_in_part_order() { + let (files, sizes) = local_file(300); + + let tasks = build_part_tasks( + &files, + &sizes, + &response("weights.bin", &[(2, 100), (1, 200)]), + ) + .unwrap(); + + let layout: Vec<(u32, u64, u64)> = tasks + .iter() + .map(|task| (task.part, task.offset, task.size_bytes)) + .collect(); + assert_eq!(layout, [(1, 0, 200), (2, 200, 100)]); } #[test] - fn given_gap_in_part_numbers_when_build_part_tasks_then_returns_error() { - let mut files = BTreeMap::new(); - files.insert("weights.bin".to_string(), PathBuf::from("/tmp/weights.bin")); - let mut file_sizes = BTreeMap::new(); - file_sizes.insert("weights.bin".to_string(), 300u64); - - let upload_files = vec![PresignedModelFileUploadUrlsResponse { - rel_path: "weights.bin".to_string(), - urls: MultipartUploadResponse { - id: "upload-1".to_string(), - parts: vec![ - PresignedUploadUrlResponse { - part: 1, - url: "https://example.com/part1".to_string(), - size_bytes: 200, - }, - PresignedUploadUrlResponse { - part: 3, - url: "https://example.com/part3".to_string(), - size_bytes: 100, - }, - ], - }, - }]; - - let result = build_part_tasks(&files, &file_sizes, &upload_files); - - assert!(result.is_err()); + fn an_upload_response_that_does_not_cover_the_file_is_rejected() { + let (files, sizes) = local_file(300); + for (rel_path, parts, named) in [ + ("missing.bin", &[(1, 300)][..], "'missing.bin'"), + ("weights.bin", &[], "'weights.bin'"), + ("weights.bin", &[(1, 200)], "200 bytes"), + ("weights.bin", &[(1, 150), (1, 150)], "got 1"), + ("weights.bin", &[(1, 200), (3, 100)], "got 3"), + ] { + let error = build_part_tasks(&files, &sizes, &response(rel_path, parts)).unwrap_err(); + assert!(error.to_string().contains(named), "{parts:?}: {error}"); + } } } diff --git a/crates/tracel-cli/src/helpers/model_upload/upload.rs b/crates/tracel-cli/src/helpers/model_upload/upload.rs index 3ef4445..05e7968 100644 --- a/crates/tracel-cli/src/helpers/model_upload/upload.rs +++ b/crates/tracel-cli/src/helpers/model_upload/upload.rs @@ -159,100 +159,73 @@ pub fn upload_parts( #[cfg(test)] mod tests { use super::*; - use crate::ui::{Format, Layout, Place, channels}; + use crate::test_support::quiet_terminal; use std::fs; - use std::path::PathBuf; - use std::sync::Mutex as StdMutex; - - fn make_temp_dir(name: &str) -> PathBuf { - let dir = - std::env::temp_dir().join(format!("tracel-cli-test-{name}-{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(dir.join("nested")).unwrap(); - dir - } + use std::sync::Mutex; - struct FakeUploader { - calls: StdMutex>, - fail_url: Option, + /// Records the URLs it uploads to, and fails on `fail_url`. + struct RecordingUploader { + calls: Mutex>, + fail_url: Option<&'static str>, } - impl FakeUploader { - fn new(fail_url: Option<&str>) -> Self { + impl RecordingUploader { + fn new(fail_url: Option<&'static str>) -> Self { Self { - calls: StdMutex::new(Vec::new()), - fail_url: fail_url.map(str::to_string), + calls: Mutex::new(Vec::new()), + fail_url, } } } - impl PartUploader for FakeUploader { - fn upload(&self, url: &str, _bytes: Vec) -> Result<(), tracel_client::ClientError> { + impl PartUploader for RecordingUploader { + fn upload(&self, url: &str, _bytes: Vec) -> Result<(), ClientError> { self.calls.lock().unwrap().push(url.to_string()); - if self.fail_url.as_deref() == Some(url) { - return Err(tracel_client::ClientError::NotFound); + if self.fail_url == Some(url) { + return Err(ClientError::NotFound); } Ok(()) } } - fn make_task( - dir: &Path, - rel_path: &str, - part: u32, - url: &str, - content: &[u8], - ) -> PartUploadTask { - let path = dir.join(rel_path); - fs::write(&path, content).unwrap(); - PartUploadTask { - rel_path: rel_path.to_string(), - absolute_path: path, - part, - url: url.to_string(), - offset: 0, - size_bytes: content.len() as u64, - } + fn tasks(directory: &Path) -> Vec { + ["a", "b"] + .into_iter() + .map(|name| { + let path = directory.join(format!("{name}.bin")); + fs::write(&path, b"data").unwrap(); + PartUploadTask { + rel_path: format!("{name}.bin"), + absolute_path: path, + part: 1, + url: format!("https://example.com/{name}"), + offset: 0, + size_bytes: 4, + } + }) + .collect() } #[test] - fn given_all_parts_succeed_when_upload_parts_then_returns_ok_and_uploads_each_once() { - let dir = make_temp_dir("upload-parts-ok"); - let tasks = vec![ - make_task(&dir, "a.bin", 1, "https://example.com/a", b"a-data"), - make_task(&dir, "b.bin", 1, "https://example.com/b", b"b-data"), - ]; - let uploader = FakeUploader::new(None); - - let result = upload_parts( - &uploader, - tasks, - &channels(Format::Json, true, Layout::Bare, Place::default()).1, - ); - - assert!(result.is_ok()); - assert_eq!(uploader.calls.lock().unwrap().len(), 2); - - fs::remove_dir_all(&dir).unwrap(); + fn every_part_is_uploaded_once() { + let directory = tempfile::tempdir().unwrap(); + let uploader = RecordingUploader::new(None); + + upload_parts(&uploader, tasks(directory.path()), &quiet_terminal()).unwrap(); + + let mut calls = uploader.calls.into_inner().unwrap(); + calls.sort(); + assert_eq!(calls, ["https://example.com/a", "https://example.com/b"]); } #[test] - fn given_one_part_fails_when_upload_parts_then_returns_error() { - let dir = make_temp_dir("upload-parts-fail"); - let tasks = vec![ - make_task(&dir, "a.bin", 1, "https://example.com/a", b"a-data"), - make_task(&dir, "b.bin", 1, "https://example.com/b", b"b-data"), - ]; - let uploader = FakeUploader::new(Some("https://example.com/b")); - - let result = upload_parts( - &uploader, - tasks, - &channels(Format::Json, true, Layout::Bare, Place::default()).1, - ); - - assert!(result.is_err()); - - fs::remove_dir_all(&dir).unwrap(); + fn a_failed_part_fails_the_upload_naming_the_part() { + let directory = tempfile::tempdir().unwrap(); + let uploader = RecordingUploader::new(Some("https://example.com/b")); + + let error = + upload_parts(&uploader, tasks(directory.path()), &quiet_terminal()).unwrap_err(); + + assert!(error.to_string().contains("'b.bin' (part 1)"), "{error}"); } } diff --git a/crates/tracel-cli/src/helpers/project.rs b/crates/tracel-cli/src/helpers/project.rs index 1b7aa23..1237381 100644 --- a/crates/tracel-cli/src/helpers/project.rs +++ b/crates/tracel-cli/src/helpers/project.rs @@ -251,109 +251,105 @@ pub fn validate_project_exists_on_server( #[cfg(test)] mod tests { use super::*; + use crate::test_support::report; #[test] - fn project_flag_requires_two_nonempty_parts() { - let project = parse_project("alice/demo").unwrap(); - assert_eq!(project.namespace, "alice"); - assert_eq!(project.name, "demo"); - for value in [ - "", - "alice", - "/demo", - "alice/", - "/", - "alice/demo/extra", - "alice//demo", + fn a_project_flag_is_a_namespace_and_a_name() { + for (value, expected) in [ + ("alice/demo", Some(("alice", "demo"))), + ("", None), + ("alice", None), + ("/demo", None), + ("alice/", None), + ("/", None), + ("alice/demo/extra", None), + ("alice//demo", None), ] { - let error = parse_project(value).unwrap_err(); - assert_eq!(error.kind, CliErrorKind::Usage); - assert!(error.to_string().contains("--project")); + match parse_project(value) { + Ok(project) => assert_eq!( + Some((project.namespace.as_str(), project.name.as_str())), + expected, + "{value:?}" + ), + Err(error) => { + assert_eq!(expected, None, "{value:?}"); + let error = anyhow::Error::new(error); + assert_eq!(report(&error), (CliErrorKind::Usage, 2, false), "{value:?}"); + assert!(error.to_string().contains(&format!("'{value}'"))); + } + } } } #[test] - fn complete_overrides_skip_file_discovery() { - let flag = parse_project("alice/demo").unwrap(); - let resolved = resolve_project_from( - Some(&flag), - Some("other".into()), - Some("project".into()), - || panic!("File discovery must be skipped"), - ) - .unwrap(); - assert_eq!(resolved.project.namespace, "alice"); - assert_eq!(resolved.project.name, "demo"); - assert_eq!(resolved.source, ProjectSource::Flag); - - let resolved = - resolve_project_from(None, Some("alice".into()), Some("demo".into()), || { - panic!("File discovery must be skipped") - }) - .unwrap(); - assert_eq!(resolved.project.namespace, "alice"); - assert_eq!(resolved.project.name, "demo"); - assert_eq!(resolved.source, ProjectSource::Env); - } - - #[test] - fn environment_parts_independently_override_file() { - for (namespace, project, expected_namespace, expected_project, source) in [ + fn the_flag_then_the_variables_then_tracel_toml_name_the_project() { + let flag = parse_project("flag/project").unwrap(); + let file = "namespace = 'file'\nproject = 'project'"; + for (flag, namespace, project, toml, expected) in [ + ( + Some(&flag), + Some("env"), + Some("env"), + None, + ("flag", "project", ProjectSource::Flag), + ), ( None, + Some("env"), + Some("demo"), None, - "file-owner", - "file-project", - ProjectSource::TracelToml, + ("env", "demo", ProjectSource::Env), ), ( - Some("alice"), None, - "alice", - "file-project", - ProjectSource::Env, + None, + None, + Some(file), + ("file", "project", ProjectSource::TracelToml), ), - (None, Some("demo"), "file-owner", "demo", ProjectSource::Env), - ] { - let resolved = resolve_project_from( + ( None, - namespace.map(str::to_owned), - project.map(str::to_owned), - || { - Ok(TracelTomlConfig { - namespace: Some("file-owner".into()), - project: Some("file-project".into()), - }) - }, - ) - .unwrap(); - assert_eq!(resolved.project.namespace, expected_namespace); - assert_eq!(resolved.project.name, expected_project); - assert_eq!(resolved.source, source); - } - } - - #[test] - fn partial_config_and_legacy_keys_match_sdk() { - for (contents, namespace, project) in [ - ("project = 'demo'", Some("alice"), None), - ("namespace = 'alice'", None, Some("demo")), - ("owner = 'alice'\nname = 'demo'", None, None), + Some("env"), + None, + Some(file), + ("env", "project", ProjectSource::Env), + ), + ( + None, + None, + Some("demo"), + Some(file), + ("file", "demo", ProjectSource::Env), + ), + ( + None, + None, + None, + Some("owner = 'old'\nname = 'names'"), + ("old", "names", ProjectSource::TracelToml), + ), ] { + let case = (namespace, project, toml); + let config = || match toml { + Some(toml) => Ok(toml::from_str(toml).unwrap()), + None => panic!("tracel.toml is read for {case:?}"), + }; let resolved = resolve_project_from( - None, + flag, namespace.map(str::to_owned), project.map(str::to_owned), - || Ok(toml::from_str(contents).unwrap()), + config, ) .unwrap(); - assert_eq!(resolved.project.namespace, "alice"); - assert_eq!(resolved.project.name, "demo"); + let (namespace, name, source) = expected; + assert_eq!(resolved.project.namespace, namespace, "{case:?}"); + assert_eq!(resolved.project.name, name, "{case:?}"); + assert_eq!(resolved.source, source, "{case:?}"); } } #[test] - fn incomplete_identity_reports_not_found_and_hint() { + fn an_incomplete_project_is_not_found_with_a_hint() { for (namespace, project) in [(None, None), (Some("alice"), None), (None, Some("demo"))] { let error = resolve_project_from( None, @@ -362,20 +358,40 @@ mod tests { || Ok(TracelTomlConfig::default()), ) .unwrap_err(); - let report = crate::error::ErrorReport::new(&error); - assert_eq!(crate::error::classify(&error), CliErrorKind::NotFound); - assert_eq!(report.exit_code, 5); assert_eq!( - report.hint, - Some("Run `tracel init`, or pass --project /.") + report(&error), + (CliErrorKind::NotFound, 5, true), + "{namespace:?} {project:?}" + ); + } + } + + #[test] + fn a_missing_project_names_itself_however_it_was_selected() { + let project = parse_project("alice/demo").unwrap(); + for source in [ + ProjectSource::Flag, + ProjectSource::Env, + ProjectSource::TracelToml, + ] { + let error = anyhow::Error::new(missing_project(&project, &source)); + assert_eq!( + report(&error), + (CliErrorKind::NotFound, 5, true), + "{source:?}" ); + assert!(error.to_string().contains("alice/demo"), "{source:?}"); } + } + + #[test] + fn a_project_source_has_its_documented_json_name() { for (source, value) in [ (ProjectSource::Flag, "flag"), (ProjectSource::Env, "env"), (ProjectSource::TracelToml, "tracel.toml"), ] { - assert_eq!(serde_json::to_value(source).unwrap(), value); + assert_eq!(serde_json::to_value(&source).unwrap(), value, "{source:?}"); } } } diff --git a/crates/tracel-cli/src/helpers/resources.rs b/crates/tracel-cli/src/helpers/resources.rs index 8b09423..ff8c3bc 100644 --- a/crates/tracel-cli/src/helpers/resources.rs +++ b/crates/tracel-cli/src/helpers/resources.rs @@ -149,11 +149,10 @@ pub fn select_artifact<'a>( #[cfg(test)] mod tests { use super::*; - use crate::error::ErrorReport; + use crate::test_support::report; - #[test] - fn missing_models_use_the_resolved_project_in_every_model_context() { - for resource in [ + fn models() -> [Resource<'static>; 4] { + [ Resource::Model("weights"), Resource::ModelVersionRef { model: "weights", @@ -167,45 +166,39 @@ mod tests { model: "weights", alias: "production", }, - ] { - let error = map_resource_error( - ClientError::NotFoundWithCode(ApiErrorCode::Model), - "alice", - "demo", - resource, - ); - assert_eq!( - error.downcast_ref::().unwrap().kind, - ErrorKind::NotFound - ); - let report = ErrorReport::new(&error); - assert_eq!(report.message, "No model 'weights' in alice/demo."); - assert_eq!(report.hint, Some("List models with `tracel models list`.")); - assert_eq!(report.exit_code, 5); - } - for client_error in [ - ClientError::NotFound, - ClientError::NotFoundWithCode(ApiErrorCode::Unknown), - ] { - let error = - map_resource_error(client_error, "alice", "demo", Resource::Model("weights")); - let report = ErrorReport::new(&error); - assert_eq!(report.message, "No model 'weights' in alice/demo."); - assert_eq!(report.exit_code, 5); - } + ] } #[test] - fn missing_resources_have_specific_messages_and_listing_hints() { - for (client_error, resource, message, hint) in [ + fn a_missing_resource_names_itself_with_a_listing_hint() { + let mut cases: Vec<(ClientError, Resource<'static>, &str)> = models() + .into_iter() + .map(|resource| { + ( + ClientError::NotFoundWithCode(ApiErrorCode::Model), + resource, + "'weights'", + ) + }) + .collect(); + cases.extend([ + ( + ClientError::NotFound, + Resource::Model("weights"), + "'weights'", + ), + ( + ClientError::NotFoundWithCode(ApiErrorCode::Unknown), + Resource::Model("weights"), + "'weights'", + ), ( ClientError::NotFoundWithCode(ApiErrorCode::ModelVersion), Resource::ModelVersionRef { model: "weights", reference: "v7", }, - "No version or alias 'v7' on model 'weights'.", - "List versions with `tracel models versions weights` and aliases with `tracel models alias list weights`.", + "'v7'", ), ( ClientError::NotFoundWithCode(ApiErrorCode::ModelAlias), @@ -213,8 +206,7 @@ mod tests { model: "weights", reference: "production", }, - "No version or alias 'production' on model 'weights'.", - "List versions with `tracel models versions weights` and aliases with `tracel models alias list weights`.", + "'production'", ), ( ClientError::NotFoundWithCode(ApiErrorCode::ModelVersion), @@ -222,8 +214,7 @@ mod tests { model: "weights", version: 7, }, - "Model 'weights' has no version 7.", - "List versions with `tracel models versions weights`.", + "version 7", ), ( ClientError::NotFoundWithCode(ApiErrorCode::ModelAlias), @@ -231,60 +222,36 @@ mod tests { model: "weights", alias: "production", }, - "Model 'weights' has no alias 'production'.", - "List aliases with `tracel models alias list weights`.", + "'production'", ), ( ClientError::NotFound, Resource::Experiment(99), - "No experiment 99 in alice/demo.", - "List experiments with `tracel experiments list`.", + "experiment 99", ), ( ClientError::NotFoundWithCode(ApiErrorCode::Unknown), Resource::Experiment(99), - "No experiment 99 in alice/demo.", - "List experiments with `tracel experiments list`.", + "experiment 99", ), ( ClientError::NotFoundWithCode(ApiErrorCode::Dataset), Resource::Dataset("images"), - "No dataset 'images' in alice/demo.", - "List datasets with `tracel datasets list`.", + "'images'", ), - ] { + ]); + for (client_error, resource, named) in cases { + let case = format!("{client_error:?}"); let error = map_resource_error(client_error, "alice", "demo", resource); - assert_eq!( - error.downcast_ref::().unwrap().kind, - ErrorKind::NotFound - ); - let report = ErrorReport::new(&error); - assert_eq!(report.message, message); - assert_eq!(report.hint, Some(hint)); - assert_eq!(report.exit_code, 5); + assert_eq!(report(&error), (ErrorKind::NotFound, 5, true), "{case}"); + assert!(error.to_string().contains(named), "{case}: {error}"); } } #[test] - fn unrelated_errors_and_codes_keep_the_original_client_error() { - for resource in [ - Resource::Model("weights"), - Resource::ModelVersionRef { - model: "weights", - reference: "production", - }, - Resource::ModelVersion { - model: "weights", - version: 7, - }, - Resource::ModelAlias { - model: "weights", - alias: "production", - }, - Resource::Experiment(99), - Resource::Dataset("images"), - ] { - for client_error in [ + fn other_errors_pass_through_unchanged() { + let unrelated = || { + [ ClientError::Unauthenticated, ClientError::CredentialNotAllowed, ClientError::InternalServerError, @@ -298,17 +265,14 @@ mod tests { message: "original message".into(), }, }, - ] { - let original = format!("{client_error:?}"); - let error = map_resource_error(client_error, "alice", "demo", resource); - assert_eq!( - format!("{:?}", error.downcast_ref::().unwrap()), - original - ); - assert_eq!(ErrorReport::new(&error).hint, None); - } - } - for (client_error, resource) in [ + ] + }; + let mut cases: Vec<(ClientError, Resource<'static>)> = models() + .into_iter() + .chain([Resource::Experiment(99), Resource::Dataset("images")]) + .flat_map(|resource| unrelated().into_iter().map(move |error| (error, resource))) + .collect(); + cases.extend([ ( ClientError::NotFoundWithCode(ApiErrorCode::Unknown), Resource::Dataset("images"), @@ -339,61 +303,74 @@ mod tests { ClientError::NotFoundWithCode(ApiErrorCode::Dataset), Resource::Model("weights"), ), - ] { + ]); + for (client_error, resource) in cases { let original = format!("{client_error:?}"); let error = map_resource_error(client_error, "alice", "demo", resource); assert_eq!( format!("{:?}", error.downcast_ref::().unwrap()), original ); - assert_eq!(ErrorReport::new(&error).hint, None); + assert!(!report(&error).2, "{original}"); } } #[test] - fn artifact_selection_prefers_id_and_reports_missing_or_ambiguous_names() { + fn an_artifact_is_found_by_id_then_by_a_unique_name() { let items = [ ("a", "weights"), ("b", "weights"), ("c", "a"), ("d", "logs"), ]; - assert_eq!(select_artifact(items, "a", 7).unwrap(), 0); - assert_eq!(select_artifact(items, "logs", 7).unwrap(), 3); - let error = select_artifact(items, "weights", 7).unwrap_err(); - assert_eq!(error.kind, ErrorKind::Conflict); - assert_eq!( - ErrorReport::new(&error.into()).hint, - Some("Pass one of their ids: a, b.") - ); - let error = select_artifact(items, "missing", 7).unwrap_err(); - assert_eq!(error.kind, ErrorKind::NotFound); - assert_eq!(error.to_string(), "No artifact 'missing' in experiment 7."); - assert!(select_artifact([], "missing", 7).is_err()); - assert_eq!( - select_artifact([("a", "b"), ("b", "weights")], "b", 7).unwrap(), - 1 - ); + for (value, expected) in [ + ("a", Ok(0)), + ("logs", Ok(3)), + ("weights", Err((ErrorKind::Conflict, 6, true))), + ("missing", Err((ErrorKind::NotFound, 5, true))), + ] { + let selected = select_artifact(items, value, 7).map_err(|error| { + let error = anyhow::Error::new(error); + assert!(error.to_string().contains(&format!("'{value}'")), "{value}"); + report(&error) + }); + assert_eq!(selected, expected, "{value}"); + } } #[test] - fn metadata_requires_a_json_object_and_description_requires_creation() { - assert_eq!( - parse_metadata(r#"{"format":"bin"}"#).unwrap()["format"], - "bin" - ); - for value in ["", "{", "null", "[]", "1", "true", r#""text""#] { - assert_eq!(parse_metadata(value).unwrap_err().kind, ErrorKind::Usage); + fn metadata_is_a_json_object() { + for (value, valid) in [ + (r#"{"format":"bin"}"#, true), + ("{}", true), + ("", false), + ("{", false), + ("null", false), + ("[]", false), + ("1", false), + (r#""text""#, false), + ] { + assert_eq!(parse_metadata(value).is_ok(), valid, "{value:?}"); + if let Err(error) = parse_metadata(value) { + assert_eq!(error.kind, ErrorKind::Usage, "{value:?}"); + } } - for auto_create in [None, Some(false)] { + } + + #[test] + fn a_description_needs_auto_create_true() { + for (auto_create, description, valid) in [ + (Some(true), Some("text"), true), + (None, None, true), + (Some(false), None, true), + (None, Some("text"), false), + (Some(false), Some("text"), false), + ] { assert_eq!( - validate_auto_create(auto_create, Some("text")) - .unwrap_err() - .kind, - ErrorKind::Usage + validate_auto_create(auto_create, description).is_ok(), + valid, + "{auto_create:?} {description:?}" ); } - assert!(validate_auto_create(Some(true), Some("text")).is_ok()); - assert!(validate_auto_create(None, None).is_ok()); } } diff --git a/crates/tracel-cli/src/lib.rs b/crates/tracel-cli/src/lib.rs index 2a4176d..0678269 100644 --- a/crates/tracel-cli/src/lib.rs +++ b/crates/tracel-cli/src/lib.rs @@ -4,6 +4,8 @@ mod context; mod environment; mod error; mod helpers; +#[cfg(test)] +mod test_support; mod tools; mod ui; diff --git a/crates/tracel-cli/src/test_support.rs b/crates/tracel-cli/src/test_support.rs new file mode 100644 index 0000000..d6471e6 --- /dev/null +++ b/crates/tracel-cli/src/test_support.rs @@ -0,0 +1,53 @@ +//! Helpers shared by the tests of several modules. + +use std::io; + +use crate::cli::{CliArgs, Commands}; +use crate::error::{ErrorKind, ErrorReport, classify}; +use crate::ui::{self, Format, Human, Layout, Place, Render, Terminal}; + +/// Parse `tracel` with `args`, as the shell would pass them. +pub fn parse(args: &[&str]) -> Result { + CliArgs::try_parse_args(std::iter::once("tracel").chain(args.iter().copied())) +} + +/// The command `args` name, which must parse. +pub fn command(args: &[&str]) -> Commands { + parse(args) + .unwrap_or_else(|error| panic!("{args:?} don't parse: {error}")) + .command + .unwrap_or_else(|| panic!("{args:?} name no command")) +} + +/// What a failure reports: its kind, its exit code, and whether it has a hint. +pub fn report(error: &anyhow::Error) -> (ErrorKind, i32, bool) { + let report = ErrorReport::new(error); + (classify(error), report.exit_code, report.hint.is_some()) +} + +/// The plain text of a result, without its summary. +pub fn plain(result: &impl Render) -> String { + let mut text = Vec::new(); + result + .render(&mut Human::plain(&mut text, None)) + .expect("writing to memory succeeds"); + String::from_utf8(text).expect("results are UTF-8") +} + +/// A terminal that never draws or asks: JSON output, and `--no-input`. +pub fn quiet_terminal() -> Terminal { + ui::channels(Format::Json, true, Layout::Bare, Place::default()).1 +} + +/// A writer whose reader has gone away. +pub struct ClosedPipe; + +impl io::Write for ClosedPipe { + fn write(&mut self, _: &[u8]) -> io::Result { + Err(io::ErrorKind::BrokenPipe.into()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} diff --git a/crates/tracel-cli/src/tools/build_driver.rs b/crates/tracel-cli/src/tools/build_driver.rs index 058c43a..925802f 100644 --- a/crates/tracel-cli/src/tools/build_driver.rs +++ b/crates/tracel-cli/src/tools/build_driver.rs @@ -150,45 +150,26 @@ pub fn cross_preflight( mod tests { use super::*; - const LINUX_X86: (Os, Arch) = (Os::Linux, Arch::X86_64); - const LINUX_ARM: (Os, Arch) = (Os::Linux, Arch::Arm64); - const MAC_ARM: (Os, Arch) = (Os::Macos, Arch::Arm64); - const WIN_X86: (Os, Arch) = (Os::Windows, Arch::X86_64); - - fn both() -> AvailableDrivers { - AvailableDrivers { - zigbuild: true, - xwin: true, - } - } - fn none() -> AvailableDrivers { - AvailableDrivers { - zigbuild: false, - xwin: false, - } - } - #[test] - fn same_os_always_uses_cargo() { - // Even with every driver installed, same-OS cross-arch stays on cargo build. - assert_eq!(choose(LINUX_X86, LINUX_ARM, &both()), BuildDriver::Cargo); - assert_eq!(choose(LINUX_X86, LINUX_X86, &both()), BuildDriver::Cargo); - } - - #[test] - fn cross_os_windows_uses_xwin_only() { - assert_eq!(choose(LINUX_X86, WIN_X86, &both()), BuildDriver::Xwin); - // Zig can't target MSVC, so without xwin it falls back to cargo, not zigbuild. - let only_zig = AvailableDrivers { - zigbuild: true, - xwin: false, - }; - assert_eq!(choose(LINUX_X86, WIN_X86, &only_zig), BuildDriver::Cargo); - } - - #[test] - fn cross_os_unix_uses_zigbuild() { - assert_eq!(choose(LINUX_X86, MAC_ARM, &both()), BuildDriver::Zigbuild); - assert_eq!(choose(LINUX_X86, MAC_ARM, &none()), BuildDriver::Cargo); + fn the_driver_follows_the_target_os_and_the_installed_tools() { + const LINUX_X86: (Os, Arch) = (Os::Linux, Arch::X86_64); + const LINUX_ARM: (Os, Arch) = (Os::Linux, Arch::Arm64); + const MAC_ARM: (Os, Arch) = (Os::Macos, Arch::Arm64); + const WIN_X86: (Os, Arch) = (Os::Windows, Arch::X86_64); + let drivers = |zigbuild, xwin| AvailableDrivers { zigbuild, xwin }; + for (target, available, expected) in [ + (LINUX_X86, drivers(true, true), BuildDriver::Cargo), + (LINUX_ARM, drivers(true, true), BuildDriver::Cargo), + (WIN_X86, drivers(true, true), BuildDriver::Xwin), + (WIN_X86, drivers(true, false), BuildDriver::Cargo), + (MAC_ARM, drivers(true, true), BuildDriver::Zigbuild), + (MAC_ARM, drivers(false, true), BuildDriver::Cargo), + ] { + assert_eq!( + choose(LINUX_X86, target, &available), + expected, + "{target:?}" + ); + } } } diff --git a/crates/tracel-cli/src/tools/fs.rs b/crates/tracel-cli/src/tools/fs.rs index 2d445f5..c5cbe40 100644 --- a/crates/tracel-cli/src/tools/fs.rs +++ b/crates/tracel-cli/src/tools/fs.rs @@ -90,57 +90,44 @@ mod tests { use super::*; use std::fs; - fn make_temp_dir(name: &str) -> PathBuf { - let dir = - std::env::temp_dir().join(format!("tracel-cli-test-{name}-{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(dir.join("nested")).unwrap(); - dir - } - #[test] - fn given_nested_directory_when_collect_files_then_returns_all_files_with_forward_slash_rel_paths() - { - let dir = make_temp_dir("collect-files"); - fs::write(dir.join("a.txt"), b"a").unwrap(); - fs::write(dir.join("nested").join("b.txt"), b"b").unwrap(); - - let files = collect_files(&dir).unwrap(); - - assert_eq!(files.len(), 2); - assert!(files.contains_key("a.txt")); - assert!(files.contains_key("nested/b.txt")); - fs::remove_dir_all(&dir).unwrap(); + fn files_are_listed_by_their_relative_path_with_slashes() { + let directory = tempfile::tempdir().unwrap(); + fs::create_dir(directory.path().join("nested")).unwrap(); + fs::write(directory.path().join("a.txt"), b"a").unwrap(); + fs::write(directory.path().join("nested").join("b.txt"), b"b").unwrap(); + + let files = collect_files(directory.path()).unwrap(); + + assert_eq!( + files.keys().map(String::as_str).collect::>(), + ["a.txt", "nested/b.txt"] + ); } #[test] - fn given_empty_directory_when_collect_files_then_returns_error() { - let dir = make_temp_dir("collect-files-empty"); + fn an_empty_directory_has_no_files_to_upload() { + let directory = tempfile::tempdir().unwrap(); - let result = collect_files(&dir); + let error = collect_files(directory.path()).unwrap_err(); - assert!(result.is_err()); - fs::remove_dir_all(&dir).unwrap(); + assert!(error.to_string().starts_with("No files"), "{error}"); } #[test] - fn given_known_content_when_build_file_specs_then_computes_correct_sha256_and_size() { - let dir = make_temp_dir("build-file-specs"); - fs::write(dir.join("hello.txt"), b"hello world").unwrap(); - - let mut files = BTreeMap::new(); - files.insert("hello.txt".to_string(), dir.join("hello.txt")); + fn a_file_spec_has_its_size_and_sha256() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("hello.txt"); + fs::write(&path, b"hello world").unwrap(); + let files = BTreeMap::from([("hello.txt".to_string(), path)]); let specs = build_file_specs(&files).unwrap(); - assert_eq!(specs.len(), 1); - assert_eq!(specs[0].rel_path, "hello.txt"); - assert_eq!(specs[0].size_bytes, 11); + let spec = &specs[0]; + assert_eq!((spec.rel_path.as_str(), spec.size_bytes), ("hello.txt", 11)); assert_eq!( - specs[0].checksum, + spec.checksum, "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" ); - - fs::remove_dir_all(&dir).unwrap(); } } diff --git a/crates/tracel-cli/src/tools/git.rs b/crates/tracel-cli/src/tools/git.rs index 23b573f..19e0986 100644 --- a/crates/tracel-cli/src/tools/git.rs +++ b/crates/tracel-cli/src/tools/git.rs @@ -49,25 +49,15 @@ mod tests { use super::*; #[test] - fn test_get_first_commit_hash() { - let hash = get_first_commit_hash().expect("Failed to get first commit hash"); - + fn the_first_commit_is_the_root_of_head() { let output = std::process::Command::new("git") - .args(["rev-list", "--parents", "HEAD"]) + .args(["rev-list", "--max-parents=0", "--first-parent", "HEAD"]) .output() - .expect("Failed to run git rev-list"); + .expect("git runs"); + let root = String::from_utf8_lossy(&output.stdout); - let stdout = String::from_utf8_lossy(&output.stdout); + let first = get_first_commit_hash().expect("the repository has commits"); - if let Some(last_line) = stdout.lines().last() { - let parts: Vec<&str> = last_line.split_whitespace().collect(); - if let Some(first_commit_hash) = parts.first() { - assert_eq!(hash, *first_commit_hash); - } else { - panic!("No commit hash found in the last line of git rev-list output."); - } - } else { - eprintln!("No commits found."); - } + assert_eq!(first, root.trim()); } } diff --git a/crates/tracel-cli/src/tools/linker.rs b/crates/tracel-cli/src/tools/linker.rs index 55f773b..f227cfc 100644 --- a/crates/tracel-cli/src/tools/linker.rs +++ b/crates/tracel-cli/src/tools/linker.rs @@ -134,70 +134,26 @@ pub fn resolve_linker( mod tests { use super::*; - /// A unique, empty scratch directory for a test, removed on drop. - struct TempRoot(PathBuf); - - impl TempRoot { - fn new(name: &str) -> Self { - let dir = std::env::temp_dir() - .join(format!("burn_linker_test_{}_{name}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - TempRoot(dir) - } - - fn path(&self) -> &Path { - &self.0 - } - } - - impl Drop for TempRoot { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - - const LINUX_X86: (Os, Arch) = (Os::Linux, Arch::X86_64); - const LINUX_ARM: (Os, Arch) = (Os::Linux, Arch::Arm64); - const MAC_X86: (Os, Arch) = (Os::Macos, Arch::X86_64); - const MAC_ARM: (Os, Arch) = (Os::Macos, Arch::Arm64); - const WIN_X86: (Os, Arch) = (Os::Windows, Arch::X86_64); - #[test] - fn linker_need_same_target_is_none() { - assert!(matches!( - linker_need(LINUX_X86, LINUX_X86), - LinkerNeed::None - )); - } - - #[test] - fn linker_need_linux_cross_arch_is_config_entry() { - match linker_need(LINUX_X86, LINUX_ARM) { - LinkerNeed::ConfigEntry { linker, .. } => assert_eq!(linker, "aarch64-linux-gnu-gcc"), - _ => panic!("expected ConfigEntry"), - } - match linker_need(LINUX_ARM, LINUX_X86) { - LinkerNeed::ConfigEntry { linker, .. } => assert_eq!(linker, "x86_64-linux-gnu-gcc"), - _ => panic!("expected ConfigEntry"), + fn a_linker_setting_is_needed_only_across_linux_architectures() { + const LINUX_X86: (Os, Arch) = (Os::Linux, Arch::X86_64); + const LINUX_ARM: (Os, Arch) = (Os::Linux, Arch::Arm64); + const MAC_X86: (Os, Arch) = (Os::Macos, Arch::X86_64); + const MAC_ARM: (Os, Arch) = (Os::Macos, Arch::Arm64); + const WIN_X86: (Os, Arch) = (Os::Windows, Arch::X86_64); + for (host, target, expected) in [ + (LINUX_X86, LINUX_X86, None), + (LINUX_X86, LINUX_ARM, Some("aarch64-linux-gnu-gcc")), + (LINUX_ARM, LINUX_X86, Some("x86_64-linux-gnu-gcc")), + (MAC_X86, MAC_ARM, None), + (LINUX_X86, WIN_X86, None), + (LINUX_X86, MAC_ARM, None), + ] { + let linker = match linker_need(host, target) { + LinkerNeed::None => None, + LinkerNeed::ConfigEntry { linker, .. } => Some(linker), + }; + assert_eq!(linker, expected, "{host:?} {target:?}"); } } - - #[test] - fn linker_need_macos_cross_arch_is_none() { - assert!(matches!(linker_need(MAC_X86, MAC_ARM), LinkerNeed::None)); - } - - #[test] - fn linker_need_different_os_is_none() { - // Cross-OS linker setup is handled by the build driver, not a linker entry. - assert!(matches!(linker_need(LINUX_X86, WIN_X86), LinkerNeed::None)); - assert!(matches!(linker_need(LINUX_X86, MAC_ARM), LinkerNeed::None)); - } - - #[test] - fn linker_configured_false_when_absent() { - let root = TempRoot::new("absent"); - assert!(!linker_configured(root.path(), "aarch64-unknown-linux-gnu")); - } } diff --git a/crates/tracel-cli/src/tools/target.rs b/crates/tracel-cli/src/tools/target.rs index 1d46201..b5cbcb4 100644 --- a/crates/tracel-cli/src/tools/target.rs +++ b/crates/tracel-cli/src/tools/target.rs @@ -225,30 +225,40 @@ pub fn install_missing_target( #[cfg(test)] mod tests { use super::*; + use crate::test_support::report; #[test] - fn no_explicit_targets_defaults_to_host() { + fn no_requested_target_means_the_host() { for host in ALL_TARGETS { - assert_eq!(resolve_targets(host, &[]).unwrap(), vec![host]); + assert_eq!(resolve_targets(host, &[]).unwrap(), [host], "{host:?}"); } } #[test] - fn explicit_targets_are_validated_and_deduplicated_in_order() { - let host = (Os::Linux, Arch::X86_64); + fn requested_targets_keep_their_order_without_repeats() { let requested = [ "aarch64-unknown-linux-gnu".to_string(), "x86_64-pc-windows-msvc".to_string(), "aarch64-unknown-linux-gnu".to_string(), ]; + + let targets = resolve_targets((Os::Linux, Arch::X86_64), &requested).unwrap(); + assert_eq!( - resolve_targets(host, &requested).unwrap(), - vec![(Os::Linux, Arch::Arm64), (Os::Windows, Arch::X86_64),] + targets, + [(Os::Linux, Arch::Arm64), (Os::Windows, Arch::X86_64)] ); - let error = parse_target("unknown-triple").unwrap_err(); - assert_eq!(error.kind, ErrorKind::Usage); + } + + #[test] + fn an_unknown_target_lists_the_targets() { + let error = anyhow::Error::new(parse_target("unknown-triple").unwrap_err()); + + assert_eq!(report(&error), (ErrorKind::Usage, 2, false)); + let message = error.to_string(); + assert!(message.contains("'unknown-triple'"), "{message}"); for (os, arch) in ALL_TARGETS { - assert!(error.to_string().contains(target_triple(os, arch))); + assert!(message.contains(target_triple(os, arch)), "{message}"); } } } diff --git a/crates/tracel-cli/src/tools/workspace.rs b/crates/tracel-cli/src/tools/workspace.rs index fe3c0a8..a58cad9 100644 --- a/crates/tracel-cli/src/tools/workspace.rs +++ b/crates/tracel-cli/src/tools/workspace.rs @@ -98,67 +98,26 @@ mod tests { use super::*; #[test] - fn test_extract_workspace_name_from_workspace_package() { - let toml_content = r#" -[workspace] -members = ["crate1", "crate2"] - -[workspace.package] -name = "my-awesome-workspace" -version = "0.1.0" -"#; - let result = extract_workspace_name_from_toml(toml_content); - assert_eq!(result, Some("my-awesome-workspace".to_string())); - } - - #[test] - fn test_extract_workspace_name_from_package() { - let toml_content = r#" -[package] -name = "single-crate-project" -version = "0.1.0" -"#; - let result = extract_workspace_name_from_toml(toml_content); - assert_eq!(result, Some("single-crate-project".to_string())); - } - - #[test] - fn test_extract_workspace_name_prefers_workspace_over_package() { - let toml_content = r#" -[workspace] -members = ["crate1"] - -[workspace.package] -name = "workspace-name" - -[package] -name = "package-name" -"#; - let result = extract_workspace_name_from_toml(toml_content); - assert_eq!(result, Some("workspace-name".to_string())); - } - - #[test] - fn test_extract_workspace_name_returns_none_when_no_name() { - let toml_content = r#" -[workspace] -members = ["crate1", "crate2"] -"#; - let result = extract_workspace_name_from_toml(toml_content); - assert_eq!(result, None); - } - - #[test] - fn test_extract_workspace_name_returns_none_for_invalid_toml() { - let toml_content = "this is not valid toml { [ }"; - let result = extract_workspace_name_from_toml(toml_content); - assert_eq!(result, None); - } - - #[test] - fn test_extract_workspace_name_returns_none_for_empty_toml() { - let toml_content = ""; - let result = extract_workspace_name_from_toml(toml_content); - assert_eq!(result, None); + fn the_workspace_name_comes_from_workspace_package_then_package() { + for (toml, expected) in [ + ( + "[workspace]\nmembers = ['a']\n[workspace.package]\nname = 'workspace-name'\n", + Some("workspace-name"), + ), + ("[package]\nname = 'single-crate'\n", Some("single-crate")), + ( + "[workspace.package]\nname = 'workspace-name'\n[package]\nname = 'package-name'\n", + Some("workspace-name"), + ), + ("[workspace]\nmembers = ['a']\n", None), + ("this is not valid toml { [ }", None), + ("", None), + ] { + assert_eq!( + extract_workspace_name_from_toml(toml).as_deref(), + expected, + "{toml:?}" + ); + } } } diff --git a/crates/tracel-cli/src/ui/format.rs b/crates/tracel-cli/src/ui/format.rs index c96381b..215084c 100644 --- a/crates/tracel-cli/src/ui/format.rs +++ b/crates/tracel-cli/src/ui/format.rs @@ -65,47 +65,58 @@ mod tests { use super::*; #[test] - fn auto_is_the_command_default() { - for auto in [Format::Human, Format::Json] { - assert_eq!(Format::resolve(None, false, None, auto).unwrap(), auto); - assert_eq!( - Format::resolve(Some(FormatArg::Auto), false, Some("json"), auto).unwrap(), - auto - ); + fn json_then_output_then_tracel_output_choose_the_format() { + use FormatArg::{Auto, Human as HumanArg, Json as JsonArg}; + for (flag, json, environment, auto, expected) in [ + (None, false, None, Format::Human, Some(Format::Human)), + (None, false, None, Format::Json, Some(Format::Json)), + ( + Some(Auto), + false, + Some("json"), + Format::Human, + Some(Format::Human), + ), + ( + None, + false, + Some("human"), + Format::Json, + Some(Format::Human), + ), + (None, false, Some("json"), Format::Human, Some(Format::Json)), + ( + Some(HumanArg), + false, + Some("json"), + Format::Json, + Some(Format::Human), + ), + ( + Some(JsonArg), + false, + Some("human"), + Format::Human, + Some(Format::Json), + ), + ( + Some(HumanArg), + false, + Some("xml"), + Format::Json, + Some(Format::Human), + ), + (None, true, Some("human"), Format::Human, Some(Format::Json)), + (None, true, Some("xml"), Format::Human, Some(Format::Json)), + (None, false, Some("xml"), Format::Human, None), + (None, false, Some("auto"), Format::Human, None), + ] { + let case = (flag, json, environment, auto); + let resolved = Format::resolve(flag, json, environment, auto); + assert_eq!(resolved.as_ref().ok().copied(), expected, "{case:?}"); + if let Err(error) = resolved { + assert_eq!(error.kind, ErrorKind::Usage, "{case:?}"); + } } - assert_eq!(Format::for_stdout(true), Format::Human); - assert_eq!(Format::for_stdout(false), Format::Json); - } - - #[test] - fn flags_override_environment() { - for environment in [Some("human"), Some("json"), Some("invalid")] { - assert_eq!( - Format::resolve(Some(FormatArg::Human), false, environment, Format::Json).unwrap(), - Format::Human - ); - assert_eq!( - Format::resolve(None, true, environment, Format::Human).unwrap(), - Format::Json - ); - assert_eq!( - Format::resolve(Some(FormatArg::Auto), false, environment, Format::Json).unwrap(), - Format::Json - ); - } - } - - #[test] - fn environment_selects_format_or_reports_usage() { - assert_eq!( - Format::resolve(None, false, Some("human"), Format::Json).unwrap(), - Format::Human - ); - assert_eq!( - Format::resolve(None, false, Some("json"), Format::Human).unwrap(), - Format::Json - ); - let error = Format::resolve(None, false, Some("auto"), Format::Human).unwrap_err(); - assert_eq!(error.kind, ErrorKind::Usage); } } diff --git a/crates/tracel-cli/src/ui/human.rs b/crates/tracel-cli/src/ui/human.rs index 1e32d88..c9048d3 100644 --- a/crates/tracel-cli/src/ui/human.rs +++ b/crates/tracel-cli/src/ui/human.rs @@ -17,22 +17,15 @@ pub struct Human<'a> { } impl<'a> Human<'a> { - /// Write plain text on `out`, with tables fitted to `width` columns when given. - pub fn plain(out: &'a mut dyn Write, width: Option) -> Self { - Self { - out, - width, - styled: false, - } + /// Write text on `out`, with tables fitted to `width` columns when given, and + /// styled for a terminal when `styled`. + pub fn new(out: &'a mut dyn Write, width: Option, styled: bool) -> Self { + Self { out, width, styled } } - /// Write text for a box on the terminal, styled unless stderr shows no colors. - pub fn for_box(out: &'a mut dyn Write, width: Option) -> Self { - Self { - out, - width, - styled: console::colors_enabled_stderr(), - } + /// Write plain text on `out`, with tables fitted to `width` columns when given. + pub fn plain(out: &'a mut dyn Write, width: Option) -> Self { + Self::new(out, width, false) } } @@ -384,23 +377,18 @@ mod tests { use super::*; use crate::ui::screen::BOX_PADDING; - fn text(width: Option, write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> String { + fn text( + width: Option, + styled: bool, + write: impl FnOnce(&mut Human<'_>) -> io::Result<()>, + ) -> String { let mut out = Vec::new(); - write(&mut Human::plain(&mut out, width)).unwrap(); + write(&mut Human::new(&mut out, width, styled)).unwrap(); String::from_utf8(out).unwrap() } fn plain(write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> String { - text(None, write) - } - - /// Text for a box, styled. - fn boxed(width: Option, write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> String { - let mut out = Vec::new(); - let mut human = Human::for_box(&mut out, width); - human.styled = true; - write(&mut human).unwrap(); - String::from_utf8(out).unwrap() + text(None, false, write) } fn table() -> Table<3> { @@ -411,9 +399,11 @@ mod tests { } #[test] - fn table_aligns_columns_and_keeps_cells_on_one_line() { + fn a_table_aligns_its_cells_on_one_line_each() { + let written = plain(|out| table().write(out)); + assert_eq!( - plain(|out| table().write(out)), + written, "NAME DESCRIPTION ID\n\ weights two lines 1\n\ w - 22\n" @@ -421,8 +411,9 @@ mod tests { } #[test] - fn an_empty_table_writes_its_empty_text_or_nothing() { + fn an_empty_table_writes_only_its_empty_text() { let empty = || Table::new(["NAME"]); + assert_eq!(plain(|out| empty().write(out)), ""); assert_eq!( plain(|out| empty().empty("No files.").write(out)), @@ -439,21 +430,28 @@ mod tests { "0123456789".into(), ]]) }; + + let shrunk = text(Some(36), false, |out| { + table().shrink("DESCRIPTION").write(out) + }); + let whole = text(Some(36), false, |out| table().write(out)); + assert_eq!( - text(Some(36), |out| table().shrink("DESCRIPTION").write(out)), + shrunk, "NAME DESCRIPTION ID\n\ weights a rather long… 0123456789\n" ); - assert_eq!( - text(Some(36), |out| table().write(out)), - plain(|out| table().write(out)) - ); + assert_eq!(whole, plain(|out| table().write(out))); } #[test] fn a_shrinking_column_can_cut_its_header() { + let written = text(Some(18), false, |out| { + table().shrink("DESCRIPTION").write(out) + }); + assert_eq!( - text(Some(18), |out| table().shrink("DESCRIPTION").write(out)), + written, "NAME DESC… ID\n\ weights two… 1\n\ w - 22\n" @@ -466,18 +464,21 @@ mod tests { .optional("Description", None::<&str>) .section( "Config", - &serde_json::json!({"lr": 0.1, "schedule": {"warmup": 2}, "tags": ["a"]}), + &serde_json::json!({"lr": 0.1, "loss": 0.7499999999999999, "schedule": {"warmup": 2}, "tags": ["a"]}), ) .section("Attributes", &serde_json::json!({})) } #[test] - fn details_align_values_and_list_json_fields() { + fn details_align_their_fields_above_sections_of_dotted_keys() { + let written = plain(|out| details().write(out)); + assert_eq!( - plain(|out| details().write(out)), + written, "Name: weights\n\ Description: -\n\ Config:\n \ + loss 0.75\n \ lr 0.1\n \ schedule.warmup 2\n \ tags [\"a\"]\n" @@ -485,27 +486,18 @@ mod tests { } #[test] - fn plain_text_is_never_styled_and_a_box_is_the_same_text_styled() { + fn a_styled_text_is_the_plain_text_with_bold_and_dim_spans() { let write = |out: &mut Human<'_>| { + let (bold, dim) = (out.bold("NAME"), out.dim("-")); + writeln!(out, "{bold} {dim} x")?; details().write(out)?; table().write(out) }; - let plain = plain(write); - let boxed = boxed(None, write); - assert!(!plain.contains('\u{1b}')); - assert!(boxed.contains('\u{1b}')); - assert_eq!(console::strip_ansi_codes(&boxed), plain); - } - #[test] - fn a_box_styles_its_spans() { - assert_eq!( - boxed(None, |out| { - let (bold, dim) = (out.bold("NAME"), out.dim("-")); - writeln!(out, "{bold} {dim} x") - }), - "\u{1b}[1mNAME\u{1b}[0m \u{1b}[2m-\u{1b}[0m x\n" - ); + let styled = text(None, true, write); + + assert!(styled.starts_with("\u{1b}[1mNAME\u{1b}[0m \u{1b}[2m-\u{1b}[0m x\n")); + assert_eq!(console::strip_ansi_codes(&styled), plain(write)); } /// The columns inside a box, as cliclack measures them to wrap its text. @@ -516,7 +508,8 @@ mod tests { #[test] fn a_table_fitted_to_the_box_is_not_wrapped_again() { let width = box_width(); - let fitted = boxed(Some(width), |out| { + + let fitted = text(Some(width), true, |out| { Table::new(["NAME", "DESCRIPTION", "ID"]) .shrink("DESCRIPTION") .rows([[ @@ -526,6 +519,7 @@ mod tests { ]]) .write(out) }); + let fitted = fitted.trim_end(); assert!( fitted @@ -536,11 +530,71 @@ mod tests { } #[test] - fn counts_mention_what_was_left_out() { - let count = |shown, total| list_summary(shown, total, ["model", "models"], "No models."); - assert_eq!(count(0, 0), "No models."); - assert_eq!(count(1, 1), "1 model."); - assert_eq!(count(3, 3), "3 models."); - assert_eq!(count(10, 16), "Showing 10 of 16 models."); + fn a_count_uses_the_plural_from_two() { + for (count, expected) in [(0, "0 files"), (1, "1 file"), (2, "2 files")] { + assert_eq!(counted(count, ["file", "files"]), expected, "{count}"); + } + } + + #[test] + fn a_list_summary_says_what_was_left_out() { + for (shown, total, expected) in [ + (0, 0, "No models."), + (1, 1, "1 model."), + (3, 3, "3 models."), + (10, 16, "Showing 10 of 16 models."), + (3, 0, "3 models."), + ] { + assert_eq!( + list_summary(shown, total, ["model", "models"], "No models."), + expected, + "{shown} {total}" + ); + } + } + + #[test] + fn a_number_keeps_about_four_significant_digits() { + for (value, expected) in [ + (0.7499999999999999, "0.75"), + (0.923076923076923, "0.9231"), + (0.00001, "0.00001"), + (43.4, "43.4"), + (1234.5678, "1235"), + (1000000.0, "1000000"), + (-0.5, "-0.5"), + (2.0, "2"), + (0.0, "0"), + ] { + assert_eq!(number(value), expected, "{value}"); + } + } + + #[test] + fn a_size_is_in_bytes_kb_mb_or_gb() { + for (bytes, expected) in [ + (0, "0 B"), + (999, "999 B"), + (6900, "6.9 KB"), + (4_732_426, "4.732 MB"), + (713_713_674, "713.7 MB"), + (2_500_000_000, "2.5 GB"), + ] { + assert_eq!(size(bytes), expected, "{bytes}"); + } + } + + #[test] + fn a_digest_shows_its_first_twelve_characters() { + for (digest, expected) in [ + ( + "a8dd58d70d796abee1ef941f1f14b350310f17cba90c37813305ce8254aee225", + "a8dd58d70d79", + ), + ("9e24f16700f2fb16090f7e18c7f174c4660e963b", "9e24f16700f2"), + ("short", "short"), + ] { + assert_eq!(short_digest(digest), expected, "{digest}"); + } } } diff --git a/crates/tracel-cli/src/ui/layout.rs b/crates/tracel-cli/src/ui/layout.rs index e4d0027..0a2d2bf 100644 --- a/crates/tracel-cli/src/ui/layout.rs +++ b/crates/tracel-cli/src/ui/layout.rs @@ -41,20 +41,22 @@ mod tests { use super::*; #[test] - fn a_place_names_what_it_has() { - let place = |project: Option<&str>, environment: Option<&str>| { - Place { + fn a_place_names_its_project_and_environment() { + for (project, environment, expected) in [ + ( + Some("test/demo"), + Some("development"), + "test/demo · development", + ), + (Some("test/demo"), None, "test/demo"), + (None, Some("development"), "development"), + (None, None, ""), + ] { + let place = Place { project: project.map(str::to_string), environment: environment.map(str::to_string), - } - .to_string() - }; - assert_eq!( - place(Some("test/demo"), Some("development")), - "test/demo · development" - ); - assert_eq!(place(Some("test/demo"), None), "test/demo"); - assert_eq!(place(None, Some("development")), "development"); - assert_eq!(place(None, None), ""); + }; + assert_eq!(place.to_string(), expected, "{project:?} {environment:?}"); + } } } diff --git a/crates/tracel-cli/src/ui/screen.rs b/crates/tracel-cli/src/ui/screen.rs index c6f640b..c843981 100644 --- a/crates/tracel-cli/src/ui/screen.rs +++ b/crates/tracel-cli/src/ui/screen.rs @@ -283,3 +283,68 @@ fn columns(terminal: &console::Term) -> Option { .size_checked() .map(|(_, columns)| usize::from(columns)) } + +#[cfg(test)] +mod tests { + use super::*; + + fn screen(format: Format, layout: Layout, stderr: bool, stdout: bool) -> Screen { + let place = Place { + project: Some("test/demo".into()), + environment: Some("development".into()), + }; + Screen::new(format, layout, place, Terminals { stderr, stdout }) + } + + #[test] + fn a_read_shows_its_result_in_a_box_only_where_stdout_shares_the_terminal() { + for (format, layout, stderr, stdout, boxed) in [ + (Format::Human, Layout::Read, true, true, true), + (Format::Human, Layout::Read, true, false, false), + (Format::Human, Layout::Read, false, true, false), + (Format::Json, Layout::Read, true, true, false), + (Format::Human, Layout::flow("Link"), true, true, false), + (Format::Human, Layout::Bare, true, true, false), + ] { + let case = format!("{format:?} {stderr} {stdout}"); + let delivery = screen(format, layout, stderr, stdout).delivery(); + assert_eq!(matches!(delivery, Delivery::Box(_)), boxed, "{case}"); + } + } + + #[test] + fn a_flow_opens_a_session_saying_what_it_does_and_where() { + let flow = screen( + Format::Human, + Layout::flow("Link this workspace"), + true, + false, + ); + + let header = flow + .header() + .map(|header| console::strip_ansi_codes(&header).into_owned()); + + assert_eq!( + header.as_deref(), + Some("Link this workspace in test/demo · development") + ); + } + + #[test] + fn only_a_flow_drawn_on_a_terminal_opens_a_session() { + for (format, layout, stderr) in [ + (Format::Human, Layout::Read, true), + (Format::Human, Layout::Bare, true), + (Format::Human, Layout::flow("Link"), false), + (Format::Json, Layout::flow("Link"), true), + ] { + let screen = screen(format, layout, stderr, true); + + screen.intro(); + + assert!(screen.header().is_none(), "{format:?} {stderr}"); + assert!(!screen.session_open(), "{format:?} {stderr}"); + } + } +} diff --git a/crates/tracel-cli/src/ui/stderr.rs b/crates/tracel-cli/src/ui/stderr.rs index 81aec92..a26c852 100644 --- a/crates/tracel-cli/src/ui/stderr.rs +++ b/crates/tracel-cli/src/ui/stderr.rs @@ -347,9 +347,10 @@ impl Progress { #[cfg(test)] mod tests { use super::*; + use crate::test_support::{quiet_terminal, report}; #[test] - fn interactivity_requires_terminals_human_output_and_allowed_environment() { + fn prompts_need_terminals_human_output_and_no_ci() { for ci in [None, Some(""), Some("0"), Some("false")] { for format in [Format::Human, Format::Json] { for stdin in [false, true] { @@ -369,12 +370,62 @@ mod tests { } } + fn unlinking() -> Unanswered { + Unanswered::new( + "Unlinking test/demo needs confirmation.", + "Pass --yes to unlink without asking.", + ) + } + + #[test] + fn an_unanswerable_confirmation_requires_its_flag() { + let terminal = quiet_terminal(); + + let error = terminal + .confirm("Unlink test/demo?", false, unlinking()) + .unwrap_err(); + + assert_eq!(report(&error), (ErrorKind::ConfirmationRequired, 7, true)); + assert_eq!( + ErrorReport::new(&error).hint, + Some("Pass --yes to unlink without asking.") + ); + } + #[test] - fn a_hyperlink_shows_its_text_and_opens_its_url() { + fn other_unanswerable_input_is_a_usage_error() { + let terminal = quiet_terminal(); + let items = [(1, "one", ""), (2, "two", "")]; + for error in [ + terminal.input("Name", unlinking()).unwrap_err(), + terminal + .select("Pick", &items, None, unlinking()) + .unwrap_err(), + terminal + .multiselect("Pick", &items, vec![1], unlinking()) + .unwrap_err(), + ] { + assert_eq!(report(&error), (ErrorKind::Usage, 2, true)); + } + } + + #[test] + fn an_unanswerable_offer_is_declined_without_asking() { + let terminal = quiet_terminal(); + + let accepted = terminal.offer("Open it in your browser?").unwrap(); + + assert!(!accepted); + } + + #[test] + fn a_hyperlink_opens_its_url_from_its_text() { let url = url::Url::parse("http://localhost:9001/verify?code=BCDF-GHJK").unwrap(); + let link = hyperlink(&url, "the approval page"); + assert_eq!( - hyperlink(&url, "the approval page"), + link, "\u{1b}]8;;http://localhost:9001/verify?code=BCDF-GHJK\u{1b}\\the approval page\u{1b}]8;;\u{1b}\\" ); } diff --git a/crates/tracel-cli/src/ui/stdout.rs b/crates/tracel-cli/src/ui/stdout.rs index 1768925..fb6fd7e 100644 --- a/crates/tracel-cli/src/ui/stdout.rs +++ b/crates/tracel-cli/src/ui/stdout.rs @@ -150,17 +150,23 @@ impl Output { &self, write: impl FnOnce(&mut dyn Write) -> io::Result<()>, ) -> anyhow::Result<()> { - match write(&mut self.screen.stdout()) { - Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Err(StdoutClosed.into()), - result => Ok(result?), - } + closed_on_broken_pipe(write(&mut self.screen.stdout())) + } +} + +/// Turn a write that failed because the reader went away into `StdoutClosed`. +fn closed_on_broken_pipe(written: io::Result<()>) -> anyhow::Result<()> { + match written { + Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Err(StdoutClosed.into()), + written => Ok(written?), } } /// Write a result's text for its box, fitted to `width`. fn box_text(report: &dyn Report, width: Option) -> io::Result { let mut text = Vec::new(); - report.write_text(&mut Human::for_box(&mut text, width))?; + let styled = console::colors_enabled_stderr(); + report.write_text(&mut Human::new(&mut text, width, styled))?; Ok(String::from_utf8_lossy(&text).into_owned()) } @@ -218,6 +224,7 @@ fn write_failure(report: &ErrorReport<'_>, out: &mut dyn Write) -> io::Result<() mod tests { use super::*; use crate::error::{CliError, ErrorKind}; + use crate::test_support::ClosedPipe; #[derive(Serialize)] struct Greeting { @@ -234,81 +241,83 @@ mod tests { } } - #[derive(Serialize)] - struct Done {} - - impl Render for Done { - fn render(&self, _: &mut Human<'_>) -> io::Result<()> { - Ok(()) - } - } - fn written(write: impl FnOnce(&mut dyn Write) -> io::Result<()>) -> String { let mut out = Vec::new(); write(&mut out).unwrap(); String::from_utf8(out).unwrap() } - fn result(format: Format, report: &dyn Report, summary: Option<&str>) -> String { - written(|out| write_result(format, report, summary, None, out)) - } - #[test] - fn results_are_an_envelope_or_their_text() { + fn a_result_on_stdout_is_an_envelope_or_text_ending_with_its_summary() { let greeting = Greeting { name: "Ada" }; - assert_eq!( - result(Format::Json, &greeting, None), - "{\"ok\":true,\"data\":{\"name\":\"Ada\"}}\n" - ); - assert_eq!(result(Format::Human, &greeting, None), "Hello, Ada.\n"); - assert_eq!(result(Format::Human, &Done {}, None), ""); + let data = Outcome::data(Greeting { name: "Ada" }); + let data = data.0.as_deref().unwrap(); + for (format, report, summary, expected) in [ + ( + Format::Json, + &greeting as &dyn Report, + Some("Greeted."), + "{\"ok\":true,\"data\":{\"name\":\"Ada\"}}\n", + ), + (Format::Human, &greeting, None, "Hello, Ada.\n"), + ( + Format::Human, + &greeting, + Some("Greeted."), + "Hello, Ada.\nGreeted.\n", + ), + ( + Format::Json, + data, + None, + "{\"ok\":true,\"data\":{\"name\":\"Ada\"}}\n", + ), + (Format::Human, data, None, ""), + ] { + assert_eq!( + written(|out| write_result(format, report, summary, None, out)), + expected, + "{format:?} {summary:?}" + ); + } } #[test] - fn plain_text_ends_with_the_summary() { + fn a_box_holds_the_text_of_a_result_without_its_summary() { let greeting = Greeting { name: "Ada" }; - assert_eq!( - result(Format::Human, &greeting, Some("Greeted.")), - "Hello, Ada.\nGreeted.\n" - ); - assert_eq!( - result(Format::Json, &greeting, Some("Greeted.")), - "{\"ok\":true,\"data\":{\"name\":\"Ada\"}}\n" - ); - } - #[test] - fn a_box_holds_the_text_of_a_result() { - assert_eq!( - box_text(&Greeting { name: "Ada" }, None).unwrap(), - "Hello, Ada.\n" - ); - assert_eq!(box_text(&Done {}, None).unwrap(), ""); + let text = box_text(&greeting, None).unwrap(); + + assert_eq!(console::strip_ansi_codes(&text), "Hello, Ada.\n"); } #[test] - fn events_are_bare_json_lines_or_their_text() { + fn an_event_is_a_bare_json_line_or_its_text() { let event = Greeting { name: "Ada" }; - assert_eq!( - written(|out| write_event(Format::Json, None, &event, out)), - "{\"name\":\"Ada\"}\n" - ); - assert_eq!( - written(|out| write_event(Format::Human, None, &event, out)), - "Hello, Ada.\n" - ); + for (format, expected) in [ + (Format::Json, "{\"name\":\"Ada\"}\n"), + (Format::Human, "Hello, Ada.\n"), + ] { + assert_eq!( + written(|out| write_event(format, None, &event, out)), + expected, + "{format:?}" + ); + } } #[test] - fn failures_carry_the_error_report() { + fn a_failure_envelope_carries_the_error_report() { let error = anyhow::Error::new( CliError::new(ErrorKind::NotFound, "No model 'weights'.").with_hint("List models."), ); let report = ErrorReport::new(&error); - let value: serde_json::Value = + + let envelope: serde_json::Value = serde_json::from_str(&written(|out| write_failure(&report, out))).unwrap(); + assert_eq!( - value, + envelope, serde_json::json!({ "ok": false, "error": { @@ -320,4 +329,14 @@ mod tests { }) ); } + + #[test] + fn a_reader_that_went_away_closes_stdout() { + let greeting = Greeting { name: "Ada" }; + let written = write_result(Format::Human, &greeting, None, None, &mut ClosedPipe); + + let error = closed_on_broken_pipe(written).unwrap_err(); + + assert!(error.is::(), "{error:?}"); + } } diff --git a/crates/tracel-cli/src/ui/time.rs b/crates/tracel-cli/src/ui/time.rs index 674cb8d..c448ea3 100644 --- a/crates/tracel-cli/src/ui/time.rs +++ b/crates/tracel-cli/src/ui/time.rs @@ -23,14 +23,17 @@ impl Serialize for Timestamp { /// Show a server's RFC 3339 time in the local zone, to the minute. Other text stays as /// it is. pub fn when(text: &str) -> String { - local(text, &Local, "%Y-%m-%d %H:%M") + local(text, &Local, TO_THE_MINUTE) } /// Show a log entry's RFC 3339 time in the local zone, to the second. pub fn log_time(text: &str) -> String { - local(text, &Local, "%Y-%m-%d %H:%M:%S") + local(text, &Local, TO_THE_SECOND) } +const TO_THE_MINUTE: &str = "%Y-%m-%d %H:%M"; +const TO_THE_SECOND: &str = "%Y-%m-%d %H:%M:%S"; + fn local(text: &str, zone: &Tz, format: &str) -> String where Tz::Offset: Display, @@ -49,27 +52,33 @@ mod tests { #[test] fn server_times_read_in_the_local_zone() { - let zone = chrono::FixedOffset::west_opt(4 * 3600).unwrap(); - let minute = "%Y-%m-%d %H:%M"; - assert_eq!( - local("2026-10-09T20:21:06.075Z", &zone, minute), - "2026-10-09 16:21" - ); - assert_eq!( - local("2026-10-09T20:21:06Z", &Utc, minute), - "2026-10-09 20:21" - ); - assert_eq!(local("yesterday", &Utc, minute), "yesterday"); - assert_eq!(local("", &Utc, minute), ""); + let west = chrono::FixedOffset::west_opt(4 * 3600).unwrap(); + for (text, expected) in [ + ("2026-10-09T20:21:06.075Z", "2026-10-09 16:21"), + ("2026-10-09T20:21:06+00:00", "2026-10-09 16:21"), + ("yesterday", "yesterday"), + ("", ""), + ] { + assert_eq!(local(text, &west, TO_THE_MINUTE), expected, "{text:?}"); + } + } + + #[test] + fn a_log_time_keeps_its_seconds() { + let utc = chrono::Utc; + + let time = local("2026-10-09T20:21:06.075Z", &utc, TO_THE_SECOND); + + assert_eq!(time, "2026-10-09 20:21:06"); } #[test] - fn timestamps_are_rfc_3339_in_utc() { + fn a_timestamp_is_rfc_3339_in_utc() { let time = Timestamp(UNIX_EPOCH + Duration::from_secs(1_900_000_000)); - assert_eq!(time.to_string(), "2030-03-17T17:46:40+00:00"); - assert_eq!( - serde_json::to_value(time).unwrap(), - "2030-03-17T17:46:40+00:00" - ); + + let (text, json) = (time.to_string(), serde_json::to_value(time).unwrap()); + + assert_eq!(text, "2030-03-17T17:46:40+00:00"); + assert_eq!(json, "2030-03-17T17:46:40+00:00"); } } From e0709c23e7d607f2da373358b8083fbd1d5775cc Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 13 Oct 2026 14:50:06 -0400 Subject: [PATCH 26/32] docs: the README is a user guide, the reference moves to docs/cli.md The README says what tracel is for, how to install it and get started, and shows each command with an example. docs/cli.md states once what the commands share: output formats and the JSON each command returns, exit codes, questions and --no-input, how the project and the credential are chosen, and the rules of packaging, downloads, models and experiments. The SDK link no longer reads "tracetracell", the source command is `cargo run --bin tracel -- --help`, and the README no longer documents a hidden flag. --- README.md | 431 ++++++++++++---------------------------------------- docs/cli.md | 177 +++++++++++++++++++++ 2 files changed, 276 insertions(+), 332 deletions(-) create mode 100644 docs/cli.md diff --git a/README.md b/README.md index e733230..d83a5f8 100644 --- a/README.md +++ b/README.md @@ -11,27 +11,23 @@ -## Description +The Tracel CLI (`tracel`) works with the +[Tracel Console](https://console.tracel.ai/), where +[Burn](https://github.com/tracel-ai/burn) users track experiments, share models +and run training on compute providers. Together with the +[Tracel SDK](https://github.com/tracel-ai/tracel) in your project, it lets you: -The Tracel CLI (`tracel`) is the command-line tool for interacting with [Tracel Console](https://console.tracel.ai/), the centralized platform for experiment tracking, model sharing, and deployment for [Burn](https://github.com/tracel-ai/burn) users. - -This CLI works in conjunction with the [Tracel SDK](https://github.com/tracel-ai/tracetracell) to provide a seamless workflow for: - -- Running training jobs locally or remotely -- Managing experiments and tracking metrics -- Packaging and deploying models -- Integrating with compute providers -- Managing project configurations +- run your project locally and browse its experiments, metrics and logs; +- package your code for compute providers; +- push, pull and promote models, and download artifacts and datasets. ## Installation -### Install from crates.io - ```bash cargo install tracel-cli ``` -### Build from source +Or build it from source: ```bash git clone https://github.com/tracel-ai/tracel-cli.git @@ -39,395 +35,166 @@ cd tracel-cli cargo install --path crates/tracel-cli ``` -After installation, the `tracel` command will be available in your terminal. - -## Prerequisites - -1. **Tracel Account**: Create an account at [console.tracel.ai](https://console.tracel.ai/) -2. **Rust**: Version 1.87.0 or higher -3. **Tracel SDK**: Add the SDK to your Burn project - -## Commands - -All commands accept `--json` or `-o, --output `. The default -`auto` format uses human output on a terminal and JSON when stdout is redirected. -`TRACEL_OUTPUT=human` or `TRACEL_OUTPUT=json` sets the format when no output flag -is given. `--json` and `--output` cannot be combined. - -All commands also accept `--no-input` to disable prompts. Prompts require stdin -and stderr to be terminals and human output. Missing input in scripts produces -`USAGE`, and a missing yes/no confirmation produces `CONFIRMATION_REQUIRED`, each -with a hint naming the flag to pass. - -Select a project with global `--project /`. It takes precedence -over `TRACEL_NAMESPACE` and `TRACEL_PROJECT`, which each independently fall back -to `tracel.toml` at the Cargo workspace root. `project`, `models`, `artifacts`, -and `datasets` work from any directory with a flag or both variables; `package` -still requires a Cargo workspace. Global `-C ` runs as if started in that -directory. `init` and `unlink` operate on `tracel.toml` and ignore project overrides. - -```bash -tracel --project alice/demo project --json -TRACEL_NAMESPACE=alice TRACEL_PROJECT=demo tracel -C ./trainer project --json -``` - -JSON results are one line on stdout: `{"ok":true,"data":{...}}` on success, or -`{"ok":false,"error":{"code":"NOT_FOUND","message":"...","hint":null,"exit_code":5}}` -on failure. Diagnostics go to stderr. Help and version output retain their normal -format, and `train` inherits the executed program's output and exit code. - -Human output keeps the same split: stdout carries only results, such as the -tables and details of commands that read, while progress, prompts, warnings, -and errors go to stderr. Commands that change something, such as `init`, -`login`, `models push`, or `artifacts download`, report on stderr and print -nothing on stdout. `auth token` prints the bare token unless JSON is requested, -even when stdout is redirected. - -On a terminal, a read is a box and a flow is a session. A command that reads, such -as `me`, `project list`, or `experiments get`, shows its result in one box, titled -with a line that sums it up, the project, and the environment when it is not -production; a result with nothing in it is just that line. A command that does -work, such as `login`, `init`, `package`, or `models push`, runs as a session: it -says what it does and where, shows its progress and prompts as it goes, and ends on -its outcome, or on the commands to run next. An error shows its message and hint -together, and a session that fails ends on what it did not do. Tables shorten long -descriptions to fit the box and show times in your time zone. What a command -streams, such as `experiments logs --follow`, is written as it arrives. Redirected -output is plain text without colors or symbols, is never shortened, and ends with -the line that sums it up. - -| Error code | Exit code | -| --- | --- | -| Success | 0 | -| `INTERNAL` | 1 | -| `USAGE` | 2 | -| `NOT_AUTHENTICATED` | 3 | -| `FORBIDDEN` | 4 | -| `NOT_FOUND` | 5 | -| `CONFLICT` | 6 | -| `CONFIRMATION_REQUIRED` | 7 | -| `LIMIT_REACHED` | 8 | -| `TIMEOUT` | 10 | -| `UNAVAILABLE` | 11 | - -Exit code 9 is reserved. - -### `tracel train` - -Run your project locally. This is a thin alias for `cargo run`: every argument -after `--` is forwarded to your binary, so `tracel train -- ` is equivalent -to `cargo run -- `. stdin/stdout/stderr are inherited and the binary's -exit code is propagated. - -```bash -# Equivalent to `cargo run` -tracel train - -# Equivalent to `cargo run -- train mnist --epochs 100` -tracel train -- train mnist --epochs 100 -``` - -### `tracel package` +You need Rust 1.87 or later, an account on the +[Tracel Console](https://console.tracel.ai/), and the Tracel SDK in your Burn +project. -Package your project for deployment on remote compute providers. +## Getting started ```bash -tracel package -# Source packaging without prompts -tracel package --mode source --allow-dirty --json -# Build selected binaries and install missing targets without prompts -tracel package --mode binary --target x86_64-unknown-linux-gnu --bin trainer --install-targets --commit --json +tracel login # approve the login in your browser +tracel init # link this workspace to a Console project +tracel train # run it locally with `cargo run` +tracel experiments list ``` -This creates a deployable artifact containing your code, dependencies, and configurations. +Every command takes `--help`. Commands print tables and messages on a terminal, +and JSON when their output goes to another program; `--json` asks for JSON +anywhere. `--no-input` makes a command fail instead of asking a question, and +`--project /` picks a project other than the one the workspace +is linked to. The [CLI reference](docs/cli.md) covers output formats, exit +codes, credentials and each command's JSON. -`--mode ` is required without prompts. In binary mode, repeat -`--target ` to choose targets; without prompts, omitting it builds for the -host. Use `--bin ` when several binaries are built. `--install-targets` -installs missing Rust targets without asking. `--commit` commits all current -changes before packaging; `--allow-dirty` continues with uncommitted changes. -These two flags cannot be combined. The code version digest remains the current -commit hash. JSON data contains `namespace`, `project`, `digest`, `version_id`, -`mode`, `targets`, and `uploaded` (false when the commit was already packaged). +## Commands -### `tracel login` +### `tracel login`, `logout` and `auth` -Authenticate with the Console platform. The CLI prints a link and a code to -approve in your browser, and the login then lasts seven days. On a terminal it -offers to open the link in your browser (`$BROWSER` names which one, when set), -and in terminals that render hyperlinks the link also opens with a click, like -the other links in the CLI's messages. +`tracel login` shows a link and a code, and offers to open the link in your +browser. Approve the login there and the CLI is logged in for seven days. ```bash tracel login +tracel logout +tracel auth status # which credential commands use, and when the login ends ``` -For scripts, start a login with `--no-wait`, hand the approval link and user code -to a person, then finish with `--complete`: +Scripts can log in in two steps, or use an API key from the Console instead: ```bash -tracel login --no-wait --json -tracel login --complete --json -# Wait up to 30 seconds, keeping the pending login if approval takes longer -tracel login --complete --timeout 30 --json +tracel login --no-wait # prints the link and the code to approve +tracel login --complete # waits for the approval +TRACEL_API_KEY=... tracel me +curl -H "Authorization: Bearer $(tracel auth token)" \ + https://console.tracel.ai/api/v1/user/organizations ``` -`--no-wait` saves the pending login locally and exits successfully without -waiting. Starting again replaces the previous pending login. JSON data contains -`status` (`"pending"`), `verification_uri_complete`, `user_code`, and `expires_at` -(RFC 3339). The device code is kept in an owner-only file beside the session -file and is never printed. - -`--complete` waits for approval until the code expires, or until `--timeout -` elapses. A timeout returns `TIMEOUT` (exit code 10); run `tracel login ---complete` again to continue. Expired or denied logins must be started again. -Successful completion returns the same JSON data as plain login: `username` and -`environment`. `--no-wait` and `--complete` conflict; `--timeout` requires -`--complete`. Plain `tracel login` still waits for approval. - -When `TRACEL_API_KEY` is set, commands use that API key instead of the login. - -### `tracel logout` - -End the login on the server and forget it locally. - -```bash -tracel logout -``` +### `tracel me` -### `tracel auth` +Show who you are logged in as. ```bash -# Which credential commands use, the user they act as, and when the login ends -tracel auth status - -# Print an access token of the login for scripts; TRACEL_API_KEY is ignored -curl -H "Authorization: Bearer $(tracel auth token)" https://console.tracel.ai/api/v1/user/organizations +tracel me ``` -### `tracel init` +### `tracel init` and `unlink` -Initialize or reinitialize a Tracel project in the current directory. +Link the workspace, the Cargo workspace you run it in, to a project on the +Tracel Console, creating the project if it doesn't exist. The link is saved in +`tracel.toml` at the workspace root. ```bash -# Interactive initialization tracel init -# Initialize without prompts, accepting a project that already exists -tracel init --owner my-namespace --name my-project --description "" --yes --allow-dirty --json +tracel init --owner my-org --name mnist --yes --allow-dirty +tracel unlink ``` -`--owner ` must name your own namespace or one of your organizations. -`--name ` accepts alphanumeric characters, underscores, and hyphens. -`--description ` supplies the new project's description; without a terminal -it defaults to empty. These flags replace the corresponding prompts. `--yes` -links an existing project without asking. `--commit` commits all current changes, -including the first commit if needed; `--allow-dirty` continues without committing -when a commit already exists. These two flags cannot be combined. A dirty -repository needs one of them without prompts. `--force` reinitializes an already -linked project; without it, asking for a different project fails with `CONFLICT`. -JSON data contains `namespace`, `name`, `created`, and `url`; `created` is false -when an existing project is linked, and `url` is null when nothing changed. +`init` needs a git commit, and offers to commit your changes. `--force` links a +workspace that is already linked to another project. -### `tracel models` +### `tracel project` -Browse and manage models in the selected project's model registry. +Show the project the workspace is linked to, or list your projects and your +organizations' projects. ```bash -tracel models list -tracel models get my-model -tracel models get my-model --version production --json -tracel models versions my-model --all -tracel models pull my-model --version v7 --directory ./weights --force -tracel --project alice/demo models push my-model --directory ./weights --auto-create true --description "Model weights" --metadata '{"format":"safetensors"}' --json -tracel models promote my-model --experiment 42 --artifact weights --alias production --metadata '{"accuracy":0.98}' --json -tracel models alias list my-model -tracel models alias set my-model production 7 --expect 6 -tracel models alias remove my-model production +tracel project +tracel project list +tracel project list my-org ``` -- `list` shows names, version counts, latest versions, aliases, and creation times. -- `get ` shows model details. `--version ` shows version details, - files, and metadata. References accept `latest`, a number such as `7` or `v7`, - or an alias, and are resolved by the server. -- `versions ` lists ready versions. `--all` includes pending, failed, - and deleted versions. -- `pull ` defaults to `--version latest` and downloads into - `./-v`. `-d, --directory ` selects another - destination. `--force` overwrites existing files. Paths and overwrite conflicts - are checked before downloading; declared sizes and SHA-256 checksums are - verified before each file is saved. -- `push -d, --directory ` uploads local files as a new version. - `-a, --auto-create ` creates a missing model when true and requires - an existing model when false. Omit it to ask when interactive; a missing model - without prompts requires `--auto-create true`. `--description ` sets - the new model's description and requires `--auto-create true`. `--metadata - ` sets version metadata and must be a JSON object. -- `promote --experiment --artifact ` copies an experiment - artifact into a ready model version. Artifact ids take precedence over names; - an ambiguous name requires the id. `--alias ` points an alias at the - new version. `--metadata `, `--auto-create `, and - `--description ` have the same meaning as for `push`. -- `alias list ` lists aliases and their versions. `alias set - ` creates or moves an alias to a ready version; `--expect - ` requires that the alias currently points at that version. - Version numbers must be at least 1. `alias remove ` removes it. - -JSON data is the server response: the model list (`items` and `total`), a -model, a version, the version list, the alias list, or an alias. `push` and `promote` -return the new version, including its state, source, manifest, metadata, and -aliases. `pull` data contains `model`, the resolved version object as `version`, -`directory`, `files` (each with `rel_path`, `path`, and downloaded `bytes`), -and total `bytes`. `alias remove` returns `model`, `alias`, and `removed: true`. - -### `tracel artifacts` +### `tracel train` -Browse and download artifacts using positive project-scoped experiment numbers. +Run the workspace locally with `cargo run`. Arguments after `--` go to your +program, and its output and exit code are the command's. ```bash -tracel artifacts list 42 --name weights --json -tracel artifacts download 42 weights --directory ./weights --force --json +tracel train -- --epochs 10 ``` -`list ` shows name, kind, id, manifest file count, and creation time. -`--name ` keeps artifacts whose name contains `NAME`. `download - ` selects an artifact by id first, otherwise by an exact, -unique name. Ambiguous names require an id. -The destination defaults to `./` when the name is a single normal -path component; otherwise pass `-d, --directory `. `--force` overwrites -existing files. Declared manifest sizes and SHA-256 checksums are verified. - -JSON data for `list` is the server response (`items` and `total`). `download` -data contains `experiment`, the server's artifact object as `artifact`, -`directory`, `files` (each with `rel_path`, `path`, and downloaded `bytes`), -and total `bytes`. - -### `tracel datasets` +### `tracel package` -Browse datasets in the selected project. +Upload a code version of the workspace, the code compute providers run: a +compiled binary, or the source to build there. A code version is the current +commit, so `package` offers to commit your changes first. ```bash -tracel datasets list --page 0 --per-page 25 --json -tracel datasets get images -tracel datasets versions images --page 0 --per-page 10 --json +tracel package +tracel package --mode binary --target x86_64-unknown-linux-gnu --json +tracel package --mode source --allow-dirty ``` -`list` shows name, description, and id, followed by the number shown and total -when more datasets exist. `get ` shows details and pretty metadata. -`versions ` lists version, item count, source, and creation time. -`list` and `versions` accept zero-based `--page ` and positive -`--per-page `; omitted values use the server's defaults. - -JSON data is the server response. The list responses contain `items` and -`total_count`; dataset objects contain `id`, `name`, `description`, and -`metadata`. - -### `tracel unlink` +### `tracel experiments` -Unlink the current directory from Tracel project. `--yes` skips the confirmation. +Browse the project's experiments by their number, along with their metrics and +logs. `exp` is short for `experiments`. ```bash -tracel unlink -tracel unlink --yes +tracel experiments list --sort created_at,desc --limit 10 +tracel exp get latest +tracel exp metrics 42 --metric loss +tracel exp logs 42 --level warn --level error +tracel exp logs 42 --follow ``` -### `tracel me` +### `tracel models` -Display information about the currently authenticated user. +Browse the project's models and their versions, download and upload versions, +make an experiment's artifact a new version, and point aliases at versions. +A version is a number such as `7` or `v7`, an alias, or `latest`. ```bash -tracel me +tracel models list +tracel models get my-model --version production +tracel models versions my-model --all +tracel models pull my-model --version v7 --directory ./weights +tracel models push my-model --directory ./weights --auto-create true +tracel models promote my-model --experiment 42 --artifact weights +tracel models alias set my-model production 7 ``` -### `tracel project` +### `tracel artifacts` -Display information about the current project. +List the artifacts of an experiment, and download one by name or id. ```bash -tracel project -tracel project list --json -tracel project list alice +tracel artifacts list 42 +tracel artifacts download 42 weights --directory ./weights ``` -`list [NAMESPACE]` lists your own projects followed by each of your organizations' -projects. Supply your namespace or an organization namespace to limit the list. -It works without a linked project or `tracel.toml`. Human output shows -`namespace/name`, visibility, description, and creation time. JSON data is an -array of the server's project objects, including `namespace_name`. -Bare `project` returns `namespace`, `name`, `description`, `created_by`, -`visibility`, and project resolution `source`. - -### `tracel experiments` +### `tracel datasets` -Browse experiments in the selected project; `exp` is a visible alias. `list` -accepts zero-based `--page`, `--limit`, and repeated `--sort` values in the server -format: `field`, `field,asc`, or `field,desc`. `get ` shows details and -config. Numbers are project-scoped experiment numbers. +Browse the project's datasets and their published versions. ```bash -tracel --project alice/demo experiments list --sort created_at,desc --limit 10 --json -tracel exp get latest -tracel exp metrics 42 --metric loss --max-points 100 --downsampling 1 -tracel exp logs 42 --level info --level error --follow --json -``` - -`metrics ` lists definitions without `--metric`; adding `--summary` returns -the named metric's summary. Series default to 100 maximum points and a -downsampling factor of 1. `logs ` reads one page (100 entries by default), -with `--level`, `--search`, `--from`, `--to`, `--offset`, `--after`, and metadata -filters (`--metadata key=value`, `--metadata-not key=value`, `--metadata-exists -key`). `--after` is a log sequence cursor and cannot be combined with time ranges -or offsets. `--follow` uses that cursor and polls every two seconds until the -experiment finishes, including any remaining pages. - -JSON data is the server response, with `null` for an unavailable metric series -or summary. Following logs emits NDJSON instead of a success envelope: each log -item has `"type":"log"` added, followed by `{"type":"end","running":false}`. -Errors still use the standard error envelope. Human output uses tables, experiment -key/value lines, or one timestamp, level, and message line per log entry. - -## Project Structure - -The Tracel CLI is organized as a Cargo workspace: - -```text -tracel-cli/ -├── crates/ -│ └── tracel-cli/ -└── xtask/ # Build utilities +tracel datasets list +tracel datasets get images +tracel datasets versions images ``` ## Development -### Running from source - -```bash -cargo run --bin tracel-- --help -``` - -### Running tests - ```bash +cargo run --bin tracel -- --help cargo test ``` -### Development mode - -For testing against a local Console instance: - -```bash -tracel --dev login -``` - -This connects to `http://localhost:9001` and uses separate development credentials. - -## Contribution +The repository is a Cargo workspace: the CLI is in `crates/tracel-cli`, and +`xtask` holds the build tasks. -Contributions are welcome! Please feel free to: +## Contributing -- Report issues or bugs -- Request new features -- Submit pull requests -- Improve documentation +Issues and pull requests are welcome, whether they report a bug, ask for a +feature or improve the documentation. ## License @@ -440,6 +207,6 @@ at your option. ## Links -- [Console Platform](https://console.tracel.ai/) +- [Tracel Console](https://console.tracel.ai/) - [Tracel SDK](https://github.com/tracel-ai/tracel) -- [Burn Framework](https://github.com/tracel-ai/burn) +- [Burn](https://github.com/tracel-ai/burn) diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..75687e8 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,177 @@ +# Tracel CLI reference + +This page states the rules the commands share. `tracel --help` lists +each command's flags. + +## Output + +A command writes its result on stdout and everything else on stderr: progress, +questions, warnings and errors. The format is the first of these that is set: + +1. `--json`, which can't be combined with `--output`; +2. `-o, --output `; +3. `TRACEL_OUTPUT=human` or `TRACEL_OUTPUT=json`; +4. `auto`: human on a terminal, and JSON when stdout goes to a file or another + program. + +`auth token` and `train` are plain text under `auto` too: `auth token` prints +the bare token, and `train` writes what the program it runs writes. Help and +version output keep clap's own format. + +On a terminal, a command that reads shows its result in a frame titled with a +line that sums it up, the project and the environment when it isn't +production. A command that changes something says what it does and where, +shows its steps and questions, and ends on a line saying what it did. Times are +in your time zone, and tables shorten descriptions to fit the terminal. A +failure is its message, then a hint when one names what to do. + +Human output that goes to a file or another program is plain text: no colors, +nothing shortened, and the summary line last. Commands that change something +write nothing on stdout in human output. + +## JSON + +A command writes one line of JSON on stdout. On success: + +```json +{"ok":true,"data":{...}} +``` + +On failure: + +```json +{"ok":false,"error":{"code":"NOT_FOUND","message":"No model 'weights' in alice/demo.","hint":"List models with `tracel models list`.","exit_code":5}} +``` + +`hint` is null when there is none. With JSON output, stderr carries only errors +and the instructions a login asks you to follow. + +`tracel experiments logs --follow --json` writes one line of JSON per event +instead: each log entry, with `"type":"log"` added, then +`{"type":"end","running":false}` when the experiment has finished. An error +still writes the failure line. + +The data of commands that read is the Tracel Console's own response, with +`null` for a metric series or summary that doesn't exist. The other commands +return: + +| Command | Data | +| --- | --- | +| `login`, `login --complete` | `username`, `environment` | +| `login --no-wait` | `status` (`"pending"`), `verification_uri_complete`, `user_code`, `expires_at` | +| `logout` | `logged_out` (false when you weren't logged in) | +| `auth status` | `credential` (`"api_key"` or `"login"`), `login_ends_at`, `user` (`username`, `namespace`), `environment` | +| `auth token` | `access_token` | +| `me` | `username`, `email`, `namespace`, `environment`, `api_url` | +| `init` | `namespace`, `name`, `created`, `url` (null when the workspace was already linked) | +| `unlink` | `unlinked`, `namespace`, `name` | +| `project` | `namespace`, `name`, `description`, `created_by`, `visibility`, `source` (`"flag"`, `"env"` or `"tracel.toml"`) | +| `package` | `namespace`, `project`, `digest`, `version_id`, `mode`, `targets`, `uploaded` (false when the code version already existed) | +| `models push`, `models promote` | the new version | +| `models pull` | `model`, `version`, `directory`, `files` (each `rel_path`, `path`, `bytes`), `bytes` | +| `models alias set` | the alias | +| `models alias remove` | `model`, `alias`, `removed` | +| `artifacts download` | `experiment`, `artifact`, `directory`, `files`, `bytes` | + +Times are RFC 3339 in UTC. + +## Exit codes + +| Error code | Exit code | +| --- | --- | +| Success | 0 | +| `INTERNAL` | 1 | +| `USAGE` | 2 | +| `NOT_AUTHENTICATED` | 3 | +| `FORBIDDEN` | 4 | +| `NOT_FOUND` | 5 | +| `CONFLICT` | 6 | +| `CONFIRMATION_REQUIRED` | 7 | +| `LIMIT_REACHED` | 8 | +| `TIMEOUT` | 10 | +| `UNAVAILABLE` | 11 | + +Exit code 9 is reserved. `train` exits with the code of the program it runs. + +## Questions + +A command asks a question only when stdin and stderr are terminals, the output +is human, and `CI` isn't set. `--no-input` turns questions off. A command that +needs an answer it can't ask fails with `USAGE`, or `CONFIRMATION_REQUIRED` for +a yes or no, and a hint naming the flag that answers it. Declining what a +command asks to do, such as linking an existing project, ends it without +changing anything, with exit code 0. + +## The project + +A command that works on a project uses the first of these: + +1. `--project /`; +2. `TRACEL_NAMESPACE` and `TRACEL_PROJECT`, each of which can also override + its half of `tracel.toml`; +3. `tracel.toml` at the root of the Cargo workspace, which `tracel init` writes + with the keys `namespace` and `project`. + +With the flag or both variables, `project`, `experiments`, `models`, +`artifacts` and `datasets` work in any directory. `package` and `train` need +the Cargo workspace, and `init` and `unlink` change `tracel.toml` whatever +selects the project. `-C ` runs a command as if it started in ``. + +## Credentials + +Commands use `TRACEL_API_KEY` when it is set, and your login otherwise. +`tracel auth token` always prints the login's access token, renewed when it +has expired. + +`tracel login --no-wait` saves a pending login and exits, and a new one +replaces it. The pending login is kept in a file only you can read, beside the +login itself, and its device code is never printed. `tracel login --complete` +waits for the approval until the code expires, or for `--timeout `. +After a timeout, which exits with `TIMEOUT`, the pending login is kept for the +next `--complete`; a denied or expired login has to start again. + +## Packaging + +`tracel package` uploads a code version, which the current commit names: + +- `--mode binary` builds a release binary for each `--target `, the + host when there is none and no question can be asked. `--bin ` picks + the binary when the build makes several. On Linux, building for another + architecture needs its linker, and building for another OS needs + `cargo-zigbuild` or, for Windows, `cargo-xwin`. +- `--mode source` uploads the workspace's files, without what git ignores, to + be built where the code runs. +- `--install-targets` installs missing Rust targets with rustup without asking. +- `--commit` commits all changes first, and `--allow-dirty` packages without + them. Uncommitted changes are never part of the code version. + +## Downloads + +`models pull` downloads into `./-v`, and `artifacts download` +into `./` when the artifact's name is a plain file name; `-d, +--directory ` chooses another directory. Every path and existing file is +checked before anything downloads, and `--force` overwrites existing files. +Each file's size and SHA-256 checksum are checked before it is saved. + +## Models + +- A version is `latest`, a number such as `7` or `v7`, or an alias. +- `models push --auto-create true` creates a missing model, and `false` needs + an existing one; without the flag, the command asks. `--description` sets a + created model's description and needs `--auto-create true`. `--metadata` is + a JSON object. +- `models promote --artifact` finds the artifact by id, then by a name only + one artifact of the experiment has. +- `models alias set --expect ` moves the alias only if it points at + that version. + +## Experiments + +- `experiments list` takes `--page` (from 0), `--limit`, and `--sort field`, + `field,asc` or `field,desc`, which can repeat. +- `experiments metrics ` lists the metrics, and `--metric ` shows + one's series, or its summary with `--summary`. +- `experiments logs ` reads one page of entries, 100 by default, and + shows them oldest first. `--after ` reads from a sequence number and + can't be combined with `--from`, `--to` or `--offset`. `--follow` reads from + there until the experiment finishes. From c708d4b7075914560018aa168020f14cf9bc091f Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 13 Oct 2026 14:52:57 -0400 Subject: [PATCH 27/32] refactor: the remaining internal messages follow the style The errors about an upload response's parts say that the Tracel Console sent them, two context messages start with "Can't", fs uses "isn't" as the rest of the CLI does, and the warning about a missing cross-build tool fits in the frame. --- crates/tracel-cli/src/helpers/model_upload/parts.rs | 10 +++++----- crates/tracel-cli/src/tools/build_driver.rs | 2 +- crates/tracel-cli/src/tools/cargo.rs | 4 ++-- crates/tracel-cli/src/tools/fs.rs | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/tracel-cli/src/helpers/model_upload/parts.rs b/crates/tracel-cli/src/helpers/model_upload/parts.rs index cc7d752..4b756ee 100644 --- a/crates/tracel-cli/src/helpers/model_upload/parts.rs +++ b/crates/tracel-cli/src/helpers/model_upload/parts.rs @@ -23,7 +23,7 @@ fn validate_part_sequence( let expected = index as u32 + 1; if part.part != expected { anyhow::bail!( - "Upload response for '{rel_path}' has non-contiguous or duplicate part numbers (expected part {expected}, got {}).", + "The Tracel Console numbered the parts of '{rel_path}' wrongly: expected part {expected}, got {}.", part.part ); } @@ -39,7 +39,7 @@ fn validate_part_total_size( let total: u64 = parts.iter().map(|part| part.size_bytes).sum(); if total != expected_size { anyhow::bail!( - "Upload response for '{rel_path}' parts sum to {total} bytes but local file is {expected_size} bytes." + "The Tracel Console's parts of '{rel_path}' sum to {total} bytes, not {expected_size}." ); } Ok(()) @@ -57,20 +57,20 @@ pub fn build_part_tasks( for upload_file in upload_files { let absolute_path = files.get(&upload_file.rel_path).ok_or_else(|| { anyhow::anyhow!( - "Upload response referenced unknown file path '{}'.", + "The Tracel Console sent parts of an unknown file '{}'.", upload_file.rel_path ) })?; let expected_size = *file_sizes.get(&upload_file.rel_path).ok_or_else(|| { anyhow::anyhow!( - "Upload response referenced file '{}' with no known local size.", + "The Tracel Console sent parts of '{}', whose size is unknown.", upload_file.rel_path ) })?; if upload_file.urls.parts.is_empty() { anyhow::bail!( - "Upload response for '{}' does not contain any part.", + "The Tracel Console sent no parts of '{}'.", upload_file.rel_path ); } diff --git a/crates/tracel-cli/src/tools/build_driver.rs b/crates/tracel-cli/src/tools/build_driver.rs index 925802f..f955b11 100644 --- a/crates/tracel-cli/src/tools/build_driver.rs +++ b/crates/tracel-cli/src/tools/build_driver.rs @@ -126,7 +126,7 @@ pub fn cross_preflight( BuildDriver::Cargo => { // Across operating systems without a driver, plain cargo won't link. terminal.warning(&format!( - "No cross-build tool for {triple}, so the build will likely fail.\n{}", + "No cross-build tool for {triple}, so it may not build.\n{}", install_hint(target) )); Ok(None) diff --git a/crates/tracel-cli/src/tools/cargo.rs b/crates/tracel-cli/src/tools/cargo.rs index 0c238df..b72703e 100644 --- a/crates/tracel-cli/src/tools/cargo.rs +++ b/crates/tracel-cli/src/tools/cargo.rs @@ -68,14 +68,14 @@ pub fn build_quietly( .stderr(Stdio::piped()) .spawn() .with_context(|| format!("Can't run `{label}`"))?; - let mut stdout = child.stdout.take().context("cargo's stdout is not piped")?; + let mut stdout = child.stdout.take().context("Can't read cargo's output")?; let messages = std::thread::spawn(move || { let mut messages = Vec::new(); stdout.read_to_end(&mut messages).map(|_| messages) }); let mut output = Vec::new(); - let mut stderr = BufReader::new(child.stderr.take().context("cargo's stderr is not piped")?); + let mut stderr = BufReader::new(child.stderr.take().context("Can't read cargo's output")?); let mut line = Vec::new(); while stderr.read_until(b'\n', &mut line)? > 0 { if let Some(line) = status_line(&String::from_utf8_lossy(&line)) { diff --git a/crates/tracel-cli/src/tools/fs.rs b/crates/tracel-cli/src/tools/fs.rs index c5cbe40..02abf5b 100644 --- a/crates/tracel-cli/src/tools/fs.rs +++ b/crates/tracel-cli/src/tools/fs.rs @@ -10,7 +10,7 @@ pub fn collect_files(directory: &Path) -> anyhow::Result anyhow::Result Date: Tue, 13 Oct 2026 21:09:09 -0400 Subject: [PATCH 28/32] fix: a failure ends on one red line, with its hint under it On a terminal a failed command ends on cliclack's red closing line, in a session or not, instead of an error line, a spacer and an empty corner. The hint sits under the message, dimmed, and long lines wrap inside the frame. --- crates/tracel-cli/src/ui/screen.rs | 6 ++--- crates/tracel-cli/src/ui/stderr.rs | 40 ++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/crates/tracel-cli/src/ui/screen.rs b/crates/tracel-cli/src/ui/screen.rs index c843981..93ba037 100644 --- a/crates/tracel-cli/src/ui/screen.rs +++ b/crates/tracel-cli/src/ui/screen.rs @@ -180,10 +180,10 @@ impl Screen { } } - /// Close the frame an error was just drawn in with a bare corner. - pub fn close_frame(&self) { + /// End the command on its `failure`: cliclack's red closing line, in a session or not. + pub fn fail(&self, failure: &str) { self.session.store(false, Ordering::Relaxed); - self.draw(|| cliclack::outro("")); + self.draw(|| cliclack::outro_cancel(failure)); } /// Settle the live widget, then draw with cliclack. diff --git a/crates/tracel-cli/src/ui/stderr.rs b/crates/tracel-cli/src/ui/stderr.rs index a26c852..89ac19f 100644 --- a/crates/tracel-cli/src/ui/stderr.rs +++ b/crates/tracel-cli/src/ui/stderr.rs @@ -8,6 +8,20 @@ use super::Format; use super::screen::Screen; use crate::error::{CliError, ErrorKind, ErrorReport}; +/// The columns cliclack's closing corner and the spaces after it take. +const CLOSING_INDENT: usize = 3; + +/// A failure as its closing line shows it: the message, then the hint dimmed, every +/// line after the first indented under the first. +fn failure(message: &str, hint: Option<&str>) -> String { + let hint = hint + .into_iter() + .flat_map(str::lines) + .map(|line| console::style(line).for_stderr().dim().to_string()); + let lines: Vec = message.lines().map(str::to_string).chain(hint).collect(); + lines.join(&format!("\n{}", " ".repeat(CLOSING_INDENT))) +} + /// A prompt the user answered no. The command stops there and ends on what it didn't /// do, such as "Nothing was linked.", as a normal outcome. #[derive(Debug, thiserror::Error)] @@ -123,19 +137,17 @@ impl Terminal { } /// Report a failed command: its message and hint here, and its envelope on stdout - /// with JSON output. On a terminal both are one error, then a bare corner closes the - /// frame. + /// with JSON output. On a terminal the command ends on cliclack's red closing line, + /// with the hint under the message. pub fn error(&self, report: &ErrorReport<'_>) { self.screen.clear_live(); if !self.is_human() { self.line(format_args!("error: {}", one_line(&report.message))); } else if self.is_styled() { - let text = match report.hint { - Some(hint) => format!("{}\n{hint}", report.message), - None => report.message.clone(), - }; - self.screen.draw(|| cliclack::log::error(text)); - self.screen.close_frame(); + let wrap = |text: &str| cliclack::termwrap(text, CLOSING_INDENT as u16); + let hint = report.hint.map(wrap); + self.screen + .fail(&failure(&wrap(&report.message), hint.as_deref())); } else { self.line(format_args!("error: {}", report.message)); if let Some(hint) = report.hint { @@ -370,6 +382,18 @@ mod tests { } } + #[test] + fn a_failure_shows_its_hint_under_its_message() { + assert_eq!(failure("Job 'fail' failed.", None), "Job 'fail' failed."); + assert_eq!( + failure( + "No job 'nope'.\nJobs: train.", + Some("See them with `tracel run --list`.") + ), + "No job 'nope'.\n Jobs: train.\n See them with `tracel run --list`." + ); + } + fn unlinking() -> Unanswered { Unanswered::new( "Unlinking test/demo needs confirmation.", From 090ebbf37ab369db847b73f6dd63a4204f367e00 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Tue, 13 Oct 2026 21:49:37 -0400 Subject: [PATCH 29/32] fix: packaging again uploads only the files the Console asks for A code version that already has some of the package's files gets no upload URL for them. Those files are skipped instead of failing the upload, and when the Console asks for none, the code version is reported as already uploaded. --- crates/tracel-cli/src/commands/package.rs | 57 ++++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/tracel-cli/src/commands/package.rs b/crates/tracel-cli/src/commands/package.rs index ebf00a2..7425549 100644 --- a/crates/tracel-cli/src/commands/package.rs +++ b/crates/tracel-cli/src/commands/package.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -405,19 +406,20 @@ fn upload( targets: prepared.targets.clone(), uploaded, }; - let Some(urls) = response.urls else { + let uploads = match &response.urls { + Some(urls) => requested(prepared.uploads, urls), + None => Vec::new(), + }; + if uploads.is_empty() { context.terminal().outro(&format!( "Code version {} is already uploaded.", short_digest(digest) )); return Ok(packaged(false)); - }; + } let spinner = context.terminal().spinner("Uploading the code version"); - for (key, path) in prepared.uploads { - let url = urls - .get(&key) - .with_context(|| format!("The Tracel Console sent no upload URL for '{key}'"))?; + for (key, url, path) in uploads { let bytes = std::fs::read(&path).with_context(|| format!("Can't read '{}'", path.display()))?; client @@ -441,3 +443,46 @@ fn upload( )); Ok(packaged(true)) } + +/// The files of a package the Tracel Console asked for, each with its upload URL. A file +/// the code version already has gets no URL and is left out. +fn requested( + uploads: Vec<(String, PathBuf)>, + urls: &HashMap, +) -> Vec<(String, &str, PathBuf)> { + uploads + .into_iter() + .filter_map(|(key, path)| urls.get(&key).map(|url| (key, url.as_str(), path))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_files_the_console_asks_for_are_uploaded() { + let uploads = vec![ + ( + "x86_64-unknown-linux-gnu".to_string(), + PathBuf::from("linux"), + ), + ("aarch64-apple-darwin".to_string(), PathBuf::from("macos")), + ]; + let urls = HashMap::from([( + "aarch64-apple-darwin".to_string(), + "https://storage/macos".to_string(), + )]); + + let requested = requested(uploads, &urls); + + assert_eq!( + requested, + [( + "aarch64-apple-darwin".to_string(), + "https://storage/macos", + PathBuf::from("macos") + )] + ); + } +} From 24fe6a7b22c734bbc89f2efef50f54c5f547161b Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Wed, 14 Oct 2026 11:10:47 -0400 Subject: [PATCH 30/32] test: one readable test per behavior Tests are named given/when/then, and inputs that share a behavior are named rstest cases instead of tuple tables in a loop. Dropped the tests of other layers and of one-liners: clap's argument rules, serde's JSON for a type, the terminal width cliclack wraps at, exit codes mirrored from their match, plurals, short digests, and the place line, which the session header test covers. --- Cargo.lock | 39 +++ Cargo.toml | 1 + crates/tracel-cli/Cargo.toml | 1 + crates/tracel-cli/src/cli.rs | 123 ++++--- crates/tracel-cli/src/commands/artifacts.rs | 77 ++-- crates/tracel-cli/src/commands/auth.rs | 34 +- crates/tracel-cli/src/commands/experiments.rs | 136 ++------ crates/tracel-cli/src/commands/init.rs | 48 ++- crates/tracel-cli/src/commands/login.rs | 110 +++--- .../tracel-cli/src/commands/login/pending.rs | 116 +++--- crates/tracel-cli/src/commands/models.rs | 58 +-- crates/tracel-cli/src/context.rs | 4 +- crates/tracel-cli/src/environment.rs | 104 +++--- crates/tracel-cli/src/error.rs | 203 +++-------- crates/tracel-cli/src/helpers/download.rs | 187 ++++++---- .../src/helpers/model_upload/parts.rs | 59 ++-- .../src/helpers/model_upload/upload.rs | 72 ++-- crates/tracel-cli/src/helpers/project.rs | 227 ++++++------ crates/tracel-cli/src/helpers/resources.rs | 329 +++++++----------- crates/tracel-cli/src/test_support.rs | 22 -- crates/tracel-cli/src/tools/build_driver.rs | 76 ++-- crates/tracel-cli/src/tools/cargo.rs | 62 ++-- crates/tracel-cli/src/tools/fs.rs | 28 +- crates/tracel-cli/src/tools/linker.rs | 74 ++-- crates/tracel-cli/src/tools/target.rs | 29 +- crates/tracel-cli/src/tools/workspace.rs | 72 ++-- crates/tracel-cli/src/ui/format.rs | 95 +++-- crates/tracel-cli/src/ui/human.rs | 244 +++++-------- crates/tracel-cli/src/ui/layout.rs | 25 -- crates/tracel-cli/src/ui/screen.rs | 78 +++-- crates/tracel-cli/src/ui/stderr.rs | 124 ++++--- crates/tracel-cli/src/ui/stdout.rs | 100 +++--- crates/tracel-cli/src/ui/time.rs | 36 +- 33 files changed, 1406 insertions(+), 1587 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8cfe56e..186fcd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1730,6 +1730,12 @@ dependencies = [ "gix-validate", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "globset" version = "0.4.20" @@ -2795,6 +2801,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.13.4" @@ -2851,6 +2863,32 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rstest" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948203e6d13b83e51a90d7cf236dd58a0065f23f4b902f00f306e7eb2bd09b9c" +dependencies = [ + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d92eaf7b6e51e12471d71ddc72608e7151573de44099aacfbb1128177c0da4" +dependencies = [ + "cfg-if", + "glob", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.119", + "unicode-ident", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3642,6 +3680,7 @@ dependencies = [ "flate2", "gix", "ignore", + "rstest", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml index 1aaeca1..1579e00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ gix = { version = "0.77.0", default-features = false, features = [ chrono = "0.4.45" strum = { version = "0.27.2", features = ["derive"] } tempfile = "3.27.0" +rstest = { version = "0.27.0", default-features = false } ## External Crate diff --git a/crates/tracel-cli/Cargo.toml b/crates/tracel-cli/Cargo.toml index 72b8414..a2722ca 100644 --- a/crates/tracel-cli/Cargo.toml +++ b/crates/tracel-cli/Cargo.toml @@ -39,4 +39,5 @@ tar.workspace = true walkdir.workspace = true [dev-dependencies] +rstest.workspace = true tempfile.workspace = true diff --git a/crates/tracel-cli/src/cli.rs b/crates/tracel-cli/src/cli.rs index fd3245a..bcaac22 100644 --- a/crates/tracel-cli/src/cli.rs +++ b/crates/tracel-cli/src/cli.rs @@ -345,71 +345,98 @@ fn handle_command(command: Commands, context: CliContext) -> anyhow::Result Vec { + args.iter().map(OsString::from).collect() + } + #[test] - fn command_definitions_pass_clap_checks() { + fn given_the_command_definitions_when_clap_checks_them_then_they_are_valid() { CliArgs::command().debug_assert(); } + #[rstest] + #[case::auth_token(&["auth", "token"])] + #[case::train(&["train", "--", "--epochs", "1"])] + fn given_a_command_whose_stdout_is_its_own_when_piped_then_its_output_stays_text( + #[case] args: &[&str], + ) { + let format = command(args).auto_format(false); + + assert_eq!(format, Format::Human); + } + #[test] - fn auto_output_is_plain_text_where_another_program_reads_stdout() { - for (args, stdout_is_terminal, expected) in [ - (&["auth", "token"][..], false, Format::Human), - (&["auth", "token"], true, Format::Human), - (&["train", "--", "--epochs", "1"], false, Format::Human), - (&["auth", "status"], false, Format::Json), - (&["auth", "status"], true, Format::Human), - (&["me"], false, Format::Json), - ] { - assert_eq!( - command(args).auto_format(stdout_is_terminal), - expected, - "{args:?} {stdout_is_terminal}" - ); - } + fn given_another_command_when_stdout_is_piped_then_its_output_is_json() { + let format = command(&["me"]).auto_format(false); + + assert_eq!(format, Format::Json); } #[test] - fn arguments_that_do_not_parse_report_in_the_format_they_ask_for() { - for (args, environment, expected) in [ - (&["models", "get", "--json"][..], None, Format::Json), - (&["-o", "json", "models", "get"], None, Format::Json), - (&["--output=json", "models", "get"], None, Format::Json), - (&["-ojson", "models", "get"], None, Format::Json), - (&["models", "get"], Some("json"), Format::Json), - ( - &["models", "get", "-o", "human"], - Some("json"), - Format::Human, - ), - (&["models", "get"], None, Format::Human), - (&["models", "get"], Some("xml"), Format::Human), - (&["train", "--", "--json"], None, Format::Human), - ] { - let arguments = args.iter().map(OsString::from); - assert_eq!( - usage_format(arguments, environment, true), - expected, - "{args:?} {environment:?}" - ); - } + fn given_another_command_when_stdout_is_a_terminal_then_its_output_is_text() { + let format = command(&["me"]).auto_format(true); + + assert_eq!(format, Format::Human); + } + + #[rstest] + #[case::json_flag(&["models", "get", "--json"])] + #[case::output_flag(&["-o", "json", "models", "get"])] + #[case::output_equals(&["--output=json", "models", "get"])] + #[case::output_attached(&["-ojson", "models", "get"])] + fn given_an_output_flag_among_arguments_that_do_not_parse_when_reporting_then_the_error_is_json( + #[case] args: &[&str], + ) { + let format = usage_format(arguments(args), None, true); + + assert_eq!(format, Format::Json); } #[test] - fn arguments_that_do_not_parse_are_a_usage_error() { - let parse_error = parse(&["models", "get", "--json"]).unwrap_err(); + fn given_tracel_output_json_when_arguments_do_not_parse_then_the_error_is_json() { + let format = usage_format(arguments(&["models", "get"]), Some("json"), true); + + assert_eq!(format, Format::Json); + } + + #[test] + fn given_an_output_flag_and_tracel_output_when_arguments_do_not_parse_then_the_flag_wins() { + let format = usage_format( + arguments(&["models", "get", "-o", "human"]), + Some("json"), + true, + ); + + assert_eq!(format, Format::Human); + } + + #[test] + fn given_json_after_a_double_dash_when_arguments_do_not_parse_then_it_is_not_an_output_flag() { + let format = usage_format(arguments(&["train", "--", "--json"]), None, true); + + assert_eq!(format, Format::Human); + } + + #[test] + fn given_arguments_that_do_not_parse_when_reporting_them_then_it_is_a_usage_error_without_clap_prefix() + { + let parse_error = parse(&["models", "get", "--unknown"]).unwrap_err(); let error = anyhow::Error::new(usage_error(&parse_error)); - assert_eq!(report(&error), (ErrorKind::Usage, 2, false)); + assert_eq!(classify(&error), ErrorKind::Usage); assert!(!error.to_string().starts_with("error:"), "{error}"); } #[test] - fn a_closed_stdout_ends_the_command_with_exit_code_0() { + fn given_a_reader_that_closed_stdout_when_the_command_stops_then_it_exits_with_0() { let error = anyhow::Error::new(StdoutClosed).context("Can't write the result"); let stop = Stop::of(&error); @@ -419,7 +446,7 @@ mod tests { } #[test] - fn a_declined_prompt_ends_the_command_on_its_outcome_with_exit_code_0() { + fn given_a_declined_prompt_when_the_command_stops_then_it_ends_on_its_outcome_with_0() { let error = anyhow::Error::new(Declined::new("Nothing was linked.")) .context("Can't link the workspace"); @@ -430,9 +457,11 @@ mod tests { } #[test] - fn a_failure_ends_the_command_with_its_exit_code() { + fn given_a_failure_when_the_command_stops_then_it_exits_with_the_failure_code() { let error = anyhow::Error::new(CliError::new(ErrorKind::NotFound, "No model 'w'.")); - assert_eq!(Stop::of(&error).exit_code(), 5); + let stop = Stop::of(&error); + + assert_eq!(stop.exit_code(), 5); } } diff --git a/crates/tracel-cli/src/commands/artifacts.rs b/crates/tracel-cli/src/commands/artifacts.rs index 1e11f98..2b2b334 100644 --- a/crates/tracel-cli/src/commands/artifacts.rs +++ b/crates/tracel-cli/src/commands/artifacts.rs @@ -229,47 +229,56 @@ pub fn handle_command(args: ArtifactsArgs, context: CliContext) -> anyhow::Resul #[cfg(test)] mod tests { + use rstest::rstest; use serde_json::json; use super::*; - use crate::test_support::report; + use crate::error::{ErrorReport, classify}; #[test] - fn an_artifact_downloads_into_a_directory_named_after_it() { - for (name, expected) in [ - ("weights", Some("./weights")), - ("", None), - (".", None), - ("..", None), - ("../weights", None), - ("weights/nested", None), - ("/weights", None), - ("C:\\weights", None), - ] { - match default_directory(name) { - Ok(directory) => assert_eq!(Some(directory.as_path()), expected.map(Path::new)), - Err(error) => { - assert_eq!(expected, None, "{name:?}"); - let error = anyhow::Error::new(error); - assert_eq!(report(&error), (ErrorKind::Usage, 2, true), "{name:?}"); - } - } - } + fn given_a_plain_artifact_name_when_choosing_where_to_download_then_it_is_a_directory_named_after_it() + { + let directory = default_directory("weights").unwrap(); + + assert_eq!(directory, PathBuf::from("./weights")); + } + + #[rstest] + #[case::empty("")] + #[case::nested("weights/best")] + #[case::parent("../weights")] + #[case::absolute("/weights")] + fn given_a_name_that_is_not_one_plain_directory_when_choosing_where_to_download_then_one_must_be_passed( + #[case] name: &str, + ) { + let error = anyhow::Error::new(default_directory(name).unwrap_err()); + + assert_eq!(classify(&error), ErrorKind::Usage); + assert!( + ErrorReport::new(&error) + .hint + .unwrap() + .contains("--directory") + ); } #[test] - fn a_file_is_checked_against_its_manifest_entry() { - let manifest = json!({"files": [ - {"rel_path": "a", "size_bytes": 7, "checksum": "abc"}, - {"rel_path": "b"}, - ]}); - for (rel_path, expected) in [ - ("a", (Some(7), Some("abc".to_string()))), - ("b", (None, None)), - ("missing", (None, None)), - ] { - assert_eq!(file_integrity(&manifest, rel_path), expected, "{rel_path}"); - } - assert_eq!(file_integrity(&Value::Null, "a"), (None, None)); + fn given_a_manifest_entry_for_a_file_when_reading_its_integrity_then_it_has_its_size_and_checksum() + { + let manifest = + json!({"files": [{"rel_path": "weights.bin", "size_bytes": 7, "checksum": "abc"}]}); + + let integrity = file_integrity(&manifest, "weights.bin"); + + assert_eq!(integrity, (Some(7), Some("abc".to_string()))); + } + + #[test] + fn given_a_file_the_manifest_does_not_list_when_reading_its_integrity_then_there_is_none() { + let manifest = json!({"files": [{"rel_path": "weights.bin", "size_bytes": 7}]}); + + let integrity = file_integrity(&manifest, "config.json"); + + assert_eq!(integrity, (None, None)); } } diff --git a/crates/tracel-cli/src/commands/auth.rs b/crates/tracel-cli/src/commands/auth.rs index f8874e2..fe15b6e 100644 --- a/crates/tracel-cli/src/commands/auth.rs +++ b/crates/tracel-cli/src/commands/auth.rs @@ -159,20 +159,32 @@ fn time_left(until: SystemTime, now: SystemTime) -> String { mod tests { use std::time::{Duration, UNIX_EPOCH}; + use rstest::rstest; + use super::*; + #[rstest] + #[case::under_a_minute(59, "0m")] + #[case::minutes(45 * 60, "45m")] + #[case::hours(3 * 3600 + 5 * 60, "3h 5m")] + #[case::days(6 * 86400 + 23 * 3600 + 59 * 60, "6d 23h")] + fn given_the_time_until_a_login_expires_when_showing_it_then_it_reads_in_its_two_largest_units( + #[case] seconds: u64, + #[case] expected: &str, + ) { + let now = UNIX_EPOCH + Duration::from_secs(1_900_000_000); + + let left = time_left(now + Duration::from_secs(seconds), now); + + assert_eq!(left, expected); + } + #[test] - fn the_time_left_on_a_login_reads_in_its_two_largest_units() { + fn given_an_expired_login_when_showing_the_time_left_then_no_time_is_left() { let now = UNIX_EPOCH + Duration::from_secs(1_900_000_000); - for (seconds, expected) in [ - (0, "0m"), - (59, "0m"), - (45 * 60, "45m"), - (3 * 3600 + 5 * 60, "3h 5m"), - (6 * 86400 + 23 * 3600 + 59 * 60, "6d 23h"), - ] { - let until = now + Duration::from_secs(seconds); - assert_eq!(time_left(until, now), expected, "{seconds}"); - } + + let left = time_left(now - Duration::from_secs(60), now); + + assert_eq!(left, "0m"); } } diff --git a/crates/tracel-cli/src/commands/experiments.rs b/crates/tracel-cli/src/commands/experiments.rs index eeb97e5..e3ddf60 100644 --- a/crates/tracel-cli/src/commands/experiments.rs +++ b/crates/tracel-cli/src/commands/experiments.rs @@ -507,6 +507,7 @@ pub fn handle_command(args: ExperimentsArgs, context: CliContext) -> anyhow::Res #[cfg(test)] mod tests { + use rstest::rstest; use serde_json::json; use super::*; @@ -514,8 +515,7 @@ mod tests { use crate::test_support::{command, plain}; fn logs_args(args: &[&str]) -> LogsArgs { - let mut argv = vec!["experiments", "logs", "42"]; - argv.extend(args); + let argv = [&["experiments", "logs", "42"][..], args].concat(); match command(&argv) { Commands::Experiments(ExperimentsArgs { command: ExperimentsCommands::Logs(args), @@ -530,88 +530,50 @@ mod tests { log_level: LogLevelResponse::Warn, timestamp: "2026-10-07T00:00:00Z".into(), message: message.into(), - metadata: json!({"epoch": 3}), + metadata: json!({}), } } #[test] - fn an_experiment_is_a_positive_number_or_latest() { - for (value, expected) in [ - ("latest", Some("latest")), - ("42", Some("42")), - ("0", None), - ("-1", None), - ("v7", None), - ] { - let parsed = parse_experiment(value).ok().map(|selector| match selector { - ExperimentSelector::Latest => "latest".to_string(), - ExperimentSelector::Number(number) => number.to_string(), - }); - assert_eq!(parsed.as_deref(), expected, "{value}"); - } + fn given_latest_when_parsing_an_experiment_then_it_selects_the_latest() { + let selector = parse_experiment("latest").unwrap(); + + assert!(matches!(selector, ExperimentSelector::Latest)); } #[test] - fn log_filters_become_the_client_request() { - let args = logs_args(&[ - "--limit", - "25", - "--level", - "info", - "--level", - "error", - "--from", - "2026-10-06T00:00:00Z", - "--to", - "2026-10-07T00:00:00Z", - "--offset", - "5", - "--search", - "loss", - "--metadata", - "worker=gpu=0", - "--metadata-not", - "stage=init", - "--metadata-exists", - "epoch", - ]); - - let request = args.into_request(); - - assert_eq!(request.limit, Some(25)); - assert_eq!( - request.levels, - Some(vec![LogLevelRequest::Info, LogLevelRequest::Error]) - ); - assert_eq!(request.from.as_deref(), Some("2026-10-06T00:00:00Z")); - assert_eq!(request.to.as_deref(), Some("2026-10-07T00:00:00Z")); - assert_eq!(request.offset, Some(5)); - assert_eq!(request.search.as_deref(), Some("loss")); - assert_eq!(request.after, None); - assert_eq!( - serde_json::to_value(request.metadata_filters).unwrap(), - json!([ - {"type":"equals", "key":"worker", "value":"gpu=0"}, - {"type":"not_equals", "key":"stage", "value":"init"}, - {"type":"exists", "key":"epoch"}, - ]) - ); + fn given_a_number_when_parsing_an_experiment_then_it_selects_that_experiment() { + let selector = parse_experiment("42").unwrap(); + + assert!(matches!(selector, ExperimentSelector::Number(42))); } - #[test] - fn following_logs_reads_from_a_sequence_cursor() { - for (args, after) in [ - (&["--follow"][..], Some(0)), - (&["--follow", "--after", "12"], Some(12)), - (&["--after", "12"], Some(12)), - (&[], None), - ] { - assert_eq!(logs_args(args).into_request().after, after, "{args:?}"); - } + #[rstest] + #[case::zero("0")] + #[case::negative("-1")] + #[case::text("v7")] + fn given_anything_else_when_parsing_an_experiment_then_it_is_refused(#[case] value: &str) { + let parsed = parse_experiment(value); + + assert!(parsed.is_err()); + } + + #[rstest] + #[case::follow(&["--follow"], Some(0))] + #[case::follow_after(&["--follow", "--after", "12"], Some(12))] + #[case::after(&["--after", "12"], Some(12))] + #[case::neither(&[], None)] + fn given_follow_or_after_when_querying_logs_then_reading_starts_after_that_sequence( + #[case] args: &[&str], + #[case] after: Option, + ) { + let request = logs_args(args).into_request(); + + assert_eq!(request.after, after); } #[test] - fn logs_read_oldest_first() { + fn given_logs_newest_first_when_writing_them_as_text_then_they_read_oldest_first() { let logs = ExperimentLogQueryResponse { running: false, items: vec![log(9, "second"), log(8, "first")], @@ -630,34 +592,10 @@ mod tests { } #[test] - fn a_followed_log_is_an_event_with_its_type() { - let item = log(7, "loss\nspiked"); - for (event, expected) in [ - ( - LogEvent::Log(&item), - json!({ - "type": "log", - "seq": 7, - "log_level": "warn", - "timestamp": "2026-10-07T00:00:00Z", - "message": "loss\nspiked", - "metadata": {"epoch": 3}, - }), - ), - ( - LogEvent::End { running: false }, - json!({"type": "end", "running": false}), - ), - ] { - assert_eq!(serde_json::to_value(&event).unwrap(), expected); - } - } - - #[test] - fn a_log_line_shows_its_local_time_level_and_one_line_message() { - let item = log(7, "loss\nspiked"); + fn given_a_log_entry_when_writing_it_as_text_then_it_is_one_line_with_its_time_and_level() { + let entry = log(7, "loss\nspiked"); - let text = plain(&LogEvent::Log(&item)) + &plain(&LogEvent::End { running: false }); + let text = plain(&LogEvent::Log(&entry)); assert_eq!( text, diff --git a/crates/tracel-cli/src/commands/init.rs b/crates/tracel-cli/src/commands/init.rs index 1a280ca..24e8232 100644 --- a/crates/tracel-cli/src/commands/init.rs +++ b/crates/tracel-cli/src/commands/init.rs @@ -418,34 +418,32 @@ pub fn commit_all(terminal: &Terminal) -> anyhow::Result<()> { #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; - #[test] - fn a_project_name_has_only_letters_digits_underscores_and_hyphens() { - for (name, valid) in [ - ("project", true), - ("Project123", true), - ("my_project-1", true), - ("modèle", true), - ("", false), - ("my project", false), - ("project.name", false), - ("project/name", false), - ] { - assert_eq!(validate_project_name(name).is_ok(), valid, "{name:?}"); - } + #[rstest] + #[case::letters_and_digits("Project123")] + #[case::underscore_and_hyphen("my_project-1")] + #[case::accented_letter("modèle")] + fn given_letters_digits_underscores_or_hyphens_when_validating_a_project_name_then_it_is_valid( + #[case] name: &str, + ) { + let validated = validate_project_name(name); + + assert_eq!(validated, Ok(())); } - #[test] - fn a_project_is_on_the_web_console_under_its_namespace() { - for (namespace, expected) in [ - (Namespace::User("ada".into()), "/users/ada/projects/demo"), - ( - Namespace::Organization("tracel".into()), - "/orgs/tracel/projects/demo", - ), - ] { - assert_eq!(namespace.project_path("demo"), expected); - } + #[rstest] + #[case::empty("")] + #[case::space("my project")] + #[case::dot("project.name")] + #[case::slash("project/name")] + fn given_another_character_when_validating_a_project_name_then_it_is_invalid( + #[case] name: &str, + ) { + let validated = validate_project_name(name); + + assert!(validated.is_err()); } } diff --git a/crates/tracel-cli/src/commands/login.rs b/crates/tracel-cli/src/commands/login.rs index f265ecf..420dcae 100644 --- a/crates/tracel-cli/src/commands/login.rs +++ b/crates/tracel-cli/src/commands/login.rs @@ -322,75 +322,67 @@ mod tests { use super::*; + fn now() -> SystemTime { + UNIX_EPOCH + Duration::from_secs(1_900_000_000) + } + #[test] - fn polling_stops_at_the_earlier_deadline() { - let now = UNIX_EPOCH + Duration::from_secs(1_900_000_000); - let expires_at = now + Duration::from_secs(60); - for (timeout, elapsed, remaining, deadline) in [ - (None, 0, 60, PollDeadline::Expiry), - (Some(90), 0, 60, PollDeadline::Expiry), - (Some(60), 0, 60, PollDeadline::Expiry), - (Some(30), 10, 20, PollDeadline::Timeout), - (Some(30), 30, 0, PollDeadline::Timeout), - (Some(30), 31, 0, PollDeadline::Timeout), - (Some(0), 0, 0, PollDeadline::Timeout), - ] { - assert_eq!( - poll_window( - expires_at, - now, - timeout.map(Duration::from_secs), - Duration::from_secs(elapsed) - ), - (Duration::from_secs(remaining), deadline), - "{timeout:?} {elapsed}" - ); - } + fn given_no_timeout_when_polling_a_login_then_it_polls_until_the_login_expires() { + let expires_at = now() + Duration::from_secs(60); + + let window = poll_window(expires_at, now(), None, Duration::ZERO); + + assert_eq!(window, (Duration::from_secs(60), PollDeadline::Expiry)); } #[test] - fn an_expired_login_stops_polling_whatever_the_timeout() { - let expires_at = UNIX_EPOCH + Duration::from_secs(1_900_000_000); - for timeout in [None, Some(Duration::ZERO), Some(Duration::MAX)] { - for now in [expires_at, expires_at + Duration::from_secs(1)] { - assert_eq!( - poll_window(expires_at, now, timeout, Duration::ZERO), - (Duration::ZERO, PollDeadline::Expiry), - "{timeout:?} {now:?}" - ); - } - } + fn given_a_timeout_after_the_expiry_when_polling_a_login_then_it_polls_until_the_login_expires() + { + let expires_at = now() + Duration::from_secs(60); + + let window = poll_window( + expires_at, + now(), + Some(Duration::from_secs(90)), + Duration::ZERO, + ); + + assert_eq!(window, (Duration::from_secs(60), PollDeadline::Expiry)); } #[test] - fn a_pending_login_says_so_in_its_json() { - let pending = LoginPending { - verification_uri_complete: "http://localhost:9001/verify?code=BCDF-GHJK".into(), - user_code: "BCDF-GHJK".into(), - expires_at: Timestamp(UNIX_EPOCH + Duration::from_secs(1_900_000_000)), - }; - - let json = serde_json::to_value(pending).unwrap(); - - assert_eq!( - json, - serde_json::json!({ - "status": "pending", - "verification_uri_complete": "http://localhost:9001/verify?code=BCDF-GHJK", - "user_code": "BCDF-GHJK", - "expires_at": "2030-03-17T17:46:40+00:00", - }) + fn given_a_timeout_before_the_expiry_when_polling_a_login_then_it_polls_for_the_time_the_timeout_leaves() + { + let expires_at = now() + Duration::from_secs(60); + + let window = poll_window( + expires_at, + now(), + Some(Duration::from_secs(30)), + Duration::from_secs(10), ); + + assert_eq!(window, (Duration::from_secs(20), PollDeadline::Timeout)); } #[test] - fn a_slow_down_adds_five_seconds_to_the_interval() { - for (interval, expected) in [ - (Duration::from_secs(7), Duration::from_secs(12)), - (Duration::from_secs(12), Duration::from_secs(17)), - (Duration::MAX, Duration::MAX), - ] { - assert_eq!(poll_backoff(interval), expected, "{interval:?}"); - } + fn given_an_expired_login_when_polling_it_then_polling_stops_on_the_expiry() { + let expires_at = now() - Duration::from_secs(1); + + let window = poll_window( + expires_at, + now(), + Some(Duration::from_secs(30)), + Duration::ZERO, + ); + + assert_eq!(window, (Duration::ZERO, PollDeadline::Expiry)); + } + + #[test] + fn given_a_slow_down_when_polling_a_login_then_the_interval_grows_by_five_seconds() { + let interval = poll_backoff(Duration::from_secs(7)); + + assert_eq!(interval, Duration::from_secs(12)); } } diff --git a/crates/tracel-cli/src/commands/login/pending.rs b/crates/tracel-cli/src/commands/login/pending.rs index 35d55d0..f1c6ad8 100644 --- a/crates/tracel-cli/src/commands/login/pending.rs +++ b/crates/tracel-cli/src/commands/login/pending.rs @@ -140,35 +140,73 @@ mod tests { } } - fn authorization(device_code: &str) -> DeviceCodeResponse { - DeviceCodeResponse { + fn pending(device_code: &str) -> PendingLogin { + let authorization = DeviceCodeResponse { device_code: device_code.to_string(), user_code: "BCDF-GHJK".to_string(), verification_uri: "http://localhost:9001/verify".to_string(), verification_uri_complete: "http://localhost:9001/verify?code=BCDF-GHJK".to_string(), expires_in: 600, interval: 5, - } + }; + PendingLogin::new( + authorization, + UNIX_EPOCH + Duration::from_secs(1_900_000_000), + ) + .unwrap() } #[test] - fn a_saved_pending_login_loads_back_until_it_is_cleared() { + fn given_no_pending_login_when_loading_it_then_there_is_none() { + let directory = tempfile::tempdir().unwrap(); + + let loaded = store(&directory).load().unwrap(); + + assert!(loaded.is_none()); + } + + #[test] + fn given_a_saved_pending_login_when_loading_it_then_it_is_the_one_saved() { let directory = tempfile::tempdir().unwrap(); let store = store(&directory); - let now = UNIX_EPOCH + Duration::from_secs(1_900_000_000); - assert!(store.load().unwrap().is_none()); + store.save(&pending("first")).unwrap(); + + let loaded = store.load().unwrap().unwrap(); + + assert_eq!(loaded.authorization.device_code, "first"); + assert_eq!(loaded.expires_at, pending("first").expires_at); + } + + #[test] + fn given_a_saved_pending_login_when_saving_another_then_it_replaces_it() { + let directory = tempfile::tempdir().unwrap(); + let store = store(&directory); + store.save(&pending("first")).unwrap(); + + store.save(&pending("second")).unwrap(); + + let loaded = store.load().unwrap().unwrap(); + assert_eq!(loaded.authorization.device_code, "second"); + } + + #[test] + fn given_a_saved_pending_login_when_saving_another_then_no_temporary_file_is_left() { + let directory = tempfile::tempdir().unwrap(); + let store = store(&directory); + store.save(&pending("first")).unwrap(); + + store.save(&pending("second")).unwrap(); + + let files = fs::read_dir(store.path.parent().unwrap()).unwrap().count(); + assert_eq!(files, 1); + } + + #[test] + fn given_a_saved_pending_login_when_clearing_it_twice_then_there_is_none() { + let directory = tempfile::tempdir().unwrap(); + let store = store(&directory); + store.save(&pending("first")).unwrap(); - for device_code in ["first", "second"] { - let pending = PendingLogin::new(authorization(device_code), now).unwrap(); - store.save(&pending).unwrap(); - let loaded = store.load().unwrap().unwrap(); - assert_eq!( - serde_json::to_value(&loaded).unwrap(), - serde_json::to_value(&pending).unwrap() - ); - let entries = fs::read_dir(store.path.parent().unwrap()).unwrap().count(); - assert_eq!(entries, 1, "a save leaves no temporary file"); - } store.clear().unwrap(); store.clear().unwrap(); @@ -177,50 +215,20 @@ mod tests { #[cfg(unix)] #[test] - fn a_pending_login_is_readable_by_its_owner_only() { + fn given_a_pending_login_others_could_read_when_saving_it_then_only_its_owner_can() { use std::os::unix::fs::PermissionsExt; let directory = tempfile::tempdir().unwrap(); let store = store(&directory); - let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777; - let pending = PendingLogin::new(authorization("first"), SystemTime::now()).unwrap(); - store.save(&pending).unwrap(); + let sessions = store.path.parent().unwrap().to_path_buf(); + store.save(&pending("first")).unwrap(); fs::set_permissions(&store.path, fs::Permissions::from_mode(0o644)).unwrap(); - let parent = store.path.parent().unwrap(); - fs::set_permissions(parent, fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(&sessions, fs::Permissions::from_mode(0o755)).unwrap(); - store.save(&pending).unwrap(); + store.save(&pending("first")).unwrap(); + let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777; assert_eq!(mode(&store.path), 0o600); - assert_eq!(mode(parent), 0o700); - } - - #[test] - fn a_pending_login_expires_its_lifetime_after_it_started() { - let now = UNIX_EPOCH + Duration::from_secs(1_900_000_000); - for (seconds, expected) in [(-1, 0), (0, 0), (1, 1), (600, 600)] { - let mut authorization = authorization("device-code"); - authorization.expires_in = seconds; - let pending = PendingLogin::new(authorization, now).unwrap(); - assert_eq!( - pending.expires_at, - now + Duration::from_secs(expected), - "{seconds}" - ); - } - } - - #[test] - fn a_pending_login_sits_beside_its_session() { - for url in ["https://console.tracel.ai/api/", "http://localhost:9001/"] { - let url = Url::parse(url).unwrap(); - let session = FileSessionStore::for_server(&url).unwrap(); - let pending = PendingLoginStore::for_server(&url).unwrap(); - assert_eq!( - pending.path, - session.path().with_extension("pending.json"), - "{url}" - ); - } + assert_eq!(mode(&sessions), 0o700); } } diff --git a/crates/tracel-cli/src/commands/models.rs b/crates/tracel-cli/src/commands/models.rs index 4da61c2..55f78a6 100644 --- a/crates/tracel-cli/src/commands/models.rs +++ b/crates/tracel-cli/src/commands/models.rs @@ -780,34 +780,40 @@ fn alias( #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; - use crate::test_support::report; + use crate::error::{ErrorReport, classify}; #[test] - fn a_default_pull_directory_needs_a_plain_model_name() { - for (model, valid) in [ - ("weights", true), - ("", false), - (".", false), - ("..", false), - ("/weights", false), - ("../weights", false), - ("nested/weights", false), - ("C:\\weights", false), - ] { - let checked = validate_download_directory(None, model); - match checked { - Ok(()) => assert!(valid, "{model:?}"), - Err(error) => { - assert!(!valid, "{model:?}"); - assert_eq!(report(&error), (ErrorKind::Usage, 2, true), "{model:?}"); - assert!( - error.to_string().contains(&format!("'{model}'")), - "{model:?}" - ); - } - } - assert!(validate_download_directory(Some(Path::new("./out")), model).is_ok()); - } + fn given_a_plain_model_name_when_pulling_without_a_directory_then_it_is_the_directory() { + let checked = validate_download_directory(None, "weights"); + + assert!(checked.is_ok()); + } + + #[rstest] + #[case::nested("team/weights")] + #[case::parent("../weights")] + #[case::absolute("/weights")] + fn given_a_model_name_that_is_not_one_plain_directory_when_pulling_without_a_directory_then_one_must_be_passed( + #[case] model: &str, + ) { + let error = validate_download_directory(None, model).unwrap_err(); + + assert_eq!(classify(&error), ErrorKind::Usage); + assert!( + ErrorReport::new(&error) + .hint + .unwrap() + .contains("--directory") + ); + } + + #[test] + fn given_a_directory_when_pulling_then_any_model_name_is_fine() { + let checked = validate_download_directory(Some(Path::new("./out")), "team/weights"); + + assert!(checked.is_ok()); } } diff --git a/crates/tracel-cli/src/context.rs b/crates/tracel-cli/src/context.rs index a3a43b5..79f38cb 100644 --- a/crates/tracel-cli/src/context.rs +++ b/crates/tracel-cli/src/context.rs @@ -138,10 +138,10 @@ mod tests { use super::*; #[test] - fn tracel_api_key_comes_before_a_stored_login() { + fn given_tracel_api_key_when_choosing_credentials_then_the_stored_login_is_not_read() { let api_key = TracelCredentials::ApiKey("key".into()); - let chosen = first_credentials(Some(api_key), || panic!("the stored login is read")); + let chosen = first_credentials(Some(api_key), || panic!("the stored login was read")); assert!(matches!(chosen, Ok(Some(TracelCredentials::ApiKey(key))) if key == "key")); } diff --git a/crates/tracel-cli/src/environment.rs b/crates/tracel-cli/src/environment.rs index b3f6086..4ae4569 100644 --- a/crates/tracel-cli/src/environment.rs +++ b/crates/tracel-cli/src/environment.rs @@ -68,77 +68,59 @@ fn parse(value: &str) -> Option { #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; - use crate::test_support::report; #[test] - fn flags_choose_the_environment_before_tracel_env() { - for (dev, staging, variable, expected) in [ - (false, None, None, "production"), - (false, None, Some("development"), "development"), - (true, None, Some("production"), "development"), - (true, None, Some("bogus"), "development"), - (false, Some(2), Some("Development"), "staging-2"), - (false, Some(2), Some("bogus"), "staging-2"), - ] { - let environment = resolve(dev, staging, variable).unwrap(); - assert_eq!( - name(&environment), - expected, - "{dev} {staging:?} {variable:?}" - ); - } + fn given_dev_when_resolving_the_environment_then_it_is_development_whatever_tracel_env_says() { + let environment = resolve(true, None, Some("production")).unwrap(); + + assert_eq!(name(&environment), "development"); } #[test] - fn tracel_env_reads_every_environment_as_the_cli_and_the_sdk_write_it() { - for environment in [ - Env::Production, - Env::Development, - Env::Staging(0), - Env::Staging(255), - ] { - for written in [name(&environment), sdk_value(&environment)] { - let read = resolve(false, None, Some(&written)).unwrap(); - assert_eq!(name(&read), name(&environment), "{written}"); - } - } + fn given_staging_when_resolving_the_environment_then_it_is_that_staging_whatever_tracel_env_says() + { + let environment = resolve(false, Some(2), Some("development")).unwrap(); + + assert_eq!(name(&environment), "staging-2"); } #[test] - fn an_unknown_tracel_env_is_a_usage_error() { - for value in [ - "bogus", - "", - "Staging()", - "Staging(1", - "staging-", - "staging--1", - "Staging(256)", - "staging-256", - "Production ", - ] { - let error = anyhow::Error::new(resolve(false, None, Some(value)).unwrap_err()); - assert_eq!(report(&error), (ErrorKind::Usage, 2, false), "{value:?}"); - assert!( - error.to_string().contains(&format!("'{value}'")), - "{value:?}" - ); - } + fn given_no_flag_or_tracel_env_when_resolving_the_environment_then_it_is_production() { + let environment = resolve(false, None, None).unwrap(); + + assert_eq!(name(&environment), "production"); } - #[test] - fn each_environment_has_its_web_console() { - for (environment, expected) in [ - (Env::Production, "https://console.tracel.ai/"), - (Env::Staging(3), "https://s3-console.tracel.ai/"), - (Env::Development, "http://localhost:9000/"), - ] { - assert_eq!( - web_console(&environment).as_str(), - expected, - "{environment:?}" - ); - } + #[rstest] + #[case::cli_development("development", "development")] + #[case::sdk_development("Development", "development")] + #[case::cli_staging("staging-2", "staging-2")] + #[case::sdk_staging("Staging(2)", "staging-2")] + #[case::sdk_production("Production", "production")] + fn given_tracel_env_as_the_cli_or_the_sdk_writes_it_when_resolving_then_it_names_that_environment( + #[case] variable: &str, + #[case] expected: &str, + ) { + let environment = resolve(false, None, Some(variable)).unwrap(); + + assert_eq!(name(&environment), expected); + } + + #[rstest] + #[case::unknown("bogus")] + #[case::no_version("staging-")] + #[case::version_too_large("Staging(256)")] + #[case::trailing_space("Production ")] + fn given_an_unknown_tracel_env_when_resolving_then_it_is_a_usage_error(#[case] variable: &str) { + let error = resolve(false, None, Some(variable)).unwrap_err(); + + assert_eq!(error.kind, ErrorKind::Usage); + assert!( + error.to_string().contains(&format!("'{variable}'")), + "{error}" + ); } } diff --git a/crates/tracel-cli/src/error.rs b/crates/tracel-cli/src/error.rs index 84fb64f..26d54e2 100644 --- a/crates/tracel-cli/src/error.rs +++ b/crates/tracel-cli/src/error.rs @@ -153,26 +153,9 @@ fn classify_client(error: &ClientError) -> ErrorKind { #[cfg(test)] mod tests { - use super::*; + use rstest::rstest; - #[test] - fn each_kind_keeps_its_documented_code_and_exit_code() { - for (kind, code, exit) in [ - (ErrorKind::Internal, "INTERNAL", 1), - (ErrorKind::Usage, "USAGE", 2), - (ErrorKind::NotAuthenticated, "NOT_AUTHENTICATED", 3), - (ErrorKind::Forbidden, "FORBIDDEN", 4), - (ErrorKind::NotFound, "NOT_FOUND", 5), - (ErrorKind::Conflict, "CONFLICT", 6), - (ErrorKind::ConfirmationRequired, "CONFIRMATION_REQUIRED", 7), - (ErrorKind::LimitReached, "LIMIT_REACHED", 8), - (ErrorKind::Timeout, "TIMEOUT", 10), - (ErrorKind::Unavailable, "UNAVAILABLE", 11), - ] { - assert_eq!(kind.exit_code(), exit, "{kind:?}"); - assert_eq!(serde_json::to_value(kind).unwrap(), code, "{kind:?}"); - } - } + use super::*; fn api_error(status: u16, code: ApiErrorCode) -> ClientError { ClientError::ApiError { @@ -185,145 +168,73 @@ mod tests { } #[test] - fn an_error_takes_the_kind_of_its_cause_under_any_context() { - let console = url::Url::parse("http://localhost:9001/").unwrap(); - let unreachable = || ClientError::UnknownError("connection refused".into()); - for (error, kind) in [ - ( - ClientError::Unauthenticated.into(), - ErrorKind::NotAuthenticated, - ), - ( - ClientError::AppSessionEnded.into(), - ErrorKind::NotAuthenticated, - ), - ( - ClientError::CredentialNotAllowed.into(), - ErrorKind::Forbidden, - ), - (ClientError::NotFound.into(), ErrorKind::NotFound), - ( - ClientError::NotFoundWithCode(ApiErrorCode::Unknown).into(), - ErrorKind::NotFound, - ), - ( - ClientError::InternalServerError.into(), - ErrorKind::Unavailable, - ), - (unreachable().into(), ErrorKind::Unavailable), - ( - ClientError::SessionStore("unreadable".into()).into(), - ErrorKind::Internal, - ), - ( - api_error(400, ApiErrorCode::Unknown).into(), - ErrorKind::Usage, - ), - ( - api_error(422, ApiErrorCode::Unknown).into(), - ErrorKind::Usage, - ), - ( - api_error(401, ApiErrorCode::Unknown).into(), - ErrorKind::NotAuthenticated, - ), - ( - api_error(403, ApiErrorCode::Unknown).into(), - ErrorKind::Forbidden, - ), - ( - api_error(404, ApiErrorCode::Unknown).into(), - ErrorKind::NotFound, - ), - ( - api_error(409, ApiErrorCode::Unknown).into(), - ErrorKind::Conflict, - ), - ( - api_error(429, ApiErrorCode::Unknown).into(), - ErrorKind::Unavailable, - ), - ( - api_error(503, ApiErrorCode::Unknown).into(), - ErrorKind::Unavailable, - ), - ( - api_error(408, ApiErrorCode::Unknown).into(), - ErrorKind::Internal, - ), - ( - api_error(400, ApiErrorCode::LimitReached).into(), - ErrorKind::LimitReached, - ), - ( - api_error(403, ApiErrorCode::LimitReached).into(), - ErrorKind::LimitReached, - ), - ( - DeviceFlowError::AccessDenied.into(), - ErrorKind::NotAuthenticated, - ), - ( - DeviceFlowError::ExpiredToken.into(), - ErrorKind::NotAuthenticated, - ), - ( - DeviceFlowError::Client(unreachable()).into(), - ErrorKind::Unavailable, - ), - ( - ClientCreationError::NoCredentials.into(), - ErrorKind::NotAuthenticated, - ), - ( - ClientCreationError::InvalidCredentials.into(), - ErrorKind::NotAuthenticated, - ), - ( - ClientCreationError::ApiKeyRefused.into(), - ErrorKind::NotAuthenticated, - ), - ( - ClientCreationError::ServerConnectionError(console.clone()).into(), - ErrorKind::Unavailable, - ), - ( - ClientCreationError::SessionStore(ClientError::SessionStore("x".into())).into(), - ErrorKind::Internal, - ), - (anyhow::anyhow!("unclassified"), ErrorKind::Internal), - ] { - let error: anyhow::Error = error; - let shown = format!("{error:?}"); - assert_eq!(classify(&error.context("Can't do it")), kind, "{shown}"); - } + fn given_a_client_error_under_context_when_classifying_then_the_client_error_decides() { + let error = anyhow::Error::new(ClientError::NotFound).context("Can't read model 'weights'"); + + assert_eq!(classify(&error), ErrorKind::NotFound); } #[test] - fn a_cli_error_keeps_its_kind_and_hint_under_context() { + fn given_a_cli_error_under_context_when_reporting_then_its_kind_and_hint_are_kept() { let error = anyhow::Error::new(ClientError::NotFound) .context(CliError::new(ErrorKind::LimitReached, "Limit reached.").with_hint("Wait.")) - .context("Can't do it"); + .context("Can't upload"); let report = ErrorReport::new(&error); assert_eq!(report.code, ErrorKind::LimitReached); assert_eq!(report.hint, Some("Wait.")); - assert_eq!(report.exit_code, 8); } #[test] - fn a_reported_message_ends_as_a_sentence() { - for (error, expected) in [ - (anyhow::anyhow!("No model 'w'."), "No model 'w'."), - ( - anyhow::anyhow!("No such file").context("Can't read 'a'"), - "Can't read 'a': No such file.", - ), - (anyhow::anyhow!("Ready?"), "Ready?"), - (anyhow::anyhow!("trailing space "), "trailing space."), - ] { - assert_eq!(ErrorReport::new(&error).message, expected); - } + fn given_a_limit_reached_error_with_a_forbidden_status_when_classifying_then_the_limit_decides() + { + let error = anyhow::Error::new(api_error(403, ApiErrorCode::LimitReached)); + + assert_eq!(classify(&error), ErrorKind::LimitReached); + } + + #[rstest] + #[case::no_response(ClientError::UnknownError("connection refused".into()))] + #[case::server_error(ClientError::InternalServerError)] + #[case::too_many_requests(api_error(429, ApiErrorCode::Unknown))] + fn given_a_console_that_cannot_answer_when_classifying_then_it_is_unavailable( + #[case] client_error: ClientError, + ) { + let error = anyhow::Error::new(client_error); + + assert_eq!(classify(&error), ErrorKind::Unavailable); + } + + #[test] + fn given_a_login_that_failed_on_a_client_error_when_classifying_then_the_client_error_decides() + { + let error = anyhow::Error::new(DeviceFlowError::Client(ClientError::InternalServerError)); + + assert_eq!(classify(&error), ErrorKind::Unavailable); + } + + #[test] + fn given_an_error_nothing_classifies_when_classifying_then_it_is_internal() { + let error = anyhow::anyhow!("unexpected"); + + assert_eq!(classify(&error), ErrorKind::Internal); + } + + #[rstest] + #[case::sentence(anyhow::anyhow!("No model 'w'."), "No model 'w'.")] + #[case::question(anyhow::anyhow!("Ready?"), "Ready?")] + #[case::chain( + anyhow::anyhow!("No such file").context("Can't read 'a'"), + "Can't read 'a': No such file." + )] + #[case::trailing_space(anyhow::anyhow!("Can't read 'a' "), "Can't read 'a'.")] + fn given_an_error_when_reporting_it_then_its_message_ends_as_a_sentence( + #[case] error: anyhow::Error, + #[case] message: &str, + ) { + let report = ErrorReport::new(&error); + + assert_eq!(report.message, message); } } diff --git a/crates/tracel-cli/src/helpers/download.rs b/crates/tracel-cli/src/helpers/download.rs index d4728cc..55a63c4 100644 --- a/crates/tracel-cli/src/helpers/download.rs +++ b/crates/tracel-cli/src/helpers/download.rs @@ -180,54 +180,32 @@ fn download_each( #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; use crate::error::classify; - #[test] - fn a_download_path_stays_inside_its_directory() { - for (path, safe) in [ - ("a", true), - ("nested/weights.bin", true), - ("a-b/c_d", true), - ("", false), - (".", false), - ("./a", false), - ("a/./b", false), - ("..", false), - ("../a", false), - ("a/../b", false), - ("/a", false), - ("C:\\a", false), - ("C:a", false), - ("C:/a", false), - ("\\\\server\\file", false), - ("\\a", false), - ("a\0b", false), - ] { - match validate_rel_path(path) { - Ok(()) => assert!(safe, "{path:?}"), - Err(error) => { - assert!(!safe, "{path:?}"); - assert_eq!(classify(&error), ErrorKind::Internal, "{path:?}"); - assert!(error.to_string().contains(path), "{path:?}"); - } - } - } - } + const HELLO_WORLD_SHA256: &str = + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"; - fn file(size_bytes: Option, checksum: Option<&str>) -> DownloadFile { + fn file(rel_path: &str, size_bytes: Option, checksum: Option<&str>) -> DownloadFile { DownloadFile { - rel_path: "hello".into(), + rel_path: rel_path.into(), url: String::new(), size_bytes, checksum: checksum.map(str::to_string), } } - /// A writer that takes two bytes at a time, as a socket may. - struct PartialWriter(Vec); + fn written(contents: &[u8]) -> HashingWriter> { + let mut writer = HashingWriter::new(Vec::new()); + writer.write_all(contents).unwrap(); + writer + } - impl Write for PartialWriter { + struct TwoBytesAtATime(Vec); + + impl Write for TwoBytesAtATime { fn write(&mut self, data: &[u8]) -> std::io::Result { let count = data.len().min(2); self.0.extend_from_slice(&data[..count]); @@ -239,58 +217,119 @@ mod tests { } } + #[rstest] + #[case::file("weights.bin")] + #[case::nested("nested/weights.bin")] + fn given_a_relative_path_when_validating_it_then_it_is_safe(#[case] rel_path: &str) { + let validated = validate_rel_path(rel_path); + + assert!(validated.is_ok()); + } + + #[rstest] + #[case::empty("")] + #[case::current_directory("./a")] + #[case::parent("../a")] + #[case::inner_parent("a/../b")] + #[case::absolute("/a")] + #[case::windows_drive("C:a")] + #[case::backslash(r"a\b")] + #[case::nul("a\0b")] + fn given_a_path_that_can_leave_its_directory_when_validating_it_then_it_is_refused( + #[case] rel_path: &str, + ) { + let validated = validate_rel_path(rel_path); + + assert!(validated.is_err()); + } + #[test] - fn the_writer_hashes_only_the_bytes_written() { - let mut writer = HashingWriter::new(PartialWriter(Vec::new())); + fn given_a_writer_that_takes_two_bytes_at_a_time_when_writing_then_only_written_bytes_are_hashed() + { + let mut writer = HashingWriter::new(TwoBytesAtATime(Vec::new())); writer.write_all(b"hello world").unwrap(); - assert_eq!(writer.bytes, 11); assert_eq!(writer.inner.0, b"hello world"); + assert_eq!(writer.bytes, 11); assert_eq!( - format!("{:x}", writer.hasher.clone().finalize()), - "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + format!("{:x}", writer.hasher.finalize()), + HELLO_WORLD_SHA256 ); } #[test] - fn a_download_must_match_its_declared_size_and_checksum() { - let checksum = "B94D27B9934D3E08A52E52D7DA7DABFAC484EFE37A5380EE9088F7ACE2EFCDE9"; - let mut writer = HashingWriter::new(Vec::new()); - writer.write_all(b"hello world").unwrap(); - for (declared, matches) in [ - (file(Some(11), Some(checksum)), true), - (file(Some(0), Some("")), true), - (file(None, None), true), - (file(Some(10), None), false), - (file(None, Some("bad")), false), - ] { - let case = (declared.size_bytes, declared.checksum.clone()); - assert_eq!(writer.verify(&declared).is_ok(), matches, "{case:?}"); - } + fn given_the_declared_size_and_checksum_when_verifying_a_download_then_it_passes() { + let writer = written(b"hello world"); + let declared = file("hello", Some(11), Some(&HELLO_WORLD_SHA256.to_uppercase())); + + let verified = writer.verify(&declared); + + assert!(verified.is_ok()); + } + + #[rstest] + #[case::none(None, None)] + #[case::zero_and_empty(Some(0), Some(""))] + fn given_no_declared_size_or_checksum_when_verifying_a_download_then_it_passes( + #[case] size_bytes: Option, + #[case] checksum: Option<&str>, + ) { + let writer = written(b"hello world"); + + let verified = writer.verify(&file("hello", size_bytes, checksum)); + + assert!(verified.is_ok()); } #[test] - fn every_path_is_checked_before_any_existing_file() { + fn given_another_size_when_verifying_a_download_then_it_fails() { + let writer = written(b"hello world"); + + let verified = writer.verify(&file("hello", Some(10), None)); + + assert!(verified.is_err()); + } + + #[test] + fn given_another_checksum_when_verifying_a_download_then_it_fails() { + let writer = written(b"hello world"); + + let verified = writer.verify(&file("hello", None, Some("0123abcd"))); + + assert!(verified.is_err()); + } + + #[test] + fn given_an_existing_file_when_checking_destinations_then_it_is_a_conflict() { let directory = tempfile::tempdir().unwrap(); fs::write(directory.path().join("hello"), b"existing").unwrap(); - let new = || DownloadFile { - rel_path: "new".into(), - ..file(None, None) - }; - let unsafe_path = DownloadFile { - rel_path: "../unsafe".into(), - ..file(None, None) - }; - - let existing = check_destinations(directory.path(), &[new(), file(None, None)], false); - let forced = check_destinations(directory.path(), &[new(), file(None, None)], true); - let unsafe_first = - check_destinations(directory.path(), &[file(None, None), unsafe_path], false); - - assert_eq!(classify(&existing.unwrap_err()), ErrorKind::Conflict); - assert!(forced.is_ok()); - assert_eq!(classify(&unsafe_first.unwrap_err()), ErrorKind::Internal); - assert!(!directory.path().join("new").exists()); + + let error = + check_destinations(directory.path(), &[file("hello", None, None)], false).unwrap_err(); + + assert_eq!(classify(&error), ErrorKind::Conflict); + } + + #[test] + fn given_force_when_checking_destinations_then_an_existing_file_may_be_overwritten() { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("hello"), b"existing").unwrap(); + + let checked = check_destinations(directory.path(), &[file("hello", None, None)], true); + + assert!(checked.is_ok()); + } + + #[test] + fn given_an_unsafe_path_after_an_existing_file_when_checking_destinations_then_the_unsafe_path_fails_first() + { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("hello"), b"existing").unwrap(); + let files = [file("hello", None, None), file("../escape", None, None)]; + + let error = check_destinations(directory.path(), &files, false).unwrap_err(); + + assert!(error.to_string().contains("'../escape'"), "{error}"); } } diff --git a/crates/tracel-cli/src/helpers/model_upload/parts.rs b/crates/tracel-cli/src/helpers/model_upload/parts.rs index 4b756ee..0791b21 100644 --- a/crates/tracel-cli/src/helpers/model_upload/parts.rs +++ b/crates/tracel-cli/src/helpers/model_upload/parts.rs @@ -98,16 +98,18 @@ pub fn build_part_tasks( #[cfg(test)] mod tests { - use super::*; + use rstest::rstest; use tracel_client::console::artifact::response::MultipartUploadResponse; - fn local_file(size: u64) -> (BTreeMap, BTreeMap) { + use super::*; + + fn weights(size: u64) -> (BTreeMap, BTreeMap) { let files = BTreeMap::from([("weights.bin".to_string(), PathBuf::from("weights.bin"))]); let sizes = BTreeMap::from([("weights.bin".to_string(), size)]); (files, sizes) } - fn response(rel_path: &str, parts: &[(u32, u64)]) -> Vec { + fn parts_of(rel_path: &str, parts: &[(u32, u64)]) -> Vec { vec![PresignedModelFileUploadUrlsResponse { rel_path: rel_path.to_string(), urls: MultipartUploadResponse { @@ -116,7 +118,7 @@ mod tests { .iter() .map(|&(part, size_bytes)| PresignedUploadUrlResponse { part, - url: format!("https://example.com/part{part}"), + url: format!("https://storage/part{part}"), size_bytes, }) .collect(), @@ -125,35 +127,42 @@ mod tests { } #[test] - fn parts_get_sequential_offsets_in_part_order() { - let (files, sizes) = local_file(300); + fn given_parts_out_of_order_when_splitting_a_file_then_each_part_starts_where_the_previous_ends() + { + let (files, sizes) = weights(300); let tasks = build_part_tasks( &files, &sizes, - &response("weights.bin", &[(2, 100), (1, 200)]), + &parts_of("weights.bin", &[(2, 100), (1, 200)]), ) .unwrap(); - let layout: Vec<(u32, u64, u64)> = tasks - .iter() - .map(|task| (task.part, task.offset, task.size_bytes)) - .collect(); - assert_eq!(layout, [(1, 0, 200), (2, 200, 100)]); + assert_eq!( + (tasks[0].part, tasks[0].offset, tasks[0].size_bytes), + (1, 0, 200) + ); + assert_eq!( + (tasks[1].part, tasks[1].offset, tasks[1].size_bytes), + (2, 200, 100) + ); } - #[test] - fn an_upload_response_that_does_not_cover_the_file_is_rejected() { - let (files, sizes) = local_file(300); - for (rel_path, parts, named) in [ - ("missing.bin", &[(1, 300)][..], "'missing.bin'"), - ("weights.bin", &[], "'weights.bin'"), - ("weights.bin", &[(1, 200)], "200 bytes"), - ("weights.bin", &[(1, 150), (1, 150)], "got 1"), - ("weights.bin", &[(1, 200), (3, 100)], "got 3"), - ] { - let error = build_part_tasks(&files, &sizes, &response(rel_path, parts)).unwrap_err(); - assert!(error.to_string().contains(named), "{parts:?}: {error}"); - } + #[rstest] + #[case::unknown_file("other.bin", &[(1, 300)], "'other.bin'")] + #[case::no_parts("weights.bin", &[], "no parts")] + #[case::too_small("weights.bin", &[(1, 200)], "200 bytes")] + #[case::repeated_part("weights.bin", &[(1, 150), (1, 150)], "got 1")] + #[case::missing_part("weights.bin", &[(1, 200), (3, 100)], "got 3")] + fn given_parts_that_do_not_cover_the_file_when_splitting_it_then_the_upload_is_refused_for_that_reason( + #[case] rel_path: &str, + #[case] parts: &[(u32, u64)], + #[case] reason: &str, + ) { + let (files, sizes) = weights(300); + + let error = build_part_tasks(&files, &sizes, &parts_of(rel_path, parts)).unwrap_err(); + + assert!(error.to_string().contains(reason), "{error}"); } } diff --git a/crates/tracel-cli/src/helpers/model_upload/upload.rs b/crates/tracel-cli/src/helpers/model_upload/upload.rs index 05e7968..0c313f8 100644 --- a/crates/tracel-cli/src/helpers/model_upload/upload.rs +++ b/crates/tracel-cli/src/helpers/model_upload/upload.rs @@ -158,73 +158,75 @@ pub fn upload_parts( #[cfg(test)] mod tests { - use super::*; - use crate::test_support::quiet_terminal; use std::fs; use std::sync::Mutex; - /// Records the URLs it uploads to, and fails on `fail_url`. + use super::*; + use crate::test_support::quiet_terminal; + struct RecordingUploader { - calls: Mutex>, - fail_url: Option<&'static str>, + urls: Mutex>, + failing_url: Option<&'static str>, } impl RecordingUploader { - fn new(fail_url: Option<&'static str>) -> Self { + fn new(failing_url: Option<&'static str>) -> Self { Self { - calls: Mutex::new(Vec::new()), - fail_url, + urls: Mutex::new(Vec::new()), + failing_url, } } } impl PartUploader for RecordingUploader { fn upload(&self, url: &str, _bytes: Vec) -> Result<(), ClientError> { - self.calls.lock().unwrap().push(url.to_string()); - if self.fail_url == Some(url) { + self.urls.lock().unwrap().push(url.to_string()); + if self.failing_url == Some(url) { return Err(ClientError::NotFound); } Ok(()) } } - fn tasks(directory: &Path) -> Vec { - ["a", "b"] - .into_iter() - .map(|name| { - let path = directory.join(format!("{name}.bin")); - fs::write(&path, b"data").unwrap(); - PartUploadTask { - rel_path: format!("{name}.bin"), - absolute_path: path, - part: 1, - url: format!("https://example.com/{name}"), - offset: 0, - size_bytes: 4, - } - }) - .collect() + fn task(directory: &Path, name: &str) -> PartUploadTask { + let path = directory.join(name); + fs::write(&path, b"data").unwrap(); + PartUploadTask { + rel_path: name.to_string(), + absolute_path: path, + part: 1, + url: format!("https://storage/{name}"), + offset: 0, + size_bytes: 4, + } } #[test] - fn every_part_is_uploaded_once() { + fn given_two_parts_when_uploading_then_each_is_uploaded_once() { let directory = tempfile::tempdir().unwrap(); + let tasks = vec![ + task(directory.path(), "a.bin"), + task(directory.path(), "b.bin"), + ]; let uploader = RecordingUploader::new(None); - upload_parts(&uploader, tasks(directory.path()), &quiet_terminal()).unwrap(); + upload_parts(&uploader, tasks, &quiet_terminal()).unwrap(); - let mut calls = uploader.calls.into_inner().unwrap(); - calls.sort(); - assert_eq!(calls, ["https://example.com/a", "https://example.com/b"]); + let mut urls = uploader.urls.into_inner().unwrap(); + urls.sort(); + assert_eq!(urls, ["https://storage/a.bin", "https://storage/b.bin"]); } #[test] - fn a_failed_part_fails_the_upload_naming_the_part() { + fn given_a_part_that_fails_when_uploading_then_the_error_names_it() { let directory = tempfile::tempdir().unwrap(); - let uploader = RecordingUploader::new(Some("https://example.com/b")); + let tasks = vec![ + task(directory.path(), "a.bin"), + task(directory.path(), "b.bin"), + ]; + let uploader = RecordingUploader::new(Some("https://storage/b.bin")); - let error = - upload_parts(&uploader, tasks(directory.path()), &quiet_terminal()).unwrap_err(); + let error = upload_parts(&uploader, tasks, &quiet_terminal()).unwrap_err(); assert!(error.to_string().contains("'b.bin' (part 1)"), "{error}"); } diff --git a/crates/tracel-cli/src/helpers/project.rs b/crates/tracel-cli/src/helpers/project.rs index 1237381..2ca7133 100644 --- a/crates/tracel-cli/src/helpers/project.rs +++ b/crates/tracel-cli/src/helpers/project.rs @@ -250,148 +250,119 @@ pub fn validate_project_exists_on_server( #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; - use crate::test_support::report; + use crate::error::{ErrorReport, classify}; - #[test] - fn a_project_flag_is_a_namespace_and_a_name() { - for (value, expected) in [ - ("alice/demo", Some(("alice", "demo"))), - ("", None), - ("alice", None), - ("/demo", None), - ("alice/", None), - ("/", None), - ("alice/demo/extra", None), - ("alice//demo", None), - ] { - match parse_project(value) { - Ok(project) => assert_eq!( - Some((project.namespace.as_str(), project.name.as_str())), - expected, - "{value:?}" - ), - Err(error) => { - assert_eq!(expected, None, "{value:?}"); - let error = anyhow::Error::new(error); - assert_eq!(report(&error), (CliErrorKind::Usage, 2, false), "{value:?}"); - assert!(error.to_string().contains(&format!("'{value}'"))); - } - } + fn tracel_toml(namespace: Option<&str>, project: Option<&str>) -> TracelTomlConfig { + TracelTomlConfig { + namespace: namespace.map(str::to_string), + project: project.map(str::to_string), } } + fn unread() -> anyhow::Result { + panic!("tracel.toml was read") + } + + fn full_name(project: &TracelProject) -> String { + format!("{}/{}", project.namespace, project.name) + } + #[test] - fn the_flag_then_the_variables_then_tracel_toml_name_the_project() { - let flag = parse_project("flag/project").unwrap(); - let file = "namespace = 'file'\nproject = 'project'"; - for (flag, namespace, project, toml, expected) in [ - ( - Some(&flag), - Some("env"), - Some("env"), - None, - ("flag", "project", ProjectSource::Flag), - ), - ( - None, - Some("env"), - Some("demo"), - None, - ("env", "demo", ProjectSource::Env), - ), - ( - None, - None, - None, - Some(file), - ("file", "project", ProjectSource::TracelToml), - ), - ( - None, - Some("env"), - None, - Some(file), - ("env", "project", ProjectSource::Env), - ), - ( - None, - None, - Some("demo"), - Some(file), - ("file", "demo", ProjectSource::Env), - ), - ( - None, - None, - None, - Some("owner = 'old'\nname = 'names'"), - ("old", "names", ProjectSource::TracelToml), - ), - ] { - let case = (namespace, project, toml); - let config = || match toml { - Some(toml) => Ok(toml::from_str(toml).unwrap()), - None => panic!("tracel.toml is read for {case:?}"), - }; - let resolved = resolve_project_from( - flag, - namespace.map(str::to_owned), - project.map(str::to_owned), - config, - ) - .unwrap(); - let (namespace, name, source) = expected; - assert_eq!(resolved.project.namespace, namespace, "{case:?}"); - assert_eq!(resolved.project.name, name, "{case:?}"); - assert_eq!(resolved.source, source, "{case:?}"); - } + fn given_a_namespace_and_a_name_when_parsing_a_project_then_it_has_both() { + let project = parse_project("alice/demo").unwrap(); + + assert_eq!(project.namespace, "alice"); + assert_eq!(project.name, "demo"); + } + + #[rstest] + #[case::empty("")] + #[case::no_slash("alice")] + #[case::no_namespace("/demo")] + #[case::no_name("alice/")] + #[case::extra_part("alice/demo/extra")] + fn given_a_value_that_is_not_namespace_slash_name_when_parsing_a_project_then_it_is_a_usage_error( + #[case] value: &str, + ) { + let error = parse_project(value).unwrap_err(); + + assert_eq!(error.kind, CliErrorKind::Usage); + assert!(error.to_string().contains(&format!("'{value}'")), "{error}"); } #[test] - fn an_incomplete_project_is_not_found_with_a_hint() { - for (namespace, project) in [(None, None), (Some("alice"), None), (None, Some("demo"))] { - let error = resolve_project_from( - None, - namespace.map(str::to_owned), - project.map(str::to_owned), - || Ok(TracelTomlConfig::default()), - ) - .unwrap_err(); - assert_eq!( - report(&error), - (CliErrorKind::NotFound, 5, true), - "{namespace:?} {project:?}" - ); - } + fn given_a_project_flag_when_resolving_the_project_then_the_flag_wins() { + let flag = parse_project("alice/demo").unwrap(); + + let resolved = + resolve_project_from(Some(&flag), Some("env".into()), Some("env".into()), unread) + .unwrap(); + + assert_eq!(full_name(&resolved.project), "alice/demo"); + assert_eq!(resolved.source, ProjectSource::Flag); } #[test] - fn a_missing_project_names_itself_however_it_was_selected() { - let project = parse_project("alice/demo").unwrap(); - for source in [ - ProjectSource::Flag, - ProjectSource::Env, - ProjectSource::TracelToml, - ] { - let error = anyhow::Error::new(missing_project(&project, &source)); - assert_eq!( - report(&error), - (CliErrorKind::NotFound, 5, true), - "{source:?}" - ); - assert!(error.to_string().contains("alice/demo"), "{source:?}"); - } + fn given_both_variables_when_resolving_the_project_then_they_name_it_without_tracel_toml() { + let resolved = + resolve_project_from(None, Some("alice".into()), Some("demo".into()), unread).unwrap(); + + assert_eq!(full_name(&resolved.project), "alice/demo"); + assert_eq!(resolved.source, ProjectSource::Env); } #[test] - fn a_project_source_has_its_documented_json_name() { - for (source, value) in [ - (ProjectSource::Flag, "flag"), - (ProjectSource::Env, "env"), - (ProjectSource::TracelToml, "tracel.toml"), - ] { - assert_eq!(serde_json::to_value(&source).unwrap(), value, "{source:?}"); - } + fn given_no_flag_or_variable_when_resolving_the_project_then_tracel_toml_names_it() { + let resolved = resolve_project_from(None, None, None, || { + Ok(tracel_toml(Some("alice"), Some("demo"))) + }) + .unwrap(); + + assert_eq!(full_name(&resolved.project), "alice/demo"); + assert_eq!(resolved.source, ProjectSource::TracelToml); + } + + #[test] + fn given_only_tracel_namespace_when_resolving_the_project_then_it_overrides_tracel_toml_namespace() + { + let resolved = resolve_project_from(None, Some("team".into()), None, || { + Ok(tracel_toml(Some("alice"), Some("demo"))) + }) + .unwrap(); + + assert_eq!(full_name(&resolved.project), "team/demo"); + assert_eq!(resolved.source, ProjectSource::Env); + } + + #[test] + fn given_no_project_anywhere_when_resolving_the_project_then_it_is_not_found() { + let error = + resolve_project_from(None, None, None, || Ok(tracel_toml(None, None))).unwrap_err(); + + assert_eq!(classify(&error), CliErrorKind::NotFound); + assert!( + ErrorReport::new(&error) + .hint + .unwrap() + .contains("tracel init") + ); + } + + #[rstest] + #[case::flag(ProjectSource::Flag, "tracel project list")] + #[case::env(ProjectSource::Env, "tracel project list")] + #[case::tracel_toml(ProjectSource::TracelToml, "tracel init --force")] + fn given_how_a_project_was_selected_when_the_console_lacks_it_then_the_hint_suggests_the_fitting_command( + #[case] source: ProjectSource, + #[case] command: &str, + ) { + let project = parse_project("alice/demo").unwrap(); + + let error = anyhow::Error::new(missing_project(&project, &source)); + + assert!(ErrorReport::new(&error).hint.unwrap().contains(command)); } } diff --git a/crates/tracel-cli/src/helpers/resources.rs b/crates/tracel-cli/src/helpers/resources.rs index ff8c3bc..67d7e98 100644 --- a/crates/tracel-cli/src/helpers/resources.rs +++ b/crates/tracel-cli/src/helpers/resources.rs @@ -148,229 +148,142 @@ pub fn select_artifact<'a>( #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; - use crate::test_support::report; + use crate::error::{ErrorReport, classify}; + + #[rstest] + #[case::model( + ApiErrorCode::Model, + Resource::Model("weights"), + "'weights'", + "tracel models list" + )] + #[case::model_of_a_version( + ApiErrorCode::Model, + Resource::ModelVersion { model: "weights", version: 7 }, + "'weights'", + "tracel models list" + )] + #[case::version( + ApiErrorCode::ModelVersion, + Resource::ModelVersion { model: "weights", version: 7 }, + "version 7", + "tracel models versions weights" + )] + #[case::alias( + ApiErrorCode::ModelAlias, + Resource::ModelAlias { model: "weights", alias: "production" }, + "'production'", + "tracel models alias list weights" + )] + #[case::version_or_alias( + ApiErrorCode::ModelAlias, + Resource::ModelVersionRef { model: "weights", reference: "production" }, + "'production'", + "tracel models versions weights" + )] + #[case::dataset( + ApiErrorCode::Dataset, + Resource::Dataset("images"), + "'images'", + "tracel datasets list" + )] + fn given_the_console_says_what_is_missing_when_mapping_the_error_then_it_names_it_and_how_to_list_them( + #[case] code: ApiErrorCode, + #[case] resource: Resource<'static>, + #[case] named: &str, + #[case] command: &str, + ) { + let error = map_resource_error( + ClientError::NotFoundWithCode(code), + "alice", + "demo", + resource, + ); - fn models() -> [Resource<'static>; 4] { - [ - Resource::Model("weights"), - Resource::ModelVersionRef { - model: "weights", - reference: "production", - }, - Resource::ModelVersion { - model: "weights", - version: 7, - }, - Resource::ModelAlias { - model: "weights", - alias: "production", - }, - ] + assert!(error.to_string().contains(named), "{error}"); + assert!(ErrorReport::new(&error).hint.unwrap().contains(command)); } #[test] - fn a_missing_resource_names_itself_with_a_listing_hint() { - let mut cases: Vec<(ClientError, Resource<'static>, &str)> = models() - .into_iter() - .map(|resource| { - ( - ClientError::NotFoundWithCode(ApiErrorCode::Model), - resource, - "'weights'", - ) - }) - .collect(); - cases.extend([ - ( - ClientError::NotFound, - Resource::Model("weights"), - "'weights'", - ), - ( - ClientError::NotFoundWithCode(ApiErrorCode::Unknown), - Resource::Model("weights"), - "'weights'", - ), - ( - ClientError::NotFoundWithCode(ApiErrorCode::ModelVersion), - Resource::ModelVersionRef { - model: "weights", - reference: "v7", - }, - "'v7'", - ), - ( - ClientError::NotFoundWithCode(ApiErrorCode::ModelAlias), - Resource::ModelVersionRef { - model: "weights", - reference: "production", - }, - "'production'", - ), - ( - ClientError::NotFoundWithCode(ApiErrorCode::ModelVersion), - Resource::ModelVersion { - model: "weights", - version: 7, - }, - "version 7", - ), - ( - ClientError::NotFoundWithCode(ApiErrorCode::ModelAlias), - Resource::ModelAlias { - model: "weights", - alias: "production", - }, - "'production'", - ), - ( - ClientError::NotFound, - Resource::Experiment(99), - "experiment 99", - ), - ( - ClientError::NotFoundWithCode(ApiErrorCode::Unknown), - Resource::Experiment(99), - "experiment 99", - ), - ( - ClientError::NotFoundWithCode(ApiErrorCode::Dataset), - Resource::Dataset("images"), - "'images'", - ), - ]); - for (client_error, resource, named) in cases { - let case = format!("{client_error:?}"); - let error = map_resource_error(client_error, "alice", "demo", resource); - assert_eq!(report(&error), (ErrorKind::NotFound, 5, true), "{case}"); - assert!(error.to_string().contains(named), "{case}: {error}"); - } + fn given_a_plain_not_found_for_an_experiment_when_mapping_the_error_then_it_names_the_experiment() + { + let error = map_resource_error( + ClientError::NotFound, + "alice", + "demo", + Resource::Experiment(9), + ); + + assert!(error.to_string().contains("experiment 9"), "{error}"); + assert!( + ErrorReport::new(&error) + .hint + .unwrap() + .contains("tracel experiments list") + ); } - #[test] - fn other_errors_pass_through_unchanged() { - let unrelated = || { - [ - ClientError::Unauthenticated, - ClientError::CredentialNotAllowed, - ClientError::InternalServerError, - ClientError::UnknownError("connection refused".into()), - ClientError::NotFoundWithCode(ApiErrorCode::DatasetVersion), - ClientError::NotFoundWithCode(ApiErrorCode::ModelVersionNotReady), - ClientError::ApiError { - status: 404.try_into().unwrap(), - body: tracel_client::error::ApiErrorBody { - code: ApiErrorCode::Model, - message: "original message".into(), - }, - }, - ] - }; - let mut cases: Vec<(ClientError, Resource<'static>)> = models() - .into_iter() - .chain([Resource::Experiment(99), Resource::Dataset("images")]) - .flat_map(|resource| unrelated().into_iter().map(move |error| (error, resource))) - .collect(); - cases.extend([ - ( - ClientError::NotFoundWithCode(ApiErrorCode::Unknown), - Resource::Dataset("images"), - ), - ( - ClientError::NotFoundWithCode(ApiErrorCode::Model), - Resource::Experiment(99), - ), - ( - ClientError::NotFoundWithCode(ApiErrorCode::ModelVersion), - Resource::Model("weights"), - ), - ( - ClientError::NotFoundWithCode(ApiErrorCode::ModelAlias), - Resource::ModelVersion { - model: "weights", - version: 7, - }, - ), - ( - ClientError::NotFoundWithCode(ApiErrorCode::ModelVersion), - Resource::ModelAlias { - model: "weights", - alias: "production", - }, - ), - ( - ClientError::NotFoundWithCode(ApiErrorCode::Dataset), - Resource::Model("weights"), - ), - ]); - for (client_error, resource) in cases { - let original = format!("{client_error:?}"); - let error = map_resource_error(client_error, "alice", "demo", resource); - assert_eq!( - format!("{:?}", error.downcast_ref::().unwrap()), - original - ); - assert!(!report(&error).2, "{original}"); - } + #[rstest] + #[case::unauthenticated(ClientError::Unauthenticated)] + #[case::server_error(ClientError::InternalServerError)] + #[case::another_resource(ClientError::NotFoundWithCode(ApiErrorCode::Dataset))] + fn given_another_error_when_mapping_it_then_it_stays_the_client_error( + #[case] client_error: ClientError, + ) { + let error = map_resource_error(client_error, "alice", "demo", Resource::Model("weights")); + + assert!(error.is::(), "{error:?}"); + } + + const ARTIFACTS: [(&str, &str); 3] = [("a1", "weights"), ("a2", "weights"), ("a3", "logs")]; + + #[rstest] + #[case::by_id("a2", 1)] + #[case::by_unique_name("logs", 2)] + fn given_an_id_or_a_unique_name_when_selecting_an_artifact_then_it_is_found( + #[case] value: &str, + #[case] index: usize, + ) { + let selected = select_artifact(ARTIFACTS, value, 7).unwrap(); + + assert_eq!(selected, index); } #[test] - fn an_artifact_is_found_by_id_then_by_a_unique_name() { - let items = [ - ("a", "weights"), - ("b", "weights"), - ("c", "a"), - ("d", "logs"), - ]; - for (value, expected) in [ - ("a", Ok(0)), - ("logs", Ok(3)), - ("weights", Err((ErrorKind::Conflict, 6, true))), - ("missing", Err((ErrorKind::NotFound, 5, true))), - ] { - let selected = select_artifact(items, value, 7).map_err(|error| { - let error = anyhow::Error::new(error); - assert!(error.to_string().contains(&format!("'{value}'")), "{value}"); - report(&error) - }); - assert_eq!(selected, expected, "{value}"); - } + fn given_a_name_several_artifacts_share_when_selecting_an_artifact_then_their_ids_are_offered() + { + let error = anyhow::Error::new(select_artifact(ARTIFACTS, "weights", 7).unwrap_err()); + + assert_eq!(classify(&error), ErrorKind::Conflict); + assert!(ErrorReport::new(&error).hint.unwrap().contains("a1, a2")); } #[test] - fn metadata_is_a_json_object() { - for (value, valid) in [ - (r#"{"format":"bin"}"#, true), - ("{}", true), - ("", false), - ("{", false), - ("null", false), - ("[]", false), - ("1", false), - (r#""text""#, false), - ] { - assert_eq!(parse_metadata(value).is_ok(), valid, "{value:?}"); - if let Err(error) = parse_metadata(value) { - assert_eq!(error.kind, ErrorKind::Usage, "{value:?}"); - } - } + fn given_an_unknown_artifact_when_selecting_it_then_it_is_not_found() { + let error = select_artifact(ARTIFACTS, "missing", 7).unwrap_err(); + + assert_eq!(error.kind, ErrorKind::NotFound); } #[test] - fn a_description_needs_auto_create_true() { - for (auto_create, description, valid) in [ - (Some(true), Some("text"), true), - (None, None, true), - (Some(false), None, true), - (None, Some("text"), false), - (Some(false), Some("text"), false), - ] { - assert_eq!( - validate_auto_create(auto_create, description).is_ok(), - valid, - "{auto_create:?} {description:?}" - ); - } + fn given_a_json_object_when_parsing_metadata_then_it_is_the_metadata() { + let metadata = parse_metadata(r#"{"format": "bin"}"#).unwrap(); + + assert_eq!(metadata, serde_json::json!({"format": "bin"})); + } + + #[rstest] + #[case::array("[]")] + #[case::number("1")] + #[case::null("null")] + fn given_json_that_is_not_an_object_when_parsing_metadata_then_it_is_a_usage_error( + #[case] value: &str, + ) { + let error = parse_metadata(value).unwrap_err(); + + assert_eq!(error.kind, ErrorKind::Usage); } } diff --git a/crates/tracel-cli/src/test_support.rs b/crates/tracel-cli/src/test_support.rs index d6471e6..11af988 100644 --- a/crates/tracel-cli/src/test_support.rs +++ b/crates/tracel-cli/src/test_support.rs @@ -1,9 +1,6 @@ //! Helpers shared by the tests of several modules. -use std::io; - use crate::cli::{CliArgs, Commands}; -use crate::error::{ErrorKind, ErrorReport, classify}; use crate::ui::{self, Format, Human, Layout, Place, Render, Terminal}; /// Parse `tracel` with `args`, as the shell would pass them. @@ -19,12 +16,6 @@ pub fn command(args: &[&str]) -> Commands { .unwrap_or_else(|| panic!("{args:?} name no command")) } -/// What a failure reports: its kind, its exit code, and whether it has a hint. -pub fn report(error: &anyhow::Error) -> (ErrorKind, i32, bool) { - let report = ErrorReport::new(error); - (classify(error), report.exit_code, report.hint.is_some()) -} - /// The plain text of a result, without its summary. pub fn plain(result: &impl Render) -> String { let mut text = Vec::new(); @@ -38,16 +29,3 @@ pub fn plain(result: &impl Render) -> String { pub fn quiet_terminal() -> Terminal { ui::channels(Format::Json, true, Layout::Bare, Place::default()).1 } - -/// A writer whose reader has gone away. -pub struct ClosedPipe; - -impl io::Write for ClosedPipe { - fn write(&mut self, _: &[u8]) -> io::Result { - Err(io::ErrorKind::BrokenPipe.into()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} diff --git a/crates/tracel-cli/src/tools/build_driver.rs b/crates/tracel-cli/src/tools/build_driver.rs index f955b11..0867474 100644 --- a/crates/tracel-cli/src/tools/build_driver.rs +++ b/crates/tracel-cli/src/tools/build_driver.rs @@ -148,28 +148,62 @@ pub fn cross_preflight( #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; - #[test] - fn the_driver_follows_the_target_os_and_the_installed_tools() { - const LINUX_X86: (Os, Arch) = (Os::Linux, Arch::X86_64); - const LINUX_ARM: (Os, Arch) = (Os::Linux, Arch::Arm64); - const MAC_ARM: (Os, Arch) = (Os::Macos, Arch::Arm64); - const WIN_X86: (Os, Arch) = (Os::Windows, Arch::X86_64); - let drivers = |zigbuild, xwin| AvailableDrivers { zigbuild, xwin }; - for (target, available, expected) in [ - (LINUX_X86, drivers(true, true), BuildDriver::Cargo), - (LINUX_ARM, drivers(true, true), BuildDriver::Cargo), - (WIN_X86, drivers(true, true), BuildDriver::Xwin), - (WIN_X86, drivers(true, false), BuildDriver::Cargo), - (MAC_ARM, drivers(true, true), BuildDriver::Zigbuild), - (MAC_ARM, drivers(false, true), BuildDriver::Cargo), - ] { - assert_eq!( - choose(LINUX_X86, target, &available), - expected, - "{target:?}" - ); - } + const LINUX_X86: (Os, Arch) = (Os::Linux, Arch::X86_64); + const LINUX_ARM: (Os, Arch) = (Os::Linux, Arch::Arm64); + const MAC_ARM: (Os, Arch) = (Os::Macos, Arch::Arm64); + const WIN_X86: (Os, Arch) = (Os::Windows, Arch::X86_64); + + const ALL_DRIVERS: AvailableDrivers = AvailableDrivers { + zigbuild: true, + xwin: true, + }; + + #[rstest] + #[case::same_target(LINUX_X86)] + #[case::other_architecture(LINUX_ARM)] + fn given_a_target_on_the_host_os_when_choosing_a_driver_then_it_is_cargo( + #[case] target: (Os, Arch), + ) { + let driver = choose(LINUX_X86, target, &ALL_DRIVERS); + + assert_eq!(driver, BuildDriver::Cargo); + } + + #[rstest] + #[case::xwin_installed(true, BuildDriver::Xwin)] + #[case::xwin_missing(false, BuildDriver::Cargo)] + fn given_a_windows_target_when_choosing_a_driver_then_only_xwin_cross_builds( + #[case] xwin: bool, + #[case] expected: BuildDriver, + ) { + let drivers = AvailableDrivers { + zigbuild: true, + xwin, + }; + + let driver = choose(LINUX_X86, WIN_X86, &drivers); + + assert_eq!(driver, expected); + } + + #[rstest] + #[case::zigbuild_installed(true, BuildDriver::Zigbuild)] + #[case::zigbuild_missing(false, BuildDriver::Cargo)] + fn given_a_macos_target_when_choosing_a_driver_then_zigbuild_cross_builds( + #[case] zigbuild: bool, + #[case] expected: BuildDriver, + ) { + let drivers = AvailableDrivers { + zigbuild, + xwin: true, + }; + + let driver = choose(LINUX_X86, MAC_ARM, &drivers); + + assert_eq!(driver, expected); } } diff --git a/crates/tracel-cli/src/tools/cargo.rs b/crates/tracel-cli/src/tools/cargo.rs index b72703e..00ea06c 100644 --- a/crates/tracel-cli/src/tools/cargo.rs +++ b/crates/tracel-cli/src/tools/cargo.rs @@ -141,46 +141,52 @@ fn executables(messages: &[u8]) -> anyhow::Result> { #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; #[test] - fn executables_are_the_artifacts_with_an_executable() { + fn given_cargo_build_messages_when_listing_executables_then_only_artifacts_with_one_are_listed() + { let messages = br#"{"reason":"compiler-artifact","target":{"name":"helper","kind":["lib"]},"executable":null} -not json -{"reason":"build-script-executed","package_id":"x"} -{"reason":"compiler-artifact","target":{"name":"trainer","kind":["bin"]},"executable":"/work/target/debug/trainer"} +{"reason":"build-script-executed","package_id":"trainer"} +{"reason":"compiler-artifact","target":{"name":"trainer","kind":["bin"]},"executable":"/work/target/release/trainer"} {"reason":"build-finished","success":true} "#; + + let executables = executables(messages).unwrap(); + assert_eq!( - executables(messages).unwrap(), + executables, [( "trainer".to_string(), - PathBuf::from("/work/target/debug/trainer") + PathBuf::from("/work/target/release/trainer") )] ); - assert!(executables(b"").unwrap().is_empty()); } - #[test] - fn status_lines_say_what_cargo_does_without_paths() { - assert_eq!( - status_line( - "\u{1b}[1m\u{1b}[32m Compiling\u{1b}[0m trainer v0.1.0 (/work/trainer)\n" - ) - .as_deref(), - Some("Compiling trainer v0.1.0") - ); - assert_eq!( - status_line(" Downloaded serde v1.0.228\n").as_deref(), - Some("Downloaded serde v1.0.228") - ); - for line in [ - " Finished `release` profile [optimized] target(s) in 0.20s\n", - "warning: unused variable: `x`\n", - " --> src/main.rs:3:9\n", - "\n", - ] { - assert_eq!(status_line(line), None, "{line:?}"); - } + #[rstest] + #[case::compiling( + "\u{1b}[1m\u{1b}[32m Compiling\u{1b}[0m trainer v0.1.0 (/work/trainer)\n", + "Compiling trainer v0.1.0" + )] + #[case::downloaded(" Downloaded serde v1.0.228\n", "Downloaded serde v1.0.228")] + fn given_a_cargo_status_when_reading_it_then_it_says_what_cargo_does_without_paths( + #[case] line: &str, + #[case] expected: &str, + ) { + let status = status_line(line); + + assert_eq!(status.as_deref(), Some(expected)); + } + + #[rstest] + #[case::finished(" Finished `release` profile [optimized] target(s) in 0.20s\n")] + #[case::warning("warning: unused variable: `x`\n")] + #[case::source_location(" --> src/main.rs:3:9\n")] + fn given_another_cargo_line_when_reading_it_then_it_is_no_status(#[case] line: &str) { + let status = status_line(line); + + assert_eq!(status, None); } } diff --git a/crates/tracel-cli/src/tools/fs.rs b/crates/tracel-cli/src/tools/fs.rs index 02abf5b..8591db5 100644 --- a/crates/tracel-cli/src/tools/fs.rs +++ b/crates/tracel-cli/src/tools/fs.rs @@ -87,11 +87,13 @@ pub fn build_file_specs(files: &BTreeMap) -> anyhow::Result>(), - ["a.txt", "nested/b.txt"] - ); + assert_eq!(files.keys().collect::>(), ["a.txt", "nested/b.txt"]); } #[test] - fn an_empty_directory_has_no_files_to_upload() { + fn given_an_empty_directory_when_collecting_its_files_then_there_is_nothing_to_upload() { let directory = tempfile::tempdir().unwrap(); - let error = collect_files(directory.path()).unwrap_err(); + let collected = collect_files(directory.path()); - assert!(error.to_string().starts_with("No files"), "{error}"); + assert!(collected.is_err()); } #[test] - fn a_file_spec_has_its_size_and_sha256() { + fn given_a_file_when_describing_it_then_it_has_its_size_and_sha256() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("hello.txt"); fs::write(&path, b"hello world").unwrap(); - let files = BTreeMap::from([("hello.txt".to_string(), path)]); - let specs = build_file_specs(&files).unwrap(); + let specs = build_file_specs(&BTreeMap::from([("hello.txt".to_string(), path)])).unwrap(); - let spec = &specs[0]; - assert_eq!((spec.rel_path.as_str(), spec.size_bytes), ("hello.txt", 11)); + assert_eq!(specs[0].rel_path, "hello.txt"); + assert_eq!(specs[0].size_bytes, 11); assert_eq!( - spec.checksum, + specs[0].checksum, "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" ); } diff --git a/crates/tracel-cli/src/tools/linker.rs b/crates/tracel-cli/src/tools/linker.rs index f227cfc..cac7b44 100644 --- a/crates/tracel-cli/src/tools/linker.rs +++ b/crates/tracel-cli/src/tools/linker.rs @@ -132,28 +132,62 @@ pub fn resolve_linker( #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; - #[test] - fn a_linker_setting_is_needed_only_across_linux_architectures() { - const LINUX_X86: (Os, Arch) = (Os::Linux, Arch::X86_64); - const LINUX_ARM: (Os, Arch) = (Os::Linux, Arch::Arm64); - const MAC_X86: (Os, Arch) = (Os::Macos, Arch::X86_64); - const MAC_ARM: (Os, Arch) = (Os::Macos, Arch::Arm64); - const WIN_X86: (Os, Arch) = (Os::Windows, Arch::X86_64); - for (host, target, expected) in [ - (LINUX_X86, LINUX_X86, None), - (LINUX_X86, LINUX_ARM, Some("aarch64-linux-gnu-gcc")), - (LINUX_ARM, LINUX_X86, Some("x86_64-linux-gnu-gcc")), - (MAC_X86, MAC_ARM, None), - (LINUX_X86, WIN_X86, None), - (LINUX_X86, MAC_ARM, None), - ] { - let linker = match linker_need(host, target) { - LinkerNeed::None => None, - LinkerNeed::ConfigEntry { linker, .. } => Some(linker), - }; - assert_eq!(linker, expected, "{host:?} {target:?}"); + const LINUX_X86: (Os, Arch) = (Os::Linux, Arch::X86_64); + const LINUX_ARM: (Os, Arch) = (Os::Linux, Arch::Arm64); + const MAC_X86: (Os, Arch) = (Os::Macos, Arch::X86_64); + const MAC_ARM: (Os, Arch) = (Os::Macos, Arch::Arm64); + const WIN_X86: (Os, Arch) = (Os::Windows, Arch::X86_64); + + fn linker(need: LinkerNeed) -> Option<&'static str> { + match need { + LinkerNeed::None => None, + LinkerNeed::ConfigEntry { linker, .. } => Some(linker), } } + + #[test] + fn given_the_host_target_when_building_then_no_linker_is_needed() { + let need = linker_need(LINUX_X86, LINUX_X86); + + assert_eq!(linker(need), None); + } + + #[rstest] + #[case::to_arm(LINUX_X86, LINUX_ARM, "aarch64-linux-gnu-gcc")] + #[case::to_x86(LINUX_ARM, LINUX_X86, "x86_64-linux-gnu-gcc")] + fn given_another_linux_architecture_when_building_then_its_cross_gcc_links( + #[case] host: (Os, Arch), + #[case] target: (Os, Arch), + #[case] expected: &str, + ) { + let need = linker_need(host, target); + + assert_eq!(linker(need), Some(expected)); + } + + #[rstest] + #[case::macos_architectures(MAC_X86, MAC_ARM)] + #[case::linux_to_windows(LINUX_X86, WIN_X86)] + #[case::linux_to_macos(LINUX_X86, MAC_ARM)] + fn given_a_target_the_toolchain_or_build_driver_links_when_building_then_no_linker_is_needed( + #[case] host: (Os, Arch), + #[case] target: (Os, Arch), + ) { + let need = linker_need(host, target); + + assert_eq!(linker(need), None); + } + + #[test] + fn given_no_cargo_config_when_checking_the_linker_then_it_is_not_configured() { + let root = tempfile::tempdir().unwrap(); + + let configured = linker_configured(root.path(), "aarch64-unknown-linux-gnu"); + + assert!(!configured); + } } diff --git a/crates/tracel-cli/src/tools/target.rs b/crates/tracel-cli/src/tools/target.rs index b5cbcb4..db791f4 100644 --- a/crates/tracel-cli/src/tools/target.rs +++ b/crates/tracel-cli/src/tools/target.rs @@ -225,17 +225,17 @@ pub fn install_missing_target( #[cfg(test)] mod tests { use super::*; - use crate::test_support::report; #[test] - fn no_requested_target_means_the_host() { - for host in ALL_TARGETS { - assert_eq!(resolve_targets(host, &[]).unwrap(), [host], "{host:?}"); - } + fn given_no_requested_target_when_resolving_targets_then_it_is_the_host() { + let targets = resolve_targets((Os::Macos, Arch::Arm64), &[]).unwrap(); + + assert_eq!(targets, [(Os::Macos, Arch::Arm64)]); } #[test] - fn requested_targets_keep_their_order_without_repeats() { + fn given_a_target_requested_twice_when_resolving_targets_then_it_appears_once_in_request_order() + { let requested = [ "aarch64-unknown-linux-gnu".to_string(), "x86_64-pc-windows-msvc".to_string(), @@ -251,14 +251,19 @@ mod tests { } #[test] - fn an_unknown_target_lists_the_targets() { - let error = anyhow::Error::new(parse_target("unknown-triple").unwrap_err()); + fn given_an_unknown_triple_when_parsing_a_target_then_the_error_lists_the_targets() { + let error = parse_target("x86_64-apple-darwin").unwrap_err(); - assert_eq!(report(&error), (ErrorKind::Usage, 2, false)); let message = error.to_string(); - assert!(message.contains("'unknown-triple'"), "{message}"); - for (os, arch) in ALL_TARGETS { - assert!(message.contains(target_triple(os, arch)), "{message}"); + assert_eq!(error.kind, ErrorKind::Usage); + for target in [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", + ] { + assert!(message.contains(target), "{message}"); } } } diff --git a/crates/tracel-cli/src/tools/workspace.rs b/crates/tracel-cli/src/tools/workspace.rs index a58cad9..207ad43 100644 --- a/crates/tracel-cli/src/tools/workspace.rs +++ b/crates/tracel-cli/src/tools/workspace.rs @@ -95,29 +95,59 @@ fn extract_workspace_name_from_toml(toml_content: &str) -> Option { #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; #[test] - fn the_workspace_name_comes_from_workspace_package_then_package() { - for (toml, expected) in [ - ( - "[workspace]\nmembers = ['a']\n[workspace.package]\nname = 'workspace-name'\n", - Some("workspace-name"), - ), - ("[package]\nname = 'single-crate'\n", Some("single-crate")), - ( - "[workspace.package]\nname = 'workspace-name'\n[package]\nname = 'package-name'\n", - Some("workspace-name"), - ), - ("[workspace]\nmembers = ['a']\n", None), - ("this is not valid toml { [ }", None), - ("", None), - ] { - assert_eq!( - extract_workspace_name_from_toml(toml).as_deref(), - expected, - "{toml:?}" - ); - } + fn given_a_workspace_package_name_when_reading_the_manifest_then_it_names_the_workspace() { + let manifest = r#" +[workspace] +members = ["trainer"] + +[workspace.package] +name = "my-workspace" +"#; + + let name = extract_workspace_name_from_toml(manifest); + + assert_eq!(name.as_deref(), Some("my-workspace")); + } + + #[test] + fn given_a_single_package_when_reading_the_manifest_then_its_name_names_the_workspace() { + let manifest = r#" +[package] +name = "trainer" +"#; + + let name = extract_workspace_name_from_toml(manifest); + + assert_eq!(name.as_deref(), Some("trainer")); + } + + #[test] + fn given_both_names_when_reading_the_manifest_then_the_workspace_package_name_wins() { + let manifest = r#" +[workspace.package] +name = "my-workspace" + +[package] +name = "trainer" +"#; + + let name = extract_workspace_name_from_toml(manifest); + + assert_eq!(name.as_deref(), Some("my-workspace")); + } + + #[rstest] + #[case::no_name("[workspace]\nmembers = [\"trainer\"]\n")] + #[case::empty("")] + #[case::invalid_toml("this is not valid toml { [ }")] + fn given_a_manifest_without_a_name_when_reading_it_then_there_is_none(#[case] manifest: &str) { + let name = extract_workspace_name_from_toml(manifest); + + assert_eq!(name, None); } } diff --git a/crates/tracel-cli/src/ui/format.rs b/crates/tracel-cli/src/ui/format.rs index 215084c..8fef444 100644 --- a/crates/tracel-cli/src/ui/format.rs +++ b/crates/tracel-cli/src/ui/format.rs @@ -62,61 +62,50 @@ impl Format { #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; #[test] - fn json_then_output_then_tracel_output_choose_the_format() { - use FormatArg::{Auto, Human as HumanArg, Json as JsonArg}; - for (flag, json, environment, auto, expected) in [ - (None, false, None, Format::Human, Some(Format::Human)), - (None, false, None, Format::Json, Some(Format::Json)), - ( - Some(Auto), - false, - Some("json"), - Format::Human, - Some(Format::Human), - ), - ( - None, - false, - Some("human"), - Format::Json, - Some(Format::Human), - ), - (None, false, Some("json"), Format::Human, Some(Format::Json)), - ( - Some(HumanArg), - false, - Some("json"), - Format::Json, - Some(Format::Human), - ), - ( - Some(JsonArg), - false, - Some("human"), - Format::Human, - Some(Format::Json), - ), - ( - Some(HumanArg), - false, - Some("xml"), - Format::Json, - Some(Format::Human), - ), - (None, true, Some("human"), Format::Human, Some(Format::Json)), - (None, true, Some("xml"), Format::Human, Some(Format::Json)), - (None, false, Some("xml"), Format::Human, None), - (None, false, Some("auto"), Format::Human, None), - ] { - let case = (flag, json, environment, auto); - let resolved = Format::resolve(flag, json, environment, auto); - assert_eq!(resolved.as_ref().ok().copied(), expected, "{case:?}"); - if let Err(error) = resolved { - assert_eq!(error.kind, ErrorKind::Usage, "{case:?}"); - } - } + fn given_json_when_choosing_the_format_then_it_is_json_whatever_else_says() { + let format = Format::resolve(Some(FormatArg::Human), true, Some("human"), Format::Human); + + assert_eq!(format.unwrap(), Format::Json); + } + + #[rstest] + #[case::human_over_json(FormatArg::Human, "json", Format::Human)] + #[case::json_over_human(FormatArg::Json, "human", Format::Json)] + #[case::auto_over_json(FormatArg::Auto, "json", Format::Human)] + fn given_output_when_choosing_the_format_then_it_wins_over_tracel_output( + #[case] flag: FormatArg, + #[case] tracel_output: &str, + #[case] expected: Format, + ) { + let format = Format::resolve(Some(flag), false, Some(tracel_output), Format::Human); + + assert_eq!(format.unwrap(), expected); + } + + #[test] + fn given_only_tracel_output_when_choosing_the_format_then_it_decides() { + let format = Format::resolve(None, false, Some("json"), Format::Human); + + assert_eq!(format.unwrap(), Format::Json); + } + + #[test] + fn given_no_choice_when_choosing_the_format_then_it_is_the_commands_default() { + let format = Format::resolve(None, false, None, Format::Json); + + assert_eq!(format.unwrap(), Format::Json); + } + + #[test] + fn given_an_unknown_tracel_output_when_choosing_the_format_then_it_is_a_usage_error() { + let error = Format::resolve(None, false, Some("xml"), Format::Human).unwrap_err(); + + assert_eq!(error.kind, ErrorKind::Usage); + assert!(error.to_string().contains("'xml'"), "{error}"); } } diff --git a/crates/tracel-cli/src/ui/human.rs b/crates/tracel-cli/src/ui/human.rs index c9048d3..81e6e9a 100644 --- a/crates/tracel-cli/src/ui/human.rs +++ b/crates/tracel-cli/src/ui/human.rs @@ -374,111 +374,80 @@ fn cell_text(value: &str) -> String { #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; - use crate::ui::screen::BOX_PADDING; - fn text( - width: Option, - styled: bool, - write: impl FnOnce(&mut Human<'_>) -> io::Result<()>, - ) -> String { + fn text(width: Option, write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> String { let mut out = Vec::new(); - write(&mut Human::new(&mut out, width, styled)).unwrap(); + write(&mut Human::plain(&mut out, width)).unwrap(); String::from_utf8(out).unwrap() } - fn plain(write: impl FnOnce(&mut Human<'_>) -> io::Result<()>) -> String { - text(None, false, write) - } - - fn table() -> Table<3> { + fn files() -> Table<3> { Table::new(["NAME", "DESCRIPTION", "ID"]).rows([ - ["weights".into(), "two\nlines".into(), "1".into()], + ["weights".into(), "best\nepoch".into(), "1".into()], ["w".into(), String::new(), "22".into()], ]) } #[test] - fn a_table_aligns_its_cells_on_one_line_each() { - let written = plain(|out| table().write(out)); + fn given_rows_when_writing_a_table_then_its_columns_align_with_one_line_per_row() { + let written = text(None, |out| files().write(out)); assert_eq!( written, "NAME DESCRIPTION ID\n\ - weights two lines 1\n\ + weights best epoch 1\n\ w - 22\n" ); } #[test] - fn an_empty_table_writes_only_its_empty_text() { - let empty = || Table::new(["NAME"]); + fn given_no_rows_when_writing_a_table_then_only_its_empty_text_is_written() { + let written = text(None, |out| { + Table::new(["NAME"]).empty("No files.").write(out) + }); - assert_eq!(plain(|out| empty().write(out)), ""); - assert_eq!( - plain(|out| empty().empty("No files.").write(out)), - "No files.\n" - ); + assert_eq!(written, "No files.\n"); } #[test] - fn a_narrow_width_shortens_only_shrinkable_columns() { - let table = || { - Table::new(["NAME", "DESCRIPTION", "ID"]).rows([[ + fn given_a_narrow_width_when_writing_a_table_then_only_the_shrinkable_column_is_cut() { + let table = Table::new(["NAME", "DESCRIPTION", "ID"]) + .shrink("DESCRIPTION") + .rows([[ "weights".into(), "a rather long description".into(), "0123456789".into(), - ]]) - }; + ]]); - let shrunk = text(Some(36), false, |out| { - table().shrink("DESCRIPTION").write(out) - }); - let whole = text(Some(36), false, |out| table().write(out)); + let written = text(Some(36), |out| table.write(out)); assert_eq!( - shrunk, + written, "NAME DESCRIPTION ID\n\ weights a rather long… 0123456789\n" ); - assert_eq!(whole, plain(|out| table().write(out))); } #[test] - fn a_shrinking_column_can_cut_its_header() { - let written = text(Some(18), false, |out| { - table().shrink("DESCRIPTION").write(out) - }); - - assert_eq!( - written, - "NAME DESC… ID\n\ - weights two… 1\n\ - w - 22\n" - ); - } - - fn details() -> Details { - Details::new() + fn given_fields_and_a_section_when_writing_details_then_fields_align_above_dotted_keys() { + let details = Details::new() .field("Name", "weights") .optional("Description", None::<&str>) .section( "Config", - &serde_json::json!({"lr": 0.1, "loss": 0.7499999999999999, "schedule": {"warmup": 2}, "tags": ["a"]}), - ) - .section("Attributes", &serde_json::json!({})) - } + &serde_json::json!({"lr": 0.1, "schedule": {"warmup": 2}, "tags": ["a"]}), + ); - #[test] - fn details_align_their_fields_above_sections_of_dotted_keys() { - let written = plain(|out| details().write(out)); + let written = text(None, |out| details.write(out)); assert_eq!( written, "Name: weights\n\ Description: -\n\ Config:\n \ - loss 0.75\n \ lr 0.1\n \ schedule.warmup 2\n \ tags [\"a\"]\n" @@ -486,115 +455,60 @@ mod tests { } #[test] - fn a_styled_text_is_the_plain_text_with_bold_and_dim_spans() { + fn given_styles_when_writing_then_removing_them_leaves_the_plain_text() { let write = |out: &mut Human<'_>| { - let (bold, dim) = (out.bold("NAME"), out.dim("-")); - writeln!(out, "{bold} {dim} x")?; - details().write(out)?; - table().write(out) + let name = out.bold("NAME"); + writeln!(out, "{name}")?; + files().write(out) }; - - let styled = text(None, true, write); - - assert!(styled.starts_with("\u{1b}[1mNAME\u{1b}[0m \u{1b}[2m-\u{1b}[0m x\n")); - assert_eq!(console::strip_ansi_codes(&styled), plain(write)); - } - - /// The columns inside a box, as cliclack measures them to wrap its text. - fn box_width() -> usize { - usize::from(console::Term::stderr().size().1.saturating_sub(BOX_PADDING)) - } - - #[test] - fn a_table_fitted_to_the_box_is_not_wrapped_again() { - let width = box_width(); - - let fitted = text(Some(width), true, |out| { - Table::new(["NAME", "DESCRIPTION", "ID"]) - .shrink("DESCRIPTION") - .rows([[ - "weights".into(), - "a rather long description ".repeat(width / 8), - String::new(), - ]]) - .write(out) - }); - - let fitted = fitted.trim_end(); - assert!( - fitted - .lines() - .any(|line| console::measure_text_width(line) == width) - ); - assert_eq!(cliclack::termwrap(fitted, BOX_PADDING), fitted); - } - - #[test] - fn a_count_uses_the_plural_from_two() { - for (count, expected) in [(0, "0 files"), (1, "1 file"), (2, "2 files")] { - assert_eq!(counted(count, ["file", "files"]), expected, "{count}"); - } - } - - #[test] - fn a_list_summary_says_what_was_left_out() { - for (shown, total, expected) in [ - (0, 0, "No models."), - (1, 1, "1 model."), - (3, 3, "3 models."), - (10, 16, "Showing 10 of 16 models."), - (3, 0, "3 models."), - ] { - assert_eq!( - list_summary(shown, total, ["model", "models"], "No models."), - expected, - "{shown} {total}" - ); - } - } - - #[test] - fn a_number_keeps_about_four_significant_digits() { - for (value, expected) in [ - (0.7499999999999999, "0.75"), - (0.923076923076923, "0.9231"), - (0.00001, "0.00001"), - (43.4, "43.4"), - (1234.5678, "1235"), - (1000000.0, "1000000"), - (-0.5, "-0.5"), - (2.0, "2"), - (0.0, "0"), - ] { - assert_eq!(number(value), expected, "{value}"); - } - } - - #[test] - fn a_size_is_in_bytes_kb_mb_or_gb() { - for (bytes, expected) in [ - (0, "0 B"), - (999, "999 B"), - (6900, "6.9 KB"), - (4_732_426, "4.732 MB"), - (713_713_674, "713.7 MB"), - (2_500_000_000, "2.5 GB"), - ] { - assert_eq!(size(bytes), expected, "{bytes}"); - } - } - - #[test] - fn a_digest_shows_its_first_twelve_characters() { - for (digest, expected) in [ - ( - "a8dd58d70d796abee1ef941f1f14b350310f17cba90c37813305ce8254aee225", - "a8dd58d70d79", - ), - ("9e24f16700f2fb16090f7e18c7f174c4660e963b", "9e24f16700f2"), - ("short", "short"), - ] { - assert_eq!(short_digest(digest), expected, "{digest}"); - } + let mut styled = Vec::new(); + + write(&mut Human::new(&mut styled, None, true)).unwrap(); + + let styled = String::from_utf8(styled).unwrap(); + assert_ne!(styled, text(None, write)); + assert_eq!(console::strip_ansi_codes(&styled), text(None, write)); + } + + #[rstest] + #[case::none(0, 0, "No models.")] + #[case::all(3, 3, "3 models.")] + #[case::some(10, 16, "Showing 10 of 16 models.")] + fn given_a_list_when_summing_it_up_then_it_says_how_many_were_shown( + #[case] shown: usize, + #[case] total: u64, + #[case] expected: &str, + ) { + let summary = list_summary(shown, total, ["model", "models"], "No models."); + + assert_eq!(summary, expected); + } + + #[rstest] + #[case::float_noise(0.7499999999999999, "0.75")] + #[case::long_fraction(0.923076923076923, "0.9231")] + #[case::large(1234.5678, "1235")] + #[case::whole(2.0, "2")] + fn given_a_metric_when_showing_it_then_it_keeps_about_four_significant_digits( + #[case] value: f64, + #[case] expected: &str, + ) { + let shown = number(value); + + assert_eq!(shown, expected); + } + + #[rstest] + #[case::bytes(999, "999 B")] + #[case::kilobytes(6900, "6.9 KB")] + #[case::megabytes(713_713_674, "713.7 MB")] + #[case::gigabytes(2_500_000_000, "2.5 GB")] + fn given_a_byte_count_when_showing_it_then_it_is_in_its_largest_unit( + #[case] bytes: u64, + #[case] expected: &str, + ) { + let shown = size(bytes); + + assert_eq!(shown, expected); } } diff --git a/crates/tracel-cli/src/ui/layout.rs b/crates/tracel-cli/src/ui/layout.rs index 0a2d2bf..6646ecf 100644 --- a/crates/tracel-cli/src/ui/layout.rs +++ b/crates/tracel-cli/src/ui/layout.rs @@ -35,28 +35,3 @@ impl Display for Place { f.write_str(&parts.join(" · ")) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_place_names_its_project_and_environment() { - for (project, environment, expected) in [ - ( - Some("test/demo"), - Some("development"), - "test/demo · development", - ), - (Some("test/demo"), None, "test/demo"), - (None, Some("development"), "development"), - (None, None, ""), - ] { - let place = Place { - project: project.map(str::to_string), - environment: environment.map(str::to_string), - }; - assert_eq!(place.to_string(), expected, "{project:?} {environment:?}"); - } - } -} diff --git a/crates/tracel-cli/src/ui/screen.rs b/crates/tracel-cli/src/ui/screen.rs index 93ba037..16f2b82 100644 --- a/crates/tracel-cli/src/ui/screen.rs +++ b/crates/tracel-cli/src/ui/screen.rs @@ -286,6 +286,8 @@ fn columns(terminal: &console::Term) -> Option { #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; fn screen(format: Format, layout: Layout, stderr: bool, stdout: bool) -> Screen { @@ -297,54 +299,60 @@ mod tests { } #[test] - fn a_read_shows_its_result_in_a_box_only_where_stdout_shares_the_terminal() { - for (format, layout, stderr, stdout, boxed) in [ - (Format::Human, Layout::Read, true, true, true), - (Format::Human, Layout::Read, true, false, false), - (Format::Human, Layout::Read, false, true, false), - (Format::Json, Layout::Read, true, true, false), - (Format::Human, Layout::flow("Link"), true, true, false), - (Format::Human, Layout::Bare, true, true, false), - ] { - let case = format!("{format:?} {stderr} {stdout}"); - let delivery = screen(format, layout, stderr, stdout).delivery(); - assert_eq!(matches!(delivery, Delivery::Box(_)), boxed, "{case}"); - } + fn given_a_read_whose_stdout_shares_the_terminal_when_delivering_then_its_result_is_boxed() { + let screen = screen(Format::Human, Layout::Read, true, true); + + let delivery = screen.delivery(); + + assert!(matches!(delivery, Delivery::Box(_))); + } + + #[rstest] + #[case::stdout_piped(Format::Human, Layout::Read, true, false)] + #[case::stderr_piped(Format::Human, Layout::Read, false, true)] + #[case::json(Format::Json, Layout::Read, true, true)] + #[case::flow(Format::Human, Layout::flow("Link"), true, true)] + fn given_anything_but_a_read_sharing_the_terminal_when_delivering_then_the_result_goes_to_stdout( + #[case] format: Format, + #[case] layout: Layout, + #[case] stderr: bool, + #[case] stdout: bool, + ) { + let screen = screen(format, layout, stderr, stdout); + + let delivery = screen.delivery(); + + assert!(matches!(delivery, Delivery::Stdout)); } #[test] - fn a_flow_opens_a_session_saying_what_it_does_and_where() { - let flow = screen( + fn given_a_flow_on_a_terminal_when_it_starts_then_its_header_says_what_it_does_and_where() { + let screen = screen( Format::Human, Layout::flow("Link this workspace"), true, false, ); - let header = flow - .header() - .map(|header| console::strip_ansi_codes(&header).into_owned()); + let header = screen.header().unwrap(); assert_eq!( - header.as_deref(), - Some("Link this workspace in test/demo · development") + console::strip_ansi_codes(&header), + "Link this workspace in test/demo · development" ); } - #[test] - fn only_a_flow_drawn_on_a_terminal_opens_a_session() { - for (format, layout, stderr) in [ - (Format::Human, Layout::Read, true), - (Format::Human, Layout::Bare, true), - (Format::Human, Layout::flow("Link"), false), - (Format::Json, Layout::flow("Link"), true), - ] { - let screen = screen(format, layout, stderr, true); - - screen.intro(); - - assert!(screen.header().is_none(), "{format:?} {stderr}"); - assert!(!screen.session_open(), "{format:?} {stderr}"); - } + #[rstest] + #[case::stderr_piped(Format::Human, false)] + #[case::json(Format::Json, true)] + fn given_a_flow_that_does_not_draw_when_it_starts_then_no_session_opens( + #[case] format: Format, + #[case] stderr: bool, + ) { + let screen = screen(format, Layout::flow("Link"), stderr, true); + + screen.intro(); + + assert!(!screen.session_open()); } } diff --git a/crates/tracel-cli/src/ui/stderr.rs b/crates/tracel-cli/src/ui/stderr.rs index 89ac19f..4d5a5c2 100644 --- a/crates/tracel-cli/src/ui/stderr.rs +++ b/crates/tracel-cli/src/ui/stderr.rs @@ -358,99 +358,95 @@ impl Progress { #[cfg(test)] mod tests { + use rstest::rstest; + use super::*; - use crate::test_support::{quiet_terminal, report}; + use crate::error::classify; + use crate::test_support::quiet_terminal; + + fn unlinking() -> Unanswered { + Unanswered::new( + "Unlinking test/demo needs confirmation.", + "Pass --yes to unlink without asking.", + ) + } #[test] - fn prompts_need_terminals_human_output_and_no_ci() { - for ci in [None, Some(""), Some("0"), Some("false")] { - for format in [Format::Human, Format::Json] { - for stdin in [false, true] { - for stderr in [false, true] { - for no_input in [false, true] { - assert_eq!( - allows_input(stdin, stderr, format, ci, no_input), - stdin && stderr && format == Format::Human && !no_input - ); - } - } - } - } - } - for ci in ["1", "true", "yes", "FALSE", " "] { - assert!(!allows_input(true, true, Format::Human, Some(ci), false)); - } + fn given_terminals_and_text_output_when_checking_for_input_then_prompts_may_ask() { + let allowed = allows_input(true, true, Format::Human, None, false); + + assert!(allowed); + } + + #[rstest] + #[case::stdin_piped(false, true, Format::Human, None, false)] + #[case::stderr_piped(true, false, Format::Human, None, false)] + #[case::json_output(true, true, Format::Json, None, false)] + #[case::ci(true, true, Format::Human, Some("true"), false)] + #[case::no_input(true, true, Format::Human, None, true)] + fn given_no_one_to_answer_when_checking_for_input_then_prompts_may_not_ask( + #[case] stdin: bool, + #[case] stderr: bool, + #[case] format: Format, + #[case] ci: Option<&str>, + #[case] no_input: bool, + ) { + let allowed = allows_input(stdin, stderr, format, ci, no_input); + + assert!(!allowed); + } + + #[rstest] + #[case::empty("")] + #[case::zero("0")] + #[case::false_("false")] + fn given_ci_set_to_false_when_checking_for_input_then_prompts_may_ask(#[case] ci: &str) { + let allowed = allows_input(true, true, Format::Human, Some(ci), false); + + assert!(allowed); } #[test] - fn a_failure_shows_its_hint_under_its_message() { - assert_eq!(failure("Job 'fail' failed.", None), "Job 'fail' failed."); + fn given_a_failure_with_a_hint_when_closing_on_it_then_its_lines_and_hint_are_indented_under_it() + { + let closing = failure( + "No job 'nope'.\nJobs: train.", + Some("See them with `tracel run --list`."), + ); + assert_eq!( - failure( - "No job 'nope'.\nJobs: train.", - Some("See them with `tracel run --list`.") - ), + closing, "No job 'nope'.\n Jobs: train.\n See them with `tracel run --list`." ); } - fn unlinking() -> Unanswered { - Unanswered::new( - "Unlinking test/demo needs confirmation.", - "Pass --yes to unlink without asking.", - ) - } - #[test] - fn an_unanswerable_confirmation_requires_its_flag() { + fn given_a_confirmation_no_one_can_answer_when_asking_then_it_requires_its_flag() { let terminal = quiet_terminal(); let error = terminal .confirm("Unlink test/demo?", false, unlinking()) .unwrap_err(); - assert_eq!(report(&error), (ErrorKind::ConfirmationRequired, 7, true)); - assert_eq!( - ErrorReport::new(&error).hint, - Some("Pass --yes to unlink without asking.") - ); + assert_eq!(classify(&error), ErrorKind::ConfirmationRequired); + assert!(ErrorReport::new(&error).hint.unwrap().contains("--yes")); } #[test] - fn other_unanswerable_input_is_a_usage_error() { + fn given_other_input_no_one_can_answer_when_asking_then_it_is_a_usage_error() { let terminal = quiet_terminal(); - let items = [(1, "one", ""), (2, "two", "")]; - for error in [ - terminal.input("Name", unlinking()).unwrap_err(), - terminal - .select("Pick", &items, None, unlinking()) - .unwrap_err(), - terminal - .multiselect("Pick", &items, vec![1], unlinking()) - .unwrap_err(), - ] { - assert_eq!(report(&error), (ErrorKind::Usage, 2, true)); - } + + let error = terminal.input("Name", unlinking()).unwrap_err(); + + assert_eq!(classify(&error), ErrorKind::Usage); } #[test] - fn an_unanswerable_offer_is_declined_without_asking() { + fn given_an_offer_no_one_can_answer_when_offering_then_it_is_declined_without_asking() { let terminal = quiet_terminal(); let accepted = terminal.offer("Open it in your browser?").unwrap(); assert!(!accepted); } - - #[test] - fn a_hyperlink_opens_its_url_from_its_text() { - let url = url::Url::parse("http://localhost:9001/verify?code=BCDF-GHJK").unwrap(); - - let link = hyperlink(&url, "the approval page"); - - assert_eq!( - link, - "\u{1b}]8;;http://localhost:9001/verify?code=BCDF-GHJK\u{1b}\\the approval page\u{1b}]8;;\u{1b}\\" - ); - } } diff --git a/crates/tracel-cli/src/ui/stdout.rs b/crates/tracel-cli/src/ui/stdout.rs index fb6fd7e..d0606a7 100644 --- a/crates/tracel-cli/src/ui/stdout.rs +++ b/crates/tracel-cli/src/ui/stdout.rs @@ -224,7 +224,6 @@ fn write_failure(report: &ErrorReport<'_>, out: &mut dyn Write) -> io::Result<() mod tests { use super::*; use crate::error::{CliError, ErrorKind}; - use crate::test_support::ClosedPipe; #[derive(Serialize)] struct Greeting { @@ -235,9 +234,17 @@ mod tests { fn render(&self, out: &mut Human<'_>) -> io::Result<()> { writeln!(out, "Hello, {}.", self.name) } + } + + struct ClosedPipe; - fn summary(&self) -> Option { - Some("Greeted.".into()) + impl Write for ClosedPipe { + fn write(&mut self, _: &[u8]) -> io::Result { + Err(io::ErrorKind::BrokenPipe.into()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) } } @@ -248,76 +255,61 @@ mod tests { } #[test] - fn a_result_on_stdout_is_an_envelope_or_text_ending_with_its_summary() { + fn given_json_output_when_writing_a_result_then_it_is_an_envelope_without_the_summary() { let greeting = Greeting { name: "Ada" }; - let data = Outcome::data(Greeting { name: "Ada" }); - let data = data.0.as_deref().unwrap(); - for (format, report, summary, expected) in [ - ( - Format::Json, - &greeting as &dyn Report, - Some("Greeted."), - "{\"ok\":true,\"data\":{\"name\":\"Ada\"}}\n", - ), - (Format::Human, &greeting, None, "Hello, Ada.\n"), - ( - Format::Human, - &greeting, - Some("Greeted."), - "Hello, Ada.\nGreeted.\n", - ), - ( - Format::Json, - data, - None, - "{\"ok\":true,\"data\":{\"name\":\"Ada\"}}\n", - ), - (Format::Human, data, None, ""), - ] { - assert_eq!( - written(|out| write_result(format, report, summary, None, out)), - expected, - "{format:?} {summary:?}" - ); - } + + let text = + written(|out| write_result(Format::Json, &greeting, Some("Greeted."), None, out)); + + assert_eq!(text, "{\"ok\":true,\"data\":{\"name\":\"Ada\"}}\n"); } #[test] - fn a_box_holds_the_text_of_a_result_without_its_summary() { + fn given_text_output_when_writing_a_result_then_its_text_ends_with_the_summary() { let greeting = Greeting { name: "Ada" }; - let text = box_text(&greeting, None).unwrap(); + let text = + written(|out| write_result(Format::Human, &greeting, Some("Greeted."), None, out)); + + assert_eq!(text, "Hello, Ada.\nGreeted.\n"); + } + + #[test] + fn given_a_result_for_json_only_when_writing_it_as_text_then_nothing_is_written() { + let outcome = Outcome::data(Greeting { name: "Ada" }); + + let text = written(|out| { + write_result( + Format::Human, + outcome.0.as_deref().unwrap(), + None, + None, + out, + ) + }); - assert_eq!(console::strip_ansi_codes(&text), "Hello, Ada.\n"); + assert_eq!(text, ""); } #[test] - fn an_event_is_a_bare_json_line_or_its_text() { + fn given_json_output_when_writing_an_event_then_it_is_a_bare_json_line() { let event = Greeting { name: "Ada" }; - for (format, expected) in [ - (Format::Json, "{\"name\":\"Ada\"}\n"), - (Format::Human, "Hello, Ada.\n"), - ] { - assert_eq!( - written(|out| write_event(format, None, &event, out)), - expected, - "{format:?}" - ); - } + + let text = written(|out| write_event(Format::Json, None, &event, out)); + + assert_eq!(text, "{\"name\":\"Ada\"}\n"); } #[test] - fn a_failure_envelope_carries_the_error_report() { + fn given_a_failure_when_writing_its_envelope_then_it_carries_the_error_report() { let error = anyhow::Error::new( CliError::new(ErrorKind::NotFound, "No model 'weights'.").with_hint("List models."), ); - let report = ErrorReport::new(&error); - let envelope: serde_json::Value = - serde_json::from_str(&written(|out| write_failure(&report, out))).unwrap(); + let text = written(|out| write_failure(&ErrorReport::new(&error), out)); assert_eq!( - envelope, + serde_json::from_str::(&text).unwrap(), serde_json::json!({ "ok": false, "error": { @@ -331,7 +323,7 @@ mod tests { } #[test] - fn a_reader_that_went_away_closes_stdout() { + fn given_a_reader_that_went_away_when_writing_then_stdout_is_closed() { let greeting = Greeting { name: "Ada" }; let written = write_result(Format::Human, &greeting, None, None, &mut ClosedPipe); diff --git a/crates/tracel-cli/src/ui/time.rs b/crates/tracel-cli/src/ui/time.rs index c448ea3..145cdfe 100644 --- a/crates/tracel-cli/src/ui/time.rs +++ b/crates/tracel-cli/src/ui/time.rs @@ -51,34 +51,34 @@ mod tests { use super::*; #[test] - fn server_times_read_in_the_local_zone() { - let west = chrono::FixedOffset::west_opt(4 * 3600).unwrap(); - for (text, expected) in [ - ("2026-10-09T20:21:06.075Z", "2026-10-09 16:21"), - ("2026-10-09T20:21:06+00:00", "2026-10-09 16:21"), - ("yesterday", "yesterday"), - ("", ""), - ] { - assert_eq!(local(text, &west, TO_THE_MINUTE), expected, "{text:?}"); - } + fn given_a_server_time_when_showing_it_then_it_reads_in_the_local_zone_to_the_minute() { + let four_hours_west = chrono::FixedOffset::west_opt(4 * 3600).unwrap(); + + let time = local("2026-10-09T20:21:06.075Z", &four_hours_west, TO_THE_MINUTE); + + assert_eq!(time, "2026-10-09 16:21"); } #[test] - fn a_log_time_keeps_its_seconds() { - let utc = chrono::Utc; - - let time = local("2026-10-09T20:21:06.075Z", &utc, TO_THE_SECOND); + fn given_a_log_time_when_showing_it_then_it_keeps_its_seconds() { + let time = local("2026-10-09T20:21:06.075Z", &Utc, TO_THE_SECOND); assert_eq!(time, "2026-10-09 20:21:06"); } #[test] - fn a_timestamp_is_rfc_3339_in_utc() { - let time = Timestamp(UNIX_EPOCH + Duration::from_secs(1_900_000_000)); + fn given_text_that_is_not_a_time_when_showing_it_then_it_stays_as_it_is() { + let time = local("yesterday", &Utc, TO_THE_MINUTE); + + assert_eq!(time, "yesterday"); + } + + #[test] + fn given_a_timestamp_when_writing_it_then_it_is_rfc_3339_in_utc() { + let timestamp = Timestamp(UNIX_EPOCH + Duration::from_secs(1_900_000_000)); - let (text, json) = (time.to_string(), serde_json::to_value(time).unwrap()); + let text = timestamp.to_string(); assert_eq!(text, "2030-03-17T17:46:40+00:00"); - assert_eq!(json, "2030-03-17T17:46:40+00:00"); } } From c5f2cfb5fa2f84890061991093f1ead5ee97d5e1 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Wed, 14 Oct 2026 11:47:19 -0400 Subject: [PATCH 31/32] refactor: read cargo's machine output with cargo_metadata The build's executables come from cargo_metadata's typed messages instead of JSON read by hand, and the workspace manifest from `cargo locate-project --message-format plain`. --- crates/tracel-cli/src/tools/cargo.rs | 64 ++++++++-------------------- 1 file changed, 17 insertions(+), 47 deletions(-) diff --git a/crates/tracel-cli/src/tools/cargo.rs b/crates/tracel-cli/src/tools/cargo.rs index 00ea06c..98af800 100644 --- a/crates/tracel-cli/src/tools/cargo.rs +++ b/crates/tracel-cli/src/tools/cargo.rs @@ -1,22 +1,22 @@ use std::ffi::OsString; -use std::io::{self, BufRead, BufReader, Read, Write}; +use std::io::{self, BufRead, BufReader, Write}; use std::path::PathBuf; use std::process::Stdio; use anyhow::Context; +use cargo_metadata::Message; /// Find the manifest of the Cargo workspace around the current directory. pub fn try_locate_manifest() -> Option { let output = command() - .arg("locate-project") - .arg("--workspace") + .args(["locate-project", "--workspace", "--message-format", "plain"]) .output() .ok()?; if !output.status.success() { return None; } - let location: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; - location["root"].as_str().map(PathBuf::from) + let path = String::from_utf8(output.stdout).ok()?; + Some(PathBuf::from(path.trim_end_matches(['\r', '\n']))) } /// Start a cargo command, with the cargo `CARGO` names when it is set. @@ -29,7 +29,7 @@ pub struct QuietBuild { label: String, succeeded: bool, output: Vec, - messages: Vec, + executables: Vec<(String, PathBuf)>, } impl QuietBuild { @@ -48,7 +48,7 @@ impl QuietBuild { if !self.succeeded { anyhow::bail!("`{}` failed.", self.label); } - executables(&self.messages) + Ok(self.executables) } } @@ -68,11 +68,8 @@ pub fn build_quietly( .stderr(Stdio::piped()) .spawn() .with_context(|| format!("Can't run `{label}`"))?; - let mut stdout = child.stdout.take().context("Can't read cargo's output")?; - let messages = std::thread::spawn(move || { - let mut messages = Vec::new(); - stdout.read_to_end(&mut messages).map(|_| messages) - }); + let stdout = child.stdout.take().context("Can't read cargo's output")?; + let executables = std::thread::spawn(move || executables(BufReader::new(stdout))); let mut output = Vec::new(); let mut stderr = BufReader::new(child.stderr.take().context("Can't read cargo's output")?); @@ -83,14 +80,14 @@ pub fn build_quietly( } output.append(&mut line); } - let messages = messages + let executables = executables .join() .map_err(|_| anyhow::anyhow!("Can't read the output of `{label}`."))??; Ok(QuietBuild { label: label.to_string(), succeeded: child.wait()?.success(), output, - messages, + executables, }) } @@ -118,23 +115,16 @@ fn status_line(line: &str) -> Option { Some(line.to_string()) } -/// List the executables in cargo's JSON build messages. -fn executables(messages: &[u8]) -> anyhow::Result> { +/// List the executables in cargo's JSON build messages as `(target name, path)` pairs. +fn executables(messages: impl BufRead) -> io::Result> { let mut executables = Vec::new(); - for line in messages.split(|&b| b == b'\n') { - let Ok(message) = serde_json::from_slice::(line) else { + for message in Message::parse_stream(messages) { + let Message::CompilerArtifact(artifact) = message? else { continue; }; - if message["reason"] != "compiler-artifact" { - continue; + if let Some(path) = artifact.executable { + executables.push((artifact.target.name, path.into_std_path_buf())); } - let Some(executable) = message["executable"].as_str() else { - continue; - }; - let name = message["target"]["name"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("Cargo sent no name for the binary {executable}."))?; - executables.push((name.to_string(), PathBuf::from(executable))); } Ok(executables) } @@ -145,26 +135,6 @@ mod tests { use super::*; - #[test] - fn given_cargo_build_messages_when_listing_executables_then_only_artifacts_with_one_are_listed() - { - let messages = br#"{"reason":"compiler-artifact","target":{"name":"helper","kind":["lib"]},"executable":null} -{"reason":"build-script-executed","package_id":"trainer"} -{"reason":"compiler-artifact","target":{"name":"trainer","kind":["bin"]},"executable":"/work/target/release/trainer"} -{"reason":"build-finished","success":true} -"#; - - let executables = executables(messages).unwrap(); - - assert_eq!( - executables, - [( - "trainer".to_string(), - PathBuf::from("/work/target/release/trainer") - )] - ); - } - #[rstest] #[case::compiling( "\u{1b}[1m\u{1b}[32m Compiling\u{1b}[0m trainer v0.1.0 (/work/trainer)\n", From 1cb95e55a859a51ec583fb2d411d68a291482b70 Mon Sep 17 00:00:00 2001 From: Jonathan Richard Date: Wed, 14 Oct 2026 18:01:00 -0400 Subject: [PATCH 32/32] chore: build against tracel-client after its experiment and model reads This branch still moves bytes with the client's presigned URL transfers, which tracel-client 0.22.0 removes. It builds against the tracel-client commit that merged the reads and still has them, until the transfers move to tracel-artifact. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 186fcd5..0375fdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3699,7 +3699,7 @@ dependencies = [ [[package]] name = "tracel-client" version = "0.21.0" -source = "git+https://github.com/tracel-ai/burn-central-client?rev=1f0095dae2933a70f3a07eaf02138ca664d1708c#1f0095dae2933a70f3a07eaf02138ca664d1708c" +source = "git+https://github.com/tracel-ai/burn-central-client?rev=5a618779e8b9288f51415da6af4f385194b6e950#5a618779e8b9288f51415da6af4f385194b6e950" dependencies = [ "directories", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 1579e00..94b58c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,7 @@ rstest = { version = "0.27.0", default-features = false } ## External Crate -tracel-client = { git = "https://github.com/tracel-ai/burn-central-client", rev = "1f0095dae2933a70f3a07eaf02138ca664d1708c" } +tracel-client = { git = "https://github.com/tracel-ai/burn-central-client", rev = "5a618779e8b9288f51415da6af4f385194b6e950" } ## Crate tracel-cli = { version = "0.5.0", path = "crates/tracel-cli" }