From 17eb641ddb468a5a03a10e74751722d43965ca66 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:37:22 -0700 Subject: [PATCH 01/14] refactor: move tracing deps to workspace.dependencies --- Cargo.toml | 2 ++ crates/hm-plugin-cloud/Cargo.toml | 2 +- crates/hm/Cargo.toml | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 71a66fc..d01c1ad 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" 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/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" From 2ffadfae5d4caf7655a1594a1d9d889bc2407e74 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:39:16 -0700 Subject: [PATCH 02/14] feat: add CliLayer tracing subscriber for user-facing output --- crates/hm/src/output/cli_layer.rs | 146 ++++++++++++++++++++++++++++++ crates/hm/src/output/mod.rs | 1 + 2 files changed, 147 insertions(+) create mode 100644 crates/hm/src/output/cli_layer.rs diff --git a/crates/hm/src/output/cli_layer.rs b/crates/hm/src/output/cli_layer.rs new file mode 100644 index 0000000..0f613f0 --- /dev/null +++ b/crates/hm/src/output/cli_layer.rs @@ -0,0 +1,146 @@ +use std::fmt::Write as FmtWrite; +use std::io::Write; +use std::sync::{Arc, Mutex}; + +use tracing::Subscriber; +use tracing_subscriber::layer::Context; +use tracing_subscriber::Layer; + +#[derive(Debug, Clone)] +pub struct CliLayer { + stdout_sink: Arc>, + stderr_sink: Arc>, +} + +impl CliLayer { + pub fn real() -> Self { + Self { + stdout_sink: Arc::new(Mutex::new(std::io::stdout())), + stderr_sink: Arc::new(Mutex::new(std::io::stderr())), + } + } +} + +impl CliLayer { + pub fn with_sinks(stdout: O, stderr: E) -> Self { + Self { + stdout_sink: Arc::new(Mutex::new(stdout)), + stderr_sink: Arc::new(Mutex::new(stderr)), + } + } +} + +#[derive(Default)] +struct MessageVisitor(String); + +impl tracing::field::Visit for MessageVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + write!(self.0, "{value:?}").ok(); + } + } +} + +impl Layer for CliLayer +where + S: Subscriber, + O: Write + Send + 'static, + E: Write + Send + 'static, +{ + fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { + let target = event.metadata().target(); + + let is_stdout = target == "user::stdout"; + let is_stderr = target == "user::stderr"; + if !is_stdout && !is_stderr { + return; + } + + let mut visitor = MessageVisitor::default(); + event.record(&mut visitor); + let msg = visitor.0; + + if is_stdout { + if let Ok(mut w) = self.stdout_sink.lock() { + writeln!(w, "{msg}").ok(); + } + } else if let Ok(mut w) = self.stderr_sink.lock() { + writeln!(w, "{msg}").ok(); + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use tracing_subscriber::layer::SubscriberExt; + + fn capture_layer() -> ( + CliLayer, Vec>, + Arc>>, + Arc>>, + ) { + let stdout_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let stderr_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let layer = CliLayer { + stdout_sink: Arc::clone(&stdout_buf), + stderr_sink: Arc::clone(&stderr_buf), + }; + (layer, stdout_buf, stderr_buf) + } + + fn buf_str(buf: &Arc>>) -> String { + String::from_utf8(buf.lock().unwrap().clone()).unwrap() + } + + #[test] + fn routes_stdout_target_to_stdout_sink() { + let (layer, stdout_buf, stderr_buf) = capture_layer(); + let subscriber = tracing_subscriber::registry().with(layer); + let _guard = tracing::subscriber::set_default(subscriber); + tracing::info!(target: "user::stdout", "hello world"); + assert_eq!(buf_str(&stdout_buf), "hello world\n"); + assert!(buf_str(&stderr_buf).is_empty()); + } + + #[test] + fn routes_stderr_target_to_stderr_sink() { + let (layer, stdout_buf, stderr_buf) = capture_layer(); + let subscriber = tracing_subscriber::registry().with(layer); + let _guard = tracing::subscriber::set_default(subscriber); + tracing::info!(target: "user::stderr", "warning msg"); + assert!(buf_str(&stdout_buf).is_empty()); + assert_eq!(buf_str(&stderr_buf), "warning msg\n"); + } + + #[test] + fn ignores_other_targets() { + let (layer, stdout_buf, stderr_buf) = capture_layer(); + let subscriber = tracing_subscriber::registry().with(layer); + let _guard = tracing::subscriber::set_default(subscriber); + tracing::info!("diagnostic message"); + assert!(buf_str(&stdout_buf).is_empty()); + assert!(buf_str(&stderr_buf).is_empty()); + } + + #[test] + fn handles_format_args() { + let (layer, stdout_buf, _) = capture_layer(); + let subscriber = tracing_subscriber::registry().with(layer); + let _guard = tracing::subscriber::set_default(subscriber); + let name = "alice"; + let count = 42; + tracing::info!(target: "user::stdout", "{name} has {count} items"); + assert_eq!(buf_str(&stdout_buf), "alice has 42 items\n"); + } + + #[test] + fn handles_empty_message() { + let (layer, stdout_buf, _) = capture_layer(); + let subscriber = tracing_subscriber::registry().with(layer); + let _guard = tracing::subscriber::set_default(subscriber); + tracing::info!(target: "user::stdout", ""); + assert_eq!(buf_str(&stdout_buf), "\n"); + } +} diff --git a/crates/hm/src/output/mod.rs b/crates/hm/src/output/mod.rs index 1d3f8bb..4bdc48f 100644 --- a/crates/hm/src/output/mod.rs +++ b/crates/hm/src/output/mod.rs @@ -1,3 +1,4 @@ +pub mod cli_layer; pub mod format; pub mod human; pub mod json; From 11a65e84cf95bf9b2070fd3eb37e4083796a6b91 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:41:21 -0700 Subject: [PATCH 03/14] feat: always-on CliLayer subscriber + ui_println/ui_eprintln macros --- crates/hm/src/main.rs | 50 +++++++++++++++++++++---------------- crates/hm/src/output/mod.rs | 14 +++++++++++ 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/crates/hm/src/main.rs b/crates/hm/src/main.rs index ae5a96e..b72732d 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" @@ -9,33 +5,43 @@ use clap::Parser; use owo_colors::OwoColorize; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::EnvFilter; +use tracing_subscriber::Layer as _; use harmont_cli::cli::{self, Cli}; use harmont_cli::context::RunContext; use harmont_cli::error::{self, HmError}; +use harmont_cli::output::cli_layer::CliLayer; use harmont_cli::output::status; #[tokio::main] 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(); - } + // Build the layered subscriber: + // 1. CliLayer — always active, routes user::stdout / user::stderr + // 2. fmt layer — only active with --verbose, for diagnostic tracing + let cli_layer = CliLayer::real(); + + let fmt_layer = if args.verbose { + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("debug")); + Some( + tracing_subscriber::fmt::layer() + .with_target(false) + .with_filter(filter), + ) + } else { + None + }; + + tracing_subscriber::registry() + .with(cli_layer) + .with(fmt_layer) + .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 +62,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::info!(target: "user::stderr", "{prefix} {msg}"); error::EXIT_BUILD_FAILED } diff --git a/crates/hm/src/output/mod.rs b/crates/hm/src/output/mod.rs index 4bdc48f..7957973 100644 --- a/crates/hm/src/output/mod.rs +++ b/crates/hm/src/output/mod.rs @@ -1,3 +1,17 @@ +/// Write a line to stdout via tracing. Equivalent to `println!`. +macro_rules! ui_println { + () => { ::tracing::info!(target: "user::stdout", "") }; + ($($arg:tt)*) => { ::tracing::info!(target: "user::stdout", $($arg)*) }; +} + +/// Write a line to stderr via tracing. Equivalent to `eprintln!`. +macro_rules! ui_eprintln { + ($($arg:tt)*) => { ::tracing::info!(target: "user::stderr", $($arg)*) }; +} + +pub(crate) use ui_eprintln; +pub(crate) use ui_println; + pub mod cli_layer; pub mod format; pub mod human; From 2cad8fc7293678125b2485648c69b572a8c17b89 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:42:25 -0700 Subject: [PATCH 04/14] refactor: route status output through tracing CliLayer --- crates/hm/src/output/status.rs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/crates/hm/src/output/status.rs b/crates/hm/src/output/status.rs index 6513eb9..8604bbf 100644 --- a/crates/hm/src/output/status.rs +++ b/crates/hm/src/output/status.rs @@ -1,31 +1,25 @@ -#![allow( - clippy::print_stdout, - clippy::print_stderr, - reason = "this module is the centralised print sink for the CLI" -)] - use owo_colors::OwoColorize; -/// Print a success message. +/// Print a success message to stdout. pub fn print_success(msg: &str) { let check = format!("{}", "\u{2714}".green().bold()); - println!("{check} {msg}"); + tracing::info!(target: "user::stdout", "{check} {msg}"); } -/// Print a warning message. +/// Print a warning message to stderr. pub fn print_warning(msg: &str) { let bang = format!("{}", "!".yellow().bold()); - eprintln!("{bang} {msg}"); + tracing::info!(target: "user::stderr", "{bang} {msg}"); } -/// Print an error message. +/// Print an error message to stderr. pub fn print_error(msg: &str) { let cross = format!("{}", "\u{2718}".red().bold()); - eprintln!("{cross} {msg}"); + tracing::info!(target: "user::stderr", "{cross} {msg}"); } -/// Print an info message. +/// Print an info message to stdout. pub fn print_info(msg: &str) { let arrow = format!("{}", "\u{25b6}".cyan()); - println!("{arrow} {msg}"); + tracing::info!(target: "user::stdout", "{arrow} {msg}"); } From b2f077cba4973aaea27401e2433dd37525a45cf2 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:43:14 -0700 Subject: [PATCH 05/14] refactor: route format output through tracing CliLayer --- crates/hm/src/output/format.rs | 47 +++++++++++++++------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/crates/hm/src/output/format.rs b/crates/hm/src/output/format.rs index c344cf3..d205f01 100644 --- a/crates/hm/src/output/format.rs +++ b/crates/hm/src/output/format.rs @@ -4,11 +4,6 @@ //! 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; @@ -158,9 +153,9 @@ pub fn hyperlink_with(url: &str, label: &str, enabled: bool) -> String { pub fn header(title: &str) { let rule_len = title.chars().count() + 4; let rule: String = "─".repeat(rule_len); - println!(); - println!(" {}", title.bold()); - println!(" {}", rule.bright_black()); + tracing::info!(target: "user::stdout", ""); + tracing::info!(target: "user::stdout", " {}", title.bold()); + tracing::info!(target: "user::stdout", " {}", rule.bright_black()); } /// Print a key/value row, aligned at column 12. @@ -169,37 +164,35 @@ pub fn kv(label: &str, value: impl Display) { // Right-pad to width 10 so values line up at column 12 from start // of line (2 spaces + 10-wide label field = 12). let padded = format!("{label_with_colon:<10}"); - println!(" {} {value}", padded.bright_black()); + tracing::info!(target: "user::stdout", " {} {value}", padded.bright_black()); } /// Print an empty-state message: blank line, bold title, dim hint, blank line. pub fn empty_state(title: &str, hint: &str) { - println!(); - println!(" {}", title.bold()); - println!(" {}", hint.bright_black()); - println!(); + tracing::info!(target: "user::stdout", ""); + tracing::info!(target: "user::stdout", " {}", title.bold()); + tracing::info!(target: "user::stdout", " {}", hint.bright_black()); + tracing::info!(target: "user::stdout", ""); } /// Print a command banner: `▌ hm · `. pub fn banner(command: &str, subtitle: &str) { - println!( - "{} {} {} {}", - "▌".cyan().bold(), - "hm".bold(), - command.cyan(), - format!("· {subtitle}").bright_black() - ); - println!(); + let icon = "▌".cyan(); + let icon = icon.bold(); + let hm = "hm".bold(); + let cmd = command.cyan(); + let sub_text = format!("· {subtitle}"); + let sub = sub_text.bright_black(); + tracing::info!(target: "user::stdout", "{icon} {hm} {cmd} {sub}"); + tracing::info!(target: "user::stdout", ""); } /// Print a single step line: ` ✓ `. pub fn step(verb: &str, result: impl Display) { - println!( - " {} {} {}", - "✓".green().bold(), - verb.bright_black(), - result - ); + let check = "✓".green(); + let check = check.bold(); + let dim_verb = verb.bright_black(); + tracing::info!(target: "user::stdout", " {check} {dim_verb} {result}"); } #[cfg(test)] From 20d619de17631aa7e4acdfe7e1b9d0cc51bdf958 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:47:00 -0700 Subject: [PATCH 06/14] refactor: replace print macros with tracing in dev commands --- crates/hm/src/commands/dev/down.rs | 5 ++--- crates/hm/src/commands/dev/exec.rs | 10 ++++------ crates/hm/src/commands/dev/logs.rs | 10 ++++------ crates/hm/src/commands/dev/ls.rs | 13 ++++++++----- crates/hm/src/commands/dev/port_of.rs | 22 ++++++++++------------ crates/hm/src/commands/dev/up.rs | 27 +++++++++------------------ 6 files changed, 37 insertions(+), 50 deletions(-) diff --git a/crates/hm/src/commands/dev/down.rs b/crates/hm/src/commands/dev/down.rs index cbbc769..0def492 100644 --- a/crates/hm/src/commands/dev/down.rs +++ b/crates/hm/src/commands/dev/down.rs @@ -16,7 +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()?; @@ -53,7 +52,7 @@ pub async fn handle(args: DevDownArgs, _ctx: RunContext) -> Result { } if to_remove.is_empty() { - eprintln!("[hm] nothing to sweep"); + tracing::info!(target: "user::stderr", "[hm] nothing to sweep"); return Ok(0); } @@ -61,7 +60,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!(target: "user::stderr", "[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 f8dab5e..a94a9cf 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()?; @@ -42,14 +38,16 @@ pub async fn handle(args: DevExecArgs, _ctx: RunContext) -> Result { } } if matches.is_empty() { - eprintln!( + tracing::info!( + target: "user::stderr", "hm: slug `{}` is not running in this worktree.\n → run `hm dev up {}` first.", args.slug, args.slug, ); return Ok(4); } if matches.len() > 1 { - eprintln!( + tracing::info!( + target: "user::stderr", "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 937c74a..baaba8b 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()?; @@ -45,14 +41,16 @@ pub async fn handle(args: DevLogsArgs, _ctx: RunContext) -> Result { } } if matches.is_empty() { - eprintln!( + tracing::info!( + target: "user::stderr", "hm: slug `{}` is not running in this worktree.\n → run `hm dev up {}` first.", args.slug, args.slug, ); return Ok(4); } if matches.len() > 1 { - eprintln!( + tracing::info!( + target: "user::stderr", "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 6ccbda4..83d22d8 100644 --- a/crates/hm/src/commands/dev/ls.rs +++ b/crates/hm/src/commands/dev/ls.rs @@ -18,14 +18,14 @@ 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!( + target: "user::stdout", "{:<10} {:<8} {:<10} {:<10} PORTS", "SLUG", "DRIVER", "SESSION", "STATUS" ); @@ -59,21 +59,24 @@ pub async fn handle(_ctx: RunContext) -> Result { if s == slug { matched = true; let ports_s = format_ports(ports); - println!( + tracing::info!( + target: "user::stdout", "{slug:<10} {:<8} {:<10} {:<10} {ports_s}", "local", sess, state ); } } if !matched { - println!( + tracing::info!( + target: "user::stdout", "{slug:<10} {:<8} {:<10} {:<10} \u{2014}", "local", "\u{2014}", "registered" ); } } RegEntry::Unhandled => { - println!( + tracing::info!( + target: "user::stdout", "{slug:<10} {:<8} {:<10} {:<10} (no local driver)", "?", "\u{2014}", "registered" ); diff --git a/crates/hm/src/commands/dev/port_of.rs b/crates/hm/src/commands/dev/port_of.rs index 9e203c6..0f7c71d 100644 --- a/crates/hm/src/commands/dev/port_of.rs +++ b/crates/hm/src/commands/dev/port_of.rs @@ -16,11 +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()?; @@ -47,14 +42,16 @@ 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::info!( + target: "user::stderr", "hm: slug `{}` registered but not running in this worktree.\n → run `hm dev up {}` first.", args.slug, args.slug, ); return Ok(4); } _ => { - eprintln!( + tracing::info!( + target: "user::stderr", "hm: slug `{}` not registered in this worktree's .harmont/.\n → run `hm dev ls` to see registered slugs.", args.slug, ); @@ -63,24 +60,25 @@ pub async fn handle(args: DevPortOfArgs, _ctx: RunContext) -> Result { } } if matches.len() > 1 { - eprintln!("hm: slug `{}` matches multiple live sessions in this worktree:", args.slug); + tracing::info!(target: "user::stderr", "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::info!(target: "user::stderr", " {sess} {p}"); } - eprintln!("pass `--session ` or run `hm dev ls`."); + tracing::info!(target: "user::stderr", "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::info!( + target: "user::stderr", "hm: container port `{}` is not published by `{}`.\n → check the deployment's port_mapping.", args.container_port, args.slug, ); return Ok(5); }; - println!("{host_port}"); + tracing::info!(target: "user::stdout", "{host_port}"); Ok(0) } diff --git a/crates/hm/src/commands/dev/up.rs b/crates/hm/src/commands/dev/up.rs index 87df347..b8db06d 100644 --- a/crates/hm/src/commands/dev/up.rs +++ b/crates/hm/src/commands/dev/up.rs @@ -43,12 +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!(target: "user::stderr", "[hm] session {session_id}. resolving deployments in .harmont/"); let registry = dump(&worktree_root).await.context("dump deployment registry")?; let boot_plan = plan(®istry, &args.slugs, args.no_deps)?; @@ -56,7 +55,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!(target: "user::stderr", "[hm] network {}: created", net.name); // Determine slug column width. let slug_width = boot_plan.slugs().map(str::len).max().unwrap_or(4); @@ -97,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!(target: "user::stderr", "[hm] all up. Ctrl-C to tear down. Logs follow."); // Wait for SIGINT/SIGTERM. wait_signal().await?; - eprintln!("[hm] tearing down..."); + tracing::info!(target: "user::stderr", "[hm] tearing down..."); teardown(&docker, &net, &booted).await; // Drop the sender so the logmux channel closes and the task can finish. @@ -112,7 +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, @@ -146,7 +144,7 @@ async fn boot_one( .collect(); format!(" | {}", parts.join(", ")) }; - eprintln!("[{slug}] ready ( {}{ports_str} )", resolved.container_name); + tracing::info!(target: "user::stderr", "[{slug}] ready ( {}{ports_str} )", resolved.container_name); // Spawn the log-stream consumer for this container. tokio::spawn(stream_logs( docker.clone(), @@ -166,10 +164,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!(target: "user::stderr", "[{slug}] pulling {tag}..."); docker.pull_image(tag).await?; } return Ok(tag.clone()); @@ -179,10 +174,7 @@ async fn resolve_image( 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!(target: "user::stderr", "[{slug}] building from Step chain..."); crate::orchestrator::build_image_from_pipeline(docker, pipeline_v0, &tag).await?; } return Ok(tag); @@ -242,14 +234,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!(target: "user::stderr", "[{}] stopped", b.slug); } let _ = remove_network(docker, net).await; - eprintln!("[hm] network {}: removed", net.name); + tracing::info!(target: "user::stderr", "[hm] network {}: removed", net.name); } From c866f10d347b3c85e60f0bd0479b27b3b4922d93 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:48:01 -0700 Subject: [PATCH 07/14] refactor: replace print macros in cli helpers and orchestrator --- crates/hm/src/cli/plugin.rs | 4 ++-- crates/hm/src/cli/version.rs | 2 +- crates/hm/src/orchestrator/output_subscriber.rs | 9 +-------- crates/hm/src/orchestrator/signal.rs | 9 +++------ 4 files changed, 7 insertions(+), 17 deletions(-) diff --git a/crates/hm/src/cli/plugin.rs b/crates/hm/src/cli/plugin.rs index 6af333a..7e99d0c 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!(target: "user::stdout", "Registered runners:"); + tracing::info!(target: "user::stdout", " docker (default, built-in)"); Ok(()) } diff --git a/crates/hm/src/cli/version.rs b/crates/hm/src/cli/version.rs index cf8554a..f7ccfdf 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!(target: "user::stdout", "hm {}", env!("CARGO_PKG_VERSION")); Ok(()) } diff --git a/crates/hm/src/orchestrator/output_subscriber.rs b/crates/hm/src/orchestrator/output_subscriber.rs index 18fad53..c951d38 100644 --- a/crates/hm/src/orchestrator/output_subscriber.rs +++ b/crates/hm/src/orchestrator/output_subscriber.rs @@ -6,13 +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; @@ -42,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..991000e 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::info!(target: "user::stderr", "\nforce-exit on second Ctrl-C"); std::process::exit(130); } - eprintln!("\ncancelling… (Ctrl-C again to force)"); + tracing::info!(target: "user::stderr", "\ncancelling… (Ctrl-C again to force)"); token.cancel(); } Err(_) => return, From 049d45bdbef7e969a5cafadb48b4be62f9ebc3c4 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:50:42 -0700 Subject: [PATCH 08/14] refactor: replace print macros with tracing in cloud plugin --- crates/hm-plugin-cloud/src/auth/login.rs | 9 ++++----- crates/hm-plugin-cloud/src/auth/logout.rs | 2 +- crates/hm-plugin-cloud/src/auth/whoami.rs | 3 ++- crates/hm-plugin-cloud/src/cli.rs | 14 +++++++++++--- crates/hm-plugin-cloud/src/lib.rs | 2 -- crates/hm-plugin-cloud/src/verbs/billing.rs | 16 +++++++++------- crates/hm-plugin-cloud/src/verbs/build.rs | 9 +++++---- crates/hm-plugin-cloud/src/verbs/job.rs | 8 +++++--- crates/hm-plugin-cloud/src/verbs/org.rs | 2 +- crates/hm-plugin-cloud/src/verbs/pipeline.rs | 5 +++-- crates/hm-plugin-cloud/src/verbs/run.rs | 2 +- 11 files changed, 42 insertions(+), 30 deletions(-) diff --git a/crates/hm-plugin-cloud/src/auth/login.rs b/crates/hm-plugin-cloud/src/auth/login.rs index 5b6ca81..c61b86d 100644 --- a/crates/hm-plugin-cloud/src/auth/login.rs +++ b/crates/hm-plugin-cloud/src/auth/login.rs @@ -41,9 +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::info!(target: "user::stderr", "couldn't auto-open the browser. Open this URL manually:\n {auth_url}"); } // Wait for a single connection with a 180-second timeout. @@ -100,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!(target: "user::stderr", "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. @@ -134,7 +132,8 @@ 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!( + target: "user::stderr", "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..505d386 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!(target: "user::stderr", "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 8845f7e..52e3337 100644 --- a/crates/hm-plugin-cloud/src/auth/whoami.rs +++ b/crates/hm-plugin-cloud/src/auth/whoami.rs @@ -19,7 +19,8 @@ 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!( + target: "user::stdout", "{} <{}> (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 9cfbe3d..5010f0d 100644 --- a/crates/hm-plugin-cloud/src/cli.rs +++ b/crates/hm-plugin-cloud/src/cli.rs @@ -161,11 +161,19 @@ pub async fn dispatch( 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) } }; @@ -193,7 +201,7 @@ pub async fn dispatch_command( match result { Ok(()) => Ok(0), Err(e) => { - eprintln!("{e:#}"); + tracing::info!(target: "user::stderr", "{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 faba1e1..fd7a5fa 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!(target: "user::stdout", "${dollars:.2}"); Ok(()) } @@ -51,7 +51,8 @@ async fn transactions(client: &Client, org: &str, limit: u32) -> Result<()> { )) .await?; for t in &list.data { - println!( + tracing::info!( + target: "user::stdout", "{} {:>10} {:<14} {}", t.at.format("%Y-%m-%d %H:%M:%S"), t.amount_cents, @@ -83,7 +84,8 @@ async fn usage( let u: UsageWindow = client .get(&format!("/organizations/{org}/billing/usage{qs}")) .await?; - println!( + tracing::info!( + target: "user::stdout", "{} -> {}: {:.2} min, ${:.2}", u.from.format("%Y-%m-%d"), u.to.format("%Y-%m-%d"), @@ -104,10 +106,10 @@ async fn topup(client: &Client, org: &str, amount_usd: u32, no_browser: bool) -> ) .await?; if no_browser { - println!("{}", r.checkout_url); + tracing::info!(target: "user::stdout", "{}", r.checkout_url); } else if webbrowser::open(&r.checkout_url).is_err() { - eprintln!("couldn't open browser; URL:"); - eprintln!("{}", r.checkout_url); + tracing::info!(target: "user::stderr", "couldn't open browser; URL:"); + tracing::info!(target: "user::stderr", "{}", r.checkout_url); } Ok(()) } @@ -123,7 +125,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!(target: "user::stderr", "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 8797b69..93f5861 100644 --- a/crates/hm-plugin-cloud/src/verbs/build.rs +++ b/crates/hm-plugin-cloud/src/verbs/build.rs @@ -35,7 +35,8 @@ 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!( + target: "user::stdout", "#{:<5} {:<10} {}", b.number, b.state, @@ -52,7 +53,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!(target: "user::stdout", "{json}"); Ok(()) } @@ -63,7 +64,7 @@ async fn cancel(client: &Client, org: &str, pipe: &str, number: i64) -> Result<( &serde_json::json!({}), ) .await?; - eprintln!("build #{number} cancelled"); + tracing::info!(target: "user::stderr", "build #{number} cancelled"); Ok(()) } @@ -76,7 +77,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!(target: "user::stderr", "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 c1d33d6..c351715 100644 --- a/crates/hm-plugin-cloud/src/verbs/job.rs +++ b/crates/hm-plugin-cloud/src/verbs/job.rs @@ -40,7 +40,8 @@ async fn list(client: &Client, org: &str, pipe: &str, build: i64) -> Result<()> )) .await?; for j in &jobs.data { - println!( + tracing::info!( + target: "user::stdout", "{} {:<10} {}", j.id, j.state, @@ -62,7 +63,8 @@ async fn show( "/organizations/{org}/pipelines/{pipe}/builds/{build}/jobs/{jid}" )) .await?; - println!( + tracing::info!( + target: "user::stdout", "{}", serde_json::to_string_pretty(&j).unwrap_or_default() ); @@ -82,7 +84,7 @@ async fn log_cmd( )) .await?; for chunk in &log.data { - println!("{}", chunk.line); + tracing::info!(target: "user::stdout", "{}", chunk.line); } Ok(()) } diff --git a/crates/hm-plugin-cloud/src/verbs/org.rs b/crates/hm-plugin-cloud/src/verbs/org.rs index b3e70da..b7c5df0 100644 --- a/crates/hm-plugin-cloud/src/verbs/org.rs +++ b/crates/hm-plugin-cloud/src/verbs/org.rs @@ -30,6 +30,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!(target: "user::stderr", "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 d152737..2c6285e 100644 --- a/crates/hm-plugin-cloud/src/verbs/pipeline.rs +++ b/crates/hm-plugin-cloud/src/verbs/pipeline.rs @@ -27,7 +27,8 @@ pub(crate) async fn run(env: &BTreeMap, cmd: PipelineCommand) -> async fn list(client: &Client, org: &str) -> Result<()> { let pipes: PipelineList = client.get(&format!("/organizations/{org}/pipelines")).await?; for p in &pipes.data { - println!( + tracing::info!( + target: "user::stdout", "{:<24} {}", p.slug, p.label.as_deref().unwrap_or("(no label)") @@ -47,7 +48,7 @@ async fn show(client: &Client, org: &str, slug: &str) -> Result<()> { "default_branch": p.default_branch, })) .unwrap_or_default(); - println!("{json}"); + tracing::info!(target: "user::stdout", "{json}"); Ok(()) } diff --git a/crates/hm-plugin-cloud/src/verbs/run.rs b/crates/hm-plugin-cloud/src/verbs/run.rs index 2b703e1..25ba5da 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!(target: "user::stderr", "submitted build #{}: {url}", build.number); if args.no_watch { return Ok(()); } From d26960a3ebc01dbb59d0400e8b29daa8bfc13a07 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:53:03 -0700 Subject: [PATCH 09/14] refactor: deny print_stdout/print_stderr lints workspace-wide --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d01c1ad..2a2cc9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,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" From 64ae87be15edf9c12eb595d87fcdb282a327fa46 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 24 May 2026 00:27:59 -0700 Subject: [PATCH 10/14] fix: resolve clippy errors in output module - Remove unused ui_println/ui_eprintln macros - Add #[must_use] to CliLayer::real() - Allow type_complexity in cli_layer tests --- crates/hm/src/output/cli_layer.rs | 3 ++- crates/hm/src/output/mod.rs | 14 -------------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/crates/hm/src/output/cli_layer.rs b/crates/hm/src/output/cli_layer.rs index 0f613f0..797acab 100644 --- a/crates/hm/src/output/cli_layer.rs +++ b/crates/hm/src/output/cli_layer.rs @@ -13,6 +13,7 @@ pub struct CliLayer { } impl CliLayer { + #[must_use] pub fn real() -> Self { Self { stdout_sink: Arc::new(Mutex::new(std::io::stdout())), @@ -71,7 +72,7 @@ where } #[cfg(test)] -#[allow(clippy::unwrap_used)] +#[allow(clippy::unwrap_used, clippy::type_complexity)] mod tests { use super::*; use tracing_subscriber::layer::SubscriberExt; diff --git a/crates/hm/src/output/mod.rs b/crates/hm/src/output/mod.rs index 7957973..4bdc48f 100644 --- a/crates/hm/src/output/mod.rs +++ b/crates/hm/src/output/mod.rs @@ -1,17 +1,3 @@ -/// Write a line to stdout via tracing. Equivalent to `println!`. -macro_rules! ui_println { - () => { ::tracing::info!(target: "user::stdout", "") }; - ($($arg:tt)*) => { ::tracing::info!(target: "user::stdout", $($arg)*) }; -} - -/// Write a line to stderr via tracing. Equivalent to `eprintln!`. -macro_rules! ui_eprintln { - ($($arg:tt)*) => { ::tracing::info!(target: "user::stderr", $($arg)*) }; -} - -pub(crate) use ui_eprintln; -pub(crate) use ui_println; - pub mod cli_layer; pub mod format; pub mod human; From 5985b7669191ee9a503fbc68fd5fb9d2167eefab Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 24 May 2026 10:42:40 -0700 Subject: [PATCH 11/14] style: fix import ordering to satisfy rustfmt --- crates/hm/src/main.rs | 7 +++---- crates/hm/src/output/cli_layer.rs | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/hm/src/main.rs b/crates/hm/src/main.rs index b72732d..8bcd92a 100644 --- a/crates/hm/src/main.rs +++ b/crates/hm/src/main.rs @@ -5,10 +5,10 @@ use clap::Parser; use owo_colors::OwoColorize; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::EnvFilter; use tracing_subscriber::Layer as _; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; use harmont_cli::cli::{self, Cli}; use harmont_cli::context::RunContext; @@ -26,8 +26,7 @@ async fn main() { let cli_layer = CliLayer::real(); let fmt_layer = if args.verbose { - let filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("debug")); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug")); Some( tracing_subscriber::fmt::layer() .with_target(false) diff --git a/crates/hm/src/output/cli_layer.rs b/crates/hm/src/output/cli_layer.rs index 797acab..92a9bb9 100644 --- a/crates/hm/src/output/cli_layer.rs +++ b/crates/hm/src/output/cli_layer.rs @@ -3,8 +3,8 @@ use std::io::Write; use std::sync::{Arc, Mutex}; use tracing::Subscriber; -use tracing_subscriber::layer::Context; use tracing_subscriber::Layer; +use tracing_subscriber::layer::Context; #[derive(Debug, Clone)] pub struct CliLayer { From 233166bea72a079a64eda217590be54439882324 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 24 May 2026 13:16:53 -0700 Subject: [PATCH 12/14] refactor: remove target-based routing, use plain tracing levels Drop CliLayer and all `target: "user::stderr"` / `target: "user::stdout"` usage. Former eprintln! calls now use tracing::error!/warn!/info! based on semantics. Former println! calls use tracing::info!. Subscriber is a single tracing_subscriber::fmt() with --verbose toggling debug level. --- crates/hm-plugin-cloud/src/auth/login.rs | 5 +- crates/hm-plugin-cloud/src/auth/logout.rs | 2 +- crates/hm-plugin-cloud/src/auth/whoami.rs | 1 - crates/hm-plugin-cloud/src/cli.rs | 2 +- crates/hm-plugin-cloud/src/verbs/billing.rs | 12 +- crates/hm-plugin-cloud/src/verbs/build.rs | 7 +- crates/hm-plugin-cloud/src/verbs/job.rs | 4 +- crates/hm-plugin-cloud/src/verbs/org.rs | 2 +- crates/hm-plugin-cloud/src/verbs/pipeline.rs | 3 +- crates/hm-plugin-cloud/src/verbs/run.rs | 2 +- crates/hm/src/cli/plugin.rs | 4 +- crates/hm/src/cli/version.rs | 2 +- crates/hm/src/commands/dev/down.rs | 4 +- crates/hm/src/commands/dev/exec.rs | 6 +- crates/hm/src/commands/dev/logs.rs | 6 +- crates/hm/src/commands/dev/ls.rs | 4 - crates/hm/src/commands/dev/port_of.rs | 17 +-- crates/hm/src/commands/dev/up.rs | 18 +-- crates/hm/src/main.rs | 30 +--- crates/hm/src/orchestrator/signal.rs | 4 +- crates/hm/src/output/cli_layer.rs | 147 ------------------- crates/hm/src/output/format.rs | 22 +-- crates/hm/src/output/mod.rs | 1 - crates/hm/src/output/status.rs | 8 +- 24 files changed, 65 insertions(+), 248 deletions(-) delete mode 100644 crates/hm/src/output/cli_layer.rs diff --git a/crates/hm-plugin-cloud/src/auth/login.rs b/crates/hm-plugin-cloud/src/auth/login.rs index c61b86d..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() { - tracing::info!(target: "user::stderr", "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, ); - tracing::info!(target: "user::stderr", "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. @@ -133,7 +133,6 @@ 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?; tracing::info!( - target: "user::stderr", "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 505d386..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); - tracing::info!(target: "user::stderr", "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 54e9dd8..1679353 100644 --- a/crates/hm-plugin-cloud/src/auth/whoami.rs +++ b/crates/hm-plugin-cloud/src/auth/whoami.rs @@ -17,7 +17,6 @@ pub(crate) async fn run(env: &BTreeMap) -> Result<()> { let client = Client::new(&cfg, Some(token)); let me: User = client.get("/auth/me").await?; tracing::info!( - target: "user::stdout", "{} <{}> (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 c66c737..c3d1144 100644 --- a/crates/hm-plugin-cloud/src/cli.rs +++ b/crates/hm-plugin-cloud/src/cli.rs @@ -195,7 +195,7 @@ pub async fn dispatch_command(command: CloudCommand, env: BTreeMap Ok(0), Err(e) => { - tracing::info!(target: "user::stderr", "{e:#}"); + tracing::error!("{e:#}"); Ok(1) } } diff --git a/crates/hm-plugin-cloud/src/verbs/billing.rs b/crates/hm-plugin-cloud/src/verbs/billing.rs index 32c5888..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; - tracing::info!(target: "user::stdout", "${dollars:.2}"); + tracing::info!("${dollars:.2}"); Ok(()) } @@ -52,7 +52,6 @@ async fn transactions(client: &Client, org: &str, limit: u32) -> Result<()> { .await?; for t in &list.data { tracing::info!( - target: "user::stdout", "{} {:>10} {:<14} {}", t.at.format("%Y-%m-%d %H:%M:%S"), t.amount_cents, @@ -80,7 +79,6 @@ async fn usage(client: &Client, org: &str, from: Option<&str>, to: Option<&str>) .get(&format!("/organizations/{org}/billing/usage{qs}")) .await?; tracing::info!( - target: "user::stdout", "{} -> {}: {:.2} min, ${:.2}", u.from.format("%Y-%m-%d"), u.to.format("%Y-%m-%d"), @@ -101,10 +99,10 @@ async fn topup(client: &Client, org: &str, amount_usd: u32, no_browser: bool) -> ) .await?; if no_browser { - tracing::info!(target: "user::stdout", "{}", r.checkout_url); + tracing::info!("{}", r.checkout_url); } else if webbrowser::open(&r.checkout_url).is_err() { - tracing::info!(target: "user::stderr", "couldn't open browser; URL:"); - tracing::info!(target: "user::stderr", "{}", r.checkout_url); + tracing::warn!("couldn't open browser; URL:"); + tracing::warn!("{}", r.checkout_url); } Ok(()) } @@ -120,7 +118,7 @@ async fn redeem(client: &Client, org: &str, code: &str) -> Result<()> { ) .await?; let dollars = r.credited_cents as f64 / 100.0; - tracing::info!(target: "user::stderr", "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 298fbe9..d5404bb 100644 --- a/crates/hm-plugin-cloud/src/verbs/build.rs +++ b/crates/hm-plugin-cloud/src/verbs/build.rs @@ -32,7 +32,6 @@ async fn list(client: &Client, org: &str, pipe: &str) -> Result<()> { .await?; for b in &builds.data { tracing::info!( - target: "user::stdout", "#{:<5} {:<10} {}", b.number, b.state, @@ -49,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(); - tracing::info!(target: "user::stdout", "{json}"); + tracing::info!("{json}"); Ok(()) } @@ -60,7 +59,7 @@ async fn cancel(client: &Client, org: &str, pipe: &str, number: i64) -> Result<( &serde_json::json!({}), ) .await?; - tracing::info!(target: "user::stderr", "build #{number} cancelled"); + tracing::info!("build #{number} cancelled"); Ok(()) } @@ -73,7 +72,7 @@ async fn watch(client: &Client, org: &str, pipe: &str, number: i64) -> Result<() )) .await?; if b.state != last_state { - tracing::info!(target: "user::stderr", "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 4e78212..2982520 100644 --- a/crates/hm-plugin-cloud/src/verbs/job.rs +++ b/crates/hm-plugin-cloud/src/verbs/job.rs @@ -41,7 +41,6 @@ async fn list(client: &Client, org: &str, pipe: &str, build: i64) -> Result<()> .await?; for j in &jobs.data { tracing::info!( - target: "user::stdout", "{} {:<10} {}", j.id, j.state, @@ -58,7 +57,6 @@ async fn show(client: &Client, org: &str, pipe: &str, build: i64, jid: &str) -> )) .await?; tracing::info!( - target: "user::stdout", "{}", serde_json::to_string_pretty(&j).unwrap_or_default() ); @@ -72,7 +70,7 @@ async fn log_cmd(client: &Client, org: &str, pipe: &str, build: i64, jid: &str) )) .await?; for chunk in &log.data { - tracing::info!(target: "user::stdout", "{}", 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 3609962..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(); - tracing::info!(target: "user::stderr", "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 dae8e26..70ad413 100644 --- a/crates/hm-plugin-cloud/src/verbs/pipeline.rs +++ b/crates/hm-plugin-cloud/src/verbs/pipeline.rs @@ -30,7 +30,6 @@ async fn list(client: &Client, org: &str) -> Result<()> { .await?; for p in &pipes.data { tracing::info!( - target: "user::stdout", "{:<24} {}", p.slug, p.label.as_deref().unwrap_or("(no label)") @@ -50,7 +49,7 @@ async fn show(client: &Client, org: &str, slug: &str) -> Result<()> { "default_branch": p.default_branch, })) .unwrap_or_default(); - tracing::info!(target: "user::stdout", "{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 25ba5da..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 ); - tracing::info!(target: "user::stderr", "submitted build #{}: {url}", build.number); + tracing::info!("submitted build #{}: {url}", build.number); if args.no_watch { return Ok(()); } diff --git a/crates/hm/src/cli/plugin.rs b/crates/hm/src/cli/plugin.rs index 7e99d0c..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<()> { - tracing::info!(target: "user::stdout", "Registered runners:"); - tracing::info!(target: "user::stdout", " 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 f7ccfdf..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<()> { - tracing::info!(target: "user::stdout", "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 a10261f..47720e2 100644 --- a/crates/hm/src/commands/dev/down.rs +++ b/crates/hm/src/commands/dev/down.rs @@ -56,7 +56,7 @@ pub async fn handle(args: DevDownArgs, _ctx: RunContext) -> Result { } if to_remove.is_empty() { - tracing::info!(target: "user::stderr", "[hm] nothing to sweep"); + tracing::info!("[hm] nothing to sweep"); return Ok(0); } @@ -64,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; - tracing::info!(target: "user::stderr", "[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 4fc943c..b6b416b 100644 --- a/crates/hm/src/commands/dev/exec.rs +++ b/crates/hm/src/commands/dev/exec.rs @@ -40,16 +40,14 @@ pub async fn handle(args: DevExecArgs, _ctx: RunContext) -> Result { } } if matches.is_empty() { - tracing::info!( - target: "user::stderr", + tracing::error!( "hm: slug `{}` is not running in this worktree.\n → run `hm dev up {}` first.", args.slug, args.slug, ); return Ok(4); } if matches.len() > 1 { - tracing::info!( - target: "user::stderr", + 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 df12700..b250d90 100644 --- a/crates/hm/src/commands/dev/logs.rs +++ b/crates/hm/src/commands/dev/logs.rs @@ -43,16 +43,14 @@ pub async fn handle(args: DevLogsArgs, _ctx: RunContext) -> Result { } } if matches.is_empty() { - tracing::info!( - target: "user::stderr", + tracing::error!( "hm: slug `{}` is not running in this worktree.\n → run `hm dev up {}` first.", args.slug, args.slug, ); return Ok(4); } if matches.len() > 1 { - tracing::info!( - target: "user::stderr", + 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 6a1519f..d48af9b 100644 --- a/crates/hm/src/commands/dev/ls.rs +++ b/crates/hm/src/commands/dev/ls.rs @@ -25,7 +25,6 @@ pub async fn handle(_ctx: RunContext) -> Result { let docker = DockerClient::connect().ok(); tracing::info!( - target: "user::stdout", "{:<10} {:<8} {:<10} {:<10} PORTS", "SLUG", "DRIVER", "SESSION", "STATUS" ); @@ -59,7 +58,6 @@ pub async fn handle(_ctx: RunContext) -> Result { matched = true; let ports_s = format_ports(ports); tracing::info!( - target: "user::stdout", "{slug:<10} {:<8} {:<10} {:<10} {ports_s}", "local", sess, state ); @@ -67,7 +65,6 @@ pub async fn handle(_ctx: RunContext) -> Result { } if !matched { tracing::info!( - target: "user::stdout", "{slug:<10} {:<8} {:<10} {:<10} \u{2014}", "local", "\u{2014}", "registered" ); @@ -75,7 +72,6 @@ pub async fn handle(_ctx: RunContext) -> Result { } RegEntry::Unhandled => { tracing::info!( - target: "user::stdout", "{slug:<10} {:<8} {:<10} {:<10} (no local driver)", "?", "\u{2014}", "registered" ); diff --git a/crates/hm/src/commands/dev/port_of.rs b/crates/hm/src/commands/dev/port_of.rs index 8e8525e..93ff4bc 100644 --- a/crates/hm/src/commands/dev/port_of.rs +++ b/crates/hm/src/commands/dev/port_of.rs @@ -46,16 +46,14 @@ 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) => { - tracing::info!( - target: "user::stderr", + tracing::error!( "hm: slug `{}` registered but not running in this worktree.\n → run `hm dev up {}` first.", args.slug, args.slug, ); return Ok(4); } _ => { - tracing::info!( - target: "user::stderr", + tracing::error!( "hm: slug `{}` not registered in this worktree's .harmont/.\n → run `hm dev ls` to see registered slugs.", args.slug, ); @@ -64,25 +62,24 @@ pub async fn handle(args: DevPortOfArgs, _ctx: RunContext) -> Result { } } if matches.len() > 1 { - tracing::info!(target: "user::stderr", "hm: slug `{}` matches multiple live sessions in this worktree:", args.slug); + tracing::error!("hm: slug `{}` matches multiple live sessions in this worktree:", args.slug); for (_, sess, ports) in &matches { let p = format_ports(ports); - tracing::info!(target: "user::stderr", " {sess} {p}"); + tracing::error!(" {sess} {p}"); } - tracing::info!(target: "user::stderr", "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 { - tracing::info!( - target: "user::stderr", + tracing::error!( "hm: container port `{}` is not published by `{}`.\n → check the deployment's port_mapping.", args.container_port, args.slug, ); return Ok(5); }; - tracing::info!(target: "user::stdout", "{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 27f0466..a35ca9a 100644 --- a/crates/hm/src/commands/dev/up.rs +++ b/crates/hm/src/commands/dev/up.rs @@ -47,7 +47,7 @@ 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(); - tracing::info!(target: "user::stderr", "[hm] session {session_id}. resolving deployments in .harmont/"); + tracing::info!("[hm] session {session_id}. resolving deployments in .harmont/"); let registry = dump(&worktree_root) .await @@ -57,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?; - tracing::info!(target: "user::stderr", "[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); @@ -96,12 +96,12 @@ pub async fn handle(args: DevUpArgs, _ctx: RunContext) -> Result { } } - tracing::info!(target: "user::stderr", "[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?; - tracing::info!(target: "user::stderr", "[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. @@ -143,7 +143,7 @@ async fn boot_one( .collect(); format!(" | {}", parts.join(", ")) }; - tracing::info!(target: "user::stderr", "[{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(), @@ -163,7 +163,7 @@ async fn resolve_image( ) -> Result { if let Some(tag) = &spec.image { if !docker.image_exists(tag).await? { - tracing::info!(target: "user::stderr", "[{slug}] pulling {tag}..."); + tracing::info!("[{slug}] pulling {tag}..."); docker.pull_image(tag).await?; } return Ok(tag.clone()); @@ -172,7 +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? { - tracing::info!(target: "user::stderr", "[{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); @@ -243,8 +243,8 @@ async fn teardown(docker: &DockerClient, net: &Network, booted: &[Booted]) { for b in booted.iter().rev() { let _ = docker.stop_container(&b.container_id).await; let _ = docker.remove_container(&b.container_id).await; - tracing::info!(target: "user::stderr", "[{}] stopped", b.slug); + tracing::info!("[{}] stopped", b.slug); } let _ = remove_network(docker, net).await; - tracing::info!(target: "user::stderr", "[hm] network {}: removed", net.name); + tracing::info!("[hm] network {}: removed", net.name); } diff --git a/crates/hm/src/main.rs b/crates/hm/src/main.rs index 8bcd92a..ded76bd 100644 --- a/crates/hm/src/main.rs +++ b/crates/hm/src/main.rs @@ -6,39 +6,23 @@ use clap::Parser; use owo_colors::OwoColorize; use tracing_subscriber::EnvFilter; -use tracing_subscriber::Layer as _; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::util::SubscriberInitExt; use harmont_cli::cli::{self, Cli}; use harmont_cli::context::RunContext; use harmont_cli::error::{self, HmError}; -use harmont_cli::output::cli_layer::CliLayer; use harmont_cli::output::status; #[tokio::main] async fn main() { let args = Cli::parse(); - // Build the layered subscriber: - // 1. CliLayer — always active, routes user::stdout / user::stderr - // 2. fmt layer — only active with --verbose, for diagnostic tracing - let cli_layer = CliLayer::real(); + let default_level = if args.verbose { "debug" } else { "info" }; + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level)); - let fmt_layer = if args.verbose { - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug")); - Some( - tracing_subscriber::fmt::layer() - .with_target(false) - .with_filter(filter), - ) - } else { - None - }; - - tracing_subscriber::registry() - .with(cli_layer) - .with(fmt_layer) + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .without_time() .init(); let color_enabled = !args.no_color @@ -69,6 +53,6 @@ fn handle_error(err: &anyhow::Error) -> i32 { let msg = format!("{err:#}"); let red = "error:".red(); let prefix = red.bold(); - tracing::info!(target: "user::stderr", "{prefix} {msg}"); + tracing::error!("{prefix} {msg}"); error::EXIT_BUILD_FAILED } diff --git a/crates/hm/src/orchestrator/signal.rs b/crates/hm/src/orchestrator/signal.rs index 991000e..c85dbd5 100644 --- a/crates/hm/src/orchestrator/signal.rs +++ b/crates/hm/src/orchestrator/signal.rs @@ -28,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) { - tracing::info!(target: "user::stderr", "\nforce-exit on second Ctrl-C"); + tracing::warn!("\nforce-exit on second Ctrl-C"); std::process::exit(130); } - tracing::info!(target: "user::stderr", "\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/cli_layer.rs b/crates/hm/src/output/cli_layer.rs deleted file mode 100644 index 92a9bb9..0000000 --- a/crates/hm/src/output/cli_layer.rs +++ /dev/null @@ -1,147 +0,0 @@ -use std::fmt::Write as FmtWrite; -use std::io::Write; -use std::sync::{Arc, Mutex}; - -use tracing::Subscriber; -use tracing_subscriber::Layer; -use tracing_subscriber::layer::Context; - -#[derive(Debug, Clone)] -pub struct CliLayer { - stdout_sink: Arc>, - stderr_sink: Arc>, -} - -impl CliLayer { - #[must_use] - pub fn real() -> Self { - Self { - stdout_sink: Arc::new(Mutex::new(std::io::stdout())), - stderr_sink: Arc::new(Mutex::new(std::io::stderr())), - } - } -} - -impl CliLayer { - pub fn with_sinks(stdout: O, stderr: E) -> Self { - Self { - stdout_sink: Arc::new(Mutex::new(stdout)), - stderr_sink: Arc::new(Mutex::new(stderr)), - } - } -} - -#[derive(Default)] -struct MessageVisitor(String); - -impl tracing::field::Visit for MessageVisitor { - fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { - if field.name() == "message" { - write!(self.0, "{value:?}").ok(); - } - } -} - -impl Layer for CliLayer -where - S: Subscriber, - O: Write + Send + 'static, - E: Write + Send + 'static, -{ - fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { - let target = event.metadata().target(); - - let is_stdout = target == "user::stdout"; - let is_stderr = target == "user::stderr"; - if !is_stdout && !is_stderr { - return; - } - - let mut visitor = MessageVisitor::default(); - event.record(&mut visitor); - let msg = visitor.0; - - if is_stdout { - if let Ok(mut w) = self.stdout_sink.lock() { - writeln!(w, "{msg}").ok(); - } - } else if let Ok(mut w) = self.stderr_sink.lock() { - writeln!(w, "{msg}").ok(); - } - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::type_complexity)] -mod tests { - use super::*; - use tracing_subscriber::layer::SubscriberExt; - - fn capture_layer() -> ( - CliLayer, Vec>, - Arc>>, - Arc>>, - ) { - let stdout_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); - let stderr_buf: Arc>> = Arc::new(Mutex::new(Vec::new())); - let layer = CliLayer { - stdout_sink: Arc::clone(&stdout_buf), - stderr_sink: Arc::clone(&stderr_buf), - }; - (layer, stdout_buf, stderr_buf) - } - - fn buf_str(buf: &Arc>>) -> String { - String::from_utf8(buf.lock().unwrap().clone()).unwrap() - } - - #[test] - fn routes_stdout_target_to_stdout_sink() { - let (layer, stdout_buf, stderr_buf) = capture_layer(); - let subscriber = tracing_subscriber::registry().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); - tracing::info!(target: "user::stdout", "hello world"); - assert_eq!(buf_str(&stdout_buf), "hello world\n"); - assert!(buf_str(&stderr_buf).is_empty()); - } - - #[test] - fn routes_stderr_target_to_stderr_sink() { - let (layer, stdout_buf, stderr_buf) = capture_layer(); - let subscriber = tracing_subscriber::registry().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); - tracing::info!(target: "user::stderr", "warning msg"); - assert!(buf_str(&stdout_buf).is_empty()); - assert_eq!(buf_str(&stderr_buf), "warning msg\n"); - } - - #[test] - fn ignores_other_targets() { - let (layer, stdout_buf, stderr_buf) = capture_layer(); - let subscriber = tracing_subscriber::registry().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); - tracing::info!("diagnostic message"); - assert!(buf_str(&stdout_buf).is_empty()); - assert!(buf_str(&stderr_buf).is_empty()); - } - - #[test] - fn handles_format_args() { - let (layer, stdout_buf, _) = capture_layer(); - let subscriber = tracing_subscriber::registry().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); - let name = "alice"; - let count = 42; - tracing::info!(target: "user::stdout", "{name} has {count} items"); - assert_eq!(buf_str(&stdout_buf), "alice has 42 items\n"); - } - - #[test] - fn handles_empty_message() { - let (layer, stdout_buf, _) = capture_layer(); - let subscriber = tracing_subscriber::registry().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); - tracing::info!(target: "user::stdout", ""); - assert_eq!(buf_str(&stdout_buf), "\n"); - } -} diff --git a/crates/hm/src/output/format.rs b/crates/hm/src/output/format.rs index d205f01..0411451 100644 --- a/crates/hm/src/output/format.rs +++ b/crates/hm/src/output/format.rs @@ -153,9 +153,9 @@ pub fn hyperlink_with(url: &str, label: &str, enabled: bool) -> String { pub fn header(title: &str) { let rule_len = title.chars().count() + 4; let rule: String = "─".repeat(rule_len); - tracing::info!(target: "user::stdout", ""); - tracing::info!(target: "user::stdout", " {}", title.bold()); - tracing::info!(target: "user::stdout", " {}", rule.bright_black()); + tracing::info!( ""); + tracing::info!( " {}", title.bold()); + tracing::info!( " {}", rule.bright_black()); } /// Print a key/value row, aligned at column 12. @@ -164,15 +164,15 @@ pub fn kv(label: &str, value: impl Display) { // Right-pad to width 10 so values line up at column 12 from start // of line (2 spaces + 10-wide label field = 12). let padded = format!("{label_with_colon:<10}"); - tracing::info!(target: "user::stdout", " {} {value}", padded.bright_black()); + tracing::info!( " {} {value}", padded.bright_black()); } /// Print an empty-state message: blank line, bold title, dim hint, blank line. pub fn empty_state(title: &str, hint: &str) { - tracing::info!(target: "user::stdout", ""); - tracing::info!(target: "user::stdout", " {}", title.bold()); - tracing::info!(target: "user::stdout", " {}", hint.bright_black()); - tracing::info!(target: "user::stdout", ""); + tracing::info!( ""); + tracing::info!( " {}", title.bold()); + tracing::info!( " {}", hint.bright_black()); + tracing::info!( ""); } /// Print a command banner: `▌ hm · `. @@ -183,8 +183,8 @@ pub fn banner(command: &str, subtitle: &str) { let cmd = command.cyan(); let sub_text = format!("· {subtitle}"); let sub = sub_text.bright_black(); - tracing::info!(target: "user::stdout", "{icon} {hm} {cmd} {sub}"); - tracing::info!(target: "user::stdout", ""); + tracing::info!( "{icon} {hm} {cmd} {sub}"); + tracing::info!( ""); } /// Print a single step line: ` ✓ `. @@ -192,7 +192,7 @@ pub fn step(verb: &str, result: impl Display) { let check = "✓".green(); let check = check.bold(); let dim_verb = verb.bright_black(); - tracing::info!(target: "user::stdout", " {check} {dim_verb} {result}"); + tracing::info!( " {check} {dim_verb} {result}"); } #[cfg(test)] diff --git a/crates/hm/src/output/mod.rs b/crates/hm/src/output/mod.rs index 4bdc48f..1d3f8bb 100644 --- a/crates/hm/src/output/mod.rs +++ b/crates/hm/src/output/mod.rs @@ -1,4 +1,3 @@ -pub mod cli_layer; pub mod format; pub mod human; pub mod json; diff --git a/crates/hm/src/output/status.rs b/crates/hm/src/output/status.rs index 8604bbf..07b206d 100644 --- a/crates/hm/src/output/status.rs +++ b/crates/hm/src/output/status.rs @@ -3,23 +3,23 @@ use owo_colors::OwoColorize; /// Print a success message to stdout. pub fn print_success(msg: &str) { let check = format!("{}", "\u{2714}".green().bold()); - tracing::info!(target: "user::stdout", "{check} {msg}"); + tracing::info!("{check} {msg}"); } /// Print a warning message to stderr. pub fn print_warning(msg: &str) { let bang = format!("{}", "!".yellow().bold()); - tracing::info!(target: "user::stderr", "{bang} {msg}"); + tracing::warn!("{bang} {msg}"); } /// Print an error message to stderr. pub fn print_error(msg: &str) { let cross = format!("{}", "\u{2718}".red().bold()); - tracing::info!(target: "user::stderr", "{cross} {msg}"); + tracing::error!("{cross} {msg}"); } /// Print an info message to stdout. pub fn print_info(msg: &str) { let arrow = format!("{}", "\u{25b6}".cyan()); - tracing::info!(target: "user::stdout", "{arrow} {msg}"); + tracing::info!("{arrow} {msg}"); } From 2d75e9def7022f25aac17dee48e852572c664368 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sun, 24 May 2026 13:25:15 -0700 Subject: [PATCH 13/14] remove junk --- crates/hm/src/commands/run/local.rs | 5 - crates/hm/src/output/format.rs | 303 ---------------------------- crates/hm/src/output/mod.rs | 1 - 3 files changed, 309 deletions(-) delete mode 100644 crates/hm/src/output/format.rs 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/output/format.rs b/crates/hm/src/output/format.rs deleted file mode 100644 index 0411451..0000000 --- a/crates/hm/src/output/format.rs +++ /dev/null @@ -1,303 +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. -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 `