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}