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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-28 17:08:57 -04:00
parent 20f2a3b8bb
commit 71573ad9ec
5 changed files with 254 additions and 4 deletions

View file

@ -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)]

View file

@ -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"
);
}
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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<Mutex<HashSet<String>>> =
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);
}
}