Skip to main content

cabin_core/
term_verbosity.rs

1//! Typed model for Cabin's terminal-output verbosity.
2//!
3//! Mirrors Cargo's `-q` / `-v` / `-vv` user surface as a four-state
4//! enum: [`Verbosity::Quiet`], [`Verbosity::Normal`],
5//! [`Verbosity::Verbose`], and [`Verbosity::VeryVerbose`].  The
6//! enum lives in `cabin-core` so the CLI parser, the config
7//! layer, and the status reporter share one parsing rule and one
8//! error wording.
9//!
10//! Parsing entry points are deliberately narrow:
11//! - [`Verbosity::parse_bool_env`] reads `CABIN_TERM_VERBOSE`
12//!   and `CABIN_TERM_QUIET`;
13//! - [`Verbosity::from_config_pair`] turns the two booleans
14//!   `term.verbose` and `term.quiet` into a single typed value
15//!   and rejects the both-true combination.
16
17use std::fmt;
18
19/// User-selected verbosity for Cabin-owned status output.
20///
21/// The default is [`Verbosity::Normal`], which preserves Cabin's
22/// pre-existing status-message volume.  Variants are ordered from
23/// quietest to loudest so callers can compare with `>=`:
24///
25/// ```ignore
26/// if verbosity >= Verbosity::Verbose { ... }
27/// ```
28#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub enum Verbosity {
30    /// Suppress Cabin-owned status / progress / log messages.
31    /// Errors and explicitly-requested output (build artifacts,
32    /// JSON documents, the user program's stdout under
33    /// `cabin run`) are unaffected.
34    Quiet,
35    /// Default volume.  Status lines such as `cabin: wrote
36    /// build.ninja` are emitted; verbose-only lines are not.
37    #[default]
38    Normal,
39    /// Adds Cabin-owned context lines such as the resolved
40    /// build profile, build directory, and toolchain summary.
41    Verbose,
42    /// Adds further detail intended for diagnosing local builds.
43    /// Output stays deterministic and never includes secrets,
44    /// tokens, or environment-dependent values that Normal /
45    /// Verbose would not already print.
46    VeryVerbose,
47}
48
49impl Verbosity {
50    /// Stable string label for this variant; matches the
51    /// spelling Cabin documents.
52    pub fn as_str(self) -> &'static str {
53        match self {
54            Verbosity::Quiet => "quiet",
55            Verbosity::Normal => "normal",
56            Verbosity::Verbose => "verbose",
57            Verbosity::VeryVerbose => "very-verbose",
58        }
59    }
60
61    /// Whether this verbosity emits Cabin-owned status messages.
62    pub fn shows_status(self) -> bool {
63        self >= Verbosity::Normal
64    }
65
66    /// Whether this verbosity emits verbose-only context lines.
67    pub fn shows_verbose(self) -> bool {
68        self >= Verbosity::Verbose
69    }
70
71    /// Whether this verbosity emits very-verbose detail lines.
72    pub fn shows_very_verbose(self) -> bool {
73        self >= Verbosity::VeryVerbose
74    }
75
76    /// Convert a `-v` repetition count into a verbosity.  Counts
77    /// of two or more clamp to [`Verbosity::VeryVerbose`] so
78    /// `-vvv` and similar keep working without erroring.
79    pub fn from_verbose_count(count: u8) -> Self {
80        match count {
81            0 => Verbosity::Normal,
82            1 => Verbosity::Verbose,
83            _ => Verbosity::VeryVerbose,
84        }
85    }
86
87    /// Combine the two config booleans `term.verbose` and
88    /// `term.quiet` into a single verbosity.  Returns
89    /// `Ok(None)` when neither is set so callers can fall through
90    /// to the next layer in the precedence chain.  Returns
91    /// [`InvalidVerbosityCombination`] when both are true.
92    ///
93    /// # Errors
94    /// Returns [`InvalidVerbosityCombination`] when both `verbose` and `quiet`
95    /// are `Some(true)`.
96    pub fn from_config_pair(
97        verbose: Option<bool>,
98        quiet: Option<bool>,
99    ) -> Result<Option<Self>, InvalidVerbosityCombination> {
100        match (verbose, quiet) {
101            (Some(true), Some(true)) => Err(InvalidVerbosityCombination),
102            (Some(true), _) => Ok(Some(Verbosity::Verbose)),
103            (_, Some(true)) => Ok(Some(Verbosity::Quiet)),
104            _ => Ok(None),
105        }
106    }
107
108    /// Parse a verbosity from a single env-var value.  Used by
109    /// `CABIN_TERM_VERBOSE` and `CABIN_TERM_QUIET`: a non-empty
110    /// truthy value (`1`, `true`) opts in; `0`, `false`, or empty
111    /// opt out.  Other strings produce a typed error so the CLI
112    /// can surface a copy-pasteable message.
113    ///
114    /// # Errors
115    /// Returns [`VerbosityEnvError`] when `raw` is non-empty and not one of
116    /// `1`, `true`, `0`, or `false`.
117    pub fn parse_bool_env(variable: &'static str, raw: &str) -> Result<bool, VerbosityEnvError> {
118        if raw.is_empty() {
119            return Ok(false);
120        }
121        match raw {
122            "1" | "true" => Ok(true),
123            "0" | "false" => Ok(false),
124            _ => Err(VerbosityEnvError {
125                variable,
126                value: raw.to_owned(),
127            }),
128        }
129    }
130}
131
132impl fmt::Display for Verbosity {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        f.write_str(self.as_str())
135    }
136}
137
138/// Returned by [`Verbosity::parse_bool_env`] when a value such as
139/// `CABIN_TERM_VERBOSE=loud` does not match the documented
140/// truthy / falsy spellings.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct VerbosityEnvError {
143    pub variable: &'static str,
144    pub value: String,
145}
146
147impl fmt::Display for VerbosityEnvError {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        write!(
150            f,
151            "invalid {} value '{}'; expected one of: 1, 0, true, false",
152            self.variable, self.value
153        )
154    }
155}
156
157impl std::error::Error for VerbosityEnvError {}
158
159/// Returned by [`Verbosity::from_config_pair`] when a single
160/// config file sets both `term.verbose = true` and
161/// `term.quiet = true`.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub struct InvalidVerbosityCombination;
164
165impl fmt::Display for InvalidVerbosityCombination {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        f.write_str("term.verbose and term.quiet cannot both be true")
168    }
169}
170
171impl std::error::Error for InvalidVerbosityCombination {}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn default_is_normal() {
179        assert_eq!(Verbosity::default(), Verbosity::Normal);
180    }
181
182    #[test]
183    fn ordering_matches_intuition() {
184        assert!(Verbosity::Quiet < Verbosity::Normal);
185        assert!(Verbosity::Normal < Verbosity::Verbose);
186        assert!(Verbosity::Verbose < Verbosity::VeryVerbose);
187    }
188
189    #[test]
190    fn shows_predicates_match_thresholds() {
191        assert!(!Verbosity::Quiet.shows_status());
192        assert!(Verbosity::Normal.shows_status());
193        assert!(Verbosity::Verbose.shows_status());
194        assert!(!Verbosity::Normal.shows_verbose());
195        assert!(Verbosity::Verbose.shows_verbose());
196        assert!(Verbosity::VeryVerbose.shows_verbose());
197        assert!(!Verbosity::Verbose.shows_very_verbose());
198        assert!(Verbosity::VeryVerbose.shows_very_verbose());
199    }
200
201    #[test]
202    fn from_verbose_count_clamps_above_two() {
203        assert_eq!(Verbosity::from_verbose_count(0), Verbosity::Normal);
204        assert_eq!(Verbosity::from_verbose_count(1), Verbosity::Verbose);
205        assert_eq!(Verbosity::from_verbose_count(2), Verbosity::VeryVerbose);
206        assert_eq!(Verbosity::from_verbose_count(5), Verbosity::VeryVerbose);
207        assert_eq!(
208            Verbosity::from_verbose_count(u8::MAX),
209            Verbosity::VeryVerbose
210        );
211    }
212
213    #[test]
214    fn from_config_pair_handles_each_combination() {
215        assert_eq!(Verbosity::from_config_pair(None, None).unwrap(), None);
216        assert_eq!(
217            Verbosity::from_config_pair(Some(true), None).unwrap(),
218            Some(Verbosity::Verbose)
219        );
220        assert_eq!(
221            Verbosity::from_config_pair(None, Some(true)).unwrap(),
222            Some(Verbosity::Quiet)
223        );
224        assert_eq!(
225            Verbosity::from_config_pair(Some(false), Some(false)).unwrap(),
226            None
227        );
228        assert!(Verbosity::from_config_pair(Some(true), Some(true)).is_err());
229    }
230
231    #[test]
232    fn parse_bool_env_accepts_documented_values() {
233        for ok in ["1", "true"] {
234            assert!(Verbosity::parse_bool_env("X", ok).unwrap());
235        }
236        for falsy in ["0", "false", ""] {
237            assert!(!Verbosity::parse_bool_env("X", falsy).unwrap());
238        }
239    }
240
241    #[test]
242    fn parse_bool_env_rejects_unknown_value() {
243        let err = Verbosity::parse_bool_env("CABIN_TERM_VERBOSE", "loud").unwrap_err();
244        assert_eq!(
245            err.to_string(),
246            "invalid CABIN_TERM_VERBOSE value 'loud'; expected one of: 1, 0, true, false"
247        );
248    }
249
250    #[test]
251    fn invalid_combination_display_is_actionable() {
252        assert_eq!(
253            InvalidVerbosityCombination.to_string(),
254            "term.verbose and term.quiet cannot both be true"
255        );
256    }
257}