Skip to main content

intent_ir/
diff.rs

1//! Spec-level diff reporting.
2//!
3//! Compares two audit reports (old vs. new) and produces a structured diff
4//! at the intent-specification level: which entities, actions, invariants,
5//! and edge cases were added, removed, or modified, and what changed within them.
6
7use std::collections::HashMap;
8use std::fmt;
9
10use serde::Serialize;
11
12use crate::audit::{AuditEntry, AuditReport, CoverageSummary, SpecItemKind, TracePart};
13
14/// A complete diff report between two versions of a spec.
15#[derive(Debug, Clone, Serialize)]
16pub struct DiffReport {
17    pub module_name: String,
18    pub changes: Vec<DiffEntry>,
19    pub summary: DiffSummary,
20}
21
22/// A single spec-item change.
23#[derive(Debug, Clone, Serialize)]
24pub struct DiffEntry {
25    pub kind: SpecItemKind,
26    pub name: String,
27    pub change: ChangeKind,
28    /// Sub-item details (only present for Modified entries).
29    pub details: Vec<DiffDetail>,
30}
31
32/// What happened to a spec item or sub-item.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
34pub enum ChangeKind {
35    Added,
36    Removed,
37    Modified,
38}
39
40/// A change to a sub-item within a spec entry.
41#[derive(Debug, Clone, Serialize)]
42pub struct DiffDetail {
43    pub label: String,
44    pub change: ChangeKind,
45    pub old_desc: Option<String>,
46    pub new_desc: Option<String>,
47}
48
49/// High-level summary of changes.
50#[derive(Debug, Clone, Serialize)]
51pub struct DiffSummary {
52    pub added: usize,
53    pub removed: usize,
54    pub modified: usize,
55    pub old_coverage: CoverageSummary,
56    pub new_coverage: CoverageSummary,
57    pub old_verify_errors: usize,
58    pub new_verify_errors: usize,
59    pub old_obligations: usize,
60    pub new_obligations: usize,
61}
62
63/// Compare two audit reports and produce a structured diff.
64pub fn diff_reports(old: &AuditReport, new: &AuditReport) -> DiffReport {
65    // Index old entries by (kind, name) for lookup.
66    let old_map: HashMap<(SpecItemKind, &str), &AuditEntry> = old
67        .entries
68        .iter()
69        .map(|e| ((e.kind, e.name.as_str()), e))
70        .collect();
71
72    let new_map: HashMap<(SpecItemKind, &str), &AuditEntry> = new
73        .entries
74        .iter()
75        .map(|e| ((e.kind, e.name.as_str()), e))
76        .collect();
77
78    let mut changes = Vec::new();
79
80    // Find removed entries (in old, not in new).
81    for entry in &old.entries {
82        let key = (entry.kind, entry.name.as_str());
83        if !new_map.contains_key(&key) {
84            changes.push(DiffEntry {
85                kind: entry.kind,
86                name: entry.name.clone(),
87                change: ChangeKind::Removed,
88                details: vec![],
89            });
90        }
91    }
92
93    // Find added and modified entries.
94    for entry in &new.entries {
95        let key = (entry.kind, entry.name.as_str());
96        match old_map.get(&key) {
97            None => {
98                changes.push(DiffEntry {
99                    kind: entry.kind,
100                    name: entry.name.clone(),
101                    change: ChangeKind::Added,
102                    details: vec![],
103                });
104            }
105            Some(old_entry) => {
106                let details = diff_parts(&old_entry.parts, &entry.parts);
107                if !details.is_empty() {
108                    changes.push(DiffEntry {
109                        kind: entry.kind,
110                        name: entry.name.clone(),
111                        change: ChangeKind::Modified,
112                        details,
113                    });
114                }
115            }
116        }
117    }
118
119    let added = changes
120        .iter()
121        .filter(|c| c.change == ChangeKind::Added)
122        .count();
123    let removed = changes
124        .iter()
125        .filter(|c| c.change == ChangeKind::Removed)
126        .count();
127    let modified = changes
128        .iter()
129        .filter(|c| c.change == ChangeKind::Modified)
130        .count();
131
132    let summary = DiffSummary {
133        added,
134        removed,
135        modified,
136        old_coverage: old.summary.clone(),
137        new_coverage: new.summary.clone(),
138        old_verify_errors: old.verify_error_count,
139        new_verify_errors: new.verify_error_count,
140        old_obligations: old.obligations.len(),
141        new_obligations: new.obligations.len(),
142    };
143
144    DiffReport {
145        module_name: new.module_name.clone(),
146        changes,
147        summary,
148    }
149}
150
151/// Compare the parts (sub-items) of two matched entries.
152fn diff_parts(old_parts: &[TracePart], new_parts: &[TracePart]) -> Vec<DiffDetail> {
153    let old_map: HashMap<&str, &TracePart> =
154        old_parts.iter().map(|p| (p.label.as_str(), p)).collect();
155    let new_map: HashMap<&str, &TracePart> =
156        new_parts.iter().map(|p| (p.label.as_str(), p)).collect();
157
158    let mut details = Vec::new();
159
160    // Removed parts.
161    for part in old_parts {
162        if !new_map.contains_key(part.label.as_str()) {
163            details.push(DiffDetail {
164                label: part.label.clone(),
165                change: ChangeKind::Removed,
166                old_desc: Some(part.ir_desc.clone()),
167                new_desc: None,
168            });
169        }
170    }
171
172    // Added and modified parts.
173    for part in new_parts {
174        match old_map.get(part.label.as_str()) {
175            None => {
176                details.push(DiffDetail {
177                    label: part.label.clone(),
178                    change: ChangeKind::Added,
179                    old_desc: None,
180                    new_desc: Some(part.ir_desc.clone()),
181                });
182            }
183            Some(old_part) => {
184                if old_part.ir_desc != part.ir_desc {
185                    details.push(DiffDetail {
186                        label: part.label.clone(),
187                        change: ChangeKind::Modified,
188                        old_desc: Some(old_part.ir_desc.clone()),
189                        new_desc: Some(part.ir_desc.clone()),
190                    });
191                }
192            }
193        }
194    }
195
196    details
197}
198
199// ── Display implementations ────────────────────────────────
200
201impl fmt::Display for ChangeKind {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        match self {
204            ChangeKind::Added => write!(f, "+"),
205            ChangeKind::Removed => write!(f, "-"),
206            ChangeKind::Modified => write!(f, "~"),
207        }
208    }
209}
210
211impl DiffReport {
212    /// Format as a human-readable diff report.
213    pub fn format(&self) -> String {
214        let mut out = format!("{} — Spec Diff Report\n", self.module_name);
215
216        if self.changes.is_empty() {
217            out.push_str("\n  No spec-level changes detected.\n");
218            return out;
219        }
220
221        for entry in &self.changes {
222            out.push('\n');
223            let symbol = entry.change;
224            out.push_str(&format!("  [{symbol}] {} {}\n", entry.kind, entry.name,));
225
226            for detail in &entry.details {
227                let dsym = detail.change;
228                match detail.change {
229                    ChangeKind::Added => {
230                        out.push_str(&format!(
231                            "      [{dsym}] {}: {}\n",
232                            detail.label,
233                            detail.new_desc.as_deref().unwrap_or(""),
234                        ));
235                    }
236                    ChangeKind::Removed => {
237                        out.push_str(&format!(
238                            "      [{dsym}] {}: {}\n",
239                            detail.label,
240                            detail.old_desc.as_deref().unwrap_or(""),
241                        ));
242                    }
243                    ChangeKind::Modified => {
244                        out.push_str(&format!(
245                            "      [{dsym}] {}: {} -> {}\n",
246                            detail.label,
247                            detail.old_desc.as_deref().unwrap_or(""),
248                            detail.new_desc.as_deref().unwrap_or(""),
249                        ));
250                    }
251                }
252            }
253        }
254
255        // Summary section.
256        let s = &self.summary;
257        out.push_str(&format!(
258            "\nSummary: {} added, {} removed, {} modified\n",
259            s.added, s.removed, s.modified,
260        ));
261
262        // Coverage delta.
263        let old_total = s.old_coverage.total();
264        let new_total = s.new_coverage.total();
265        if new_total != old_total {
266            let delta = new_total as isize - old_total as isize;
267            let sign = if delta > 0 { "+" } else { "" };
268            out.push_str(&format!(
269                "Coverage: {} -> {} spec items ({sign}{})\n",
270                old_total, new_total, delta,
271            ));
272        } else {
273            out.push_str(&format!("Coverage: {} spec items (unchanged)\n", new_total));
274        }
275
276        // Verification status.
277        match (s.old_verify_errors, s.new_verify_errors) {
278            (0, 0) => out.push_str("Verification: clean -> clean\n"),
279            (0, n) => out.push_str(&format!("Verification: clean -> {} error(s)\n", n)),
280            (o, 0) => out.push_str(&format!("Verification: {} error(s) -> clean\n", o)),
281            (o, n) if o == n => {
282                out.push_str(&format!("Verification: {} error(s) (unchanged)\n", n))
283            }
284            (o, n) => out.push_str(&format!("Verification: {} -> {} error(s)\n", o, n)),
285        }
286
287        // Obligations delta.
288        if s.new_obligations != s.old_obligations {
289            out.push_str(&format!(
290                "Obligations: {} -> {}\n",
291                s.old_obligations, s.new_obligations,
292            ));
293        }
294
295        out
296    }
297}