diff --git a/Cargo.toml b/Cargo.toml index 71a66fc..2a2cc9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ derive_more = { version = "1", default-features = false, features = ["full"] } smart-default = "0.7" tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "registry"] } [workspace.lints.rust] unsafe_code = "deny" @@ -65,8 +67,8 @@ unimplemented = "deny" mem_forget = "deny" implicit_clone = "deny" lossy_float_literal = "deny" -print_stdout = "warn" -print_stderr = "warn" +print_stdout = "deny" +print_stderr = "deny" unwrap_used = "warn" expect_used = "warn" panic = "warn" diff --git a/crates/hm-plugin-cloud/Cargo.toml b/crates/hm-plugin-cloud/Cargo.toml index 8280fc2..371f83d 100644 --- a/crates/hm-plugin-cloud/Cargo.toml +++ b/crates/hm-plugin-cloud/Cargo.toml @@ -26,7 +26,7 @@ reqwest = { version = "0.13", default-features = false, features = [" tokio = { version = "1", features = ["net", "time", "sync"] } webbrowser = "1" dialoguer = "0.11" -tracing = "0.1" +tracing = { workspace = true } [lints] workspace = true diff --git a/crates/hm-plugin-cloud/src/auth/login.rs b/crates/hm-plugin-cloud/src/auth/login.rs index df77973..1e73c48 100644 --- a/crates/hm-plugin-cloud/src/auth/login.rs +++ b/crates/hm-plugin-cloud/src/auth/login.rs @@ -41,7 +41,7 @@ async fn login_loopback( tracing::info!("opening browser to {auth_url}"); if webbrowser::open(&auth_url).is_err() { - eprintln!("couldn't auto-open the browser. Open this URL manually:\n {auth_url}"); + tracing::warn!("couldn't auto-open the browser. Open this URL manually:\n {auth_url}"); } // Wait for a single connection with a 180-second timeout. @@ -98,7 +98,7 @@ async fn login_paste( "{}/cli/login?challenge={}&redirect_uri=urn:ietf:wg:oauth:2.0:oob", cfg.api_base, challenge, ); - eprintln!("Open this URL in your browser, then paste the code:\n {auth_url}"); + tracing::info!("Open this URL in your browser, then paste the code:\n {auth_url}"); let _ = webbrowser::open(&auth_url); // Tests inject the code via `HARMONT_LOGIN_CODE` to avoid TTY. @@ -132,7 +132,7 @@ async fn finalize(cfg: &Config, code: &str, verifier: &str) -> Result<()> { let auth_client = Client::new(cfg, Some(resp.token)); let me: User = auth_client.get("/auth/me").await?; - eprintln!( + tracing::info!( "logged in as {} ({})", me.display_name.clone().unwrap_or_else(|| me.email.clone()), me.email, diff --git a/crates/hm-plugin-cloud/src/auth/logout.rs b/crates/hm-plugin-cloud/src/auth/logout.rs index 596a078..8918405 100644 --- a/crates/hm-plugin-cloud/src/auth/logout.rs +++ b/crates/hm-plugin-cloud/src/auth/logout.rs @@ -10,6 +10,6 @@ use crate::creds; pub(crate) async fn run(env: &BTreeMap) -> Result<()> { let cfg = Config::from_env(env); creds::clear_token(&cfg.api_base); - eprintln!("logged out of {}", cfg.api_base); + tracing::info!("logged out of {}", cfg.api_base); Ok(()) } diff --git a/crates/hm-plugin-cloud/src/auth/whoami.rs b/crates/hm-plugin-cloud/src/auth/whoami.rs index 47080e8..1679353 100644 --- a/crates/hm-plugin-cloud/src/auth/whoami.rs +++ b/crates/hm-plugin-cloud/src/auth/whoami.rs @@ -16,7 +16,7 @@ pub(crate) async fn run(env: &BTreeMap) -> Result<()> { })?; let client = Client::new(&cfg, Some(token)); let me: User = client.get("/auth/me").await?; - println!( + tracing::info!( "{} <{}> (id {})", me.display_name.clone().unwrap_or_else(|| me.email.clone()), me.email, diff --git a/crates/hm-plugin-cloud/src/cli.rs b/crates/hm-plugin-cloud/src/cli.rs index 9c7185a..c3d1144 100644 --- a/crates/hm-plugin-cloud/src/cli.rs +++ b/crates/hm-plugin-cloud/src/cli.rs @@ -158,11 +158,19 @@ pub async fn dispatch(argv: Vec, env: BTreeMap) -> Resul let msg = e.to_string(); return match e.kind() { ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => { - print!("{msg}"); + #[allow(clippy::print_stdout)] + { + use std::io::Write; + std::io::stdout().write_all(msg.as_bytes()).ok(); + } Ok(0) } _ => { - eprint!("{msg}"); + #[allow(clippy::print_stderr)] + { + use std::io::Write; + std::io::stderr().write_all(msg.as_bytes()).ok(); + } Ok(2) } }; @@ -187,7 +195,7 @@ pub async fn dispatch_command(command: CloudCommand, env: BTreeMap Ok(0), Err(e) => { - eprintln!("{e:#}"); + tracing::error!("{e:#}"); Ok(1) } } diff --git a/crates/hm-plugin-cloud/src/lib.rs b/crates/hm-plugin-cloud/src/lib.rs index 5ed4831..f4abea8 100644 --- a/crates/hm-plugin-cloud/src/lib.rs +++ b/crates/hm-plugin-cloud/src/lib.rs @@ -9,8 +9,6 @@ clippy::multiple_crate_versions, clippy::cargo_common_metadata, clippy::missing_errors_doc, - clippy::print_stdout, - clippy::print_stderr, reason = "quick migration from plugin crate; polish later" )] diff --git a/crates/hm-plugin-cloud/src/verbs/billing.rs b/crates/hm-plugin-cloud/src/verbs/billing.rs index d6e4fb9..31bbb6e 100644 --- a/crates/hm-plugin-cloud/src/verbs/billing.rs +++ b/crates/hm-plugin-cloud/src/verbs/billing.rs @@ -40,7 +40,7 @@ async fn balance(client: &Client, org: &str) -> Result<()> { .get(&format!("/organizations/{org}/billing/balance")) .await?; let dollars = b.credits_usd_cents as f64 / 100.0; - println!("${dollars:.2}"); + tracing::info!("${dollars:.2}"); Ok(()) } @@ -51,7 +51,7 @@ async fn transactions(client: &Client, org: &str, limit: u32) -> Result<()> { )) .await?; for t in &list.data { - println!( + tracing::info!( "{} {:>10} {:<14} {}", t.at.format("%Y-%m-%d %H:%M:%S"), t.amount_cents, @@ -78,7 +78,7 @@ async fn usage(client: &Client, org: &str, from: Option<&str>, to: Option<&str>) let u: UsageWindow = client .get(&format!("/organizations/{org}/billing/usage{qs}")) .await?; - println!( + tracing::info!( "{} -> {}: {:.2} min, ${:.2}", u.from.format("%Y-%m-%d"), u.to.format("%Y-%m-%d"), @@ -99,10 +99,10 @@ async fn topup(client: &Client, org: &str, amount_usd: u32, no_browser: bool) -> ) .await?; if no_browser { - println!("{}", r.checkout_url); + tracing::info!("{}", r.checkout_url); } else if webbrowser::open(&r.checkout_url).is_err() { - eprintln!("couldn't open browser; URL:"); - eprintln!("{}", r.checkout_url); + tracing::warn!("couldn't open browser; URL:"); + tracing::warn!("{}", r.checkout_url); } Ok(()) } @@ -118,7 +118,7 @@ async fn redeem(client: &Client, org: &str, code: &str) -> Result<()> { ) .await?; let dollars = r.credited_cents as f64 / 100.0; - eprintln!("credited ${dollars:.2}"); + tracing::info!("credited ${dollars:.2}"); Ok(()) } diff --git a/crates/hm-plugin-cloud/src/verbs/build.rs b/crates/hm-plugin-cloud/src/verbs/build.rs index fade59d..d5404bb 100644 --- a/crates/hm-plugin-cloud/src/verbs/build.rs +++ b/crates/hm-plugin-cloud/src/verbs/build.rs @@ -31,7 +31,7 @@ async fn list(client: &Client, org: &str, pipe: &str) -> Result<()> { .get(&format!("/organizations/{org}/pipelines/{pipe}/builds")) .await?; for b in &builds.data { - println!( + tracing::info!( "#{:<5} {:<10} {}", b.number, b.state, @@ -48,7 +48,7 @@ async fn show(client: &Client, org: &str, pipe: &str, number: i64) -> Result<()> )) .await?; let json = serde_json::to_string_pretty(&b).unwrap_or_default(); - println!("{json}"); + tracing::info!("{json}"); Ok(()) } @@ -59,7 +59,7 @@ async fn cancel(client: &Client, org: &str, pipe: &str, number: i64) -> Result<( &serde_json::json!({}), ) .await?; - eprintln!("build #{number} cancelled"); + tracing::info!("build #{number} cancelled"); Ok(()) } @@ -72,7 +72,7 @@ async fn watch(client: &Client, org: &str, pipe: &str, number: i64) -> Result<() )) .await?; if b.state != last_state { - eprintln!("state: {last_state} -> {}", b.state); + tracing::info!("state: {last_state} -> {}", b.state); last_state = b.state.clone(); } match b.state.as_str() { diff --git a/crates/hm-plugin-cloud/src/verbs/job.rs b/crates/hm-plugin-cloud/src/verbs/job.rs index 044a07e..147322c 100644 --- a/crates/hm-plugin-cloud/src/verbs/job.rs +++ b/crates/hm-plugin-cloud/src/verbs/job.rs @@ -40,7 +40,7 @@ async fn list(client: &Client, org: &str, pipe: &str, build: i64) -> Result<()> )) .await?; for j in &jobs.data { - println!( + tracing::info!( "{} {:<10} {}", j.id, j.state, @@ -56,7 +56,7 @@ async fn show(client: &Client, org: &str, pipe: &str, build: i64, jid: &str) -> "/organizations/{org}/pipelines/{pipe}/builds/{build}/jobs/{jid}" )) .await?; - println!("{}", serde_json::to_string_pretty(&j).unwrap_or_default()); + tracing::info!("{}", serde_json::to_string_pretty(&j).unwrap_or_default()); Ok(()) } @@ -67,7 +67,7 @@ async fn log_cmd(client: &Client, org: &str, pipe: &str, build: i64, jid: &str) )) .await?; for chunk in &log.data { - println!("{}", chunk.line); + tracing::info!("{}", chunk.line); } Ok(()) } diff --git a/crates/hm-plugin-cloud/src/verbs/org.rs b/crates/hm-plugin-cloud/src/verbs/org.rs index 45ad26c..834f994 100644 --- a/crates/hm-plugin-cloud/src/verbs/org.rs +++ b/crates/hm-plugin-cloud/src/verbs/org.rs @@ -32,6 +32,6 @@ async fn switch(client: &Client, slug: &str) -> Result<()> { let mut state = CloudState::load(); state.active_org = Some(found.slug.clone()); state.save(); - eprintln!("active organization: {} ({})", found.name, found.slug); + tracing::info!("active organization: {} ({})", found.name, found.slug); Ok(()) } diff --git a/crates/hm-plugin-cloud/src/verbs/pipeline.rs b/crates/hm-plugin-cloud/src/verbs/pipeline.rs index 233a93c..70ad413 100644 --- a/crates/hm-plugin-cloud/src/verbs/pipeline.rs +++ b/crates/hm-plugin-cloud/src/verbs/pipeline.rs @@ -29,7 +29,7 @@ async fn list(client: &Client, org: &str) -> Result<()> { .get(&format!("/organizations/{org}/pipelines")) .await?; for p in &pipes.data { - println!( + tracing::info!( "{:<24} {}", p.slug, p.label.as_deref().unwrap_or("(no label)") @@ -49,7 +49,7 @@ async fn show(client: &Client, org: &str, slug: &str) -> Result<()> { "default_branch": p.default_branch, })) .unwrap_or_default(); - println!("{json}"); + tracing::info!("{json}"); Ok(()) } diff --git a/crates/hm-plugin-cloud/src/verbs/run.rs b/crates/hm-plugin-cloud/src/verbs/run.rs index 2b703e1..951a278 100644 --- a/crates/hm-plugin-cloud/src/verbs/run.rs +++ b/crates/hm-plugin-cloud/src/verbs/run.rs @@ -75,7 +75,7 @@ pub(crate) async fn run(env: &BTreeMap, args: RunArgs) -> Result args.pipeline, build.number ); - eprintln!("submitted build #{}: {url}", build.number); + tracing::info!("submitted build #{}: {url}", build.number); if args.no_watch { return Ok(()); } diff --git a/crates/hm/Cargo.toml b/crates/hm/Cargo.toml index 49b0842..f5bf89b 100644 --- a/crates/hm/Cargo.toml +++ b/crates/hm/Cargo.toml @@ -43,8 +43,8 @@ tempfile = "3" anyhow = "1" thiserror = "2" backon = "1" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } chrono = { version = "0.4", features = ["serde"] } url = "2" base64 = "0.22" diff --git a/crates/hm/src/cli/plugin.rs b/crates/hm/src/cli/plugin.rs index 6af333a..251a32e 100644 --- a/crates/hm/src/cli/plugin.rs +++ b/crates/hm/src/cli/plugin.rs @@ -20,7 +20,7 @@ pub async fn run(cmd: PluginCommand) -> Result<()> { #[allow(clippy::unused_async)] async fn list() -> Result<()> { - println!("Registered runners:"); - println!(" docker (default, built-in)"); + tracing::info!("Registered runners:"); + tracing::info!(" docker (default, built-in)"); Ok(()) } diff --git a/crates/hm/src/cli/version.rs b/crates/hm/src/cli/version.rs index cf8554a..34683ed 100644 --- a/crates/hm/src/cli/version.rs +++ b/crates/hm/src/cli/version.rs @@ -7,6 +7,6 @@ use anyhow::Result; /// /// Returns an error on I/O failure. pub async fn run() -> Result<()> { - println!("hm {}", env!("CARGO_PKG_VERSION")); + tracing::info!("hm {}", env!("CARGO_PKG_VERSION")); Ok(()) } diff --git a/crates/hm/src/commands/dev/down.rs b/crates/hm/src/commands/dev/down.rs index 5e076f4..47720e2 100644 --- a/crates/hm/src/commands/dev/down.rs +++ b/crates/hm/src/commands/dev/down.rs @@ -16,10 +16,6 @@ use super::naming::{ /// # Errors /// /// Returns Docker errors on list / stop / remove failures. -#[allow( - clippy::print_stderr, - reason = "status messages to stderr are intentional for a foreground CLI" -)] pub async fn handle(args: DevDownArgs, _ctx: RunContext) -> Result { let docker = DockerClient::connect()?; let worktree_root = resolve_worktree_root()?; @@ -60,7 +56,7 @@ pub async fn handle(args: DevDownArgs, _ctx: RunContext) -> Result { } if to_remove.is_empty() { - eprintln!("[hm] nothing to sweep"); + tracing::info!("[hm] nothing to sweep"); return Ok(0); } @@ -68,7 +64,7 @@ pub async fn handle(args: DevDownArgs, _ctx: RunContext) -> Result { for (id, slug, session, name) in &to_remove { let _ = docker.stop_container(id).await; let _ = docker.remove_container(id).await; - eprintln!("[hm] removed {name} (slug={slug}, session={session})"); + tracing::info!("[hm] removed {name} (slug={slug}, session={session})"); sessions_swept.insert(session.clone()); } diff --git a/crates/hm/src/commands/dev/exec.rs b/crates/hm/src/commands/dev/exec.rs index 621845e..18f5438 100644 --- a/crates/hm/src/commands/dev/exec.rs +++ b/crates/hm/src/commands/dev/exec.rs @@ -16,10 +16,6 @@ use super::naming::{ /// /// Returns an error if Docker is unreachable or if the exec lifecycle /// calls fail. -#[allow( - clippy::print_stderr, - reason = "user-facing error messages for a foreground CLI" -)] pub async fn handle(args: DevExecArgs, _ctx: RunContext) -> Result { let docker = DockerClient::connect()?; let worktree_root = resolve_worktree_root()?; @@ -44,14 +40,15 @@ pub async fn handle(args: DevExecArgs, _ctx: RunContext) -> Result { } } if matches.is_empty() { - eprintln!( + tracing::error!( "hm: slug `{}` is not running in this worktree.\n → run `hm dev up {}` first.", - args.slug, args.slug, + args.slug, + args.slug, ); return Ok(4); } if matches.len() > 1 { - eprintln!( + tracing::error!( "hm: slug `{}` matches multiple live sessions; pass --session ", args.slug ); diff --git a/crates/hm/src/commands/dev/logs.rs b/crates/hm/src/commands/dev/logs.rs index 490ba27..489fe99 100644 --- a/crates/hm/src/commands/dev/logs.rs +++ b/crates/hm/src/commands/dev/logs.rs @@ -19,10 +19,6 @@ use super::naming::{ /// # Errors /// /// Returns an error if Docker is unreachable. -#[allow( - clippy::print_stderr, - reason = "user-facing error messages for a foreground CLI" -)] pub async fn handle(args: DevLogsArgs, _ctx: RunContext) -> Result { let docker = DockerClient::connect()?; let worktree_root = resolve_worktree_root()?; @@ -47,14 +43,15 @@ pub async fn handle(args: DevLogsArgs, _ctx: RunContext) -> Result { } } if matches.is_empty() { - eprintln!( + tracing::error!( "hm: slug `{}` is not running in this worktree.\n → run `hm dev up {}` first.", - args.slug, args.slug, + args.slug, + args.slug, ); return Ok(4); } if matches.len() > 1 { - eprintln!( + tracing::error!( "hm: slug `{}` matches multiple live sessions; pass --session ", args.slug ); diff --git a/crates/hm/src/commands/dev/ls.rs b/crates/hm/src/commands/dev/ls.rs index ac11b0c..601b4d4 100644 --- a/crates/hm/src/commands/dev/ls.rs +++ b/crates/hm/src/commands/dev/ls.rs @@ -18,19 +18,18 @@ use super::registry::{RegEntry, dump}; /// /// Returns an error if the worktree root cannot be resolved or the /// registry subprocess fails. -#[allow( - clippy::print_stdout, - reason = "`hm dev ls` is a table-printing command" -)] pub async fn handle(_ctx: RunContext) -> Result { let worktree_root = resolve_worktree_root()?; let wt_hash = worktree_hash(&worktree_root); let registry = dump(&worktree_root).await?; let docker = DockerClient::connect().ok(); - println!( + tracing::info!( "{:<10} {:<8} {:<10} {:<10} PORTS", - "SLUG", "DRIVER", "SESSION", "STATUS" + "SLUG", + "DRIVER", + "SESSION", + "STATUS" ); // Pre-load running containers by (slug, session) key. @@ -61,23 +60,29 @@ pub async fn handle(_ctx: RunContext) -> Result { if s == slug { matched = true; let ports_s = format_ports(ports); - println!( + tracing::info!( "{slug:<10} {:<8} {:<10} {:<10} {ports_s}", - "local", sess, state + "local", + sess, + state ); } } if !matched { - println!( + tracing::info!( "{slug:<10} {:<8} {:<10} {:<10} \u{2014}", - "local", "\u{2014}", "registered" + "local", + "\u{2014}", + "registered" ); } } RegEntry::Unhandled => { - println!( + tracing::info!( "{slug:<10} {:<8} {:<10} {:<10} (no local driver)", - "?", "\u{2014}", "registered" + "?", + "\u{2014}", + "registered" ); } } diff --git a/crates/hm/src/commands/dev/port_of.rs b/crates/hm/src/commands/dev/port_of.rs index 38c2824..e7870df 100644 --- a/crates/hm/src/commands/dev/port_of.rs +++ b/crates/hm/src/commands/dev/port_of.rs @@ -16,14 +16,6 @@ use super::naming::{ /// /// Returns an error if Docker is unreachable or if the registry subprocess /// invocation fails. -#[allow( - clippy::print_stderr, - reason = "user-facing error messages for a foreground CLI" -)] -#[allow( - clippy::print_stdout, - reason = "`hm dev port-of` prints the port to stdout for $() use" -)] pub async fn handle(args: DevPortOfArgs, _ctx: RunContext) -> Result { let docker = DockerClient::connect()?; let worktree_root = resolve_worktree_root()?; @@ -54,14 +46,15 @@ pub async fn handle(args: DevPortOfArgs, _ctx: RunContext) -> Result { // Was the slug registered at all? match super::registry::dump(&worktree_root).await { Ok(reg) if reg.deployments.contains_key(&args.slug) => { - eprintln!( + tracing::error!( "hm: slug `{}` registered but not running in this worktree.\n → run `hm dev up {}` first.", - args.slug, args.slug, + args.slug, + args.slug, ); return Ok(4); } _ => { - eprintln!( + tracing::error!( "hm: slug `{}` not registered in this worktree's .harmont/.\n → run `hm dev ls` to see registered slugs.", args.slug, ); @@ -70,27 +63,28 @@ pub async fn handle(args: DevPortOfArgs, _ctx: RunContext) -> Result { } } if matches.len() > 1 { - eprintln!( + tracing::error!( "hm: slug `{}` matches multiple live sessions in this worktree:", args.slug ); for (_, sess, ports) in &matches { let p = format_ports(ports); - eprintln!(" {sess} {p}"); + tracing::error!(" {sess} {p}"); } - eprintln!("pass `--session ` or run `hm dev ls`."); + tracing::error!("pass `--session ` or run `hm dev ls`."); return Ok(5); } let (_, _, ports) = &matches[0]; let Some(host_port) = ports.get(&args.container_port) else { - eprintln!( + tracing::error!( "hm: container port `{}` is not published by `{}`.\n → check the deployment's port_mapping.", - args.container_port, args.slug, + args.container_port, + args.slug, ); return Ok(5); }; - println!("{host_port}"); + tracing::info!("{host_port}"); Ok(0) } diff --git a/crates/hm/src/commands/dev/up.rs b/crates/hm/src/commands/dev/up.rs index 2059b35..a35ca9a 100644 --- a/crates/hm/src/commands/dev/up.rs +++ b/crates/hm/src/commands/dev/up.rs @@ -43,15 +43,11 @@ struct BootCtx { /// /// Returns an error if the registry dump fails, Docker is unreachable, /// network creation fails, or any container boot fails. -#[allow( - clippy::print_stderr, - reason = "status messages to stderr are intentional for a foreground CLI" -)] pub async fn handle(args: DevUpArgs, _ctx: RunContext) -> Result { let worktree_root = resolve_worktree_root()?; let wt_hash = worktree_hash(&worktree_root); let session_id = fresh_session_id(); - eprintln!("[hm] session {session_id}. resolving deployments in .harmont/"); + tracing::info!("[hm] session {session_id}. resolving deployments in .harmont/"); let registry = dump(&worktree_root) .await @@ -61,7 +57,7 @@ pub async fn handle(args: DevUpArgs, _ctx: RunContext) -> Result { docker.ping().await.context("docker daemon ping")?; let net = create_network(&docker, &wt_hash, &session_id).await?; - eprintln!("[hm] network {}: created", net.name); + tracing::info!("[hm] network {}: created", net.name); // Determine slug column width. let slug_width = boot_plan.slugs().map(str::len).max().unwrap_or(4); @@ -100,12 +96,12 @@ pub async fn handle(args: DevUpArgs, _ctx: RunContext) -> Result { } } - eprintln!("[hm] all up. Ctrl-C to tear down. Logs follow."); + tracing::info!("[hm] all up. Ctrl-C to tear down. Logs follow."); // Wait for SIGINT/SIGTERM. wait_signal().await?; - eprintln!("[hm] tearing down..."); + tracing::info!("[hm] tearing down..."); teardown(&docker, &net, &booted).await; // Drop the sender so the logmux channel closes and the task can finish. @@ -115,10 +111,6 @@ pub async fn handle(args: DevUpArgs, _ctx: RunContext) -> Result { Ok(0) } -#[allow( - clippy::print_stderr, - reason = "per-slug ready/pull/build messages go to stderr" -)] async fn boot_one( docker: DockerClient, slug: String, @@ -151,7 +143,7 @@ async fn boot_one( .collect(); format!(" | {}", parts.join(", ")) }; - eprintln!("[{slug}] ready ( {}{ports_str} )", resolved.container_name); + tracing::info!("[{slug}] ready ( {}{ports_str} )", resolved.container_name); // Spawn the log-stream consumer for this container. tokio::spawn(stream_logs( docker.clone(), @@ -171,10 +163,7 @@ async fn resolve_image( ) -> Result { if let Some(tag) = &spec.image { if !docker.image_exists(tag).await? { - #[allow(clippy::print_stderr, reason = "pull progress goes to stderr")] - { - eprintln!("[{slug}] pulling {tag}..."); - } + tracing::info!("[{slug}] pulling {tag}..."); docker.pull_image(tag).await?; } return Ok(tag.clone()); @@ -183,10 +172,7 @@ async fn resolve_image( let chain_key = extract_terminal_key(pipeline_v0).unwrap_or_else(|| "nocache".to_string()); let tag = format!("hm-build-{worktree_hash}-{slug}:{chain_key}"); if rebuild || !docker.image_exists(&tag).await? { - #[allow(clippy::print_stderr, reason = "build progress goes to stderr")] - { - eprintln!("[{slug}] building from Step chain..."); - } + tracing::info!("[{slug}] building from Step chain..."); crate::orchestrator::build_image_from_pipeline(docker, pipeline_v0, &tag).await?; } return Ok(tag); @@ -252,14 +238,13 @@ async fn wait_signal() -> Result<()> { Ok(()) } -#[allow(clippy::print_stderr, reason = "teardown status goes to stderr")] async fn teardown(docker: &DockerClient, net: &Network, booted: &[Booted]) { // Reverse order so dependents stop before their deps. for b in booted.iter().rev() { let _ = docker.stop_container(&b.container_id).await; let _ = docker.remove_container(&b.container_id).await; - eprintln!("[{}] stopped", b.slug); + tracing::info!("[{}] stopped", b.slug); } let _ = remove_network(docker, net).await; - eprintln!("[hm] network {}: removed", net.name); + tracing::info!("[hm] network {}: removed", net.name); } diff --git a/crates/hm/src/commands/run/local.rs b/crates/hm/src/commands/run/local.rs index 2759476..39b9b2e 100644 --- a/crates/hm/src/commands/run/local.rs +++ b/crates/hm/src/commands/run/local.rs @@ -5,7 +5,6 @@ use anyhow::{Context, Result}; use super::render::{ToolPaths, list_pipelines, render_pipeline_json}; use crate::cli::RunArgs; use crate::context::RunContext; -use crate::output::format::banner; use crate::runner::{RunnerRegistry, docker::DockerRunner}; /// Execute a v0 IR pipeline locally; return the final container id. @@ -89,10 +88,6 @@ pub async fn handle(args: RunArgs, _ctx: RunContext) -> Result { } }; - if args.format == "human" { - banner("run --local", &format!("slug={slug}")); - } - let json = render_pipeline_json(&tools, &repo_root, &slug).await?; let graph = decode_plan_to_wire(&json)?; let parallelism = args.parallelism.unwrap_or_else(|| { diff --git a/crates/hm/src/main.rs b/crates/hm/src/main.rs index ae5a96e..a8232ed 100644 --- a/crates/hm/src/main.rs +++ b/crates/hm/src/main.rs @@ -1,7 +1,3 @@ -#![allow( - clippy::print_stderr, - reason = "the panic banner in handle_error is the last-resort stderr writer" -)] #![allow( clippy::multiple_crate_versions, reason = "transitive dependency version conflicts in rand/windows-sys/thiserror chains; not fixable without upstream updates" @@ -20,22 +16,16 @@ use harmont_cli::output::status; async fn main() { let args = Cli::parse(); - // Initialize tracing if --verbose. - if args.verbose { - tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug")), - ) - .with_target(false) - .init(); - } + let default_level = if args.verbose { "debug" } else { "info" }; + let filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level)); + + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .without_time() + .init(); - // Color override propagates to every OwoColorize call site. We - // respect three signals, in priority order: explicit `--no-color`, - // the `NO_COLOR` env var (https://no-color.org), and finally - // TTY-ness of stderr. When stderr isn't a terminal — pipe to a - // file, `head`, or a test harness — turning colors off keeps the - // bytes downstream clean. let color_enabled = !args.no_color && std::env::var_os("NO_COLOR").is_none() && console::Term::stderr().is_term(); @@ -56,14 +46,14 @@ async fn run(args: Cli) -> Result { } fn handle_error(err: &anyhow::Error) -> i32 { - // Try to downcast to our typed error for a specific exit code. if let Some(hm_err) = err.downcast_ref::() { status::print_error(&format!("{hm_err}")); return hm_err.exit_code(); } - // Generic error. let msg = format!("{err:#}"); - eprintln!("{} {msg}", "error:".red().bold()); + let red = "error:".red(); + let prefix = red.bold(); + tracing::error!("{prefix} {msg}"); error::EXIT_BUILD_FAILED } diff --git a/crates/hm/src/orchestrator/output_subscriber.rs b/crates/hm/src/orchestrator/output_subscriber.rs index 576756f..c951d38 100644 --- a/crates/hm/src/orchestrator/output_subscriber.rs +++ b/crates/hm/src/orchestrator/output_subscriber.rs @@ -6,10 +6,7 @@ // Pedantic-bucket nags accepted at module scope: // - `needless_pass_by_value` on `bus`: the owned `Arc` makes // the bus->subscriber handoff explicit at the call site. -// - `print_stderr`: the Lagged arm intentionally bypasses the event -// bus (which is the source of the lag) to surface a user-visible -// drop signal, so an `eprintln!` direct to stderr is correct. -#![allow(clippy::needless_pass_by_value, clippy::print_stderr)] +#![allow(clippy::needless_pass_by_value)] use std::sync::Arc; @@ -39,7 +36,6 @@ pub fn spawn( Err(RecvError::Closed) => return, Err(RecvError::Lagged(n)) => { tracing::warn!("output: dropped {n} events"); - eprintln!("[output] dropped {n} build events"); } } } diff --git a/crates/hm/src/orchestrator/signal.rs b/crates/hm/src/orchestrator/signal.rs index f5f6917..c85dbd5 100644 --- a/crates/hm/src/orchestrator/signal.rs +++ b/crates/hm/src/orchestrator/signal.rs @@ -5,12 +5,9 @@ //! exit with code 130 (sigint). // Pedantic-bucket nags accepted at module scope: -// - `print_stderr`: this module's whole purpose is signalling the user -// on the TTY when they Ctrl-C. The output sink is not running at this -// point (or is being torn down); stderr is the correct channel. // - `exit`: force-exit on second Ctrl-C is the documented UX, matching // the legacy executor. The user has explicitly asked us to die. -#![allow(clippy::print_stderr, clippy::exit)] +#![allow(clippy::exit)] use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -31,10 +28,10 @@ pub fn install_ctrlc(token: CancellationToken) -> tokio::task::JoinHandle<()> { match tokio::signal::ctrl_c().await { Ok(()) => { if armed.swap(true, Ordering::SeqCst) { - eprintln!("\nforce-exit on second Ctrl-C"); + tracing::warn!("\nforce-exit on second Ctrl-C"); std::process::exit(130); } - eprintln!("\ncancelling… (Ctrl-C again to force)"); + tracing::info!("\ncancelling… (Ctrl-C again to force)"); token.cancel(); } Err(_) => return, diff --git a/crates/hm/src/output/format.rs b/crates/hm/src/output/format.rs deleted file mode 100644 index c344cf3..0000000 --- a/crates/hm/src/output/format.rs +++ /dev/null @@ -1,310 +0,0 @@ -//! Visual and DX helpers for the CLI: relative times, status pills, -//! hyperlinks, banners, headers, key/value rows and empty states. -//! -//! These helpers are the foundation for the CLI's "beautiful, modern, fun" -//! design language. Every command should funnel its visual output through -//! this module rather than reaching for `owo_colors` directly. -#![allow( - clippy::print_stdout, - reason = "output/format.rs is a first-class print module alongside output/status.rs" -)] - -use chrono::{DateTime, Utc}; -use owo_colors::{OwoColorize, Style}; -use std::fmt::Display; - -/// Render an epoch timestamp as a human-friendly relative time. -/// -/// - `0` → `—` -/// - `|delta| < 5s` → `just now` -/// - `< 60s` → `12s ago` / `in 12s` -/// - `< 1h` → `3m ago` / `in 3m` -/// - `< 1d` → `2h ago` / `in 2h` -/// - `< 1w` → `5d ago` / `in 5d` -/// - otherwise → `%b %e` (e.g. `Jan 12`) -#[must_use] -pub fn rel_time(epoch: i64) -> String { - if epoch == 0 { - return "—".to_string(); - } - let now = Utc::now().timestamp(); - let delta = now - epoch; - let abs = delta.abs(); - - if abs < 5 { - return "just now".to_string(); - } - - let (n, unit) = if abs < 60 { - (abs, "s") - } else if abs < 3600 { - (abs / 60, "m") - } else if abs < 86400 { - (abs / 3600, "h") - } else if abs < 7 * 86400 { - (abs / 86400, "d") - } else { - // Fall back to absolute date formatting. - return DateTime::::from_timestamp(epoch, 0) - .map_or_else(|| "—".to_string(), |dt| dt.format("%b %e").to_string()); - }; - - if delta >= 0 { - format!("{n}{unit} ago") - } else { - format!("in {n}{unit}") - } -} - -/// Render a duration in seconds as `42s` / `3m12s` / `1h05m`. -/// -/// Values `<= 0` render as `—`. -#[must_use] -pub fn duration_human(secs: i64) -> String { - if secs <= 0 { - return "—".to_string(); - } - if secs < 60 { - return format!("{secs}s"); - } - if secs < 3600 { - let m = secs / 60; - let s = secs % 60; - return format!("{m}m{s:02}s"); - } - let h = secs / 3600; - let m = (secs % 3600) / 60; - format!("{h}h{m:02}m") -} - -/// Elapsed duration between two epoch timestamps. -/// -/// - `start == 0` → `—` -/// - `end == 0` → use now as the end -/// - otherwise `duration_human(end - start)` -#[must_use] -pub fn elapsed_between(start: i64, end: i64) -> String { - if start == 0 { - return "—".to_string(); - } - let actual_end = if end == 0 { - Utc::now().timestamp() - } else { - end - }; - duration_human(actual_end - start) -} - -/// Return the (style, icon) pair for a given status string. -fn status_style(status: &str) -> (Style, &'static str) { - match status { - "passed" => (Style::new().green().bold(), "✓"), - "failed" => (Style::new().red().bold(), "✗"), - "running" => (Style::new().yellow().bold(), "◐"), - "queued" | "scheduled" => (Style::new().blue(), "◷"), - "blocked" | "waiting" => (Style::new().magenta(), "⏸"), - "canceled" | "canceling" => (Style::new().bright_black(), "⊘"), - "skipped" | "not_run" => (Style::new().bright_black(), "⤼"), - _ => (Style::new().white(), "•"), - } -} - -/// Render a status pill: `" "`, both styled in the -/// status color. -#[must_use] -pub fn status_pill(status: &str) -> String { - let (style, icon) = status_style(status); - let body = format!("{icon} {status}"); - body.style(style).to_string() -} - -/// Detect whether OSC 8 hyperlinks should be emitted by default. -/// -/// Returns false if `NO_COLOR` is set, or if `TERM_PROGRAM` is empty or -/// `"dumb"`; otherwise true. -fn supports_hyperlinks() -> bool { - if std::env::var_os("NO_COLOR").is_some() { - return false; - } - match std::env::var("TERM_PROGRAM") { - Ok(v) if v.is_empty() || v == "dumb" => false, - Ok(_) => true, - Err(_) => false, - } -} - -/// Render a hyperlink, auto-detecting terminal support. -#[must_use] -pub fn hyperlink(url: &str, label: &str) -> String { - hyperlink_with(url, label, supports_hyperlinks()) -} - -/// Render a hyperlink with explicit support toggle. -/// -/// When `enabled`, emits an OSC 8 escape sequence. Otherwise falls back -/// to `