From 927ec97b1e73b85d9c6b5ef3d024c278b7bdc6a1 Mon Sep 17 00:00:00 2001 From: "Claude Fable 5 (claude-fable-5)" Date: Thu, 27 Aug 2026 18:47:47 +0200 Subject: [PATCH] fix: match requirement identity case-insensitively Archive merges compared requirement names byte-for-byte, so a Title Case delta appended a duplicate of its sentence-case canonical twin instead of matching it. Lookups now normalize case and whitespace, an identical case-variant add is idempotent, and a conflicting one fails closed. Equality checks that decide whether an edit applies keep exact comparison, so a case-only rename still lands. Two regression tests pin both paths. Co-Authored-By: Martin Zeman --- src/spec/apply.rs | 4 +++- src/spec/apply/tests.rs | 50 ++++++++++++++++++++++++++++++++++++++++- src/spec/model.rs | 18 +++++++++++++-- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/spec/apply.rs b/src/spec/apply.rs index 6c851e7..403bfe7 100644 --- a/src/spec/apply.rs +++ b/src/spec/apply.rs @@ -58,7 +58,9 @@ fn apply_renamed( ), }); }; - if canonical.requirement_index(to).is_some() { + if let Some(target_index) = canonical.requirement_index(to) + && target_index != source_index + { return Err(ParseIssue { line: Some(*line), message: format!( diff --git a/src/spec/apply/tests.rs b/src/spec/apply/tests.rs index b914fcd..f7268a2 100644 --- a/src/spec/apply/tests.rs +++ b/src/spec/apply/tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::spec::model::Requirement; +use crate::spec::model::{CanonicalSpec, Requirement}; fn requirement(name: &str, scenarios: &[&str]) -> Requirement { Requirement { @@ -10,6 +10,54 @@ fn requirement(name: &str, scenarios: &[&str]) -> Requirement { } } +fn empty_result() -> ApplyResult { + ApplyResult { + summary: MergeSummary::default(), + warnings: Vec::new(), + } +} + +#[test] +fn added_requirement_with_case_variant_name_is_rejected() { + let mut canonical = CanonicalSpec::new("Cap", "chg"); + canonical.add_requirement(requirement("Temporary review state", &[])); + let incoming = requirement("Temporary Review State", &[]); + let mut result = empty_result(); + + let issue = apply_added( + &mut canonical, + &[DeltaOperation::Added(incoming)], + "Cap", + &mut result, + ) + .unwrap_err(); + + assert!(issue.message.contains("cannot add existing requirement")); + assert_eq!(canonical.requirement_count(), 1); +} + +#[test] +fn added_requirement_identical_up_to_case_is_skipped() { + let mut canonical = CanonicalSpec::new("Cap", "chg"); + let mut existing = requirement("Temporary review state", &[]); + existing.content = "shared content".to_string(); + canonical.add_requirement(existing); + let mut incoming = requirement("Temporary Review State", &[]); + incoming.content = "shared content".to_string(); + let mut result = empty_result(); + + apply_added( + &mut canonical, + &[DeltaOperation::Added(incoming)], + "Cap", + &mut result, + ) + .unwrap(); + + assert_eq!(result.summary.added, 0); + assert_eq!(canonical.requirement_count(), 1); +} + #[test] fn scenario_preservation_compares_occurrence_multiplicity() { let current = requirement("Repeated", &["Retry", "Retry"]); diff --git a/src/spec/model.rs b/src/spec/model.rs index 45ac4c1..dd2dfc4 100644 --- a/src/spec/model.rs +++ b/src/spec/model.rs @@ -119,8 +119,10 @@ impl CanonicalSpec { } pub(super) fn requirement_index(&self, name: &str) -> Option { + let identity = requirement_identity(name); self.body.iter().position(|element| { - matches!(element, BodyElement::Requirement(requirement) if requirement.name == name) + matches!(element, BodyElement::Requirement(requirement) + if requirement_identity(&requirement.name) == identity) }) } @@ -134,7 +136,7 @@ impl CanonicalSpec { requirement: &Requirement, ) -> bool { self.requirement(element_index).is_some_and(|existing| { - existing.name == requirement.name + requirement_identity(&existing.name) == requirement_identity(&requirement.name) && existing.content == normalize_line_endings(&requirement.content, &self.line_ending) }) @@ -254,6 +256,18 @@ pub(super) struct ParseIssue { pub(super) message: String, } +/// Requirement identity for lookups: case- and whitespace-insensitive, +/// so a Title Case delta finds its sentence-case canonical twin instead +/// of appending a duplicate. Equality checks that decide whether an +/// edit applies keep exact comparison, so a case-only rename still +/// lands. +fn requirement_identity(name: &str) -> String { + name.split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase() +} + fn normalize_line_endings(content: &str, line_ending: &str) -> String { content .replace("\r\n", "\n")