use serde::{Serialize, de::DeserializeOwned};
use std::env;
use std::fmt;
use std::str::FromStr;
use crate::error::ShikumiError;
#[non_exhaustive]
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
gen_platform::TypedDispatcher,
gen_platform::Discriminant,
gen_platform::IsVariant,
gen_platform::FromStrKind,
)]
#[discriminant(also_display)]
pub enum ConfigTierKind {
Bare,
Discovered,
#[allow(clippy::module_name_repetitions)]
Default,
Custom,
}
gen_platform::register_dispatcher!("shikumi.config-tier-kind", ConfigTierKind);
impl ConfigTierKind {
pub const ALL: &'static [Self] = &[Self::Bare, Self::Discovered, Self::Default, Self::Custom];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Bare => "bare",
Self::Discovered => "discovered",
Self::Default => "default",
Self::Custom => "custom",
}
}
#[allow(clippy::should_implement_trait)]
#[must_use]
pub fn from_str(s: &str) -> Option<Self> {
<Self as crate::ClosedAxisLabel>::from_canonical_str(s)
}
}
impl crate::ClosedAxis for ConfigTierKind {
const ALL: &'static [Self] = Self::ALL;
}
impl crate::ClosedAxisLabel for ConfigTierKind {
fn as_str(self) -> &'static str {
Self::as_str(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigTier {
Bare,
Discovered,
#[allow(clippy::module_name_repetitions)]
Default,
Custom(std::path::PathBuf),
}
impl Default for ConfigTier {
fn default() -> Self {
Self::Default
}
}
impl ConfigTier {
#[must_use]
pub fn from_env(env_var: &str) -> Self {
env::var(env_var)
.map(|raw| Self::from_str_or_default(&raw))
.unwrap_or_default()
}
#[must_use]
pub fn from_str_or_default(s: &str) -> Self {
let normalized = s.trim().to_ascii_lowercase();
if normalized.is_empty() {
return Self::default();
}
match ConfigTierKind::from_str(&normalized) {
Some(ConfigTierKind::Bare) => Self::Bare,
Some(ConfigTierKind::Discovered) => Self::Discovered,
Some(ConfigTierKind::Default) => Self::Default,
Some(ConfigTierKind::Custom) | None => {
Self::Custom(std::path::PathBuf::from(normalized))
}
}
}
#[must_use]
pub fn name(&self) -> &'static str {
self.kind().as_str()
}
#[must_use]
pub const fn kind(&self) -> ConfigTierKind {
match self {
Self::Bare => ConfigTierKind::Bare,
Self::Discovered => ConfigTierKind::Discovered,
Self::Default => ConfigTierKind::Default,
Self::Custom(_) => ConfigTierKind::Custom,
}
}
}
pub trait TieredConfig: Sized + Clone + Serialize + DeserializeOwned {
fn bare() -> Self;
fn discovered() -> Self {
Self::bare()
}
fn prescribed_default() -> Self;
fn extend(self, _base: &Self) -> Self {
self
}
fn resolve_tier(tier: ConfigTier) -> Self {
match tier {
ConfigTier::Bare => Self::bare(),
ConfigTier::Discovered => Self::discovered(),
ConfigTier::Default => Self::prescribed_default(),
ConfigTier::Custom(path) => {
let base = Self::prescribed_default();
match std::fs::read_to_string(&path) {
Ok(s) => match serde_yaml::from_str::<Self>(&s) {
Ok(overlay) => overlay.extend(&base),
Err(e) => {
tracing::warn!(
target: "shikumi::tiered",
error = %e,
path = %path.display(),
"custom tier YAML failed to deserialize — falling back to prescribed_default"
);
base
}
},
Err(e) => {
tracing::warn!(
target: "shikumi::tiered",
error = %e,
path = %path.display(),
"custom tier YAML not readable — falling back to prescribed_default"
);
base
}
}
}
}
}
fn resolve_from_env(env_var: &str) -> Self {
Self::resolve_tier(ConfigTier::from_env(env_var))
}
fn diff_against(&self, baseline: &Self) -> ConfigDiff {
let a = serde_yaml::to_string(baseline).unwrap_or_default();
let b = serde_yaml::to_string(self).unwrap_or_default();
ConfigDiff::from_yaml_pair(&a, &b)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConfigDiff {
pub lines: Vec<DiffLine>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffLine {
Removed(String),
Added(String),
Context(String),
}
impl DiffLine {
#[must_use]
pub const fn kind(&self) -> DiffLineKind {
match self {
Self::Removed(_) => DiffLineKind::Removed,
Self::Added(_) => DiffLineKind::Added,
Self::Context(_) => DiffLineKind::Context,
}
}
#[must_use]
pub fn text(&self) -> &str {
match self {
Self::Removed(s) | Self::Added(s) | Self::Context(s) => s.as_str(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum DiffLineKind {
Removed,
Added,
Context,
}
impl DiffLineKind {
pub const ALL: &'static [Self] = &[Self::Removed, Self::Added, Self::Context];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Removed => "removed",
Self::Added => "added",
Self::Context => "context",
}
}
#[must_use]
pub const fn glyph(self) -> char {
match self {
Self::Removed => '-',
Self::Added => '+',
Self::Context => ' ',
}
}
#[must_use]
pub const fn is_changed(self) -> bool {
matches!(self, Self::Added | Self::Removed)
}
#[must_use]
pub const fn is_removed(self) -> bool {
matches!(self, Self::Removed)
}
#[must_use]
pub const fn is_added(self) -> bool {
matches!(self, Self::Added)
}
#[must_use]
pub const fn is_context(self) -> bool {
matches!(self, Self::Context)
}
}
impl crate::ClosedAxis for DiffLineKind {
const ALL: &'static [Self] = Self::ALL;
}
impl crate::ClosedAxisLabel for DiffLineKind {
fn as_str(self) -> &'static str {
Self::as_str(self)
}
}
impl fmt::Display for DiffLineKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for DiffLineKind {
type Err = ShikumiError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
<Self as crate::ClosedAxisLabel>::from_canonical_str(s)
.ok_or_else(|| ShikumiError::Parse(format!("unknown diff line kind: {s}")))
}
}
impl serde::Serialize for DiffLineKind {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> serde::Deserialize<'de> for DiffLineKind {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct DiffLineKindVisitor;
impl serde::de::Visitor<'_> for DiffLineKindVisitor {
type Value = DiffLineKind;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(
"a canonical DiffLineKind lowercase label \
(`removed`, `added`, `context`; case-insensitive)",
)
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<DiffLineKind, E> {
v.parse::<DiffLineKind>().map_err(E::custom)
}
}
deserializer.deserialize_str(DiffLineKindVisitor)
}
}
impl ConfigDiff {
#[must_use]
pub fn from_yaml_pair(baseline: &str, candidate: &str) -> Self {
let a: Vec<&str> = baseline.lines().collect();
let b: Vec<&str> = candidate.lines().collect();
let mut lines = Vec::with_capacity(a.len().max(b.len()));
let mut i = 0;
let mut j = 0;
while i < a.len() || j < b.len() {
match (a.get(i), b.get(j)) {
(Some(la), Some(lb)) if la == lb => {
lines.push(DiffLine::Context((*la).to_string()));
i += 1;
j += 1;
}
(Some(la), Some(lb)) => {
lines.push(DiffLine::Removed((*la).to_string()));
lines.push(DiffLine::Added((*lb).to_string()));
i += 1;
j += 1;
}
(Some(la), None) => {
lines.push(DiffLine::Removed((*la).to_string()));
i += 1;
}
(None, Some(lb)) => {
lines.push(DiffLine::Added((*lb).to_string()));
j += 1;
}
(None, None) => break,
}
}
Self { lines }
}
#[must_use]
pub fn render_unified(&self) -> String {
let mut out = String::new();
for line in &self.lines {
out.push(line.kind().glyph());
out.push_str(line.text());
out.push('\n');
}
out
}
#[must_use]
pub fn is_empty_diff(&self) -> bool {
!self.lines.iter().any(|l| l.kind().is_changed())
}
#[must_use]
pub fn kind_histogram(&self) -> crate::AxisHistogram<DiffLineKind> {
crate::axis_histogram(self.lines.iter().map(DiffLine::kind))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
struct Toy {
name: String,
size: u32,
flag: bool,
}
impl TieredConfig for Toy {
fn bare() -> Self {
Self {
name: String::new(),
size: 0,
flag: false,
}
}
fn prescribed_default() -> Self {
Self {
name: "default-name".into(),
size: 42,
flag: true,
}
}
}
#[test]
fn bare_returns_floor_values() {
let b = Toy::bare();
assert_eq!(b.name, "");
assert_eq!(b.size, 0);
assert!(!b.flag);
}
#[test]
fn prescribed_default_is_different_from_bare() {
let b = Toy::bare();
let p = Toy::prescribed_default();
assert_ne!(b, p);
}
#[test]
fn discovered_default_impl_returns_bare() {
let d = Toy::discovered();
let b = Toy::bare();
assert_eq!(d, b);
}
#[test]
fn diff_against_self_is_empty() {
let p = Toy::prescribed_default();
let diff = p.diff_against(&p);
assert!(diff.is_empty_diff());
}
#[test]
fn diff_bare_vs_default_yields_added_and_removed_lines() {
let b = Toy::bare();
let p = Toy::prescribed_default();
let diff = p.diff_against(&b);
assert!(!diff.is_empty_diff());
let has_added = diff
.lines
.iter()
.any(|l| matches!(l, DiffLine::Added(s) if s.contains("default-name")));
let has_removed = diff
.lines
.iter()
.any(|l| matches!(l, DiffLine::Removed(s) if s.contains("name: ''")));
assert!(has_added, "diff should add the prescribed name");
assert!(has_removed, "diff should remove the bare empty name");
}
#[test]
fn render_unified_uses_diff_prefixes() {
let b = Toy::bare();
let p = Toy::prescribed_default();
let rendered = p.diff_against(&b).render_unified();
assert!(rendered.contains("-name: ''"));
assert!(rendered.contains("+name: default-name"));
}
#[test]
fn extend_default_impl_full_replaces_base() {
let b = Toy::bare();
let p = Toy::prescribed_default();
let merged = p.clone().extend(&b);
assert_eq!(merged, p);
}
#[test]
fn config_tier_default_is_default_variant() {
assert_eq!(ConfigTier::default(), ConfigTier::Default);
}
#[test]
fn config_tier_from_str_recognizes_named_tiers() {
assert_eq!(ConfigTier::from_str_or_default("bare"), ConfigTier::Bare);
assert_eq!(
ConfigTier::from_str_or_default("DISCOVERED"),
ConfigTier::Discovered
);
assert_eq!(
ConfigTier::from_str_or_default("default"),
ConfigTier::Default
);
assert_eq!(ConfigTier::from_str_or_default(""), ConfigTier::Default);
match ConfigTier::from_str_or_default("/etc/foo.yaml") {
ConfigTier::Custom(p) => {
assert_eq!(p, std::path::PathBuf::from("/etc/foo.yaml"));
}
other => panic!("expected Custom, got {other:?}"),
}
}
#[test]
fn config_tier_names_are_stable() {
assert_eq!(ConfigTier::Bare.name(), "bare");
assert_eq!(ConfigTier::Discovered.name(), "discovered");
assert_eq!(ConfigTier::Default.name(), "default");
assert_eq!(
ConfigTier::Custom(std::path::PathBuf::from("/x")).name(),
"custom"
);
}
#[test]
fn config_tier_from_env_resolves_correctly() {
let key = "SHIKUMI_TIERED_TEST_TIER_X";
unsafe {
std::env::set_var(key, "bare");
}
assert_eq!(ConfigTier::from_env(key), ConfigTier::Bare);
unsafe {
std::env::set_var(key, "");
}
assert_eq!(ConfigTier::from_env(key), ConfigTier::Default);
unsafe {
std::env::remove_var(key);
}
assert_eq!(ConfigTier::from_env(key), ConfigTier::Default);
}
#[test]
fn resolve_tier_dispatches_to_each_method() {
assert_eq!(Toy::resolve_tier(ConfigTier::Bare), Toy::bare());
assert_eq!(Toy::resolve_tier(ConfigTier::Discovered), Toy::discovered());
assert_eq!(
Toy::resolve_tier(ConfigTier::Default),
Toy::prescribed_default()
);
}
#[test]
fn resolve_tier_custom_missing_file_falls_back_to_default() {
let phantom = std::path::PathBuf::from("/nonexistent/path/shikumi-tier-fallback-test.yaml");
let resolved = Toy::resolve_tier(ConfigTier::Custom(phantom));
assert_eq!(resolved, Toy::prescribed_default());
}
#[test]
fn config_tier_kind_all_has_four_entries() {
assert_eq!(ConfigTierKind::ALL.len(), 4);
assert_eq!(ConfigTierKind::ALL[0], ConfigTierKind::Bare);
assert_eq!(ConfigTierKind::ALL[1], ConfigTierKind::Discovered);
assert_eq!(ConfigTierKind::ALL[2], ConfigTierKind::Default);
assert_eq!(ConfigTierKind::ALL[3], ConfigTierKind::Custom);
}
#[test]
fn config_tier_kind_trait_all_matches_inherent_all() {
assert_eq!(
<ConfigTierKind as crate::ClosedAxis>::ALL.len(),
ConfigTierKind::ALL.len(),
);
for (i, (trait_kind, inherent_kind)) in <ConfigTierKind as crate::ClosedAxis>::ALL
.iter()
.zip(ConfigTierKind::ALL.iter())
.enumerate()
{
assert_eq!(
trait_kind, inherent_kind,
"trait ALL[{i}] must equal inherent ALL[{i}]",
);
}
}
#[test]
fn config_tier_kind_as_str_yields_canonical_lowercase_names() {
assert_eq!(ConfigTierKind::Bare.as_str(), "bare");
assert_eq!(ConfigTierKind::Discovered.as_str(), "discovered");
assert_eq!(ConfigTierKind::Default.as_str(), "default");
assert_eq!(ConfigTierKind::Custom.as_str(), "custom");
}
#[test]
fn config_tier_kind_from_str_round_trips_with_as_str() {
for &kind in ConfigTierKind::ALL {
assert_eq!(
ConfigTierKind::from_str(kind.as_str()),
Some(kind),
"round-trip failed for kind {kind:?}",
);
}
}
#[test]
fn config_tier_kind_from_str_is_case_insensitive() {
assert_eq!(ConfigTierKind::from_str("BARE"), Some(ConfigTierKind::Bare),);
assert_eq!(
ConfigTierKind::from_str("Discovered"),
Some(ConfigTierKind::Discovered),
);
assert_eq!(
ConfigTierKind::from_str("DeFaUlT"),
Some(ConfigTierKind::Default),
);
assert_eq!(
ConfigTierKind::from_str("CUSTOM"),
Some(ConfigTierKind::Custom),
);
}
#[test]
fn config_tier_kind_from_str_returns_none_on_unknown() {
assert_eq!(ConfigTierKind::from_str(""), None);
assert_eq!(ConfigTierKind::from_str("nonexistent"), None);
assert_eq!(ConfigTierKind::from_str("/etc/foo.yaml"), None);
assert_eq!(ConfigTierKind::from_str(" bare "), None);
}
#[test]
fn config_tier_kind_projection_matches_config_tier_name() {
let pairs: [(ConfigTier, ConfigTierKind); 4] = [
(ConfigTier::Bare, ConfigTierKind::Bare),
(ConfigTier::Discovered, ConfigTierKind::Discovered),
(ConfigTier::Default, ConfigTierKind::Default),
(
ConfigTier::Custom(std::path::PathBuf::from("/x")),
ConfigTierKind::Custom,
),
];
for (tier, expected_kind) in pairs {
assert_eq!(tier.kind(), expected_kind);
assert_eq!(tier.name(), expected_kind.as_str());
}
}
#[test]
fn config_tier_from_env_still_lowercases_unknown_paths() {
let key = "SHIKUMI_TIERED_TEST_TIER_PATH";
unsafe {
std::env::set_var(key, "/Foo/Bar.YAML");
}
let tier = ConfigTier::from_env(key);
match tier {
ConfigTier::Custom(p) => assert_eq!(
p,
std::path::PathBuf::from("/foo/bar.yaml"),
"from_env preserves the pre-lift lowercase behavior",
),
other => panic!("expected Custom, got {other:?}"),
}
unsafe {
std::env::remove_var(key);
}
}
fn canonical_diff_line_kind_samples() -> Vec<(DiffLine, DiffLineKind)> {
vec![
(DiffLine::Removed("name: ''".into()), DiffLineKind::Removed),
(
DiffLine::Added("name: default-name".into()),
DiffLineKind::Added,
),
(DiffLine::Context("size: 42".into()), DiffLineKind::Context),
(DiffLine::Removed(String::new()), DiffLineKind::Removed),
(DiffLine::Added(String::new()), DiffLineKind::Added),
(DiffLine::Context(String::new()), DiffLineKind::Context),
]
}
#[test]
fn diff_line_kind_classifies_each_variant() {
assert_eq!(DiffLine::Removed("x".into()).kind(), DiffLineKind::Removed,);
assert_eq!(DiffLine::Added("y".into()).kind(), DiffLineKind::Added);
assert_eq!(DiffLine::Context("z".into()).kind(), DiffLineKind::Context,);
}
#[test]
fn diff_line_kind_is_data_free() {
for payload in ["", "a", "name: 'long value' ", "\n", "\u{1F600}"] {
assert_eq!(
DiffLine::Removed(payload.to_string()).kind(),
DiffLineKind::Removed,
);
assert_eq!(
DiffLine::Added(payload.to_string()).kind(),
DiffLineKind::Added,
);
assert_eq!(
DiffLine::Context(payload.to_string()).kind(),
DiffLineKind::Context,
);
}
}
#[test]
fn diff_line_kind_agrees_with_predicates_pointwise() {
for (line, expected) in canonical_diff_line_kind_samples() {
let k = line.kind();
assert_eq!(k, expected);
assert_eq!(k.is_removed(), k == DiffLineKind::Removed);
assert_eq!(k.is_added(), k == DiffLineKind::Added);
assert_eq!(k.is_context(), k == DiffLineKind::Context);
}
}
#[test]
fn diff_line_kind_is_changed_partitions_added_or_removed() {
assert!(DiffLineKind::Removed.is_changed());
assert!(DiffLineKind::Added.is_changed());
assert!(!DiffLineKind::Context.is_changed());
}
#[test]
fn diff_line_kind_is_static_and_copy_and_hashable() {
use std::collections::HashSet;
fn assert_static<T: 'static>() {}
assert_static::<DiffLineKind>();
let mut set: HashSet<DiffLineKind> = DiffLineKind::ALL.iter().copied().collect();
set.insert(DiffLineKind::Removed); assert_eq!(set.len(), DiffLineKind::ALL.len());
let k = DiffLineKind::Added;
let k2 = k;
assert_eq!(k, k2);
}
#[test]
fn diff_line_kind_all_has_no_duplicates() {
use std::collections::HashSet;
let unique: HashSet<DiffLineKind> = DiffLineKind::ALL.iter().copied().collect();
assert_eq!(unique.len(), DiffLineKind::ALL.len());
}
#[test]
fn diff_line_kind_all_covers_every_constructible_line() {
for (line, _) in canonical_diff_line_kind_samples() {
assert!(
DiffLineKind::ALL.contains(&line.kind()),
"DiffLineKind::ALL must contain the kind of every constructible DiffLine",
);
}
}
#[test]
fn diff_line_kind_all_equals_diff_line_kind_image() {
use std::collections::HashSet;
let image: HashSet<DiffLineKind> = canonical_diff_line_kind_samples()
.into_iter()
.map(|(l, _)| l.kind())
.collect();
let all: HashSet<DiffLineKind> = DiffLineKind::ALL.iter().copied().collect();
assert_eq!(image, all);
}
#[test]
fn diff_line_kind_all_declaration_order_is_removed_added_context() {
assert_eq!(DiffLineKind::ALL.len(), 3);
assert_eq!(DiffLineKind::ALL[0], DiffLineKind::Removed);
assert_eq!(DiffLineKind::ALL[1], DiffLineKind::Added);
assert_eq!(DiffLineKind::ALL[2], DiffLineKind::Context);
}
#[test]
fn diff_line_kind_as_str_yields_canonical_lowercase_names() {
assert_eq!(DiffLineKind::Removed.as_str(), "removed");
assert_eq!(DiffLineKind::Added.as_str(), "added");
assert_eq!(DiffLineKind::Context.as_str(), "context");
}
#[test]
fn diff_line_kind_glyph_yields_canonical_unified_diff_prefixes() {
assert_eq!(DiffLineKind::Removed.glyph(), '-');
assert_eq!(DiffLineKind::Added.glyph(), '+');
assert_eq!(DiffLineKind::Context.glyph(), ' ');
}
#[test]
fn diff_line_text_returns_inner_payload_pointwise() {
for payload in ["", "a", "name: value", " leading spaces"] {
assert_eq!(DiffLine::Removed(payload.to_string()).text(), payload);
assert_eq!(DiffLine::Added(payload.to_string()).text(), payload);
assert_eq!(DiffLine::Context(payload.to_string()).text(), payload);
}
}
#[test]
fn diff_line_kind_from_canonical_str_round_trips_through_trait() {
use crate::ClosedAxisLabel;
for &k in DiffLineKind::ALL {
let lower = k.as_str();
assert_eq!(DiffLineKind::from_canonical_str(lower), Some(k));
let upper = lower.to_ascii_uppercase();
assert_eq!(DiffLineKind::from_canonical_str(&upper), Some(k));
let mut mixed = String::new();
for (i, c) in lower.chars().enumerate() {
if i == 0 {
mixed.extend(c.to_uppercase());
} else {
mixed.push(c);
}
}
assert_eq!(DiffLineKind::from_canonical_str(&mixed), Some(k));
}
}
#[test]
fn config_diff_is_empty_diff_routes_through_diff_line_kind_is_changed() {
let only_context = ConfigDiff {
lines: vec![DiffLine::Context("a".into()), DiffLine::Context("b".into())],
};
assert!(only_context.is_empty_diff());
let with_added = ConfigDiff {
lines: vec![
DiffLine::Context("a".into()),
DiffLine::Added("c".into()),
DiffLine::Context("b".into()),
],
};
assert!(!with_added.is_empty_diff());
let with_removed = ConfigDiff {
lines: vec![DiffLine::Removed("x".into())],
};
assert!(!with_removed.is_empty_diff());
let empty_lines = ConfigDiff { lines: vec![] };
assert!(empty_lines.is_empty_diff());
}
#[test]
fn config_diff_render_unified_emits_one_glyph_per_kind() {
let diff = ConfigDiff {
lines: vec![
DiffLine::Removed("name: ''".into()),
DiffLine::Added("name: default-name".into()),
DiffLine::Context("size: 42".into()),
],
};
let rendered = diff.render_unified();
assert_eq!(
rendered, "-name: ''\n+name: default-name\n size: 42\n",
"render_unified must emit the canonical glyph per kind",
);
for (i, line) in diff.lines.iter().enumerate() {
let expected_glyph = line.kind().glyph();
let actual_first = rendered
.lines()
.nth(i)
.and_then(|s| s.chars().next())
.expect("rendered output must have at least i+1 lines");
assert_eq!(
actual_first, expected_glyph,
"rendered line {i} must start with its kind's glyph",
);
}
}
#[test]
fn config_diff_render_unified_byte_identical_to_pre_lift_form() {
let diff = ConfigDiff {
lines: vec![
DiffLine::Context(String::new()),
DiffLine::Removed("a".into()),
DiffLine::Added("b".into()),
DiffLine::Context("c".into()),
],
};
assert_eq!(diff.render_unified(), " \n-a\n+b\n c\n");
}
#[test]
fn config_tier_from_str_or_default_via_kind_dispatch() {
assert_eq!(ConfigTier::from_str_or_default("bare"), ConfigTier::Bare);
assert_eq!(
ConfigTier::from_str_or_default("DISCOVERED"),
ConfigTier::Discovered,
);
assert_eq!(
ConfigTier::from_str_or_default("default"),
ConfigTier::Default,
);
assert_eq!(ConfigTier::from_str_or_default(""), ConfigTier::Default,);
match ConfigTier::from_str_or_default("custom") {
ConfigTier::Custom(p) => {
assert_eq!(p, std::path::PathBuf::from("custom"));
}
other => panic!("expected Custom, got {other:?}"),
}
match ConfigTier::from_str_or_default("/etc/foo.yaml") {
ConfigTier::Custom(p) => {
assert_eq!(p, std::path::PathBuf::from("/etc/foo.yaml"));
}
other => panic!("expected Custom, got {other:?}"),
}
}
#[test]
fn kind_histogram_counts_each_kind_pointwise() {
let diff = ConfigDiff {
lines: vec![
DiffLine::Removed("r1".into()),
DiffLine::Added("a1".into()),
DiffLine::Added("a2".into()),
DiffLine::Context("c1".into()),
DiffLine::Context("c2".into()),
DiffLine::Context("c3".into()),
],
};
let hist = diff.kind_histogram();
assert_eq!(hist.count(DiffLineKind::Removed), 1);
assert_eq!(hist.count(DiffLineKind::Added), 2);
assert_eq!(hist.count(DiffLineKind::Context), 3);
assert_eq!(hist.total(), diff.lines.len());
}
#[test]
fn kind_histogram_empty_diff_is_zero_on_every_cell() {
let diff = ConfigDiff::default();
let hist = diff.kind_histogram();
assert_eq!(hist.total(), 0);
assert!(hist.is_empty());
for cell in [
DiffLineKind::Removed,
DiffLineKind::Added,
DiffLineKind::Context,
] {
assert_eq!(hist.count(cell), 0);
}
}
#[test]
fn kind_histogram_changed_cells_match_is_empty_diff() {
let context_only = ConfigDiff {
lines: vec![DiffLine::Context("c".into())],
};
let h1 = context_only.kind_histogram();
assert!(context_only.is_empty_diff());
assert_eq!(
h1.count(DiffLineKind::Added) + h1.count(DiffLineKind::Removed),
0
);
let with_change = ConfigDiff {
lines: vec![DiffLine::Context("c".into()), DiffLine::Added("a".into())],
};
let h2 = with_change.kind_histogram();
assert!(!with_change.is_empty_diff());
assert!(h2.count(DiffLineKind::Added) + h2.count(DiffLineKind::Removed) > 0);
}
#[test]
fn kind_histogram_iter_yields_declaration_order() {
let diff = ConfigDiff {
lines: vec![
DiffLine::Context("c".into()),
DiffLine::Added("a".into()),
DiffLine::Removed("r".into()),
],
};
let pairs: Vec<(DiffLineKind, usize)> = diff.kind_histogram().iter().collect();
assert_eq!(
pairs,
vec![
(DiffLineKind::Removed, 1),
(DiffLineKind::Added, 1),
(DiffLineKind::Context, 1),
],
);
}
#[test]
fn diff_line_kind_ord_matches_all_declaration_order() {
use std::cmp::Ordering;
for window in DiffLineKind::ALL.windows(2) {
assert!(
window[0] < window[1],
"DiffLineKind::ALL must be strictly increasing under Ord, \
but {:?} >= {:?}",
window[0],
window[1],
);
}
for (i, &a) in DiffLineKind::ALL.iter().enumerate() {
for (j, &b) in DiffLineKind::ALL.iter().enumerate() {
let expected = i.cmp(&j);
assert_eq!(
a.cmp(&b),
expected,
"DiffLineKind::cmp must match ALL-index lex for ({a:?}, {b:?})",
);
assert_eq!(
a.partial_cmp(&b),
Some(expected),
"DiffLineKind::partial_cmp must agree with cmp for ({a:?}, {b:?})",
);
if i == j {
assert_eq!(a.cmp(&b), Ordering::Equal, "Ord must be reflexive on {a:?}",);
}
}
}
}
#[test]
fn diff_line_kind_btreemap_emits_in_declaration_order() {
use std::collections::BTreeMap;
let mut counts: BTreeMap<DiffLineKind, u32> = BTreeMap::new();
counts.insert(DiffLineKind::Context, 3);
counts.insert(DiffLineKind::Removed, 1);
counts.insert(DiffLineKind::Added, 2);
let observed: Vec<DiffLineKind> = counts.keys().copied().collect();
assert_eq!(
observed,
DiffLineKind::ALL.to_vec(),
"BTreeMap<DiffLineKind, _> must emit keys in ALL declaration order",
);
}
#[test]
fn diff_line_kind_display_matches_as_str() {
for k in DiffLineKind::ALL.iter().copied() {
assert_eq!(
format!("{k}"),
k.as_str(),
"Display must agree with as_str for {k:?}",
);
}
}
#[test]
fn diff_line_kind_from_str_round_trips_over_every_variant() {
for k in DiffLineKind::ALL {
let rendered = k.to_string();
let parsed: DiffLineKind = rendered
.parse()
.expect("FromStr must round-trip Display output");
assert_eq!(parsed, *k, "FromStr must round-trip {k:?}");
}
}
#[test]
fn diff_line_kind_from_str_is_case_insensitive() {
assert_eq!(
"REMOVED".parse::<DiffLineKind>().unwrap(),
DiffLineKind::Removed,
);
assert_eq!(
"Added".parse::<DiffLineKind>().unwrap(),
DiffLineKind::Added,
);
assert_eq!(
"cOnTeXt".parse::<DiffLineKind>().unwrap(),
DiffLineKind::Context,
);
assert_eq!(
"rEmOvEd".parse::<DiffLineKind>().unwrap(),
DiffLineKind::Removed,
);
}
#[test]
fn diff_line_kind_from_str_unknown_kind_error_carries_label_verbatim() {
for bad in &["changed", "deleted", "modified", "", " removed"] {
let err = bad
.parse::<DiffLineKind>()
.expect_err("non-canonical label must reject");
let rendered = err.to_string();
assert!(
rendered.contains(bad),
"rendered error must contain the offending label verbatim: \
input={bad:?}, rendered={rendered:?}",
);
}
}
#[test]
fn diff_line_kind_serde_yaml_round_trips_over_every_variant() {
for k in DiffLineKind::ALL {
let yaml = serde_yaml::to_string(k).expect("Serialize must succeed");
let parsed: DiffLineKind =
serde_yaml::from_str(&yaml).expect("Deserialize must accept Serialize output");
assert_eq!(parsed, *k, "serde_yaml round-trip must preserve {k:?}");
}
}
#[test]
fn diff_line_kind_serde_json_round_trips_over_every_variant() {
for k in DiffLineKind::ALL {
let json = serde_json::to_string(k).expect("Serialize must succeed");
let parsed: DiffLineKind =
serde_json::from_str(&json).expect("Deserialize must accept Serialize output");
assert_eq!(parsed, *k, "serde_json round-trip must preserve {k:?}");
}
}
#[test]
fn diff_line_kind_serde_yaml_is_case_insensitive() {
let cases: &[(&str, DiffLineKind)] = &[
("Removed", DiffLineKind::Removed),
("ADDED", DiffLineKind::Added),
("CoNtExT", DiffLineKind::Context),
("rEmOvEd", DiffLineKind::Removed),
];
for (input, expected) in cases {
let parsed: DiffLineKind =
serde_yaml::from_str(input).expect("case-insensitive Deserialize must succeed");
assert_eq!(
parsed, *expected,
"serde_yaml must parse case-insensitively for input {input:?}",
);
}
}
#[test]
fn diff_line_kind_serde_yaml_unknown_kind_error_carries_label_verbatim() {
for bad in &["changed", "deleted", "modified", "noop"] {
let err = serde_yaml::from_str::<DiffLineKind>(bad)
.expect_err("non-canonical label must reject");
let rendered = err.to_string();
assert!(
rendered.contains(bad),
"rendered serde error must contain the offending label verbatim: \
input={bad:?}, rendered={rendered:?}",
);
}
}
#[test]
fn diff_line_kind_serde_yaml_emission_is_bare_scalar() {
assert_eq!(
serde_yaml::to_string(&DiffLineKind::Removed).unwrap(),
"removed\n",
);
assert_eq!(
serde_yaml::to_string(&DiffLineKind::Added).unwrap(),
"added\n",
);
assert_eq!(
serde_yaml::to_string(&DiffLineKind::Context).unwrap(),
"context\n",
);
}
}