From d6c918f09e12b3b4dfaf0d150436782a2fc774f7 Mon Sep 17 00:00:00 2001 From: grave0x Date: Mon, 27 Jul 2026 07:43:09 +0930 Subject: [PATCH] Add verbose mode, logging levels, log file output - Added --log-file CLI flag for file output - Added --log-level CLI flag (error/warn/info/debug/trace) - -v sets debug, -vv sets trace (affects both tracing and Output) - Output now has log level awareness: debug(), trace() methods - Output respects level for filtering (quiet=error only, default=info) - All 17 Output::new() callers updated for log_level parameter - tracing_subscriber upgraded: file output, no targets, configurable level Closes #26 --- crates/dwell-cli/src/commands.rs | 6 +- crates/dwell-cli/src/commands/add.rs | 2 +- crates/dwell-cli/src/commands/apply.rs | 2 +- crates/dwell-cli/src/commands/deploy.rs | 2 +- crates/dwell-cli/src/commands/deps.rs | 2 +- crates/dwell-cli/src/commands/diff.rs | 2 +- crates/dwell-cli/src/commands/init.rs | 2 +- crates/dwell-cli/src/commands/module_cmd.rs | 2 +- crates/dwell-cli/src/commands/package.rs | 8 +- crates/dwell-cli/src/commands/reset.rs | 2 +- crates/dwell-cli/src/commands/setup.rs | 2 +- crates/dwell-cli/src/commands/status.rs | 2 +- crates/dwell-cli/src/commands/sync.rs | 2 +- crates/dwell-cli/src/commands/watch.rs | 2 +- crates/dwell-cli/src/main.rs | 51 +++++++++--- crates/dwell-cli/src/output.rs | 90 +++++++++++---------- 16 files changed, 108 insertions(+), 71 deletions(-) diff --git a/crates/dwell-cli/src/commands.rs b/crates/dwell-cli/src/commands.rs index 329516c..996b244 100644 --- a/crates/dwell-cli/src/commands.rs +++ b/crates/dwell-cli/src/commands.rs @@ -20,6 +20,7 @@ pub struct CliRef { pub verbose: u8, pub quiet: bool, pub json: bool, + pub log_level: Option, } pub fn run(cli: Cli) -> dwell_core::Result<()> { @@ -29,6 +30,8 @@ pub fn run(cli: Cli) -> dwell_core::Result<()> { verbose, quiet, json, + log_file: _, + log_level, command, } = cli; let cli_ref = CliRef { @@ -36,6 +39,7 @@ pub fn run(cli: Cli) -> dwell_core::Result<()> { verbose, quiet, json, + log_level, }; let cfg = dwell_core::Config::load(&_config)?; @@ -120,7 +124,7 @@ mod doctor { use crate::output::Output; pub fn run(_cli: &CliRef) { - let out = Output::new(_cli.json, _cli.verbose, _cli.quiet); + let out = Output::new(_cli.json, _cli.verbose, _cli.quiet, _cli.log_level.clone()); out.title("dwell doctor — system health check"); for (name, default) in &[ diff --git a/crates/dwell-cli/src/commands/add.rs b/crates/dwell-cli/src/commands/add.rs index 455b3c1..3a18f87 100644 --- a/crates/dwell-cli/src/commands/add.rs +++ b/crates/dwell-cli/src/commands/add.rs @@ -5,7 +5,7 @@ use crate::output::Output; pub fn run(cli: &CliRef, _cfg: &dwell_core::Config, path: PathBuf) -> dwell_core::Result<()> { let _ = _cfg; - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); let source_dir = resolve_source(cli, None)?; let home = resolve_home(); diff --git a/crates/dwell-cli/src/commands/apply.rs b/crates/dwell-cli/src/commands/apply.rs index 9aa2b6b..526aaba 100644 --- a/crates/dwell-cli/src/commands/apply.rs +++ b/crates/dwell-cli/src/commands/apply.rs @@ -10,7 +10,7 @@ pub fn run( force: bool, ) -> dwell_core::Result<()> { let _ = _cfg; - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); let source_dir = resolve_source(cli, source)?; let home = resolve_home(); diff --git a/crates/dwell-cli/src/commands/deploy.rs b/crates/dwell-cli/src/commands/deploy.rs index b15f548..8d7ce87 100644 --- a/crates/dwell-cli/src/commands/deploy.rs +++ b/crates/dwell-cli/src/commands/deploy.rs @@ -11,7 +11,7 @@ pub fn run( no_packages: bool, dry_run: bool, ) -> dwell_core::Result<()> { - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); let source_dir = crate::commands::resolve_source(cli, source)?; let home = crate::commands::resolve_home(); diff --git a/crates/dwell-cli/src/commands/deps.rs b/crates/dwell-cli/src/commands/deps.rs index 16b1080..caddf5b 100644 --- a/crates/dwell-cli/src/commands/deps.rs +++ b/crates/dwell-cli/src/commands/deps.rs @@ -11,7 +11,7 @@ pub fn run( _cfg: &dwell_core::Config, action: crate::DepsCommand, ) -> dwell_core::Result<()> { - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); match action { crate::DepsCommand::Scan { source } => cmd_scan(&out, source), diff --git a/crates/dwell-cli/src/commands/diff.rs b/crates/dwell-cli/src/commands/diff.rs index 9fee9ee..e7dc42e 100644 --- a/crates/dwell-cli/src/commands/diff.rs +++ b/crates/dwell-cli/src/commands/diff.rs @@ -9,7 +9,7 @@ pub fn run( source: Option, ) -> dwell_core::Result<()> { let _ = _cfg; - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); let source_dir = resolve_source(cli, source)?; let home = resolve_home(); diff --git a/crates/dwell-cli/src/commands/init.rs b/crates/dwell-cli/src/commands/init.rs index 98eb030..c8c196a 100644 --- a/crates/dwell-cli/src/commands/init.rs +++ b/crates/dwell-cli/src/commands/init.rs @@ -14,7 +14,7 @@ pub fn run( ) -> dwell_core::Result<()> { let _ = _cfg; let _ = _remote_url; - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); let source_dir = source.unwrap_or_else(|| { dirs::data_dir() diff --git a/crates/dwell-cli/src/commands/module_cmd.rs b/crates/dwell-cli/src/commands/module_cmd.rs index 9dd7129..1e0f06c 100644 --- a/crates/dwell-cli/src/commands/module_cmd.rs +++ b/crates/dwell-cli/src/commands/module_cmd.rs @@ -8,7 +8,7 @@ pub fn run( _cfg: &dwell_core::Config, action: crate::ModuleCommand, ) -> dwell_core::Result<()> { - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); let reg = ModuleRegistry::new(); match action { diff --git a/crates/dwell-cli/src/commands/package.rs b/crates/dwell-cli/src/commands/package.rs index dc399b6..a7447d7 100644 --- a/crates/dwell-cli/src/commands/package.rs +++ b/crates/dwell-cli/src/commands/package.rs @@ -17,7 +17,7 @@ pub fn run( /// Install all declared system packages. fn cmd_install(cli: &CliRef, cfg: &dwell_core::Config) -> dwell_core::Result<()> { - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); let registry = dwell_package::PackageManagerRegistry::new(); let backend = registry.detect().ok_or_else(|| { @@ -65,7 +65,7 @@ fn cmd_install(cli: &CliRef, cfg: &dwell_core::Config) -> dwell_core::Result<()> /// List all installed packages per backend, marking declared ones with `*`. fn cmd_list(cli: &CliRef, cfg: &dwell_core::Config) -> dwell_core::Result<()> { - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); let registry = dwell_package::PackageManagerRegistry::new(); let declared: Vec<&str> = cfg.packages.system.iter().map(|s| s.as_str()).collect(); @@ -101,7 +101,7 @@ fn cmd_list(cli: &CliRef, cfg: &dwell_core::Config) -> dwell_core::Result<()> { /// Remove all declared packages via the first available backend. fn cmd_remove(cli: &CliRef, cfg: &dwell_core::Config) -> dwell_core::Result<()> { - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); let registry = dwell_package::PackageManagerRegistry::new(); let backend = registry.detect().ok_or_else(|| { @@ -147,7 +147,7 @@ fn cmd_remove(cli: &CliRef, cfg: &dwell_core::Config) -> dwell_core::Result<()> /// Show package drift — declared vs installed. fn cmd_diff(cli: &CliRef, cfg: &dwell_core::Config) -> dwell_core::Result<()> { - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); let registry = dwell_package::PackageManagerRegistry::new(); let backend = registry.detect().ok_or_else(|| { diff --git a/crates/dwell-cli/src/commands/reset.rs b/crates/dwell-cli/src/commands/reset.rs index 26e63da..e5a8d21 100644 --- a/crates/dwell-cli/src/commands/reset.rs +++ b/crates/dwell-cli/src/commands/reset.rs @@ -15,7 +15,7 @@ pub fn run( source: Option, dry_run: bool, ) -> dwell_core::Result<()> { - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); let source_dir = crate::commands::resolve_source(cli, source)?; let home = crate::commands::resolve_home(); diff --git a/crates/dwell-cli/src/commands/setup.rs b/crates/dwell-cli/src/commands/setup.rs index 217ed0c..0448109 100644 --- a/crates/dwell-cli/src/commands/setup.rs +++ b/crates/dwell-cli/src/commands/setup.rs @@ -95,7 +95,7 @@ pub fn run( _cfg: &dwell_core::Config, action: crate::SetupCommand, ) -> dwell_core::Result<()> { - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); match action { crate::SetupCommand::Capture { diff --git a/crates/dwell-cli/src/commands/status.rs b/crates/dwell-cli/src/commands/status.rs index 80cab9b..d850953 100644 --- a/crates/dwell-cli/src/commands/status.rs +++ b/crates/dwell-cli/src/commands/status.rs @@ -9,7 +9,7 @@ pub fn run( source: Option, ) -> dwell_core::Result<()> { let _ = _cfg; - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); let source_dir = resolve_source(cli, source)?; out.info(&format!("Checking status of {}", source_dir.display())); diff --git a/crates/dwell-cli/src/commands/sync.rs b/crates/dwell-cli/src/commands/sync.rs index 8a0fd82..b839df8 100644 --- a/crates/dwell-cli/src/commands/sync.rs +++ b/crates/dwell-cli/src/commands/sync.rs @@ -10,7 +10,7 @@ pub fn run( source: Option, dry_run: bool, ) -> dwell_core::Result<()> { - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); let source_dir = crate::commands::resolve_source(cli, source)?; let git_dir = source_dir.join(".git"); diff --git a/crates/dwell-cli/src/commands/watch.rs b/crates/dwell-cli/src/commands/watch.rs index 13d1e3b..9fdd43a 100644 --- a/crates/dwell-cli/src/commands/watch.rs +++ b/crates/dwell-cli/src/commands/watch.rs @@ -9,7 +9,7 @@ pub fn run( source: Option, ) -> dwell_core::Result<()> { let _ = _cfg; - let out = Output::new(cli.json, cli.verbose, cli.quiet); + let out = Output::new(cli.json, cli.verbose, cli.quiet, cli.log_level.clone()); let source_dir = resolve_source(cli, source)?; out.info(&format!("Watching {}", source_dir.display())); diff --git a/crates/dwell-cli/src/main.rs b/crates/dwell-cli/src/main.rs index 115ce26..88ee13d 100644 --- a/crates/dwell-cli/src/main.rs +++ b/crates/dwell-cli/src/main.rs @@ -32,6 +32,14 @@ pub struct Cli { #[arg(long)] pub json: bool, + /// Log file path (default: stderr) + #[arg(long)] + pub log_file: Option, + + /// Log level override [possible values: error, warn, info, debug, trace] + #[arg(long)] + pub log_level: Option, + #[command(subcommand)] pub command: Commands, } @@ -326,21 +334,40 @@ pub enum GenerationCommand { fn main() { let cli = Cli::parse(); - // Configure logging - let level = match cli.verbose { - 0 => "warn", - 1 => "info", - 2 => "debug", - _ => "trace", - }; - - tracing_subscriber::fmt() + // Determine log level + let level = cli.log_level.clone().unwrap_or_else(|| { + match cli.verbose { + 0 => if cli.quiet { "error" } else { "warn" }, + 1 => "info", + 2 => "debug", + _ => "trace", + }.to_string() + }); + + // Configure tracing subscriber with optional file output + let builder = tracing_subscriber::fmt() .with_env_filter(format!("dwell={}", level)) - .with_writer(std::io::stderr) - .without_time() - .init(); + .with_target(false) + .without_time(); + + if let Some(ref log_path) = cli.log_file { + use std::fs::OpenOptions; + let file = OpenOptions::new() + .create(true) + .append(true) + .open(log_path) + .unwrap_or_else(|e| { + eprintln!("dwell: warning: cannot open log file {}: {}", log_path.display(), e); + // Fallback to stderr using /dev/null as a dummy that won't matter + std::fs::File::create("/dev/null").unwrap() + }); + builder.with_writer(std::sync::Mutex::new(file)).init(); + } else { + builder.with_writer(std::io::stderr).init(); + } if let Err(e) = commands::run(cli) { + // Always print fatal errors to stderr regardless of log config eprintln!("dwell: error: {}", e); std::process::exit(1); } diff --git a/crates/dwell-cli/src/output.rs b/crates/dwell-cli/src/output.rs index 5c97a0e..7e9967a 100644 --- a/crates/dwell-cli/src/output.rs +++ b/crates/dwell-cli/src/output.rs @@ -6,22 +6,49 @@ pub struct Output { pub json_mode: bool, pub verbose: u8, pub quiet: bool, + level: u8, // 0=quiet, 1=error, 2=warn, 3=info(default), 4=debug, 5=trace } impl Output { - pub fn new(json_mode: bool, verbose: u8, quiet: bool) -> Self { - Output { - json_mode, - verbose, - quiet, + pub fn new(json_mode: bool, verbose: u8, quiet: bool, log_level: Option) -> Self { + let level = log_level + .and_then(|l| match l.as_str() { + "error" => Some(1), + "warn" => Some(2), + "info" => Some(3), + "debug" => Some(4), + "trace" => Some(5), + _ => None, + }) + .unwrap_or_else(|| { + if quiet { 1 } else { 3u8.saturating_add(verbose).min(5) } + }); + Output { json_mode, verbose, quiet, level } + } + + /// Debug — dim blue dot, only at -v or --log-level=debug + pub fn debug(&self, msg: &str) { + if self.level < 4 { return; } + if self.json_mode { + println!(r#"{{"level":"debug","message":"{}"}}"#, msg); + } else { + eprintln!(" {} {}", style("·").blue().dim(), style(msg).dim()); + } + } + + /// Trace — dim magenta hash, only at -vv or --log-level=trace + pub fn trace(&self, msg: &str) { + if self.level < 5 { return; } + if self.json_mode { + println!(r#"{{"level":"trace","message":"{}"}}"#, msg); + } else { + eprintln!(" {} {}", style("#").magenta().dim(), style(msg).dim()); } } /// Section header — bright cyan, bold pub fn section(&self, msg: &str) { - if self.quiet { - return; - } + if self.level < 3 { return; } if self.json_mode { println!(r#"{{"level":"section","message":"{}"}}"#, msg); } else { @@ -31,9 +58,7 @@ impl Output { /// Info — blue arrow, dim message pub fn info(&self, msg: &str) { - if self.quiet { - return; - } + if self.level < 3 { return; } if self.json_mode { println!(r#"{{"level":"info","message":"{}"}}"#, msg); } else { @@ -43,9 +68,7 @@ impl Output { /// Success — green checkmark, green message pub fn success(&self, msg: &str) { - if self.quiet { - return; - } + if self.level < 3 { return; } if self.json_mode { println!(r#"{{"level":"success","message":"{}"}}"#, msg); } else { @@ -55,6 +78,7 @@ impl Output { /// Warning — yellow exclamation, yellow message pub fn warn(&self, msg: &str) { + if self.level < 2 { return; } if self.json_mode { println!(r#"{{"level":"warn","message":"{}"}}"#, msg); } else { @@ -64,6 +88,7 @@ impl Output { /// Error — red X, red message pub fn error(&self, msg: &str) { + if self.level < 1 { return; } if self.json_mode { println!(r#"{{"level":"error","message":"{}"}}"#, msg); } else { @@ -73,26 +98,21 @@ impl Output { /// Step indicator — bold magenta for phase numbers pub fn step(&self, phase: &str, msg: &str) { - if self.quiet { - return; - } + if self.level < 3 { return; } if self.json_mode { println!(r#"{{"level":"step","phase":"{}","message":"{}"}}"#, phase, msg); } else { - eprintln!(" {} {}", - style(phase).magenta().bright(), - style(msg).bold(), - ); + eprintln!(" {} {}", style(phase).magenta().bright(), style(msg).bold()); } } /// Apply result — colored per action type pub fn apply_result(&self, result: &dwell_core::ApplyResult) { + if self.level < 3 { return; } if self.json_mode { println!("{}", serde_json::to_string(result).unwrap_or_default()); return; } - let icon = match result.action { dwell_core::ApplyAction::Created => style("+").green(), dwell_core::ApplyAction::Updated => style("~").yellow(), @@ -100,25 +120,18 @@ impl Output { dwell_core::ApplyAction::Skipped => style("·").dim(), dwell_core::ApplyAction::Symlinked => style("@").cyan(), }; - let path = style(result.path.display()).bold(); if result.success { eprintln!(" {} {}", icon, path); } else { let err = result.error.as_deref().unwrap_or("unknown error"); - eprintln!(" {} {} — {}", - style("✗").red().bright(), - path, - style(err).red().dim(), - ); + eprintln!(" {} {} — {}", style("✗").red().bright(), path, style(err).red().dim()); } } /// Title bar — prominent header with surrounding lines pub fn title(&self, msg: &str) { - if self.quiet { - return; - } + if self.level < 3 { return; } if self.json_mode { println!(r#"{{"level":"title","message":"{}"}}"#, msg); } else { @@ -129,26 +142,19 @@ impl Output { } } - /// Show a colored key-value pair + /// Colored key-value pair pub fn kv(&self, key: &str, value: &str) { - if self.quiet { - return; - } + if self.level < 4 { return; } if self.json_mode { println!(r#"{{"level":"kv","key":"{}","value":"{}"}}"#, key, value); } else { - eprintln!(" {} {}", - style(key).cyan().dim(), - style(value).bold(), - ); + eprintln!(" {} {}", style(key).cyan().dim(), style(value).bold()); } } /// File path — underlined for emphasis pub fn path(&self, path: &str) { - if self.quiet { - return; - } + if self.level < 3 { return; } if self.json_mode { println!(r#"{{"level":"path","path":"{}"}}"#, path); } else {