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