Skip to main content

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}