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`: the documented
110    /// truthy spellings (`1`, `true`, `yes`, `on`, case-insensitive)
111    /// opt in; the falsy spellings (empty, `0`, `false`, `no`,
112    /// `off`) opt out.  Other strings produce a typed error so the
113    /// CLI can surface a copy-pasteable message.
114    ///
115    /// # Errors
116    /// Returns [`VerbosityEnvError`] when `raw` matches none of the
117    /// documented truthy / falsy spellings.
118    pub fn parse_bool_env(variable: &'static str, raw: &str) -> Result<bool, VerbosityEnvError> {
119        cabin_env::parse_bool(raw).map_err(|_| VerbosityEnvError {
120            variable,
121            value: raw.to_owned(),
122        })
123    }
124}
125
126impl fmt::Display for Verbosity {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        f.write_str(self.as_str())
129    }
130}
131
132/// Returned by [`Verbosity::parse_bool_env`] when a value such as
133/// `CABIN_TERM_VERBOSE=loud` does not match the documented
134/// truthy / falsy spellings.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct VerbosityEnvError {
137    pub variable: &'static str,
138    pub value: String,
139}
140
141impl fmt::Display for VerbosityEnvError {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        write!(
144            f,
145            "invalid {} value '{}'; expected one of: 1, 0, true, false, yes, no, on, off",
146            self.variable, self.value
147        )
148    }
149}
150
151impl std::error::Error for VerbosityEnvError {}
152
153/// Returned by [`Verbosity::from_config_pair`] when a single
154/// config file sets both `term.verbose = true` and
155/// `term.quiet = true`.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct InvalidVerbosityCombination;
158
159impl fmt::Display for InvalidVerbosityCombination {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        f.write_str("term.verbose and term.quiet cannot both be true")
162    }
163}
164
165impl std::error::Error for InvalidVerbosityCombination {}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn default_is_normal() {
173        assert_eq!(Verbosity::default(), Verbosity::Normal);
174    }
175
176    #[test]
177    fn ordering_matches_intuition() {
178        assert!(Verbosity::Quiet < Verbosity::Normal);
179        assert!(Verbosity::Normal < Verbosity::Verbose);
180        assert!(Verbosity::Verbose < Verbosity::VeryVerbose);
181    }
182
183    #[test]
184    fn shows_predicates_match_thresholds() {
185        assert!(!Verbosity::Quiet.shows_status());
186        assert!(Verbosity::Normal.shows_status());
187        assert!(Verbosity::Verbose.shows_status());
188        assert!(!Verbosity::Normal.shows_verbose());
189        assert!(Verbosity::Verbose.shows_verbose());
190        assert!(Verbosity::VeryVerbose.shows_verbose());
191        assert!(!Verbosity::Verbose.shows_very_verbose());
192        assert!(Verbosity::VeryVerbose.shows_very_verbose());
193    }
194
195    #[test]
196    fn from_verbose_count_clamps_above_two() {
197        assert_eq!(Verbosity::from_verbose_count(0), Verbosity::Normal);
198        assert_eq!(Verbosity::from_verbose_count(1), Verbosity::Verbose);
199        assert_eq!(Verbosity::from_verbose_count(2), Verbosity::VeryVerbose);
200        assert_eq!(Verbosity::from_verbose_count(5), Verbosity::VeryVerbose);
201        assert_eq!(
202            Verbosity::from_verbose_count(u8::MAX),
203            Verbosity::VeryVerbose
204        );
205    }
206
207    #[test]
208    fn from_config_pair_handles_each_combination() {
209        assert_eq!(Verbosity::from_config_pair(None, None).unwrap(), None);
210        assert_eq!(
211            Verbosity::from_config_pair(Some(true), None).unwrap(),
212            Some(Verbosity::Verbose)
213        );
214        assert_eq!(
215            Verbosity::from_config_pair(None, Some(true)).unwrap(),
216            Some(Verbosity::Quiet)
217        );
218        assert_eq!(
219            Verbosity::from_config_pair(Some(false), Some(false)).unwrap(),
220            None
221        );
222        assert!(Verbosity::from_config_pair(Some(true), Some(true)).is_err());
223    }
224
225    #[test]
226    fn parse_bool_env_accepts_documented_values() {
227        for ok in ["1", "true", "yes", "on", "TRUE", "Yes", "ON"] {
228            assert!(
229                Verbosity::parse_bool_env("X", ok).unwrap(),
230                "truthy: {ok:?}"
231            );
232        }
233        for falsy in ["0", "false", "no", "off", "FALSE", "No", "OFF", ""] {
234            assert!(
235                !Verbosity::parse_bool_env("X", falsy).unwrap(),
236                "falsy: {falsy:?}"
237            );
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, yes, no, on, off"
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}