Skip to main content

zero_commands/
config.rs

1//! `/config show` + `/config doctor` trait + value types.
2//!
3//! `zero-commands` intentionally does not depend on `zero-config`:
4//! the command crate is compiled for tests and non-TUI callers
5//! where the real TOML + keychain layer is overkill. Instead a
6//! tiny [`ConfigSource`] trait lives here and the production
7//! adapter in `zero/src/main.rs` plugs in the real
8//! implementation. Same pattern as [`crate::SessionSource`].
9//!
10//! Data is returned as plain Rust — no `toml::Value`, no
11//! `keyring::Entry`. That keeps the command crate side-effect
12//! free (no file or network access from inside tests) and means
13//! adapter-side changes never ripple into dispatch.
14
15/// One labelled `/config show` row.
16///
17/// Intentionally minimal: a human label + its current value.
18/// The dispatcher renders `{label}: {value}` verbatim, so the
19/// adapter decides formatting (e.g. "not set" vs an empty
20/// string) — there is no shared default at this layer because
21/// what counts as "unset" differs per field.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ConfigShowRow {
24    pub label: String,
25    pub value: String,
26}
27
28impl ConfigShowRow {
29    pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
30        Self {
31            label: label.into(),
32            value: value.into(),
33        }
34    }
35}
36
37/// Severity of a doctor finding. Drives the `OutputLine` kind
38/// the dispatcher emits:
39/// - `Ok` → `System` (informational)
40/// - `Warn` → `Warn` (amber, advisory)
41/// - `Error` → `Alert` (red + bold, operator must not miss)
42///
43/// The three levels are the same ones `zero doctor` prints in
44/// its non-interactive form, so operators see consistent
45/// wording whether they run `/config doctor` inside the TUI or
46/// `zero doctor` from a shell.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum DoctorSeverity {
49    Ok,
50    Warn,
51    Error,
52}
53
54/// One doctor finding. `message` is rendered verbatim; the
55/// dispatcher does not prepend severity or reformat text, so
56/// the adapter can choose its own phrasing ("token: set in
57/// keychain", "config file missing — run `zero init`", etc.)
58/// without the dispatcher needing to know the domain.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ConfigDoctorFinding {
61    pub severity: DoctorSeverity,
62    pub message: String,
63}
64
65impl ConfigDoctorFinding {
66    #[must_use]
67    pub fn ok(message: impl Into<String>) -> Self {
68        Self {
69            severity: DoctorSeverity::Ok,
70            message: message.into(),
71        }
72    }
73    #[must_use]
74    pub fn warn(message: impl Into<String>) -> Self {
75        Self {
76            severity: DoctorSeverity::Warn,
77            message: message.into(),
78        }
79    }
80    #[must_use]
81    pub fn error(message: impl Into<String>) -> Self {
82        Self {
83            severity: DoctorSeverity::Error,
84            message: message.into(),
85        }
86    }
87}
88
89/// Read-only trait over the operator's on-disk config + its
90/// secret-resolution state. Kept read-only at this layer
91/// because write paths (`zero init`, `zero pair`) already live
92/// in dedicated non-interactive entrypoints; the TUI should
93/// never silently rewrite `config.toml`.
94///
95/// Implementations:
96/// - Production: `ConfigAdapter` in `zero/src/main.rs` wraps
97///   `zero_config::load_config` + keychain lookups.
98/// - Tests: [`MockConfig`] below is the in-memory double used
99///   by `dispatch_integration.rs`.
100pub trait ConfigSource: Send + Sync + 'static {
101    /// Rows for `/config show`. Order is preserved verbatim in
102    /// the rendered output — the adapter decides the column
103    /// order so it can group identity → engine → guardrails →
104    /// display without the dispatcher needing to know the
105    /// schema.
106    fn show(&self) -> Vec<ConfigShowRow>;
107
108    /// Findings for `/config doctor`. Return order is
109    /// preserved; the adapter is responsible for ordering
110    /// (errors first is a reasonable default, but the trait
111    /// does not mandate it because a "summary then detail"
112    /// shape is sometimes clearer).
113    fn doctor(&self) -> Vec<ConfigDoctorFinding>;
114}
115
116/// In-memory `ConfigSource` used by tests. Lets a test fix a
117/// deterministic set of rows + findings without touching
118/// `zero-config` or the filesystem.
119#[derive(Debug, Clone, Default)]
120pub struct MockConfig {
121    pub rows: Vec<ConfigShowRow>,
122    pub findings: Vec<ConfigDoctorFinding>,
123}
124
125impl MockConfig {
126    #[must_use]
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    #[must_use]
132    pub fn with_row(mut self, label: impl Into<String>, value: impl Into<String>) -> Self {
133        self.rows.push(ConfigShowRow::new(label, value));
134        self
135    }
136
137    #[must_use]
138    pub fn with_finding(mut self, f: ConfigDoctorFinding) -> Self {
139        self.findings.push(f);
140        self
141    }
142}
143
144impl ConfigSource for MockConfig {
145    fn show(&self) -> Vec<ConfigShowRow> {
146        self.rows.clone()
147    }
148    fn doctor(&self) -> Vec<ConfigDoctorFinding> {
149        self.findings.clone()
150    }
151}