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}