From 71573ad9ec20fd324c6b92eccf6d56c5e2d7def3 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 28 Mar 2026 17:08:57 -0400 Subject: [PATCH] Add Printer enum and warn_user! macros for unified verbosity control Introduces a uv-style Printer enum (Silent/Quiet/Default/Verbose) and warn_user!/warn_user_once! macros in fabro-util, wires --quiet/--verbose global flags into the CLI, and converts the `fabro init` deprecation warning as a proof of concept. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-cli/src/args.rs | 8 ++ lib/crates/fabro-cli/src/main.rs | 32 +++++- lib/crates/fabro-util/src/lib.rs | 6 + lib/crates/fabro-util/src/printer.rs | 155 ++++++++++++++++++++++++++ lib/crates/fabro-util/src/warnings.rs | 57 ++++++++++ 5 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 lib/crates/fabro-util/src/printer.rs create mode 100644 lib/crates/fabro-util/src/warnings.rs diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 48f18e2dc..dae4410c0 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -28,6 +28,14 @@ pub(crate) struct GlobalArgs { #[arg(long, global = true)] pub no_upgrade_check: bool, + /// Suppress non-essential output + #[arg(long, global = true, conflicts_with = "verbose")] + pub quiet: bool, + + /// Enable verbose output + #[arg(long, global = true, conflicts_with = "quiet")] + pub verbose: bool, + /// Execution mode: standalone (in-process) or server (delegate to API) #[cfg(feature = "server")] #[arg(long, global = true, value_parser = parse_execution_mode)] diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 06c9e60d9..bd5773840 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -12,6 +12,7 @@ use anyhow::Result; use args::{Commands, GlobalArgs, LONG_VERSION, RunCommands}; use clap::Parser; use fabro_telemetry::{git, panic as tel_panic, sanitize, sender}; +use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use rustls::crypto::ring::default_provider; use tracing::debug; @@ -98,6 +99,7 @@ async fn main_inner() -> (String, Result<()>) { } let Cli { globals, command } = cli; + let _printer = Printer::from_flags(globals.quiet, globals.verbose); let command_name = command.name().to_string(); let (config_log_level, upgrade_check_enabled) = { @@ -197,10 +199,7 @@ async fn main_inner() -> (String, Result<()>) { } Commands::Repo(ns) => commands::repo::dispatch(ns).await?, Commands::Init => { - eprintln!( - "{} `fabro init` is deprecated, use `fabro repo init` instead", - console::Style::new().yellow().apply_to("warning:") - ); + fabro_util::warn_user!("`fabro init` is deprecated, use `fabro repo init` instead"); commands::repo::init::run_init().await?; } Commands::Install { web_url } => { @@ -421,4 +420,29 @@ mod tests { _ => panic!("unexpected command variant"), } } + + #[test] + fn parse_quiet_flag() { + let cli = + Cli::try_parse_from(["fabro", "--quiet", "config", "show"]).expect("should parse"); + assert!(cli.globals.quiet); + assert!(!cli.globals.verbose); + } + + #[test] + fn parse_verbose_flag() { + let cli = + Cli::try_parse_from(["fabro", "--verbose", "config", "show"]).expect("should parse"); + assert!(!cli.globals.quiet); + assert!(cli.globals.verbose); + } + + #[test] + fn quiet_and_verbose_conflict() { + let result = Cli::try_parse_from(["fabro", "--quiet", "--verbose", "config", "show"]); + assert!( + result.is_err(), + "should fail when both --quiet and --verbose" + ); + } } diff --git a/lib/crates/fabro-util/src/lib.rs b/lib/crates/fabro-util/src/lib.rs index f55f9500a..5b7c7d14e 100644 --- a/lib/crates/fabro-util/src/lib.rs +++ b/lib/crates/fabro-util/src/lib.rs @@ -2,8 +2,14 @@ pub mod backoff; pub mod check_report; pub mod env; pub mod path; +pub mod printer; pub mod redact; pub mod run_log; pub mod terminal; pub mod text; pub mod version; +pub mod warnings; + +#[doc(hidden)] +pub use console; +pub use warnings::WARNINGS; diff --git a/lib/crates/fabro-util/src/printer.rs b/lib/crates/fabro-util/src/printer.rs new file mode 100644 index 000000000..1390a073f --- /dev/null +++ b/lib/crates/fabro-util/src/printer.rs @@ -0,0 +1,155 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Printer { + /// Suppresses all output. + Silent, + /// Suppresses most output, but preserves "important" stdout. + Quiet, + /// Prints to standard streams. + Default, + /// Prints all output, including debug messages. + Verbose, +} + +impl Printer { + /// Build from CLI flags. `quiet` wins if both are set. + pub fn from_flags(quiet: bool, verbose: bool) -> Self { + match (quiet, verbose) { + (true, _) => Self::Quiet, + (_, true) => Self::Verbose, + _ => Self::Default, + } + } + + /// Stdout writer — enabled for Default/Verbose. + pub fn stdout(self) -> Stdout { + match self { + Self::Silent | Self::Quiet => Stdout::Disabled, + Self::Default | Self::Verbose => Stdout::Enabled, + } + } + + /// Stdout for important messages — enabled for Quiet/Default/Verbose. + pub fn stdout_important(self) -> Stdout { + match self { + Self::Silent => Stdout::Disabled, + Self::Quiet | Self::Default | Self::Verbose => Stdout::Enabled, + } + } + + /// Stderr writer — enabled for Default/Verbose. + pub fn stderr(self) -> Stderr { + match self { + Self::Silent | Self::Quiet => Stderr::Disabled, + Self::Default | Self::Verbose => Stderr::Enabled, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Stdout { + Enabled, + Disabled, +} + +impl std::fmt::Write for Stdout { + #[allow(clippy::print_stdout)] + fn write_str(&mut self, s: &str) -> std::fmt::Result { + match self { + Self::Enabled => print!("{s}"), + Self::Disabled => {} + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Stderr { + Enabled, + Disabled, +} + +impl std::fmt::Write for Stderr { + #[allow(clippy::print_stderr)] + fn write_str(&mut self, s: &str) -> std::fmt::Result { + match self { + Self::Enabled => eprint!("{s}"), + Self::Disabled => {} + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_flags_default() { + assert_eq!(Printer::from_flags(false, false), Printer::Default); + } + + #[test] + fn from_flags_quiet() { + assert_eq!(Printer::from_flags(true, false), Printer::Quiet); + } + + #[test] + fn from_flags_verbose() { + assert_eq!(Printer::from_flags(false, true), Printer::Verbose); + } + + #[test] + fn from_flags_quiet_wins() { + assert_eq!(Printer::from_flags(true, true), Printer::Quiet); + } + + #[test] + fn stdout_silent() { + assert_eq!(Printer::Silent.stdout(), Stdout::Disabled); + } + + #[test] + fn stdout_quiet() { + assert_eq!(Printer::Quiet.stdout(), Stdout::Disabled); + } + + #[test] + fn stdout_default() { + assert_eq!(Printer::Default.stdout(), Stdout::Enabled); + } + + #[test] + fn stdout_verbose() { + assert_eq!(Printer::Verbose.stdout(), Stdout::Enabled); + } + + #[test] + fn stdout_important_silent() { + assert_eq!(Printer::Silent.stdout_important(), Stdout::Disabled); + } + + #[test] + fn stdout_important_quiet() { + assert_eq!(Printer::Quiet.stdout_important(), Stdout::Enabled); + } + + #[test] + fn stderr_silent() { + assert_eq!(Printer::Silent.stderr(), Stderr::Disabled); + } + + #[test] + fn stderr_quiet() { + assert_eq!(Printer::Quiet.stderr(), Stderr::Disabled); + } + + #[test] + fn stderr_default() { + assert_eq!(Printer::Default.stderr(), Stderr::Enabled); + } + + #[test] + fn stderr_verbose() { + assert_eq!(Printer::Verbose.stderr(), Stderr::Enabled); + } +} diff --git a/lib/crates/fabro-util/src/warnings.rs b/lib/crates/fabro-util/src/warnings.rs new file mode 100644 index 000000000..95ec24f07 --- /dev/null +++ b/lib/crates/fabro-util/src/warnings.rs @@ -0,0 +1,57 @@ +use std::collections::HashSet; +use std::sync::{LazyLock, Mutex}; + +/// Set of already-emitted warnings (for `warn_user_once!` deduplication). +pub static WARNINGS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashSet::new())); + +/// Emit a styled `warning: {message}` to stderr. +#[macro_export] +macro_rules! warn_user { + ($($arg:tt)*) => {{ + let message = format!($($arg)*); + let style = $crate::console::Style::new().yellow().bold(); + eprintln!("{} {message}", style.apply_to("warning:")); + }}; +} + +/// Like [`warn_user!`], but only emits each unique message once per process. +#[macro_export] +macro_rules! warn_user_once { + ($($arg:tt)*) => {{ + let message = format!($($arg)*); + let mut set = $crate::WARNINGS.lock().unwrap(); + if set.insert(message.clone()) { + drop(set); + $crate::warn_user!("{message}"); + } + }}; +} + +#[cfg(test)] +mod tests { + use crate::WARNINGS; + + #[test] + fn warn_user_once_deduplicates() { + let before = WARNINGS.lock().unwrap().len(); + warn_user_once!("dup-test-{}", "alpha"); + let after_first = WARNINGS.lock().unwrap().len(); + warn_user_once!("dup-test-{}", "alpha"); + let after_second = WARNINGS.lock().unwrap().len(); + assert_eq!(after_first, before + 1); + assert_eq!( + after_second, after_first, + "duplicate should not grow the set" + ); + } + + #[test] + fn warn_user_once_different_messages() { + let before = WARNINGS.lock().unwrap().len(); + warn_user_once!("unique-msg-beta-1"); + warn_user_once!("unique-msg-beta-2"); + let after = WARNINGS.lock().unwrap().len(); + assert_eq!(after, before + 2); + } +}