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}