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                source_span: None,
82            });
83        }
84        for code in &self.warn {
85            entries.push(PolicyEntry {
86                verb: PolicyVerb::Warn,
87                code: code.clone(),
88                source_span: None,
89            });
90        }
91        for code in &self.deny {
92            entries.push(PolicyEntry {
93                verb: PolicyVerb::Deny,
94                code: code.clone(),
95                source_span: None,
96            });
97        }
98        entries
99    }
100}
101
102/// Merge the four policy tiers into one [`DiagnosticPolicy`].
103///
104/// The tiers are concatenated low→high — `global ++ local ++ in_file ++ cli` —
105/// so that last-wins resolution yields `CLI > in-file > local > global`. The
106/// result is applied exactly once at the validation choke point. When every
107/// tier is empty the merged policy is empty (an identity pass), preserving the
108/// additive byte-identical guarantee.
109pub fn merge_policy(
110    global: &DiagnosticPolicy,
111    local: &DiagnosticPolicy,
112    in_file: &DiagnosticPolicy,
113    flags: &CliPolicyFlags,
114) -> DiagnosticPolicy {
115    let cli_entries = flags.entries();
116    let mut entries = Vec::with_capacity(
117        global.entries.len() + local.entries.len() + in_file.entries.len() + cli_entries.len(),
118    );
119    entries.extend(global.entries.iter().cloned());
120    entries.extend(local.entries.iter().cloned());
121    entries.extend(in_file.entries.iter().cloned());
122    entries.extend(cli_entries);
123    DiagnosticPolicy { entries }
124}
125
126/// Load a diagnostic policy from a KDL config file.
127///
128/// A missing file is not an error — it yields [`DiagnosticPolicy::default`]. A
129/// present-but-unreadable file, or a malformed config block, returns a
130/// human-facing error message.
131pub fn load_policy_file(path: &Path) -> Result<DiagnosticPolicy, String> {
132    let bytes = match std::fs::read(path) {
133        Ok(b) => b,
134        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
135            return Ok(DiagnosticPolicy::default());
136        }
137        Err(e) => return Err(format!("cannot read config '{}': {e}", path.display())),
138    };
139    parse_diagnostic_policy(&bytes)
140        .map_err(|e| format!("invalid config '{}': {}", path.display(), e.message))
141}
142
143/// Walk up from `start_dir` to the filesystem root, loading the first
144/// `.zenith.kdl` found. If none is found, returns [`DiagnosticPolicy::default`].
145///
146/// The walk terminates cleanly at the root (where `parent()` is `None`) without
147/// panicking.
148pub fn find_local_policy(start_dir: &Path) -> Result<DiagnosticPolicy, String> {
149    let mut dir: Option<&Path> = Some(start_dir);
150    while let Some(current) = dir {
151        let candidate = current.join(LOCAL_CONFIG_NAME);
152        if candidate.is_file() {
153            return load_policy_file(&candidate);
154        }
155        dir = current.parent();
156    }
157    Ok(DiagnosticPolicy::default())
158}
159
160/// Load the global policy from `<config_dir>/zenith/config.kdl`.
161///
162/// Injectable variant: the caller supplies the base config directory so tests
163/// can point at a temp directory. A missing file yields the default policy.
164pub fn load_global_policy_in(config_dir: &Path) -> Result<DiagnosticPolicy, String> {
165    let path = config_dir.join("zenith").join("config.kdl");
166    load_policy_file(&path)
167}
168
169/// Load the global policy from `$HOME/.config/zenith/config.kdl`.
170///
171/// Production variant: resolves the config directory from `$HOME` (matching the
172/// user-scope path convention used elsewhere in the CLI). When `$HOME` is
173/// absent there is no global config to read, so the default policy is returned.
174pub fn load_global_policy() -> Result<DiagnosticPolicy, String> {
175    match std::env::var_os("HOME").map(PathBuf::from) {
176        Some(home) => load_global_policy_in(&home.join(".config")),
177        None => Ok(DiagnosticPolicy::default()),
178    }
179}
180
181/// Load a brand contract from a KDL config file.
182///
183/// A missing file is not an error — it yields [`BrandContract::default`]. A
184/// present-but-unreadable file, or a malformed `brand` block, returns a
185/// human-facing error message. A file that has no `brand` node also yields the
186/// default (the file may contain only a `diagnostics` block).
187pub fn load_brand_file(path: &Path) -> Result<BrandContract, String> {
188    let bytes = match std::fs::read(path) {
189        Ok(b) => b,
190        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
191            return Ok(BrandContract::default());
192        }
193        Err(e) => return Err(format!("cannot read config '{}': {e}", path.display())),
194    };
195    parse_brand_contract(&bytes)
196        .map_err(|e| format!("invalid config '{}': {}", path.display(), e.message))
197}
198
199/// Walk up from `start_dir` to the filesystem root, loading the `brand { … }`
200/// block from the first `.zenith.kdl` found. If none is found, or the found
201/// file has no `brand` node, returns [`BrandContract::default`].
202///
203/// The walk terminates cleanly at the root (where `parent()` is `None`) without
204/// panicking.
205pub fn find_local_brand(start_dir: &Path) -> Result<BrandContract, String> {
206    let mut dir: Option<&Path> = Some(start_dir);
207    while let Some(current) = dir {
208        let candidate = current.join(LOCAL_CONFIG_NAME);
209        if candidate.is_file() {
210            return load_brand_file(&candidate);
211        }
212        dir = current.parent();
213    }
214    Ok(BrandContract::default())
215}
216
217/// Load the global brand contract from `<config_dir>/zenith/config.kdl`.
218///
219/// Injectable variant: the caller supplies the base config directory so tests
220/// can point at a temp directory. A missing file, or a file with no `brand`
221/// node, yields the default contract.
222pub fn load_global_brand_in(config_dir: &Path) -> Result<BrandContract, String> {
223    let path = config_dir.join("zenith").join("config.kdl");
224    load_brand_file(&path)
225}
226
227/// Load the global brand contract from `$HOME/.config/zenith/config.kdl`.
228///
229/// Production variant: resolves the config directory from `$HOME`. When `$HOME`
230/// is absent there is no global config to read, so the default contract is
231/// returned.
232pub fn load_global_brand() -> Result<BrandContract, String> {
233    match std::env::var_os("HOME").map(PathBuf::from) {
234        Some(home) => load_global_brand_in(&home.join(".config")),
235        None => Ok(BrandContract::default()),
236    }
237}
238
239/// Load the global and local policies and brand contracts for a command run.
240///
241/// This is the shared resolution preamble used by both `validate` and `render`:
242/// - Global policy and brand are always loaded from
243///   `$HOME/.config/zenith/config.kdl`.
244/// - Local policy and brand are walked up from `start_dir` when `Some`; when
245///   `None` both default to the respective empty defaults.
246///
247/// Returns `(global_policy, local_policy, global_brand, local_brand)`.
248///
249/// Returns `Err` with a human-facing message on any config I/O or parse
250/// failure. Missing files are not errors — they yield the respective defaults.
251pub fn load_global_and_local(
252    start_dir: Option<&Path>,
253) -> Result<
254    (
255        DiagnosticPolicy,
256        DiagnosticPolicy,
257        BrandContract,
258        BrandContract,
259    ),
260    String,
261> {
262    let global = load_global_policy()?;
263    let global_brand = load_global_brand()?;
264    let (local, local_brand) = match start_dir {
265        Some(dir) => (find_local_policy(dir)?, find_local_brand(dir)?),
266        None => (DiagnosticPolicy::default(), BrandContract::default()),
267    };
268    Ok((global, local, global_brand, local_brand))
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    fn deny(code: &str) -> DiagnosticPolicy {
276        DiagnosticPolicy {
277            entries: vec![PolicyEntry {
278                verb: PolicyVerb::Deny,
279                code: code.to_owned(),
280                source_span: None,
281            }],
282        }
283    }
284
285    fn allow(code: &str) -> DiagnosticPolicy {
286        DiagnosticPolicy {
287            entries: vec![PolicyEntry {
288                verb: PolicyVerb::Allow,
289                code: code.to_owned(),
290                source_span: None,
291            }],
292        }
293    }
294
295    #[test]
296    fn empty_everything_is_identity() {
297        let merged = merge_policy(
298            &DiagnosticPolicy::default(),
299            &DiagnosticPolicy::default(),
300            &DiagnosticPolicy::default(),
301            &CliPolicyFlags::default(),
302        );
303        assert!(merged.entries.is_empty());
304    }
305
306    #[test]
307    fn cli_beats_in_file_beats_local_beats_global() {
308        let global = allow("a");
309        let local = deny("a");
310        let in_file = allow("a");
311        let flags = CliPolicyFlags {
312            deny: vec!["a".to_owned()],
313            ..Default::default()
314        };
315        let merged = merge_policy(&global, &local, &in_file, &flags);
316        // Last-wins: CLI deny is final.
317        assert_eq!(merged.verb_for("a"), Some(&PolicyVerb::Deny));
318    }
319
320    #[test]
321    fn in_file_beats_config_when_no_flag() {
322        let global = deny("a");
323        let local = deny("a");
324        let in_file = allow("a");
325        let merged = merge_policy(&global, &local, &in_file, &CliPolicyFlags::default());
326        assert_eq!(merged.verb_for("a"), Some(&PolicyVerb::Allow));
327    }
328
329    #[test]
330    fn missing_file_is_default() {
331        let policy = load_policy_file(Path::new("/no/such/zenith/config.kdl"))
332            .expect("missing file must be ok");
333        assert!(policy.entries.is_empty());
334    }
335
336    #[test]
337    fn find_local_handles_root_without_panic() {
338        // Root has no parent; walk must terminate cleanly.
339        let policy = find_local_policy(Path::new("/")).expect("root walk must be ok");
340        assert!(policy.entries.is_empty());
341    }
342}