From 879fa449a46b5990785e44be6c0d316c05b812bb Mon Sep 17 00:00:00 2001 From: Jesse Luoto Date: Wed, 29 Jul 2026 21:34:06 +0300 Subject: [PATCH 1/2] Add useful verbose diagnostics --- README.md | 2 +- src/cli/anthropic.rs | 5 +- src/cli/gemini.rs | 5 +- src/cli/ollama.rs | 5 +- src/cli/openai.rs | 5 +- src/cli/openrouter.rs | 5 +- src/cli/preset.rs | 170 ++++++++++++++++++++++++++++++++++++++---- src/llm/http.rs | 5 +- src/main.rs | 12 +-- src/rename/mod.rs | 15 +++- src/rename/walker.rs | 20 ++++- tests/cli_smoke.rs | 51 +++++++++++++ 12 files changed, 263 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 0f144adb..6fab66b4 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ humanify [FLAGS] * `--json-mode ` pins a JSON-mode strategy. Options: `ladder` (default), `openai-json-schema`, `anthropic-native`, `forced-tool-call`, `tool-call-and-prompt`, `prompt`. -* `-v` enables verbose stderr logging. +* `-v` prints resolved configuration and identifier-level rename steps to stderr. Run `humanify --help` for the full reference. diff --git a/src/cli/anthropic.rs b/src/cli/anthropic.rs index 3d72082c..8d3b00b6 100644 --- a/src/cli/anthropic.rs +++ b/src/cli/anthropic.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use crate::cli::preset::{run_preset, PresetArgs, PresetDefaults, ProviderKind}; pub const DEFAULTS: PresetDefaults = PresetDefaults { + name: "anthropic", base_url: "https://api.anthropic.com/v1", model: "claude-sonnet-4-6", api_key_env: "ANTHROPIC_API_KEY", @@ -17,8 +18,8 @@ pub struct Args { pub model: Option, pub api_key: Option, pub base_url: Option, - pub context_size: usize, - pub json_mode: String, + pub context_size: Option, + pub json_mode: Option, pub verbose: bool, pub timeout_seconds: Option, } diff --git a/src/cli/gemini.rs b/src/cli/gemini.rs index 4d7016fd..b3ec259d 100644 --- a/src/cli/gemini.rs +++ b/src/cli/gemini.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use crate::cli::preset::{run_preset, PresetArgs, PresetDefaults, ProviderKind}; pub const DEFAULTS: PresetDefaults = PresetDefaults { + name: "gemini", base_url: "https://generativelanguage.googleapis.com/v1beta/openai/", model: "gemini-3.1-flash-lite", api_key_env: "GEMINI_API_KEY", @@ -17,8 +18,8 @@ pub struct Args { pub model: Option, pub api_key: Option, pub base_url: Option, - pub context_size: usize, - pub json_mode: String, + pub context_size: Option, + pub json_mode: Option, pub verbose: bool, pub timeout_seconds: Option, } diff --git a/src/cli/ollama.rs b/src/cli/ollama.rs index fd92adf9..7460e1e1 100644 --- a/src/cli/ollama.rs +++ b/src/cli/ollama.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use crate::cli::preset::{run_preset, PresetArgs, PresetDefaults, ProviderKind}; pub const DEFAULTS: PresetDefaults = PresetDefaults { + name: "ollama", base_url: "http://localhost:11434/v1", model: "qwen3.5:4b", api_key_env: "", @@ -19,8 +20,8 @@ pub struct Args { pub model: Option, pub api_key: Option, pub base_url: Option, - pub context_size: usize, - pub json_mode: String, + pub context_size: Option, + pub json_mode: Option, pub verbose: bool, pub timeout_seconds: Option, } diff --git a/src/cli/openai.rs b/src/cli/openai.rs index ed2b8ce9..c1b10702 100644 --- a/src/cli/openai.rs +++ b/src/cli/openai.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use crate::cli::preset::{run_preset, PresetArgs, PresetDefaults, ProviderKind}; pub const DEFAULTS: PresetDefaults = PresetDefaults { + name: "openai", base_url: "https://api.openai.com/v1", model: "gpt-5-mini", api_key_env: "OPENAI_API_KEY", @@ -17,8 +18,8 @@ pub struct Args { pub model: Option, pub api_key: Option, pub base_url: Option, - pub context_size: usize, - pub json_mode: String, + pub context_size: Option, + pub json_mode: Option, pub verbose: bool, pub timeout_seconds: Option, } diff --git a/src/cli/openrouter.rs b/src/cli/openrouter.rs index 34419e88..8bef521c 100644 --- a/src/cli/openrouter.rs +++ b/src/cli/openrouter.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use crate::cli::preset::{run_preset, PresetArgs, PresetDefaults, ProviderKind}; pub const DEFAULTS: PresetDefaults = PresetDefaults { + name: "openrouter", base_url: "https://openrouter.ai/api/v1", model: "openai/gpt-oss-120b", api_key_env: "OPENROUTER_API_KEY", @@ -16,8 +17,8 @@ pub struct Args { pub model: Option, pub api_key: Option, pub base_url: Option, - pub context_size: usize, - pub json_mode: String, + pub context_size: Option, + pub json_mode: Option, pub verbose: bool, pub timeout_seconds: Option, } diff --git a/src/cli/preset.rs b/src/cli/preset.rs index f1e224a0..f0e54d8f 100644 --- a/src/cli/preset.rs +++ b/src/cli/preset.rs @@ -7,7 +7,10 @@ use crate::llm::{ JsonStrategy, Ladder, LlmRenamer, OpenAIJsonSchema, PromptToJson, ToolCallAndPrompt, }; use crate::pipe; -use crate::rename::{rename_all_identifiers, RenameError}; +use crate::rename::{rename_all_identifiers_with_observer, RenameError, RenameObserver}; + +const DEFAULT_CONTEXT_SIZE: usize = 500; +const DEFAULT_JSON_MODE: &str = "ladder"; pub struct PresetConfig { pub base_url: String, @@ -26,6 +29,7 @@ pub enum ProviderKind { #[derive(Clone, Copy)] pub struct PresetDefaults { + pub name: &'static str, pub base_url: &'static str, pub model: &'static str, pub api_key_env: &'static str, @@ -43,8 +47,8 @@ pub struct PresetArgs { pub model: Option, pub api_key: Option, pub base_url: Option, - pub context_size: usize, - pub json_mode: String, + pub context_size: Option, + pub json_mode: Option, pub verbose: bool, pub timeout_seconds: Option, } @@ -68,7 +72,15 @@ pub fn validate_json_mode_for_provider(mode: &JsonMode, kind: ProviderKind) -> R /// Drives the full pipeline for any preset. Returns process exit code (0 / 1 / 2 / 64). pub fn run_preset(args: PresetArgs, defaults: PresetDefaults) -> i32 { - let json_mode = match JsonMode::parse(&args.json_mode) { + let model_from_cli = args.model.is_some(); + let api_key_from_cli = args.api_key.is_some(); + let base_url_from_cli = args.base_url.is_some(); + let context_size_from_cli = args.context_size.is_some(); + let json_mode_from_cli = args.json_mode.is_some(); + let timeout_from_cli = args.timeout_seconds.is_some(); + + let json_mode_name = args.json_mode.as_deref().unwrap_or(DEFAULT_JSON_MODE); + let json_mode = match JsonMode::parse(json_mode_name) { Ok(m) => m, Err(e) => { eprintln!("humanify: {e}"); @@ -81,17 +93,41 @@ pub fn run_preset(args: PresetArgs, defaults: PresetDefaults) -> i32 { return 64; } + let env_key = if api_key_from_cli { + None + } else { + env_api_key(defaults.api_key_env) + }; let cfg = PresetConfig { base_url: args .base_url .unwrap_or_else(|| defaults.base_url.to_string()), model: args.model.unwrap_or_else(|| defaults.model.to_string()), - api_key: args.api_key.or_else(|| env_api_key(defaults.api_key_env)), + api_key: args.api_key.or(env_key), json_mode, - context_size: args.context_size, + context_size: args.context_size.unwrap_or(DEFAULT_CONTEXT_SIZE), verbose: args.verbose, }; let output = args.output; + let timeout_seconds = args.timeout_seconds.unwrap_or(defaults.timeout_seconds); + + if cfg.verbose { + print_verbose_config( + &args.input, + output.as_deref(), + &cfg, + defaults, + ConfigSources { + model_from_cli, + api_key_from_cli, + base_url_from_cli, + context_size_from_cli, + json_mode_from_cli, + timeout_from_cli, + }, + timeout_seconds, + ); + } let source = match pipe::read_input(&args.input) { Ok(s) => s, @@ -109,16 +145,16 @@ pub fn run_preset(args: PresetArgs, defaults: PresetDefaults) -> i32 { } }; - let timeout = - std::time::Duration::from_secs(args.timeout_seconds.unwrap_or(defaults.timeout_seconds)); + let timeout = std::time::Duration::from_secs(timeout_seconds); let client = HttpClient::with_timeout(timeout); let ladder = Arc::new(build_ladder(client, &cfg, defaults.provider_kind)); let mut renamer = LlmRenamer::new(Arc::clone(&ladder), rt.handle().clone()); + let mut observer = VerboseObserver::new(cfg.verbose, Arc::clone(&ladder)); let context_size = cfg.context_size; let result = rt.block_on(async move { tokio::task::spawn_blocking(move || { - rename_all_identifiers(&source, &mut renamer, context_size) + rename_all_identifiers_with_observer(&source, &mut renamer, context_size, &mut observer) }) .await }); @@ -135,11 +171,6 @@ pub fn run_preset(args: PresetArgs, defaults: PresetDefaults) -> i32 { } }; - if cfg.verbose { - let locked = ladder.locked_strategy_name().unwrap_or("none"); - eprintln!("humanify: locked strategy: {locked}"); - } - if let Err(e) = pipe::write_output(output.as_deref(), &renamed) { eprintln!("humanify: failed to write output: {e}"); return 1; @@ -148,6 +179,113 @@ pub fn run_preset(args: PresetArgs, defaults: PresetDefaults) -> i32 { 0 } +#[derive(Clone, Copy)] +struct ConfigSources { + model_from_cli: bool, + api_key_from_cli: bool, + base_url_from_cli: bool, + context_size_from_cli: bool, + json_mode_from_cli: bool, + timeout_from_cli: bool, +} + +fn print_verbose_config( + input: &str, + output: Option<&std::path::Path>, + cfg: &PresetConfig, + defaults: PresetDefaults, + sources: ConfigSources, + timeout_seconds: u64, +) { + let source = |from_cli| { + if from_cli { + "command line" + } else { + "default" + } + }; + eprintln!("* provider: {}", defaults.name); + eprintln!( + "* model: {} ({})", + cfg.model, + source(sources.model_from_cli) + ); + eprintln!( + "* base URL: {} ({})", + cfg.base_url, + source(sources.base_url_from_cli) + ); + match (&cfg.api_key, sources.api_key_from_cli) { + (Some(_), true) => eprintln!("* API key: set (command line)"), + (Some(_), false) => eprintln!("* API key: set ({})", defaults.api_key_env), + (None, _) => eprintln!("* API key: not set"), + } + eprintln!( + "* JSON mode: {} ({})", + cfg.json_mode.as_str(), + source(sources.json_mode_from_cli) + ); + eprintln!( + "* context size: {} ({})", + cfg.context_size, + source(sources.context_size_from_cli) + ); + eprintln!( + "* timeout: {timeout_seconds}s ({})", + source(sources.timeout_from_cli) + ); + eprintln!("* input: {input}"); + match output { + Some(path) => eprintln!("* output: {}", path.display()), + None => eprintln!("* output: stdout"), + } +} + +struct VerboseObserver { + enabled: bool, + ladder: Arc, + reported_strategy: Option<&'static str>, +} + +impl VerboseObserver { + fn new(enabled: bool, ladder: Arc) -> Self { + Self { + enabled, + ladder, + reported_strategy: None, + } + } + + fn report_strategy_if_changed(&mut self) { + let strategy = self.ladder.locked_strategy_name(); + if strategy.is_some() && strategy != self.reported_strategy { + eprintln!("* selected JSON strategy: {}", strategy.unwrap()); + self.reported_strategy = strategy; + } + } +} + +impl RenameObserver for VerboseObserver { + fn identifiers_found(&mut self, total: usize) { + if self.enabled { + eprintln!("* found {total} identifiers"); + } + } + + fn rename_started(&mut self, current: usize, total: usize, original: &str) { + if self.enabled { + eprintln!("* [{current}/{total}] renaming `{original}`"); + } + } + + fn rename_finished(&mut self, current: usize, total: usize, original: &str, renamed: &str) { + if self.enabled { + self.report_strategy_if_changed(); + eprintln!("* [{current}/{total}] `{original}` -> `{renamed}`"); + } + } +} + fn build_ladder(client: HttpClient, cfg: &PresetConfig, kind: ProviderKind) -> Ladder { match cfg.json_mode { JsonMode::Ladder => build_default_ladder(client, cfg, kind), @@ -492,8 +630,8 @@ mod tests { model: None, api_key: None, base_url: None, - context_size: 500, - json_mode: json_mode.to_string(), + context_size: None, + json_mode: Some(json_mode.to_string()), verbose: false, timeout_seconds: None, } diff --git a/src/llm/http.rs b/src/llm/http.rs index a6647b29..c6f83503 100644 --- a/src/llm/http.rs +++ b/src/llm/http.rs @@ -30,7 +30,10 @@ impl HttpClient { let mut request = self .inner .post(url) - .header("User-Agent", concat!("humanify/", env!("CARGO_PKG_VERSION"))) + .header( + "User-Agent", + concat!("humanify/", env!("CARGO_PKG_VERSION")), + ) .header("Content-Type", "application/json") .header("Accept", "application/json"); diff --git a/src/main.rs b/src/main.rs index b9fa82c4..baa24427 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,13 +45,13 @@ struct SubArgs { #[arg(long)] base_url: Option, - /// Surrounding code chars per identifier - #[arg(long, default_value_t = 500)] - context_size: usize, + /// Surrounding code chars per identifier (default: 500) + #[arg(long)] + context_size: Option, - /// JSON strategy mode - #[arg(long, default_value = "ladder")] - json_mode: String, + /// JSON strategy mode (default: ladder) + #[arg(long)] + json_mode: Option, /// Per-request HTTP timeout in seconds. Overrides the preset default /// (60s for hosted APIs, 1800s for Ollama). diff --git a/src/rename/mod.rs b/src/rename/mod.rs index d64dca4a..572c743e 100644 --- a/src/rename/mod.rs +++ b/src/rename/mod.rs @@ -4,13 +4,26 @@ mod safe_name; pub mod test_dsl; mod walker; -pub use walker::rename_all_identifiers; +pub use walker::{rename_all_identifiers, rename_all_identifiers_with_observer}; pub trait Renamer { /// Returns the new name for the identifier. Returning the same string means "leave it alone". fn rename(&mut self, original: &str, surrounding_code: &str) -> String; } +pub trait RenameObserver { + fn identifiers_found(&mut self, _total: usize) {} + + fn rename_started(&mut self, _current: usize, _total: usize, _original: &str) {} + + fn rename_finished(&mut self, _current: usize, _total: usize, _original: &str, _renamed: &str) { + } +} + +pub struct NoopRenameObserver; + +impl RenameObserver for NoopRenameObserver {} + #[derive(Debug, thiserror::Error)] pub enum RenameError { #[error("failed to parse JavaScript: {0}")] diff --git a/src/rename/walker.rs b/src/rename/walker.rs index 6feeacc9..fea94dc8 100644 --- a/src/rename/walker.rs +++ b/src/rename/walker.rs @@ -8,14 +8,24 @@ use oxc_span::{GetSpan, SourceType}; use oxc_str::Ident; use super::collision::CollisionResolver; -use super::{RenameError, Renamer}; +use super::{NoopRenameObserver, RenameError, RenameObserver, Renamer}; pub fn rename_all_identifiers( source: &str, renamer: &mut dyn Renamer, context_size: usize, +) -> Result { + rename_all_identifiers_with_observer(source, renamer, context_size, &mut NoopRenameObserver) +} + +pub fn rename_all_identifiers_with_observer( + source: &str, + renamer: &mut dyn Renamer, + context_size: usize, + observer: &mut dyn RenameObserver, ) -> Result { if source.is_empty() { + observer.identifiers_found(0); return Ok(String::new()); } @@ -72,11 +82,14 @@ pub fn rename_all_identifiers( // Sort: largest scope first; ties broken by source position (ascending). entries.sort_by(|a, b| b.1.cmp(&a.1).then(a.2.cmp(&b.2))); + let total = entries.len(); + observer.identifiers_found(total); let mut visited: HashSet = HashSet::new(); let mut collisions = CollisionResolver::new(semantic.scoping()); - for (sym_id, _, _) in &entries { + for (index, (sym_id, _, _)) in entries.iter().enumerate() { + let current = index + 1; let sym_id = *sym_id; let original_name = { let scoping = semantic.scoping(); @@ -90,6 +103,7 @@ pub fn rename_all_identifiers( continue; } visited.insert(sym_id); + observer.rename_started(current, total, &original_name); // Compute surrounding code context. let surrounding = { @@ -113,6 +127,7 @@ pub fn rename_all_identifiers( if new_name == original_name { // No rename; short-circuit — skip safe-name pipeline. + observer.rename_finished(current, total, &original_name, &original_name); continue; } @@ -137,6 +152,7 @@ pub fn rename_all_identifiers( semantic .scoping_mut() .rename_symbol(sym_id, scope_id, new_ident); + observer.rename_finished(current, total, &original_name, &safe); } let scoping: Scoping = semantic.into_scoping(); diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 104e58d4..e03f117c 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -25,3 +25,54 @@ fn gemini_offline_identity() { let contents = std::fs::read_to_string(&out_path).unwrap(); assert_eq!(contents.trim(), "const x = 1;"); } + +#[test] +fn verbose_reports_resolved_config_and_rename_steps_to_stderr() { + let out = NamedTempFile::new().unwrap(); + let out_path = out.path().to_owned(); + + let assert = Command::cargo_bin("humanify") + .unwrap() + .args([ + "openai", + "-", + "-o", + out_path.to_str().unwrap(), + "--base-url", + "http://127.0.0.1:1", + "--api-key", + "must-not-be-printed", + "--context-size", + "321", + "--verbose", + ]) + .write_stdin("const x = 1;") + .assert() + .success(); + + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + assert!(stderr.contains("* provider: openai"), "stderr:\n{stderr}"); + assert!( + stderr.contains("* model: gpt-5-mini (default)"), + "stderr:\n{stderr}" + ); + assert!( + stderr.contains("* base URL: http://127.0.0.1:1 (command line)"), + "stderr:\n{stderr}" + ); + assert!( + stderr.contains("* API key: set (command line)"), + "stderr:\n{stderr}" + ); + assert!( + stderr.contains("* context size: 321 (command line)"), + "stderr:\n{stderr}" + ); + assert!( + stderr.contains("* found 1 identifiers"), + "stderr:\n{stderr}" + ); + assert!(stderr.contains("* [1/1] renaming `x`"), "stderr:\n{stderr}"); + assert!(stderr.contains("* [1/1] `x` -> `x`"), "stderr:\n{stderr}"); + assert!(!stderr.contains("must-not-be-printed"), "stderr:\n{stderr}"); +} From 627d30a270c3fa5fca290ce6905c04ed4f1407e6 Mon Sep 17 00:00:00 2001 From: Jesse Luoto Date: Wed, 29 Jul 2026 21:36:13 +0300 Subject: [PATCH 2/2] Add opt-in identifier progress bar --- README.md | 1 + src/cli/anthropic.rs | 2 + src/cli/gemini.rs | 2 + src/cli/ollama.rs | 2 + src/cli/openai.rs | 2 + src/cli/openrouter.rs | 2 + src/cli/preset.rs | 147 ++++++++++++++++++++++++++++++++++++++---- src/main.rs | 11 +++- tests/cli_smoke.rs | 34 ++++++++++ 9 files changed, 189 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6fab66b4..6ba9e52f 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ humanify [FLAGS] `ladder` (default), `openai-json-schema`, `anthropic-native`, `forced-tool-call`, `tool-call-and-prompt`, `prompt`. * `-v` prints resolved configuration and identifier-level rename steps to stderr. +* `--progress` shows an identifier progress bar on stderr. Run `humanify --help` for the full reference. diff --git a/src/cli/anthropic.rs b/src/cli/anthropic.rs index 8d3b00b6..feffd099 100644 --- a/src/cli/anthropic.rs +++ b/src/cli/anthropic.rs @@ -21,6 +21,7 @@ pub struct Args { pub context_size: Option, pub json_mode: Option, pub verbose: bool, + pub progress: bool, pub timeout_seconds: Option, } @@ -35,6 +36,7 @@ impl From for PresetArgs { context_size: a.context_size, json_mode: a.json_mode, verbose: a.verbose, + progress: a.progress, timeout_seconds: a.timeout_seconds, } } diff --git a/src/cli/gemini.rs b/src/cli/gemini.rs index b3ec259d..9c5d9413 100644 --- a/src/cli/gemini.rs +++ b/src/cli/gemini.rs @@ -21,6 +21,7 @@ pub struct Args { pub context_size: Option, pub json_mode: Option, pub verbose: bool, + pub progress: bool, pub timeout_seconds: Option, } @@ -35,6 +36,7 @@ impl From for PresetArgs { context_size: a.context_size, json_mode: a.json_mode, verbose: a.verbose, + progress: a.progress, timeout_seconds: a.timeout_seconds, } } diff --git a/src/cli/ollama.rs b/src/cli/ollama.rs index 7460e1e1..5407c935 100644 --- a/src/cli/ollama.rs +++ b/src/cli/ollama.rs @@ -23,6 +23,7 @@ pub struct Args { pub context_size: Option, pub json_mode: Option, pub verbose: bool, + pub progress: bool, pub timeout_seconds: Option, } @@ -37,6 +38,7 @@ impl From for PresetArgs { context_size: a.context_size, json_mode: a.json_mode, verbose: a.verbose, + progress: a.progress, timeout_seconds: a.timeout_seconds, } } diff --git a/src/cli/openai.rs b/src/cli/openai.rs index c1b10702..ebbe8b94 100644 --- a/src/cli/openai.rs +++ b/src/cli/openai.rs @@ -21,6 +21,7 @@ pub struct Args { pub context_size: Option, pub json_mode: Option, pub verbose: bool, + pub progress: bool, pub timeout_seconds: Option, } @@ -35,6 +36,7 @@ impl From for PresetArgs { context_size: a.context_size, json_mode: a.json_mode, verbose: a.verbose, + progress: a.progress, timeout_seconds: a.timeout_seconds, } } diff --git a/src/cli/openrouter.rs b/src/cli/openrouter.rs index 8bef521c..22a8e01f 100644 --- a/src/cli/openrouter.rs +++ b/src/cli/openrouter.rs @@ -20,6 +20,7 @@ pub struct Args { pub context_size: Option, pub json_mode: Option, pub verbose: bool, + pub progress: bool, pub timeout_seconds: Option, } @@ -34,6 +35,7 @@ impl From for PresetArgs { context_size: a.context_size, json_mode: a.json_mode, verbose: a.verbose, + progress: a.progress, timeout_seconds: a.timeout_seconds, } } diff --git a/src/cli/preset.rs b/src/cli/preset.rs index f0e54d8f..4e819d26 100644 --- a/src/cli/preset.rs +++ b/src/cli/preset.rs @@ -1,4 +1,5 @@ use std::env; +use std::io::{self, IsTerminal, Write}; use std::path::PathBuf; use std::sync::Arc; @@ -50,6 +51,7 @@ pub struct PresetArgs { pub context_size: Option, pub json_mode: Option, pub verbose: bool, + pub progress: bool, pub timeout_seconds: Option, } @@ -149,7 +151,7 @@ pub fn run_preset(args: PresetArgs, defaults: PresetDefaults) -> i32 { let client = HttpClient::with_timeout(timeout); let ladder = Arc::new(build_ladder(client, &cfg, defaults.provider_kind)); let mut renamer = LlmRenamer::new(Arc::clone(&ladder), rt.handle().clone()); - let mut observer = VerboseObserver::new(cfg.verbose, Arc::clone(&ladder)); + let mut observer = CliObserver::new(cfg.verbose, args.progress, Arc::clone(&ladder)); let context_size = cfg.context_size; let result = rt.block_on(async move { @@ -241,51 +243,145 @@ fn print_verbose_config( } } -struct VerboseObserver { - enabled: bool, +const PROGRESS_BAR_WIDTH: usize = 30; + +struct CliObserver { + verbose: bool, + progress: bool, + progress_is_terminal: bool, + completed: usize, + total: usize, + last_logged_percent: Option, + displayed_width: usize, ladder: Arc, reported_strategy: Option<&'static str>, } -impl VerboseObserver { - fn new(enabled: bool, ladder: Arc) -> Self { +impl CliObserver { + fn new(verbose: bool, progress: bool, ladder: Arc) -> Self { Self { - enabled, + verbose, + progress, + progress_is_terminal: io::stderr().is_terminal(), + completed: 0, + total: 0, + last_logged_percent: None, + displayed_width: 0, ladder, reported_strategy: None, } } - fn report_strategy_if_changed(&mut self) { + fn take_changed_strategy(&mut self) -> Option<&'static str> { let strategy = self.ladder.locked_strategy_name(); if strategy.is_some() && strategy != self.reported_strategy { - eprintln!("* selected JSON strategy: {}", strategy.unwrap()); self.reported_strategy = strategy; + strategy + } else { + None + } + } + + fn clear_terminal_progress(&mut self) { + if !self.progress || !self.progress_is_terminal || self.displayed_width == 0 { + return; + } + + eprint!( + "\r{blank:width$}\r", + blank = "", + width = self.displayed_width + ); + let _ = io::stderr().flush(); + self.displayed_width = 0; + } + + fn draw_terminal_progress(&mut self) { + if !self.progress || !self.progress_is_terminal { + return; + } + + let line = render_progress(self.completed, self.total); + eprint!("\r{line}"); + let _ = io::stderr().flush(); + self.displayed_width = line.len(); + if self.completed == self.total { + eprintln!(); + self.displayed_width = 0; + } + } + + fn log_progress_snapshot(&mut self) { + if !self.progress || self.progress_is_terminal { + return; + } + + let percent = self + .completed + .saturating_mul(100) + .checked_div(self.total) + .unwrap_or(100); + let should_log = self.last_logged_percent.is_none() + || self.completed == self.total + || percent >= self.last_logged_percent.unwrap_or(0) + 10; + if should_log { + eprintln!("{}", render_progress(self.completed, self.total)); + self.last_logged_percent = Some(percent); } } } -impl RenameObserver for VerboseObserver { +impl RenameObserver for CliObserver { fn identifiers_found(&mut self, total: usize) { - if self.enabled { + self.total = total; + if self.verbose { eprintln!("* found {total} identifiers"); } + self.draw_terminal_progress(); + self.log_progress_snapshot(); } fn rename_started(&mut self, current: usize, total: usize, original: &str) { - if self.enabled { + if self.verbose { + self.clear_terminal_progress(); eprintln!("* [{current}/{total}] renaming `{original}`"); + self.draw_terminal_progress(); } } fn rename_finished(&mut self, current: usize, total: usize, original: &str, renamed: &str) { - if self.enabled { - self.report_strategy_if_changed(); + if self.verbose { + self.clear_terminal_progress(); + if let Some(strategy) = self.take_changed_strategy() { + eprintln!("* selected JSON strategy: {strategy}"); + } eprintln!("* [{current}/{total}] `{original}` -> `{renamed}`"); } + self.completed = current; + self.total = total; + self.draw_terminal_progress(); + self.log_progress_snapshot(); } } +fn render_progress(completed: usize, total: usize) -> String { + let completed = completed.min(total); + let filled = completed + .saturating_mul(PROGRESS_BAR_WIDTH) + .checked_div(total) + .unwrap_or(PROGRESS_BAR_WIDTH); + let bar = if completed == total { + "=".repeat(PROGRESS_BAR_WIDTH) + } else { + format!( + "{}>{}", + "=".repeat(filled), + "-".repeat(PROGRESS_BAR_WIDTH - filled - 1) + ) + }; + format!("[{bar}] {completed}/{total} identifiers") +} + fn build_ladder(client: HttpClient, cfg: &PresetConfig, kind: ProviderKind) -> Ladder { match cfg.json_mode { JsonMode::Ladder => build_default_ladder(client, cfg, kind), @@ -633,6 +729,7 @@ mod tests { context_size: None, json_mode: Some(json_mode.to_string()), verbose: false, + progress: false, timeout_seconds: None, } } @@ -677,4 +774,28 @@ mod tests { ); assert_eq!(ladder.strategy_count(), 1); } + + #[test] + fn progress_bar_starts_empty() { + assert_eq!( + render_progress(0, 4), + "[>-----------------------------] 0/4 identifiers" + ); + } + + #[test] + fn progress_bar_shows_partial_completion() { + assert_eq!( + render_progress(2, 4), + "[===============>--------------] 2/4 identifiers" + ); + } + + #[test] + fn progress_bar_finishes_full() { + assert_eq!( + render_progress(4, 4), + "[==============================] 4/4 identifiers" + ); + } } diff --git a/src/main.rs b/src/main.rs index baa24427..f7bb6a83 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,9 +58,13 @@ struct SubArgs { #[arg(long)] timeout_seconds: Option, - /// Debug log to stderr + /// Show resolved configuration and rename steps on stderr #[arg(short, long)] verbose: bool, + + /// Show an identifier progress bar on stderr + #[arg(long)] + progress: bool, } fn into_openai_args(a: SubArgs) -> openai::Args { @@ -73,6 +77,7 @@ fn into_openai_args(a: SubArgs) -> openai::Args { context_size: a.context_size, json_mode: a.json_mode, verbose: a.verbose, + progress: a.progress, timeout_seconds: a.timeout_seconds, } } @@ -87,6 +92,7 @@ fn into_gemini_args(a: SubArgs) -> gemini::Args { context_size: a.context_size, json_mode: a.json_mode, verbose: a.verbose, + progress: a.progress, timeout_seconds: a.timeout_seconds, } } @@ -101,6 +107,7 @@ fn into_anthropic_args(a: SubArgs) -> anthropic::Args { context_size: a.context_size, json_mode: a.json_mode, verbose: a.verbose, + progress: a.progress, timeout_seconds: a.timeout_seconds, } } @@ -115,6 +122,7 @@ fn into_ollama_args(a: SubArgs) -> ollama::Args { context_size: a.context_size, json_mode: a.json_mode, verbose: a.verbose, + progress: a.progress, timeout_seconds: a.timeout_seconds, } } @@ -129,6 +137,7 @@ fn into_openrouter_args(a: SubArgs) -> openrouter::Args { context_size: a.context_size, json_mode: a.json_mode, verbose: a.verbose, + progress: a.progress, timeout_seconds: a.timeout_seconds, } } diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index e03f117c..239dc7a6 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -76,3 +76,37 @@ fn verbose_reports_resolved_config_and_rename_steps_to_stderr() { assert!(stderr.contains("* [1/1] `x` -> `x`"), "stderr:\n{stderr}"); assert!(!stderr.contains("must-not-be-printed"), "stderr:\n{stderr}"); } + +#[test] +fn progress_writes_plain_snapshots_to_redirected_stderr() { + let out = NamedTempFile::new().unwrap(); + let out_path = out.path().to_owned(); + + let assert = Command::cargo_bin("humanify") + .unwrap() + .args([ + "openai", + "-", + "-o", + out_path.to_str().unwrap(), + "--base-url", + "http://127.0.0.1:1", + "--progress", + ]) + .write_stdin("const x = 1; const y = 2;") + .assert() + .success(); + + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + assert!( + stderr.contains("[>-----------------------------] 0/2 identifiers"), + "stderr:\n{stderr}" + ); + assert!( + stderr.contains("[==============================] 2/2 identifiers"), + "stderr:\n{stderr}" + ); + assert!(!stderr.contains('\r'), "stderr:\n{stderr}"); + assert!(!stderr.contains("* provider:"), "stderr:\n{stderr}"); + assert!(assert.get_output().stdout.is_empty()); +}