Skip to main content

rac_engine/
diff.rs

1//! AST diff (`decided.services.diff`): compare two parsed products and classify
2//! the changes. Pure AST work — no git, no revisions, no raw-text diffing.
3//!
4//! - Requirements match by ID: same ID + same text -> unchanged (omitted);
5//!   same ID, different text -> modified; ID only in the new -> added; ID
6//!   only in the old -> removed.
7//! - Metrics and risks are ordered set-difference, de-duped, preserving
8//!   source order.
9
10use crate::markdown::Requirement;
11use crate::parse::Artifact;
12
13/// `RequirementChange` — a requirement whose text changed (same ID). Field
14/// order mirrors the Python dataclass (`id, old_text, new_text`).
15#[derive(Debug, Clone)]
16pub struct RequirementChange {
17    pub id: String,
18    pub old_text: String,
19    pub new_text: String,
20}
21
22/// `Diff` — the classified differences between two products.
23#[derive(Debug, Clone, Default)]
24pub struct Diff {
25    pub added_requirements: Vec<Requirement>,
26    pub removed_requirements: Vec<Requirement>,
27    pub modified_requirements: Vec<RequirementChange>,
28    pub added_metrics: Vec<String>,
29    pub removed_metrics: Vec<String>,
30    pub added_risks: Vec<String>,
31    pub removed_risks: Vec<String>,
32}
33
34impl Diff {
35    /// True when nothing changed across any comparison unit.
36    pub fn is_empty(&self) -> bool {
37        self.added_requirements.is_empty()
38            && self.removed_requirements.is_empty()
39            && self.modified_requirements.is_empty()
40            && self.added_metrics.is_empty()
41            && self.removed_metrics.is_empty()
42            && self.added_risks.is_empty()
43            && self.removed_risks.is_empty()
44    }
45}
46
47/// `_by_id(requirements)` — Python dict comprehension semantics: on a
48/// duplicate ID the LAST occurrence wins the value, but the key keeps its
49/// FIRST-insertion position (dict key order).
50fn by_id(requirements: &[Requirement]) -> Vec<(&str, &Requirement)> {
51    let mut out: Vec<(&str, &Requirement)> = Vec::new();
52    for r in requirements {
53        if let Some(slot) = out.iter_mut().find(|(id, _)| *id == r.id) {
54            slot.1 = r;
55        } else {
56            out.push((r.id.as_str(), r));
57        }
58    }
59    out
60}
61
62/// `_ordered_difference(a, b)` — items in `a` not present in `b`, preserving
63/// `a`'s order, de-duped.
64fn ordered_difference(a: &[String], b: &[String]) -> Vec<String> {
65    let mut seen: Vec<&str> = Vec::new();
66    let mut out: Vec<String> = Vec::new();
67    for item in a {
68        if !b.contains(item) && !seen.contains(&item.as_str()) {
69            seen.push(item.as_str());
70            out.push(item.clone());
71        }
72    }
73    out
74}
75
76/// `diff(old, new)` — the classified `Diff` between two products.
77pub fn diff(old: &Artifact, new: &Artifact) -> Diff {
78    let old_reqs = by_id(&old.product.requirements);
79    let new_reqs = by_id(&new.product.requirements);
80
81    let mut result = Diff::default();
82
83    // Added / modified: iterate new (preserves new-file order).
84    for (req_id, new_req) in &new_reqs {
85        match old_reqs.iter().find(|(id, _)| id == req_id) {
86            None => result.added_requirements.push((*new_req).clone()),
87            Some((_, old_req)) if old_req.text != new_req.text => {
88                result.modified_requirements.push(RequirementChange {
89                    id: (*req_id).to_string(),
90                    old_text: old_req.text.clone(),
91                    new_text: new_req.text.clone(),
92                });
93            }
94            Some(_) => {}
95        }
96    }
97
98    // Removed: in old but not new (preserves old-file order).
99    for (req_id, old_req) in &old_reqs {
100        if !new_reqs.iter().any(|(id, _)| id == req_id) {
101            result.removed_requirements.push((*old_req).clone());
102        }
103    }
104
105    result.added_metrics =
106        ordered_difference(&new.product.success_metrics, &old.product.success_metrics);
107    result.removed_metrics =
108        ordered_difference(&old.product.success_metrics, &new.product.success_metrics);
109    result.added_risks = ordered_difference(&new.product.risks, &old.product.risks);
110    result.removed_risks = ordered_difference(&old.product.risks, &new.product.risks);
111
112    result
113}