Skip to main content

zenith_cli/
config.rs

1//! Diagnostic-policy and brand-contract resolution from config files and
2//! command-line flags.
3//!
4//! ## Diagnostic policy
5//!
6//! The effective diagnostic policy for a `validate` run is assembled from four
7//! sources, in increasing precedence:
8//!
9//! 1. **global config** — `<config_dir>/zenith/config.kdl`
10//! 2. **local config** — the nearest `.zenith.kdl` found by walking up from the
11//!    document's directory to the filesystem root
12//! 3. **in-file policy** — the document's own `diagnostics { … }` block
13//! 4. **CLI flags** — `--allow` / `--deny` / `--warn`
14//!
15//! Because policy resolution is **last-wins** (see
16//! [`zenith_core::DiagnosticPolicy::verb_for`]), the four sources are simply
17//! concatenated low→high into one policy and applied ONCE. The highest-
18//! precedence entry for any given code wins, yielding
19//! `CLI > in-file > local > global`.
20//!
21//! ## Brand contract
22//!
23//! The effective brand contract is merged with per-category override semantics
24//! from three sources, in increasing precedence:
25//!
26//! 1. **global config** — `brand { … }` block in `<config_dir>/zenith/config.kdl`
27//! 2. **local config** — `brand { … }` block in the nearest `.zenith.kdl`
28//! 3. **in-file brand** — the document's own `brand { … }` block
29//!
30//! For each category (`colors`, `fonts`, `weights`), the highest-precedence
31//! source that declares the category wins. Absent categories in a higher-
32//! precedence source do not erase the same category from a lower-precedence
33//! source. See [`zenith_core::merge_brand_contract`].
34//!
35//! All loaders are path-injectable so tests can point them at temp directories
36//! without mutating process-global state (`$HOME`, cwd). The production
37//! [`load_global_policy`] resolves the config directory from `$HOME` exactly as
38//! [`crate::commands`] resolves user-scope paths elsewhere in the CLI.
39
40use std::path::{Path, PathBuf};
41
42use zenith_core::{
43    BrandContract, DiagnosticPolicy, PolicyEntry, PolicyVerb, parse_brand_contract,
44    parse_diagnostic_policy,
45};
46
47/// The file name of a local (per-project / per-directory) config block.
48const LOCAL_CONFIG_NAME: &str = ".zenith.kdl";
49
50/// CLI-supplied policy adjustments, one bucket per verb. Each `String` is a
51/// diagnostic code. Order within a bucket and across buckets is allow → warn →
52/// deny so a later flag of a different verb for the same code still wins via
53/// last-wins resolution; entries are appended at the highest precedence.
54#[derive(Debug, Default, Clone, PartialEq, Eq)]
55pub struct CliPolicyFlags {
56    /// Codes passed via `--allow`.
57    pub allow: Vec<String>,
58    /// Codes passed via `--warn`.
59    pub warn: Vec<String>,
60    /// Codes passed via `--deny`.
61    pub deny: Vec<String>,
62}
63
64impl CliPolicyFlags {
65    /// Whether any flag was supplied. An all-empty set contributes no entries,
66    /// keeping the merged policy byte-identical to the no-flags case.
67    pub fn is_empty(&self) -> bool {
68        self.allow.is_empty() && self.warn.is_empty() && self.deny.is_empty()
69    }
70
71    /// Convert the flag buckets into [`PolicyEntry`] records. CLI entries carry
72    /// no source span. The buckets are emitted allow → warn → deny, so when the
73    /// same code is passed under multiple verbs the last-wins rule makes
74    /// `--deny` the firmest gate.
75    fn entries(&self) -> Vec<PolicyEntry> {
76        let mut entries = Vec::with_capacity(self.allow.len() + self.warn.len() + self.deny.len());
77        for code in &self.allow {
78            entries.push(PolicyEntry {
79                verb: PolicyVerb::Allow,
80                code: code.clone(),
81                subjects: Vec::new(),
82                source_span: None,
83            });
84        }
85        for code in &self.warn {
86            entries.push(PolicyEntry {
87                verb: PolicyVerb::Warn,
88                code: code.clone(),
89                subjects: Vec::new(),
90                source_span: None,
91            });
92        }
93        for code in &self.deny {
94            entries.push(PolicyEntry {
95                verb: PolicyVerb::Deny,
96                code: code.clone(),
97                subjects: Vec::new(),
98                source_span: None,
99            });
100        }
101        entries
102    }
103}
104
105/// Merge the four policy tiers into one [`DiagnosticPolicy`].
106///
107/// The tiers are concatenated low→high — `global ++ local ++ in_file ++ cli` —
108/// so that last-wins resolution yields `CLI > in-file > local > global`. The
109/// result is applied exactly once at the validation choke point. When every
110/// tier is empty the merged policy is empty (an identity pass), preserving the
111/// additive byte-identical guarantee.
112pub fn merge_policy(
113    global: &DiagnosticPolicy,
114    local: &DiagnosticPolicy,
115    in_file: &DiagnosticPolicy,
116    flags: &CliPolicyFlags,
117) -> DiagnosticPolicy {
118    let cli_entries = flags.entries();
119    let mut entries = Vec::with_capacity(
120        global.entries.len() + local.entries.len() + in_file.entries.len() + cli_entries.len(),
121    );
122    entries.extend(global.entries.iter().cloned());
123    entries.extend(local.entries.iter().cloned());
124    entries.extend(in_file.entries.iter().cloned());
125    entries.extend(cli_entries);
126    DiagnosticPolicy { entries }
127}
128
129/// Load a diagnostic policy from a KDL config file.
130///
131/// A missing file is not an error — it yields [`DiagnosticPolicy::default`]. A
132/// present-but-unreadable file, or a malformed config block, returns a
133/// human-facing error message.
134pub fn load_policy_file(path: &Path) -> Result<DiagnosticPolicy, String> {
135    let bytes = match std::fs::read(path) {
136        Ok(b) => b,
137        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
138            return Ok(DiagnosticPolicy::default());
139        }
140        Err(e) => return Err(format!("cannot read config '{}': {e}", path.display())),
141    };
142    parse_diagnostic_policy(&bytes)
143        .map_err(|e| format!("invalid config '{}': {}", path.display(), e.message))
144}
145
146/// Walk up from `start_dir` to the filesystem root, loading the first
147/// `.zenith.kdl` found. If none is found, returns [`DiagnosticPolicy::default`].
148///
149/// The walk terminates cleanly at the root (where `parent()` is `None`) without
150/// panicking.
151pub fn find_local_policy(start_dir: &Path) -> Result<DiagnosticPolicy, String> {
152    let mut dir: Option<&Path> = Some(start_dir);
153    while let Some(current) = dir {
154        let candidate = current.join(LOCAL_CONFIG_NAME);
155        if candidate.is_file() {
156            return load_policy_file(&candidate);
157        }
158        dir = current.parent();
159    }
160    Ok(DiagnosticPolicy::default())
161}
162
163/// Load the global policy from `<config_dir>/zenith/config.kdl`.
164///
165/// Injectable variant: the caller supplies the base config directory so tests
166/// can point at a temp directory. A missing file yields the default policy.
167pub fn load_global_policy_in(config_dir: &Path) -> Result<DiagnosticPolicy, String> {
168    let path = config_dir.join("zenith").join("config.kdl");
169    load_policy_file(&path)
170}
171
172/// Load the global policy from `$HOME/.config/zenith/config.kdl`.
173///
174/// Production variant: resolves the config directory from `$HOME` (matching the
175/// user-scope path convention used elsewhere in the CLI). When `$HOME` is
176/// absent there is no global config to read, so the default policy is returned.
177pub fn load_global_policy() -> Result<DiagnosticPolicy, String> {
178    match std::env::var_os("HOME").map(PathBuf::from) {
179        Some(home) => load_global_policy_in(&home.join(".config")),
180        None => Ok(DiagnosticPolicy::default()),
181    }
182}
183
184/// Load a brand contract from a KDL config file.
185///
186/// A missing file is not an error — it yields [`BrandContract::default`]. A
187/// present-but-unreadable file, or a malformed `brand` block, returns a
188/// human-facing error message. A file that has no `brand` node also yields the
189/// default (the file may contain only a `diagnostics` block).
190pub fn load_brand_file(path: &Path) -> Result<BrandContract, String> {
191    let bytes = match std::fs::read(path) {
192        Ok(b) => b,
193        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
194            return Ok(BrandContract::default());
195        }
196        Err(e) => return Err(format!("cannot read config '{}': {e}", path.display())),
197    };
198    parse_brand_contract(&bytes)
199        .map_err(|e| format!("invalid config '{}': {}", path.display(), e.message))
200}
201
202/// Walk up from `start_dir` to the filesystem root, loading the `brand { … }`
203/// block from the first `.zenith.kdl` found. If none is found, or the found
204/// file has no `brand` node, returns [`BrandContract::default`].
205///
206/// The walk terminates cleanly at the root (where `parent()` is `None`) without
207/// panicking.
208pub fn find_local_brand(start_dir: &Path) -> Result<BrandContract, String> {
209    let mut dir: Option<&Path> = Some(start_dir);
210    while let Some(current) = dir {
211        let candidate = current.join(LOCAL_CONFIG_NAME);
212        if candidate.is_file() {
213            return load_brand_file(&candidate);
214        }
215        dir = current.parent();
216    }
217    Ok(BrandContract::default())
218}
219
220/// Load the global brand contract from `<config_dir>/zenith/config.kdl`.
221///
222/// Injectable variant: the caller supplies the base config directory so tests
223/// can point at a temp directory. A missing file, or a file with no `brand`
224/// node, yields the default contract.
225pub fn load_global_brand_in(config_dir: &Path) -> Result<BrandContract, String> {
226    let path = config_dir.join("zenith").join("config.kdl");
227    load_brand_file(&path)
228}
229
230/// Load the global brand contract from `$HOME/.config/zenith/config.kdl`.
231///
232/// Production variant: resolves the config directory from `$HOME`. When `$HOME`
233/// is absent there is no global config to read, so the default contract is
234/// returned.
235pub fn load_global_brand() -> Result<BrandContract, String> {
236    match std::env::var_os("HOME").map(PathBuf::from) {
237        Some(home) => load_global_brand_in(&home.join(".config")),
238        None => Ok(BrandContract::default()),
239    }
240}
241
242/// Load the global and local policies and brand contracts for a command run.
243///
244/// This is the shared resolution preamble used by both `validate` and `render`:
245/// - Global policy and brand are always loaded from
246///   `$HOME/.config/zenith/config.kdl`.
247/// - Local policy and brand are walked up from `start_dir` when `Some`; when
248///   `None` both default to the respective empty defaults.
249///
250/// Returns `(global_policy, local_policy, global_brand, local_brand)`.
251///
252/// Returns `Err` with a human-facing message on any config I/O or parse
253/// failure. Missing files are not errors — they yield the respective defaults.
254pub fn load_global_and_local(
255    start_dir: Option<&Path>,
256) -> Result<
257    (
258        DiagnosticPolicy,
259        DiagnosticPolicy,
260        BrandContract,
261        BrandContract,
262    ),
263    String,
264> {
265    let global = load_global_policy()?;
266    let global_brand = load_global_brand()?;
267    let (local, local_brand) = match start_dir {
268        Some(dir) => (find_local_policy(dir)?, find_local_brand(dir)?),
269        None => (DiagnosticPolicy::default(), BrandContract::default()),
270    };
271    Ok((global, local, global_brand, local_brand))
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    fn deny(code: &str) -> DiagnosticPolicy {
279        DiagnosticPolicy {
280            entries: vec![PolicyEntry {
281                verb: PolicyVerb::Deny,
282                code: code.to_owned(),
283                subjects: Vec::new(),
284                source_span: None,
285            }],
286        }
287    }
288
289    fn allow(code: &str) -> DiagnosticPolicy {
290        DiagnosticPolicy {
291            entries: vec![PolicyEntry {
292                verb: PolicyVerb::Allow,
293                code: code.to_owned(),
294                subjects: Vec::new(),
295                source_span: None,
296            }],
297        }
298    }
299
300    #[test]
301    fn empty_everything_is_identity() {
302        let merged = merge_policy(
303            &DiagnosticPolicy::default(),
304            &DiagnosticPolicy::default(),
305            &DiagnosticPolicy::default(),
306            &CliPolicyFlags::default(),
307        );
308        assert!(merged.entries.is_empty());
309    }
310
311    #[test]
312    fn cli_beats_in_file_beats_local_beats_global() {
313        let global = allow("a");
314        let local = deny("a");
315        let in_file = allow("a");
316        let flags = CliPolicyFlags {
317            deny: vec!["a".to_owned()],
318            ..Default::default()
319        };
320        let merged = merge_policy(&global, &local, &in_file, &flags);
321        // Last-wins: CLI deny is final.
322        assert_eq!(merged.verb_for("a", None), Some(&PolicyVerb::Deny));
323    }
324
325    #[test]
326    fn in_file_beats_config_when_no_flag() {
327        let global = deny("a");
328        let local = deny("a");
329        let in_file = allow("a");
330        let merged = merge_policy(&global, &local, &in_file, &CliPolicyFlags::default());
331        assert_eq!(merged.verb_for("a", None), Some(&PolicyVerb::Allow));
332    }
333
334    #[test]
335    fn missing_file_is_default() {
336        let policy = load_policy_file(Path::new("/no/such/zenith/config.kdl"))
337            .expect("missing file must be ok");
338        assert!(policy.entries.is_empty());
339    }
340
341    #[test]
342    fn find_local_handles_root_without_panic() {
343        // Root has no parent; walk must terminate cleanly.
344        let policy = find_local_policy(Path::new("/")).expect("root walk must be ok");
345        assert!(policy.entries.is_empty());
346    }
347}