shikumi/tiered.rs
1//! Tiered configuration — **the shikumi configuration prime directive**.
2//!
3//! Every typed configuration shikumi loads MUST implement
4//! [`TieredConfig`]. The bare/discovered/prescribed-default tier
5//! model is as load-bearing as shikumi's existing YAML+env+nix
6//! discovery — the two compose, they don't replace each other.
7//!
8//! # Operator workflow (the contract every app exposes)
9//!
10//! ```text
11//! <app> config-show bare # zero-opinion floor
12//! <app> config-show discovered # bare + runtime auto-detect
13//! <app> config-show default # bare + prescribed defaults + discovered
14//! diff <(<app> config-show bare) <(<app> config-show default)
15//! ```
16//!
17//! The same env-var convention applies fleet-wide:
18//!
19//! ```text
20//! MADO_TIER=bare mado # explicit tier override at launch
21//! FROST_TIER=discovered frost # autodetect-only, no prescribed opinions
22//! ```
23//!
24//! # The runtime path
25//!
26//! ```rust,ignore
27//! use shikumi::{ConfigStore, ConfigTier, TieredConfig};
28//!
29//! // Resolve the tier from env (defaults to Default if unset/invalid).
30//! let tier = ConfigTier::from_env("MADO_TIER");
31//! let store = ConfigStore::for_app("mado");
32//! let cfg: MadoConfig = store.load_tier(tier)?;
33//! ```
34//!
35//! `load_tier` composes the right tier:
36//! * `Bare` → `T::bare()`
37//! * `Discovered` → `T::discovered()`
38//! * `Default` → `T::prescribed_default()` (then overlay the
39//! operator's YAML if present — the standard
40//! shikumi YAML+env+nix path layers on top)
41//! * `Custom` → load explicit path, layered on `prescribed_default()`
42//!
43//! # The model
44//!
45//! Every operator-facing config in the pleme-io fleet has **four
46//! discrete tiers** an operator can ask about:
47//!
48//! 1. **`bare()`** — zero-opinion floor. Every field at empty / zero /
49//! None / least-surprising variant. The deliberate minimum-viable
50//! config. Documented + diffable; rarely used directly.
51//!
52//! 2. **`discovered()`** — `bare()` + runtime auto-detect outputs
53//! (display dims, system theme, available fonts, GPU class, etc.).
54//! The "what would this app look like with only detection, no
55//! developer opinions?" answer. Default impl returns `bare()` —
56//! consumers override when they have detect helpers.
57//!
58//! 3. **`prescribed_default()`** — the developer-prescribed default
59//! layered on top of `discovered()`. "App as the developers
60//! believe it should be used." This is what `Default::default()`
61//! returns for the typed config; ~90% of operators land here on
62//! first launch.
63//!
64//! 4. **`extend(base)`** — operator-supplied overlay on any prior
65//! tier. Sourced from `~/.config/<app>/<app>.yaml` via the
66//! standard shikumi `ConfigStore` discovery chain.
67//!
68//! # Diff
69//!
70//! `diff_against(baseline)` computes a structural diff between two
71//! values of the same tiered type — typically `bare()` vs
72//! `prescribed_default()` so operators can SEE knob-by-knob what
73//! defaults bought them. Default impl uses serde-yaml structural
74//! diff; consumers can override for a richer presentation.
75//!
76//! # Operator CLI contract
77//!
78//! Every app that consumes a `TieredConfig`-implementing type SHOULD
79//! ship a subcommand (`<app> config-show <tier>` + `<app> config-diff
80//! <from> <to>`) so the contract is discoverable from the terminal,
81//! not just code-reading.
82//!
83//! # Implementor responsibilities
84//!
85//! 1. `bare()` enumerates **every field** explicitly (no
86//! `..Default::default()`). The function is the operator's
87//! answer to "what does bare mean for this knob?".
88//! 2. `prescribed_default()` typically uses `bare()` + per-field
89//! overrides for the prescribed values, OR a hand-written full
90//! enumeration if it's clearer.
91//! 3. Tests pin every field of `bare()` (contract). Adding a new
92//! config field without thinking about its bare value fails the
93//! test.
94
95use serde::{Serialize, de::DeserializeOwned};
96use std::env;
97
98// ── ConfigTierKind — variant-tag projection of ConfigTier
99
100/// Typed variant tag of [`ConfigTier`] — `Bare | Discovered | Default
101/// | Custom` lifted into a [`crate::ClosedAxis`] primitive without
102/// the `Custom` variant's [`std::path::PathBuf`] payload.
103///
104/// Stands in the same relation to [`ConfigTier`] as
105/// [`crate::PartitionFace`] does to [`crate::PartitionOrdinal`]: the
106/// variant tag carried as its own [`Copy`] + [`Hash`] typescape
107/// primitive, projectable from the full enum through one named
108/// accessor ([`ConfigTier::kind`]).
109///
110/// **Single source of truth for the four operator-facing tier
111/// names.** Both [`as_str`][Self::as_str] (rendering) and
112/// [`from_str`][Self::from_str] (parsing) route through this enum,
113/// so the strings `"bare"`, `"discovered"`, `"default"`, `"custom"`
114/// appear at exactly one site — adding a fifth tier (if the model
115/// grows) extends the strings in lockstep with the variants instead
116/// of touching three duplicated `match` blocks.
117///
118/// Consumers that only need "which tier did the operator ask for?"
119/// without the `Custom` path (telemetry counters, dashboards keyed by
120/// tier, structured-log fields) carry a [`ConfigTierKind`] (one byte,
121/// [`Copy`]) rather than the full [`ConfigTier`] (variant tag plus a
122/// heap-allocated [`std::path::PathBuf`]). Reaches every closed-axis
123/// discipline the typescape closes uniformly — [`crate::axis_iter`],
124/// [`crate::axis_cardinality`], [`crate::axis_ordinal`],
125/// [`crate::axis_at`] — at the trait impl declaration.
126#[non_exhaustive]
127#[derive(
128 Debug,
129 Clone,
130 Copy,
131 PartialEq,
132 Eq,
133 Hash,
134 gen_platform::TypedDispatcher,
135 gen_platform::Discriminant,
136 gen_platform::IsVariant,
137 gen_platform::FromStrKind,
138)]
139#[discriminant(also_display)]
140pub enum ConfigTierKind {
141 /// Zero-opinion floor.
142 Bare,
143 /// `bare()` + runtime auto-detect outputs.
144 Discovered,
145 /// `bare()` + discovered + `prescribed_default()` — the ~90%
146 /// case.
147 #[allow(clippy::module_name_repetitions)]
148 Default,
149 /// YAML overlay at a caller-supplied path on top of
150 /// `prescribed_default()`.
151 Custom,
152}
153
154// Fleet-wide dispatcher-catalog registration. TWELFTH consumer
155// class adopting gen-platform's typed-dispatcher catamorphism
156// (after gen / caixa / wasm-platform / cofre / shigoto / engenho /
157// magma / kura / pangea / tatara / hanshi). See
158// theory/UNIFIED-COMPUTING-MODEL.md §VI.
159gen_platform::register_dispatcher!("shikumi.config-tier-kind", ConfigTierKind);
160
161impl ConfigTierKind {
162 /// Every [`ConfigTierKind`] value, in declaration order — the
163 /// inherent mirror of [`crate::ClosedAxis::ALL`].
164 pub const ALL: &'static [Self] = &[Self::Bare, Self::Discovered, Self::Default, Self::Custom];
165
166 /// Canonical operator-facing lowercase name of the tier kind.
167 ///
168 /// The single source of truth for the four tier names; both
169 /// [`ConfigTier::name`] (rendering) and
170 /// [`ConfigTier::from_str_or_default`] / [`ConfigTier::from_env`]
171 /// (parsing) route through this method via [`Self::from_str`].
172 /// `as_str` round-trips with [`from_str`][Self::from_str] on
173 /// every variant — pinned by
174 /// [`tests::config_tier_kind_from_str_round_trips_with_as_str`].
175 #[must_use]
176 pub const fn as_str(self) -> &'static str {
177 match self {
178 Self::Bare => "bare",
179 Self::Discovered => "discovered",
180 Self::Default => "default",
181 Self::Custom => "custom",
182 }
183 }
184
185 /// Case-insensitive parse of the four canonical tier-kind
186 /// strings. Returns [`None`] for any other input — the caller
187 /// decides what to do with unrecognized strings (e.g.
188 /// [`ConfigTier::from_str_or_default`] treats them as
189 /// path-shaped `Custom(PathBuf)` payloads).
190 ///
191 /// The trim discipline is the caller's responsibility; this
192 /// method matches on the input verbatim after ASCII-lowercasing
193 /// so `"Bare"`, `"BARE"`, `"bare"` all parse to [`Self::Bare`].
194 /// Empty string returns [`None`] (it's neither a canonical tag
195 /// nor a valid path).
196 ///
197 /// `from_str` returns [`Option`] rather than implementing
198 /// [`std::str::FromStr`] (which would force a `Result<_, Err>`
199 /// shape and an error-type ceremony for the no-error case where
200 /// "not a canonical name" is the only failure mode the caller
201 /// cares about).
202 ///
203 /// Inherent mirror of [`crate::ClosedAxisLabel::from_canonical_str`];
204 /// delegates to the trait default so the parse body lives at one
205 /// site (the trait default impl in [`crate::cube`]) and the
206 /// trait-uniform round-trip law reaches `ConfigTierKind` through
207 /// the [`crate::ClosedAxisLabel`] discipline.
208 #[allow(clippy::should_implement_trait)]
209 #[must_use]
210 pub fn from_str(s: &str) -> Option<Self> {
211 <Self as crate::ClosedAxisLabel>::from_canonical_str(s)
212 }
213}
214
215impl crate::ClosedAxis for ConfigTierKind {
216 const ALL: &'static [Self] = Self::ALL;
217}
218
219impl crate::ClosedAxisLabel for ConfigTierKind {
220 fn as_str(self) -> &'static str {
221 Self::as_str(self)
222 }
223}
224
225// ── ConfigTier — operator-facing enum picking which baseline to load
226
227/// Which tier of a `TieredConfig` to materialize at app startup.
228///
229/// Apps resolve via [`ConfigTier::from_env`] (default convention:
230/// `<APP>_TIER` env var) or via an explicit CLI flag. The four
231/// variants mirror the `TieredConfig` trait methods:
232///
233/// * `Bare` — zero-opinion floor (every field at empty/zero).
234/// * `Discovered` — bare + runtime auto-detect outputs.
235/// * `Default` — bare + discovered + prescribed_default (the
236/// ~90% case; what `Default::default()` returns).
237/// * `Custom(path)` — load YAML from `path` overlaid on
238/// `prescribed_default()`. Equivalent to the
239/// standard shikumi YAML discovery path.
240///
241/// The variant-tag projection — "which tier kind did the operator
242/// ask for, ignoring any `Custom` path payload?" — is exposed as the
243/// typed [`ConfigTierKind`] primitive through [`Self::kind`].
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub enum ConfigTier {
246 Bare,
247 Discovered,
248 #[allow(clippy::module_name_repetitions)]
249 Default,
250 Custom(std::path::PathBuf),
251}
252
253impl Default for ConfigTier {
254 fn default() -> Self {
255 Self::Default
256 }
257}
258
259impl ConfigTier {
260 /// Resolve the tier from an env var, falling back to
261 /// `ConfigTier::Default` when unset / unparseable.
262 ///
263 /// Recognized values (case-insensitive):
264 /// * `"bare"` → Bare
265 /// * `"discovered"` → Discovered
266 /// * `"default"` → Default
267 /// * any other non-empty string → Custom(value as path)
268 #[must_use]
269 pub fn from_env(env_var: &str) -> Self {
270 // Missing var → default. Present var goes through the same
271 // parse path as the explicit-string entry point, so the two
272 // helpers stay in lockstep at one site.
273 env::var(env_var)
274 .map(|raw| Self::from_str_or_default(&raw))
275 .unwrap_or_default()
276 }
277
278 /// Resolve from an explicit string (e.g. a CLI flag value).
279 /// Same matching rules as [`ConfigTier::from_env`].
280 #[must_use]
281 pub fn from_str_or_default(s: &str) -> Self {
282 // Trim + lowercase once; dispatch through ConfigTierKind so
283 // the four canonical tier-name strings live at one site
284 // (ConfigTierKind::as_str). The `Custom` kind is encoded
285 // here with a string payload — when the operator types the
286 // literal word "custom" with no path, it still falls into
287 // the path-shaped Custom arm to match prior behavior.
288 let normalized = s.trim().to_ascii_lowercase();
289 if normalized.is_empty() {
290 return Self::default();
291 }
292 match ConfigTierKind::from_str(&normalized) {
293 Some(ConfigTierKind::Bare) => Self::Bare,
294 Some(ConfigTierKind::Discovered) => Self::Discovered,
295 Some(ConfigTierKind::Default) => Self::Default,
296 Some(ConfigTierKind::Custom) | None => {
297 Self::Custom(std::path::PathBuf::from(normalized))
298 }
299 }
300 }
301
302 /// Operator-facing tier name (`"bare"` / `"discovered"` /
303 /// `"default"` / `"custom"`) — used in logs + telemetry.
304 ///
305 /// Delegates to [`ConfigTierKind::as_str`] via [`Self::kind`],
306 /// keeping the four tier names at one source of truth.
307 #[must_use]
308 pub fn name(&self) -> &'static str {
309 self.kind().as_str()
310 }
311
312 /// Typed variant-tag projection — every [`ConfigTier`] value
313 /// lands on exactly one [`ConfigTierKind`], with the `Custom`
314 /// path payload forgotten.
315 ///
316 /// The cube-axis analog of [`crate::PartitionOrdinal::face`] for
317 /// [`crate::PartitionFace`]: a consumer that only needs "which
318 /// tier kind did the operator ask for?" — without the
319 /// `Custom(PathBuf)` payload — carries one byte via this
320 /// projection rather than re-pattern-matching the enum at every
321 /// site. Pinned in lockstep with [`Self::name`] by
322 /// [`tests::config_tier_kind_matches_config_tier_name`].
323 #[must_use]
324 pub const fn kind(&self) -> ConfigTierKind {
325 match self {
326 Self::Bare => ConfigTierKind::Bare,
327 Self::Discovered => ConfigTierKind::Discovered,
328 Self::Default => ConfigTierKind::Default,
329 Self::Custom(_) => ConfigTierKind::Custom,
330 }
331 }
332}
333
334/// Trait every shikumi-typed config implements to participate in the
335/// fleet-wide tier model. See module docs for the full operator
336/// contract.
337pub trait TieredConfig: Sized + Clone + Serialize + DeserializeOwned {
338 /// Tier 0 — the documented floor. Every field at zero-opinion.
339 fn bare() -> Self;
340
341 /// Tier 1 — `bare()` overlaid with runtime auto-detect outputs.
342 /// Default: returns `bare()` unchanged. Consumers with detect
343 /// helpers override.
344 fn discovered() -> Self {
345 Self::bare()
346 }
347
348 /// Tier 2 — `bare()` + curated defaults + `discovered()`. The
349 /// prescribed first-launch experience. `Default::default()` on
350 /// the implementing type typically delegates here so the standard
351 /// idiom (`MyConfig::default()`) Just Works.
352 fn prescribed_default() -> Self;
353
354 /// Tier 3 — overlay this config on top of `base`. Default impl
355 /// returns `self.clone()` (full replacement). Consumers with
356 /// finer-grained per-field merge semantics override.
357 fn extend(self, _base: &Self) -> Self {
358 self
359 }
360
361 /// Materialize `self` from a tier selector — the operator-facing
362 /// entry point. Wraps the tier methods + env-var resolution +
363 /// optional YAML overlay into one call site every fleet app
364 /// uses identically.
365 ///
366 /// `Bare`/`Discovered`/`Default` resolve to the corresponding
367 /// trait method. `Custom(path)` attempts to deserialize YAML
368 /// at `path` and overlay it on `prescribed_default()`; falls
369 /// back to `prescribed_default()` if the file is missing or
370 /// malformed (warns via tracing).
371 fn resolve_tier(tier: ConfigTier) -> Self {
372 match tier {
373 ConfigTier::Bare => Self::bare(),
374 ConfigTier::Discovered => Self::discovered(),
375 ConfigTier::Default => Self::prescribed_default(),
376 ConfigTier::Custom(path) => {
377 let base = Self::prescribed_default();
378 match std::fs::read_to_string(&path) {
379 Ok(s) => match serde_yaml::from_str::<Self>(&s) {
380 Ok(overlay) => overlay.extend(&base),
381 Err(e) => {
382 tracing::warn!(
383 target: "shikumi::tiered",
384 error = %e,
385 path = %path.display(),
386 "custom tier YAML failed to deserialize — falling back to prescribed_default"
387 );
388 base
389 }
390 },
391 Err(e) => {
392 tracing::warn!(
393 target: "shikumi::tiered",
394 error = %e,
395 path = %path.display(),
396 "custom tier YAML not readable — falling back to prescribed_default"
397 );
398 base
399 }
400 }
401 }
402 }
403 }
404
405 /// Convenience: resolve the tier from an env var (default
406 /// `<APP>_TIER`) AND materialize the config in one call.
407 /// The fleet-wide canonical entry point at app startup.
408 fn resolve_from_env(env_var: &str) -> Self {
409 Self::resolve_tier(ConfigTier::from_env(env_var))
410 }
411
412 /// Diff `self` against `baseline`. Default: serialize both to
413 /// YAML and produce a line-oriented diff.
414 fn diff_against(&self, baseline: &Self) -> ConfigDiff {
415 let a = serde_yaml::to_string(baseline).unwrap_or_default();
416 let b = serde_yaml::to_string(self).unwrap_or_default();
417 ConfigDiff::from_yaml_pair(&a, &b)
418 }
419}
420
421/// Line-oriented diff between two YAML serializations of a
422/// `TieredConfig` value. Designed for operator-facing CLI output
423/// (`<app> config-diff <from> <to>`); not a structural patch.
424#[derive(Debug, Clone, Default, PartialEq, Eq)]
425pub struct ConfigDiff {
426 pub lines: Vec<DiffLine>,
427}
428
429#[derive(Debug, Clone, PartialEq, Eq)]
430pub enum DiffLine {
431 /// Line present in baseline, absent in candidate.
432 Removed(String),
433 /// Line absent in baseline, present in candidate.
434 Added(String),
435 /// Line identical in both (context).
436 Context(String),
437}
438
439impl DiffLine {
440 /// Data-free, `'static` discriminant of this [`DiffLine`]: the kind
441 /// of diff cell ([`DiffLineKind::Removed`] / [`DiffLineKind::Added`]
442 /// / [`DiffLineKind::Context`]) independent of the inner payload
443 /// [`String`].
444 ///
445 /// One source of truth for the diff-line kind partition over
446 /// [`DiffLine`]. Observers that need only the cell-kind axis
447 /// (counting added/removed/context lines for stats, filtering for
448 /// "show only changes", dispatching per-kind glyph or color at
449 /// render time, comparing across thread boundaries without cloning
450 /// the borrowed line text) match on this closed enum instead of
451 /// pattern-matching against the three payload-carrying variants of
452 /// [`DiffLine`].
453 ///
454 /// Peer of [`ConfigTier::kind`] on the [`ConfigTier`] axis — same
455 /// typescape closed-axis discipline (allocation-free,
456 /// `Copy + Eq + Hash + #[non_exhaustive]`, exhaustive forward map),
457 /// lifted to the diff-line surface so the
458 /// `ConfigDiff`-internal partition is named at the type level
459 /// rather than lying open-coded in [`ConfigDiff::is_empty_diff`]
460 /// and [`ConfigDiff::render_unified`].
461 ///
462 /// A future [`DiffLine`] variant landing (e.g. a hypothetical
463 /// `Header(String)` shape for hunk headers, a `Sep` shape for
464 /// inter-hunk separators) forces a corresponding
465 /// [`DiffLineKind`] arm through the exhaustive match below.
466 #[must_use]
467 pub const fn kind(&self) -> DiffLineKind {
468 match self {
469 Self::Removed(_) => DiffLineKind::Removed,
470 Self::Added(_) => DiffLineKind::Added,
471 Self::Context(_) => DiffLineKind::Context,
472 }
473 }
474
475 /// Borrow the inner line text regardless of kind. Companion of
476 /// [`Self::kind`]: the (kind, text) pair losslessly reconstructs
477 /// the original [`DiffLine`] value, and the two accessors together
478 /// replace the three-arm `match` blocks at every renderer site.
479 #[must_use]
480 pub fn text(&self) -> &str {
481 match self {
482 Self::Removed(s) | Self::Added(s) | Self::Context(s) => s.as_str(),
483 }
484 }
485}
486
487/// Data-free, `'static` discriminant of [`DiffLine`]: the closed
488/// three-way partition over the diff-cell variant space, independent
489/// of the inner payload [`String`].
490///
491/// Returned by [`DiffLine::kind`]. The enum exists so consumers that
492/// need only the cell-kind axis (per-kind counters, "only changed
493/// lines" filters, per-kind glyph or color rendering at the
494/// `ConfigDiff::render_unified` surface, structured-diagnostic
495/// legends naming the diff-cell class, comparing across thread
496/// boundaries) match on one closed enum instead of pattern-matching
497/// against three payload-carrying variants.
498///
499/// Peer of [`crate::ConfigTierKind`] (variant-tag projection of
500/// [`ConfigTier`]), [`crate::WatchEventClass`] (reload-relevance
501/// classification of [`notify::EventKind`]), and the other closed-
502/// enum kind primitives on the typescape — same discipline (closed,
503/// allocation-free, `Copy + Eq + Hash + #[non_exhaustive]`,
504/// exhaustive forward map), applied to the diff-cell axis. Before
505/// this lift, the three-way kind universe lived only inside
506/// [`DiffLine`]'s variant set: every observer wanting the data-free
507/// kind class re-pattern-matched against the payload-carrying enum,
508/// and the unified-diff glyph (`-`, `+`, ` `) appeared inline at
509/// every renderer site rather than at one canonical accessor.
510///
511/// Adding a future [`DiffLine`] variant (a hypothetical `Header`
512/// shape for hunk headers, a `Sep` shape for inter-hunk separators)
513/// means adding one [`DiffLineKind`] variant in lockstep — the
514/// exhaustive [`DiffLine::kind`] match forces the assignment at
515/// compile time.
516#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
517#[non_exhaustive]
518pub enum DiffLineKind {
519 /// Maps to [`DiffLine::Removed`] regardless of inner payload —
520 /// a line present in the baseline and absent in the candidate.
521 /// Rendered with the canonical unified-diff `-` prefix
522 /// ([`Self::glyph`]).
523 Removed,
524 /// Maps to [`DiffLine::Added`] regardless of inner payload —
525 /// a line absent in the baseline and present in the candidate.
526 /// Rendered with the canonical unified-diff `+` prefix
527 /// ([`Self::glyph`]).
528 Added,
529 /// Maps to [`DiffLine::Context`] regardless of inner payload —
530 /// a line identical in both sides. Rendered with the canonical
531 /// unified-diff ` ` (space) prefix ([`Self::glyph`]).
532 Context,
533}
534
535impl DiffLineKind {
536 /// Every [`DiffLineKind`] variant, in declaration order
537 /// ([`Self::Removed`], [`Self::Added`], [`Self::Context`]).
538 ///
539 /// The closed list of diff-line kinds shikumi recognizes. Peer of
540 /// [`crate::WatchEventClass::ALL`] (also three-cell) on the
541 /// watcher axis and the other closed-axis primitives' `ALL`
542 /// constants — same typescape discipline (closed `'static` slice,
543 /// in declaration order). Adding a new variant to [`Self`] means
544 /// extending this slice in lockstep; the cube-test cardinality
545 /// pin (`for_each_closed_axis_primitive!` checksum in
546 /// `cube::tests`) catches drift before silent dropouts.
547 pub const ALL: &'static [Self] = &[Self::Removed, Self::Added, Self::Context];
548
549 /// Canonical operator-facing lowercase name of the diff-line kind —
550 /// `"removed"`, `"added"`, or `"context"`.
551 ///
552 /// The single source of truth for the diff-cell kind label strings
553 /// on the [`DiffLineKind`] axis. Inherent mirror of the
554 /// [`crate::ClosedAxisLabel`] trait method; the trait impl
555 /// delegates here so the canonical names live at one site instead
556 /// of being re-stated at every operator-facing surface (per-kind
557 /// counters in a CLI `config-diff` summary, structured-log fields
558 /// naming the diff-cell class, attestation manifests recording the
559 /// diff-cell kind histogram between two config tiers). The
560 /// strings match the variant identifiers in ASCII-lowercase form.
561 #[must_use]
562 pub const fn as_str(self) -> &'static str {
563 match self {
564 Self::Removed => "removed",
565 Self::Added => "added",
566 Self::Context => "context",
567 }
568 }
569
570 /// Canonical unified-diff prefix character — `'-'` for
571 /// [`Self::Removed`], `'+'` for [`Self::Added`], `' '` for
572 /// [`Self::Context`]. The single source of truth for the per-kind
573 /// glyph used by [`ConfigDiff::render_unified`] and every future
574 /// renderer that emits the unified-diff line shape.
575 ///
576 /// Before this lift the three glyph characters lived inline at the
577 /// renderer's three-arm `match`; the kind axis names the
578 /// (variant → glyph) projection as a typed accessor, so a future
579 /// alternative renderer (a Markdown-fenced diff, a color-coded
580 /// terminal renderer routing glyph through a palette) reads one
581 /// accessor instead of re-stating the three-arm match.
582 #[must_use]
583 pub const fn glyph(self) -> char {
584 match self {
585 Self::Removed => '-',
586 Self::Added => '+',
587 Self::Context => ' ',
588 }
589 }
590
591 /// Whether this kind represents a structural change between the
592 /// two sides (`true` for [`Self::Added`] or [`Self::Removed`],
593 /// `false` for [`Self::Context`]).
594 ///
595 /// Refines [`ConfigDiff::is_empty_diff`]: a diff is empty exactly
596 /// when no [`DiffLine`] has a `is_changed` kind. The predicate
597 /// previously lived as inline `matches!(l, DiffLine::Added(_) |
598 /// DiffLine::Removed(_))` at the call site; the lift names the
599 /// (kind → is-it-a-change?) projection at the type level.
600 #[must_use]
601 pub const fn is_changed(self) -> bool {
602 matches!(self, Self::Added | Self::Removed)
603 }
604
605 /// Returns `true` for [`Self::Removed`]; equivalent to
606 /// `self == DiffLineKind::Removed`.
607 #[must_use]
608 pub const fn is_removed(self) -> bool {
609 matches!(self, Self::Removed)
610 }
611
612 /// Returns `true` for [`Self::Added`]; equivalent to
613 /// `self == DiffLineKind::Added`.
614 #[must_use]
615 pub const fn is_added(self) -> bool {
616 matches!(self, Self::Added)
617 }
618
619 /// Returns `true` for [`Self::Context`]; equivalent to
620 /// `self == DiffLineKind::Context`.
621 #[must_use]
622 pub const fn is_context(self) -> bool {
623 matches!(self, Self::Context)
624 }
625}
626
627impl crate::ClosedAxis for DiffLineKind {
628 const ALL: &'static [Self] = Self::ALL;
629}
630
631impl crate::ClosedAxisLabel for DiffLineKind {
632 fn as_str(self) -> &'static str {
633 Self::as_str(self)
634 }
635}
636
637impl ConfigDiff {
638 /// Minimum-viable diff: line-by-line walk of two YAML strings.
639 /// Lines that match position-wise are Context; non-matching
640 /// positions produce paired Removed/Added entries. Sufficient
641 /// for the operator UX of "see what changed between two tiers";
642 /// not a structural-merge replacement.
643 #[must_use]
644 pub fn from_yaml_pair(baseline: &str, candidate: &str) -> Self {
645 let a: Vec<&str> = baseline.lines().collect();
646 let b: Vec<&str> = candidate.lines().collect();
647 let mut lines = Vec::with_capacity(a.len().max(b.len()));
648 let mut i = 0;
649 let mut j = 0;
650 while i < a.len() || j < b.len() {
651 match (a.get(i), b.get(j)) {
652 (Some(la), Some(lb)) if la == lb => {
653 lines.push(DiffLine::Context((*la).to_string()));
654 i += 1;
655 j += 1;
656 }
657 (Some(la), Some(lb)) => {
658 lines.push(DiffLine::Removed((*la).to_string()));
659 lines.push(DiffLine::Added((*lb).to_string()));
660 i += 1;
661 j += 1;
662 }
663 (Some(la), None) => {
664 lines.push(DiffLine::Removed((*la).to_string()));
665 i += 1;
666 }
667 (None, Some(lb)) => {
668 lines.push(DiffLine::Added((*lb).to_string()));
669 j += 1;
670 }
671 (None, None) => break,
672 }
673 }
674 Self { lines }
675 }
676
677 /// Render as a unified-diff-like string for CLI display.
678 /// `-` prefix for Removed, `+` for Added, ` ` for Context.
679 ///
680 /// Routes the per-kind glyph through [`DiffLineKind::glyph`] and
681 /// the payload through [`DiffLine::text`], so the three magic
682 /// `'-' / '+' / ' '` characters live at one site
683 /// ([`DiffLineKind::glyph`]) instead of being re-stated at every
684 /// renderer's three-arm match.
685 #[must_use]
686 pub fn render_unified(&self) -> String {
687 let mut out = String::new();
688 for line in &self.lines {
689 out.push(line.kind().glyph());
690 out.push_str(line.text());
691 out.push('\n');
692 }
693 out
694 }
695
696 /// True when there are no Added or Removed lines (only Context).
697 /// I.e. baseline == candidate.
698 ///
699 /// Routes through [`DiffLineKind::is_changed`] — the
700 /// (variant → is-it-a-change?) projection lives at one site
701 /// instead of inlined here.
702 #[must_use]
703 pub fn is_empty_diff(&self) -> bool {
704 !self.lines.iter().any(|l| l.kind().is_changed())
705 }
706
707 /// Typed per-kind tally of [`Self::lines`] over the
708 /// [`DiffLineKind`] axis — the dense histogram every CLI
709 /// `config-diff` summary, dashboard, attestation manifest, and
710 /// alerting policy bucketing the (added × removed × context) line
711 /// counts has previously re-derived inline.
712 ///
713 /// Equivalent to
714 /// `crate::axis_histogram(self.lines.iter().map(DiffLine::kind))`
715 /// but named at the [`ConfigDiff`] surface so consumers reading a
716 /// diff don't reach for the cube-level generic helper. The
717 /// histogram's `total()` equals `self.lines.len()` pointwise (every
718 /// line projects to exactly one kind); `is_empty()` iff
719 /// `self.lines.is_empty()`; `count(DiffLineKind::Added) +
720 /// count(DiffLineKind::Removed)` equals zero iff [`Self::is_empty_diff`]
721 /// returns `true` — pinned by
722 /// `kind_histogram_changed_cells_match_is_empty_diff`.
723 #[must_use]
724 pub fn kind_histogram(&self) -> crate::AxisHistogram<DiffLineKind> {
725 crate::axis_histogram(self.lines.iter().map(DiffLine::kind))
726 }
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732 use serde::Deserialize;
733
734 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
735 struct Toy {
736 name: String,
737 size: u32,
738 flag: bool,
739 }
740
741 impl TieredConfig for Toy {
742 fn bare() -> Self {
743 Self {
744 name: String::new(),
745 size: 0,
746 flag: false,
747 }
748 }
749 fn prescribed_default() -> Self {
750 Self {
751 name: "default-name".into(),
752 size: 42,
753 flag: true,
754 }
755 }
756 }
757
758 #[test]
759 fn bare_returns_floor_values() {
760 let b = Toy::bare();
761 assert_eq!(b.name, "");
762 assert_eq!(b.size, 0);
763 assert!(!b.flag);
764 }
765
766 #[test]
767 fn prescribed_default_is_different_from_bare() {
768 let b = Toy::bare();
769 let p = Toy::prescribed_default();
770 assert_ne!(b, p);
771 }
772
773 #[test]
774 fn discovered_default_impl_returns_bare() {
775 // No override → discovered is identical to bare.
776 let d = Toy::discovered();
777 let b = Toy::bare();
778 assert_eq!(d, b);
779 }
780
781 #[test]
782 fn diff_against_self_is_empty() {
783 let p = Toy::prescribed_default();
784 let diff = p.diff_against(&p);
785 assert!(diff.is_empty_diff());
786 }
787
788 #[test]
789 fn diff_bare_vs_default_yields_added_and_removed_lines() {
790 let b = Toy::bare();
791 let p = Toy::prescribed_default();
792 let diff = p.diff_against(&b);
793 assert!(!diff.is_empty_diff());
794 let has_added = diff
795 .lines
796 .iter()
797 .any(|l| matches!(l, DiffLine::Added(s) if s.contains("default-name")));
798 let has_removed = diff
799 .lines
800 .iter()
801 .any(|l| matches!(l, DiffLine::Removed(s) if s.contains("name: ''")));
802 assert!(has_added, "diff should add the prescribed name");
803 assert!(has_removed, "diff should remove the bare empty name");
804 }
805
806 #[test]
807 fn render_unified_uses_diff_prefixes() {
808 let b = Toy::bare();
809 let p = Toy::prescribed_default();
810 let rendered = p.diff_against(&b).render_unified();
811 assert!(rendered.contains("-name: ''"));
812 assert!(rendered.contains("+name: default-name"));
813 }
814
815 #[test]
816 fn extend_default_impl_full_replaces_base() {
817 let b = Toy::bare();
818 let p = Toy::prescribed_default();
819 let merged = p.clone().extend(&b);
820 assert_eq!(merged, p);
821 }
822
823 // ── ConfigTier + resolve_tier coverage ──────────────────────
824
825 #[test]
826 fn config_tier_default_is_default_variant() {
827 assert_eq!(ConfigTier::default(), ConfigTier::Default);
828 }
829
830 #[test]
831 fn config_tier_from_str_recognizes_named_tiers() {
832 assert_eq!(ConfigTier::from_str_or_default("bare"), ConfigTier::Bare);
833 assert_eq!(
834 ConfigTier::from_str_or_default("DISCOVERED"),
835 ConfigTier::Discovered
836 );
837 assert_eq!(
838 ConfigTier::from_str_or_default("default"),
839 ConfigTier::Default
840 );
841 assert_eq!(ConfigTier::from_str_or_default(""), ConfigTier::Default);
842 match ConfigTier::from_str_or_default("/etc/foo.yaml") {
843 ConfigTier::Custom(p) => {
844 assert_eq!(p, std::path::PathBuf::from("/etc/foo.yaml"));
845 }
846 other => panic!("expected Custom, got {other:?}"),
847 }
848 }
849
850 #[test]
851 fn config_tier_names_are_stable() {
852 assert_eq!(ConfigTier::Bare.name(), "bare");
853 assert_eq!(ConfigTier::Discovered.name(), "discovered");
854 assert_eq!(ConfigTier::Default.name(), "default");
855 assert_eq!(
856 ConfigTier::Custom(std::path::PathBuf::from("/x")).name(),
857 "custom"
858 );
859 }
860
861 #[test]
862 fn config_tier_from_env_resolves_correctly() {
863 let key = "SHIKUMI_TIERED_TEST_TIER_X";
864 // Set to "bare", verify resolution.
865 // SAFETY: tests run single-threaded per test by default;
866 // we restore + clear the env var on every branch.
867 unsafe {
868 std::env::set_var(key, "bare");
869 }
870 assert_eq!(ConfigTier::from_env(key), ConfigTier::Bare);
871 unsafe {
872 std::env::set_var(key, "");
873 }
874 assert_eq!(ConfigTier::from_env(key), ConfigTier::Default);
875 unsafe {
876 std::env::remove_var(key);
877 }
878 assert_eq!(ConfigTier::from_env(key), ConfigTier::Default);
879 }
880
881 #[test]
882 fn resolve_tier_dispatches_to_each_method() {
883 assert_eq!(Toy::resolve_tier(ConfigTier::Bare), Toy::bare());
884 assert_eq!(Toy::resolve_tier(ConfigTier::Discovered), Toy::discovered());
885 assert_eq!(
886 Toy::resolve_tier(ConfigTier::Default),
887 Toy::prescribed_default()
888 );
889 }
890
891 #[test]
892 fn resolve_tier_custom_missing_file_falls_back_to_default() {
893 let phantom = std::path::PathBuf::from("/nonexistent/path/shikumi-tier-fallback-test.yaml");
894 let resolved = Toy::resolve_tier(ConfigTier::Custom(phantom));
895 assert_eq!(resolved, Toy::prescribed_default());
896 }
897
898 // ── ConfigTierKind + ConfigTier::kind coverage ──────────────
899
900 #[test]
901 fn config_tier_kind_all_has_four_entries() {
902 // Pin today's tier-kind cardinality. A fifth tier kind
903 // landing forces the ::ALL slice in lockstep with the
904 // enum, and the `for_each_closed_axis_primitive!` macro
905 // cardinality checksum in `cube::tests` (axis_cardinality
906 // sum) catches the drift before silent dropouts at the
907 // trait-uniform test sites.
908 assert_eq!(ConfigTierKind::ALL.len(), 4);
909 assert_eq!(ConfigTierKind::ALL[0], ConfigTierKind::Bare);
910 assert_eq!(ConfigTierKind::ALL[1], ConfigTierKind::Discovered);
911 assert_eq!(ConfigTierKind::ALL[2], ConfigTierKind::Default);
912 assert_eq!(ConfigTierKind::ALL[3], ConfigTierKind::Custom);
913 }
914
915 #[test]
916 fn config_tier_kind_trait_all_matches_inherent_all() {
917 // Mirror of the per-axis trait/inherent agreement test:
918 // <ConfigTierKind as ClosedAxis>::ALL is the same slice as
919 // ConfigTierKind::ALL pointwise in declaration order.
920 assert_eq!(
921 <ConfigTierKind as crate::ClosedAxis>::ALL.len(),
922 ConfigTierKind::ALL.len(),
923 );
924 for (i, (trait_kind, inherent_kind)) in <ConfigTierKind as crate::ClosedAxis>::ALL
925 .iter()
926 .zip(ConfigTierKind::ALL.iter())
927 .enumerate()
928 {
929 assert_eq!(
930 trait_kind, inherent_kind,
931 "trait ALL[{i}] must equal inherent ALL[{i}]",
932 );
933 }
934 }
935
936 #[test]
937 fn config_tier_kind_as_str_yields_canonical_lowercase_names() {
938 assert_eq!(ConfigTierKind::Bare.as_str(), "bare");
939 assert_eq!(ConfigTierKind::Discovered.as_str(), "discovered");
940 assert_eq!(ConfigTierKind::Default.as_str(), "default");
941 assert_eq!(ConfigTierKind::Custom.as_str(), "custom");
942 }
943
944 #[test]
945 fn config_tier_kind_from_str_round_trips_with_as_str() {
946 // Round-trip law: `from_str(kind.as_str()) == Some(kind)`
947 // for every kind. Pinned over the full ::ALL slice so a
948 // fifth tier kind inherits the law automatically.
949 for &kind in ConfigTierKind::ALL {
950 assert_eq!(
951 ConfigTierKind::from_str(kind.as_str()),
952 Some(kind),
953 "round-trip failed for kind {kind:?}",
954 );
955 }
956 }
957
958 #[test]
959 fn config_tier_kind_from_str_is_case_insensitive() {
960 assert_eq!(ConfigTierKind::from_str("BARE"), Some(ConfigTierKind::Bare),);
961 assert_eq!(
962 ConfigTierKind::from_str("Discovered"),
963 Some(ConfigTierKind::Discovered),
964 );
965 assert_eq!(
966 ConfigTierKind::from_str("DeFaUlT"),
967 Some(ConfigTierKind::Default),
968 );
969 assert_eq!(
970 ConfigTierKind::from_str("CUSTOM"),
971 Some(ConfigTierKind::Custom),
972 );
973 }
974
975 #[test]
976 fn config_tier_kind_from_str_returns_none_on_unknown() {
977 assert_eq!(ConfigTierKind::from_str(""), None);
978 assert_eq!(ConfigTierKind::from_str("nonexistent"), None);
979 assert_eq!(ConfigTierKind::from_str("/etc/foo.yaml"), None);
980 // No trim — the caller owns trim policy.
981 assert_eq!(ConfigTierKind::from_str(" bare "), None);
982 }
983
984 #[test]
985 fn config_tier_kind_projection_matches_config_tier_name() {
986 // The kind projection and the ConfigTier::name() lookup
987 // must agree pointwise — both are routed through
988 // ConfigTierKind::as_str. Pins the duplication budget at
989 // zero: the four tier-name strings live at one site
990 // (ConfigTierKind::as_str).
991 let pairs: [(ConfigTier, ConfigTierKind); 4] = [
992 (ConfigTier::Bare, ConfigTierKind::Bare),
993 (ConfigTier::Discovered, ConfigTierKind::Discovered),
994 (ConfigTier::Default, ConfigTierKind::Default),
995 (
996 ConfigTier::Custom(std::path::PathBuf::from("/x")),
997 ConfigTierKind::Custom,
998 ),
999 ];
1000 for (tier, expected_kind) in pairs {
1001 assert_eq!(tier.kind(), expected_kind);
1002 assert_eq!(tier.name(), expected_kind.as_str());
1003 }
1004 }
1005
1006 #[test]
1007 fn config_tier_from_env_still_lowercases_unknown_paths() {
1008 // Behavior preservation: prior implementation lowercased
1009 // unrecognized strings before wrapping them in Custom.
1010 // This pin catches any future drift away from that
1011 // (somewhat surprising) behavior — kept so that the lift
1012 // is purely structural and doesn't change semantics.
1013 let key = "SHIKUMI_TIERED_TEST_TIER_PATH";
1014 unsafe {
1015 std::env::set_var(key, "/Foo/Bar.YAML");
1016 }
1017 let tier = ConfigTier::from_env(key);
1018 match tier {
1019 ConfigTier::Custom(p) => assert_eq!(
1020 p,
1021 std::path::PathBuf::from("/foo/bar.yaml"),
1022 "from_env preserves the pre-lift lowercase behavior",
1023 ),
1024 other => panic!("expected Custom, got {other:?}"),
1025 }
1026 unsafe {
1027 std::env::remove_var(key);
1028 }
1029 }
1030
1031 // ── DiffLineKind + DiffLine::kind coverage ──────────────────
1032 //
1033 // The (DiffLine → DiffLineKind) lift closes the diff-cell kind
1034 // partition on the third closed three-way classification of the
1035 // typescape, alongside `ConfigSourceKind` (3 cells), `FieldPathLocalization`
1036 // (3), and `WatchEventClass` (3). Tests mirror the
1037 // `EnvMetadataTagKind` suite pointwise on the source axis:
1038 // forward-map exhaustivity, payload-independence, trait-bounds
1039 // parity, no-duplicates on the closed list, image containment in
1040 // `ALL`, declaration-order pin, concrete-position canonical
1041 // labels, glyph-pin against the operator-facing unified-diff
1042 // convention, refactor pins on the two consumer sites
1043 // (`is_empty_diff` / `render_unified`), and the trait-default
1044 // round-trip.
1045
1046 fn canonical_diff_line_kind_samples() -> Vec<(DiffLine, DiffLineKind)> {
1047 vec![
1048 (DiffLine::Removed("name: ''".into()), DiffLineKind::Removed),
1049 (
1050 DiffLine::Added("name: default-name".into()),
1051 DiffLineKind::Added,
1052 ),
1053 (DiffLine::Context("size: 42".into()), DiffLineKind::Context),
1054 (DiffLine::Removed(String::new()), DiffLineKind::Removed),
1055 (DiffLine::Added(String::new()), DiffLineKind::Added),
1056 (DiffLine::Context(String::new()), DiffLineKind::Context),
1057 ]
1058 }
1059
1060 #[test]
1061 fn diff_line_kind_classifies_each_variant() {
1062 // The forward map DiffLine → DiffLineKind is exhaustive: every
1063 // variant pins to exactly one kind.
1064 assert_eq!(DiffLine::Removed("x".into()).kind(), DiffLineKind::Removed,);
1065 assert_eq!(DiffLine::Added("y".into()).kind(), DiffLineKind::Added);
1066 assert_eq!(DiffLine::Context("z".into()).kind(), DiffLineKind::Context,);
1067 }
1068
1069 #[test]
1070 fn diff_line_kind_is_data_free() {
1071 // Inner payload does not influence kind — every Removed
1072 // variant maps to DiffLineKind::Removed regardless of the
1073 // inner String. Mirrors `env_metadata_tag_kind_is_data_free`
1074 // on the figment-Name env-name sub-axis.
1075 for payload in ["", "a", "name: 'long value' ", "\n", "\u{1F600}"] {
1076 assert_eq!(
1077 DiffLine::Removed(payload.to_string()).kind(),
1078 DiffLineKind::Removed,
1079 );
1080 assert_eq!(
1081 DiffLine::Added(payload.to_string()).kind(),
1082 DiffLineKind::Added,
1083 );
1084 assert_eq!(
1085 DiffLine::Context(payload.to_string()).kind(),
1086 DiffLineKind::Context,
1087 );
1088 }
1089 }
1090
1091 #[test]
1092 fn diff_line_kind_agrees_with_predicates_pointwise() {
1093 // The kind() projection must agree with the kind-side
1094 // `is_removed` / `is_added` / `is_context` predicates pointwise
1095 // on every constructible variant.
1096 for (line, expected) in canonical_diff_line_kind_samples() {
1097 let k = line.kind();
1098 assert_eq!(k, expected);
1099 assert_eq!(k.is_removed(), k == DiffLineKind::Removed);
1100 assert_eq!(k.is_added(), k == DiffLineKind::Added);
1101 assert_eq!(k.is_context(), k == DiffLineKind::Context);
1102 }
1103 }
1104
1105 #[test]
1106 fn diff_line_kind_is_changed_partitions_added_or_removed() {
1107 // is_changed() partitions the kind axis: true exactly on the
1108 // two changed kinds (Added, Removed), false on Context. The
1109 // partition is the structural law `ConfigDiff::is_empty_diff`
1110 // refines through `.kind().is_changed()`.
1111 assert!(DiffLineKind::Removed.is_changed());
1112 assert!(DiffLineKind::Added.is_changed());
1113 assert!(!DiffLineKind::Context.is_changed());
1114 }
1115
1116 #[test]
1117 fn diff_line_kind_is_static_and_copy_and_hashable() {
1118 // The discriminant is `'static` (no lifetime parameter) and
1119 // Copy + Hash + Eq, so it can be hashed in a `'static` map and
1120 // cross thread boundaries the borrowed payload `&String`
1121 // cannot. Trait bounds match the sibling typescape primitives.
1122 use std::collections::HashSet;
1123 fn assert_static<T: 'static>() {}
1124 assert_static::<DiffLineKind>();
1125 let mut set: HashSet<DiffLineKind> = DiffLineKind::ALL.iter().copied().collect();
1126 set.insert(DiffLineKind::Removed); // duplicate
1127 assert_eq!(set.len(), DiffLineKind::ALL.len());
1128 // Copy: rebind without move.
1129 let k = DiffLineKind::Added;
1130 let k2 = k;
1131 assert_eq!(k, k2);
1132 }
1133
1134 #[test]
1135 fn diff_line_kind_all_has_no_duplicates() {
1136 // `ALL` is a set on the closed axis — no duplicated variant.
1137 use std::collections::HashSet;
1138 let unique: HashSet<DiffLineKind> = DiffLineKind::ALL.iter().copied().collect();
1139 assert_eq!(unique.len(), DiffLineKind::ALL.len());
1140 }
1141
1142 #[test]
1143 fn diff_line_kind_all_covers_every_constructible_line() {
1144 // Every kind produced by DiffLine::kind() on the canonical
1145 // sample table appears in DiffLineKind::ALL. Catches drift if
1146 // a future DiffLine variant lands without extending ::ALL.
1147 for (line, _) in canonical_diff_line_kind_samples() {
1148 assert!(
1149 DiffLineKind::ALL.contains(&line.kind()),
1150 "DiffLineKind::ALL must contain the kind of every constructible DiffLine",
1151 );
1152 }
1153 }
1154
1155 #[test]
1156 fn diff_line_kind_all_equals_diff_line_kind_image() {
1157 // Tight image / `ALL` equality: the image of DiffLine::kind
1158 // over the canonical sample table equals DiffLineKind::ALL as
1159 // a set — no kind cell is unreachable, no orphan cell exists.
1160 use std::collections::HashSet;
1161 let image: HashSet<DiffLineKind> = canonical_diff_line_kind_samples()
1162 .into_iter()
1163 .map(|(l, _)| l.kind())
1164 .collect();
1165 let all: HashSet<DiffLineKind> = DiffLineKind::ALL.iter().copied().collect();
1166 assert_eq!(image, all);
1167 }
1168
1169 #[test]
1170 fn diff_line_kind_all_declaration_order_is_removed_added_context() {
1171 // Declaration order pin. Mirror of the renderer's natural
1172 // reading order (removed → added → context) so the canonical
1173 // axis enumeration matches the unified-diff legend operators
1174 // already read in tools.
1175 assert_eq!(DiffLineKind::ALL.len(), 3);
1176 assert_eq!(DiffLineKind::ALL[0], DiffLineKind::Removed);
1177 assert_eq!(DiffLineKind::ALL[1], DiffLineKind::Added);
1178 assert_eq!(DiffLineKind::ALL[2], DiffLineKind::Context);
1179 }
1180
1181 #[test]
1182 fn diff_line_kind_as_str_yields_canonical_lowercase_names() {
1183 // Concrete-position pin on the canonical operator-facing
1184 // labels. A rename here would surface a literal string change
1185 // before drifting through the round-trip law.
1186 assert_eq!(DiffLineKind::Removed.as_str(), "removed");
1187 assert_eq!(DiffLineKind::Added.as_str(), "added");
1188 assert_eq!(DiffLineKind::Context.as_str(), "context");
1189 }
1190
1191 #[test]
1192 fn diff_line_kind_glyph_yields_canonical_unified_diff_prefixes() {
1193 // Concrete-position pin on the canonical unified-diff glyphs.
1194 // The three glyph characters previously lived inline at the
1195 // renderer's three-arm match; pinning them at the kind axis
1196 // catches any future rename before drifting through the
1197 // renderer.
1198 assert_eq!(DiffLineKind::Removed.glyph(), '-');
1199 assert_eq!(DiffLineKind::Added.glyph(), '+');
1200 assert_eq!(DiffLineKind::Context.glyph(), ' ');
1201 }
1202
1203 #[test]
1204 fn diff_line_text_returns_inner_payload_pointwise() {
1205 // The `text` accessor borrows the inner payload regardless of
1206 // kind. Composes with `.kind()` to losslessly decompose a
1207 // DiffLine into its (kind, text) pair — the natural shape for
1208 // any renderer that previously matched on the three variants.
1209 for payload in ["", "a", "name: value", " leading spaces"] {
1210 assert_eq!(DiffLine::Removed(payload.to_string()).text(), payload);
1211 assert_eq!(DiffLine::Added(payload.to_string()).text(), payload);
1212 assert_eq!(DiffLine::Context(payload.to_string()).text(), payload);
1213 }
1214 }
1215
1216 #[test]
1217 fn diff_line_kind_from_canonical_str_round_trips_through_trait() {
1218 // Trait-default round-trip law: case-insensitively, every
1219 // canonical label parses back to its kind via the
1220 // `ClosedAxisLabel` trait default. Mixed-case inputs hit the
1221 // case-insensitive parse path.
1222 use crate::ClosedAxisLabel;
1223 for &k in DiffLineKind::ALL {
1224 let lower = k.as_str();
1225 assert_eq!(DiffLineKind::from_canonical_str(lower), Some(k));
1226 let upper = lower.to_ascii_uppercase();
1227 assert_eq!(DiffLineKind::from_canonical_str(&upper), Some(k));
1228 // Mixed: capitalize first letter only.
1229 let mut mixed = String::new();
1230 for (i, c) in lower.chars().enumerate() {
1231 if i == 0 {
1232 mixed.extend(c.to_uppercase());
1233 } else {
1234 mixed.push(c);
1235 }
1236 }
1237 assert_eq!(DiffLineKind::from_canonical_str(&mixed), Some(k));
1238 }
1239 }
1240
1241 #[test]
1242 fn config_diff_is_empty_diff_routes_through_diff_line_kind_is_changed() {
1243 // Pin the structural refactor: `ConfigDiff::is_empty_diff`
1244 // returns false iff some line has a `is_changed` kind. A diff
1245 // composed of only Context lines is empty; any Added or
1246 // Removed line makes it non-empty regardless of how many
1247 // Context lines surround it.
1248 let only_context = ConfigDiff {
1249 lines: vec![DiffLine::Context("a".into()), DiffLine::Context("b".into())],
1250 };
1251 assert!(only_context.is_empty_diff());
1252
1253 let with_added = ConfigDiff {
1254 lines: vec![
1255 DiffLine::Context("a".into()),
1256 DiffLine::Added("c".into()),
1257 DiffLine::Context("b".into()),
1258 ],
1259 };
1260 assert!(!with_added.is_empty_diff());
1261
1262 let with_removed = ConfigDiff {
1263 lines: vec![DiffLine::Removed("x".into())],
1264 };
1265 assert!(!with_removed.is_empty_diff());
1266
1267 let empty_lines = ConfigDiff { lines: vec![] };
1268 assert!(empty_lines.is_empty_diff());
1269 }
1270
1271 #[test]
1272 fn config_diff_render_unified_emits_one_glyph_per_kind() {
1273 // Pin the structural refactor: `ConfigDiff::render_unified`
1274 // routes each line's glyph through `DiffLineKind::glyph` and
1275 // each payload through `DiffLine::text`. The rendered output
1276 // is byte-identical to the prior open-coded three-arm match.
1277 let diff = ConfigDiff {
1278 lines: vec![
1279 DiffLine::Removed("name: ''".into()),
1280 DiffLine::Added("name: default-name".into()),
1281 DiffLine::Context("size: 42".into()),
1282 ],
1283 };
1284 let rendered = diff.render_unified();
1285 assert_eq!(
1286 rendered, "-name: ''\n+name: default-name\n size: 42\n",
1287 "render_unified must emit the canonical glyph per kind",
1288 );
1289 // Pointwise: each line's first character equals its kind's glyph.
1290 for (i, line) in diff.lines.iter().enumerate() {
1291 let expected_glyph = line.kind().glyph();
1292 let actual_first = rendered
1293 .lines()
1294 .nth(i)
1295 .and_then(|s| s.chars().next())
1296 .expect("rendered output must have at least i+1 lines");
1297 assert_eq!(
1298 actual_first, expected_glyph,
1299 "rendered line {i} must start with its kind's glyph",
1300 );
1301 }
1302 }
1303
1304 #[test]
1305 fn config_diff_render_unified_byte_identical_to_pre_lift_form() {
1306 // Strong pin on the refactor: the rendered output must match
1307 // what the prior three-arm match produced byte-for-byte across
1308 // every line position (empty payloads, mixed kinds, trailing
1309 // newlines). Composes with the kind-axis lift without changing
1310 // the operator-facing surface.
1311 let diff = ConfigDiff {
1312 lines: vec![
1313 DiffLine::Context(String::new()),
1314 DiffLine::Removed("a".into()),
1315 DiffLine::Added("b".into()),
1316 DiffLine::Context("c".into()),
1317 ],
1318 };
1319 // Pre-lift expected output:
1320 // " \n" + "-a\n" + "+b\n" + " c\n"
1321 assert_eq!(diff.render_unified(), " \n-a\n+b\n c\n");
1322 }
1323
1324 #[test]
1325 fn config_tier_from_str_or_default_via_kind_dispatch() {
1326 // Smoke pin on the refactored dispatch — same matching
1327 // rules as before, now routed through ConfigTierKind.
1328 assert_eq!(ConfigTier::from_str_or_default("bare"), ConfigTier::Bare);
1329 assert_eq!(
1330 ConfigTier::from_str_or_default("DISCOVERED"),
1331 ConfigTier::Discovered,
1332 );
1333 assert_eq!(
1334 ConfigTier::from_str_or_default("default"),
1335 ConfigTier::Default,
1336 );
1337 assert_eq!(ConfigTier::from_str_or_default(""), ConfigTier::Default,);
1338 // "custom" with no path → Custom(PathBuf::from("custom"))
1339 // (the literal string becomes the path). Preserves the
1340 // pre-lift fall-through behavior.
1341 match ConfigTier::from_str_or_default("custom") {
1342 ConfigTier::Custom(p) => {
1343 assert_eq!(p, std::path::PathBuf::from("custom"));
1344 }
1345 other => panic!("expected Custom, got {other:?}"),
1346 }
1347 match ConfigTier::from_str_or_default("/etc/foo.yaml") {
1348 ConfigTier::Custom(p) => {
1349 assert_eq!(p, std::path::PathBuf::from("/etc/foo.yaml"));
1350 }
1351 other => panic!("expected Custom, got {other:?}"),
1352 }
1353 }
1354
1355 #[test]
1356 fn kind_histogram_counts_each_kind_pointwise() {
1357 // Concrete pin on the [`ConfigDiff::kind_histogram`] lift: the
1358 // per-cell counts agree with the manual filter-and-count loop
1359 // it replaces. The fixture covers the three diff-cell kinds at
1360 // distinct cardinalities so the per-cell numbers are
1361 // distinguishable (1 removed, 2 added, 3 context).
1362 let diff = ConfigDiff {
1363 lines: vec![
1364 DiffLine::Removed("r1".into()),
1365 DiffLine::Added("a1".into()),
1366 DiffLine::Added("a2".into()),
1367 DiffLine::Context("c1".into()),
1368 DiffLine::Context("c2".into()),
1369 DiffLine::Context("c3".into()),
1370 ],
1371 };
1372 let hist = diff.kind_histogram();
1373 assert_eq!(hist.count(DiffLineKind::Removed), 1);
1374 assert_eq!(hist.count(DiffLineKind::Added), 2);
1375 assert_eq!(hist.count(DiffLineKind::Context), 3);
1376 assert_eq!(hist.total(), diff.lines.len());
1377 }
1378
1379 #[test]
1380 fn kind_histogram_empty_diff_is_zero_on_every_cell() {
1381 // An empty [`ConfigDiff`] yields the all-zero histogram: total
1382 // = 0, every cell = 0, `is_empty()` = true. The identity slot
1383 // of the histogram monoid on the diff-cell axis.
1384 let diff = ConfigDiff::default();
1385 let hist = diff.kind_histogram();
1386 assert_eq!(hist.total(), 0);
1387 assert!(hist.is_empty());
1388 for cell in [
1389 DiffLineKind::Removed,
1390 DiffLineKind::Added,
1391 DiffLineKind::Context,
1392 ] {
1393 assert_eq!(hist.count(cell), 0);
1394 }
1395 }
1396
1397 #[test]
1398 fn kind_histogram_changed_cells_match_is_empty_diff() {
1399 // Cross-primitive law: the sum of the [`DiffLineKind::Added`]
1400 // and [`DiffLineKind::Removed`] cells equals zero iff
1401 // [`ConfigDiff::is_empty_diff`] returns true. Both
1402 // surfaces project from the same partition over the
1403 // [`DiffLineKind`] axis (the `is_changed()` half), so the
1404 // agreement is structural — pinned here on a context-only
1405 // diff (empty by structure) and on a mixed diff.
1406 let context_only = ConfigDiff {
1407 lines: vec![DiffLine::Context("c".into())],
1408 };
1409 let h1 = context_only.kind_histogram();
1410 assert!(context_only.is_empty_diff());
1411 assert_eq!(
1412 h1.count(DiffLineKind::Added) + h1.count(DiffLineKind::Removed),
1413 0
1414 );
1415
1416 let with_change = ConfigDiff {
1417 lines: vec![DiffLine::Context("c".into()), DiffLine::Added("a".into())],
1418 };
1419 let h2 = with_change.kind_histogram();
1420 assert!(!with_change.is_empty_diff());
1421 assert!(h2.count(DiffLineKind::Added) + h2.count(DiffLineKind::Removed) > 0);
1422 }
1423
1424 #[test]
1425 fn kind_histogram_iter_yields_declaration_order() {
1426 // The histogram's `iter()` walks
1427 // [`DiffLineKind::ALL`] in declaration order
1428 // (Removed, Added, Context) regardless of input ordering.
1429 // Pinned here against an input that observes Context first,
1430 // then Added, then Removed — the histogram's iteration order
1431 // is by axis declaration, not by observation order.
1432 let diff = ConfigDiff {
1433 lines: vec![
1434 DiffLine::Context("c".into()),
1435 DiffLine::Added("a".into()),
1436 DiffLine::Removed("r".into()),
1437 ],
1438 };
1439 let pairs: Vec<(DiffLineKind, usize)> = diff.kind_histogram().iter().collect();
1440 assert_eq!(
1441 pairs,
1442 vec![
1443 (DiffLineKind::Removed, 1),
1444 (DiffLineKind::Added, 1),
1445 (DiffLineKind::Context, 1),
1446 ],
1447 );
1448 }
1449}