Skip to main content

provable_contracts/
diff.rs

1//! Contract diff — detect drift between contract versions.
2//!
3//! Compares two [`Contract`] values and produces a [`ContractDiff`]
4//! listing added, removed, and changed sections. Suggests a semver
5//! bump (major, minor, patch) based on the nature of the changes.
6
7use std::collections::BTreeSet;
8
9use crate::schema::Contract;
10
11/// The result of diffing two contracts.
12#[derive(Debug, Clone)]
13pub struct ContractDiff {
14    /// Version in the "old" contract.
15    pub old_version: String,
16    /// Version in the "new" contract.
17    pub new_version: String,
18    /// Per-section change summaries.
19    pub sections: Vec<SectionDiff>,
20    /// Suggested semver bump based on the changes.
21    pub suggested_bump: SemverBump,
22}
23
24/// A change summary for one section of the contract.
25#[derive(Debug, Clone)]
26pub struct SectionDiff {
27    /// Section name (e.g. "equations", "`proof_obligations`").
28    pub section: String,
29    /// Items added in the new contract.
30    pub added: Vec<String>,
31    /// Items removed from the old contract.
32    pub removed: Vec<String>,
33    /// Items present in both but changed.
34    pub changed: Vec<String>,
35}
36
37/// Methods for querying a section diff
38impl SectionDiff {
39    /// True if this section has no changes.
40    pub fn is_empty(&self) -> bool {
41        self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
42    }
43}
44
45/// Suggested semantic version bump.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum SemverBump {
48    /// No changes detected.
49    None,
50    /// Only additive or cosmetic changes.
51    Patch,
52    /// New equations, obligations, or tests added.
53    Minor,
54    /// Equations or obligations removed or semantically changed.
55    Major,
56}
57
58/// Display the semver bump level as a lowercase string
59impl std::fmt::Display for SemverBump {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Self::None => write!(f, "none"),
63            Self::Patch => write!(f, "patch"),
64            Self::Minor => write!(f, "minor"),
65            Self::Major => write!(f, "major"),
66        }
67    }
68}
69
70/// Diff two contracts and produce a structured change report.
71pub fn diff_contracts(old: &Contract, new: &Contract) -> ContractDiff {
72    let mut sections = Vec::new();
73    let mut max_bump = SemverBump::None;
74
75    // Equations
76    let eq_diff = diff_keys(
77        "equations",
78        &old.equations.keys().cloned().collect(),
79        &new.equations.keys().cloned().collect(),
80    );
81    let eq_changed = diff_equation_changes(old, new);
82    let eq_section = SectionDiff {
83        section: "equations".to_string(),
84        added: eq_diff.0,
85        removed: eq_diff.1,
86        changed: eq_changed,
87    };
88    max_bump = bump_max(
89        max_bump,
90        section_bump(&eq_section, SemverBump::Major, SemverBump::Minor),
91    );
92    sections.push(eq_section);
93
94    // Proof obligations
95    let old_obs: BTreeSet<String> = old
96        .proof_obligations
97        .iter()
98        .map(|o| format!("{}:{}", o.obligation_type, o.property))
99        .collect();
100    let new_obs: BTreeSet<String> = new
101        .proof_obligations
102        .iter()
103        .map(|o| format!("{}:{}", o.obligation_type, o.property))
104        .collect();
105    let ob_diff = diff_sets("proof_obligations", &old_obs, &new_obs);
106    max_bump = bump_max(
107        max_bump,
108        section_bump(&ob_diff, SemverBump::Major, SemverBump::Minor),
109    );
110    sections.push(ob_diff);
111
112    // Falsification tests
113    let old_ft: BTreeSet<String> = old
114        .falsification_tests
115        .iter()
116        .map(|t| t.id.clone())
117        .collect();
118    let new_ft: BTreeSet<String> = new
119        .falsification_tests
120        .iter()
121        .map(|t| t.id.clone())
122        .collect();
123    let ft_diff = diff_sets("falsification_tests", &old_ft, &new_ft);
124    max_bump = bump_max(
125        max_bump,
126        section_bump(&ft_diff, SemverBump::Minor, SemverBump::Minor),
127    );
128    sections.push(ft_diff);
129
130    // Kani harnesses
131    let old_kh: BTreeSet<String> = old.kani_harnesses.iter().map(|h| h.id.clone()).collect();
132    let new_kh: BTreeSet<String> = new.kani_harnesses.iter().map(|h| h.id.clone()).collect();
133    let kh_diff = diff_sets("kani_harnesses", &old_kh, &new_kh);
134    max_bump = bump_max(
135        max_bump,
136        section_bump(&kh_diff, SemverBump::Minor, SemverBump::Minor),
137    );
138    sections.push(kh_diff);
139
140    // Enforcement rules
141    let old_enf: BTreeSet<String> = old.enforcement.keys().cloned().collect();
142    let new_enf: BTreeSet<String> = new.enforcement.keys().cloned().collect();
143    let enf_diff = diff_sets("enforcement", &old_enf, &new_enf);
144    max_bump = bump_max(
145        max_bump,
146        section_bump(&enf_diff, SemverBump::Minor, SemverBump::Patch),
147    );
148    sections.push(enf_diff);
149
150    // Metadata version change
151    if old.metadata.version != new.metadata.version {
152        max_bump = bump_max(max_bump, SemverBump::Patch);
153    }
154
155    ContractDiff {
156        old_version: old.metadata.version.clone(),
157        new_version: new.metadata.version.clone(),
158        sections,
159        suggested_bump: max_bump,
160    }
161}
162
163/// Compute added and removed keys between two sorted sets
164fn diff_keys(
165    _section: &str,
166    old_keys: &BTreeSet<String>,
167    new_keys: &BTreeSet<String>,
168) -> (Vec<String>, Vec<String>) {
169    let added: Vec<String> = new_keys.difference(old_keys).cloned().collect();
170    let removed: Vec<String> = old_keys.difference(new_keys).cloned().collect();
171    (added, removed)
172}
173
174/// Build a `SectionDiff` from the symmetric difference of two string sets
175fn diff_sets(section: &str, old: &BTreeSet<String>, new: &BTreeSet<String>) -> SectionDiff {
176    let added: Vec<String> = new.difference(old).cloned().collect();
177    let removed: Vec<String> = old.difference(new).cloned().collect();
178    SectionDiff {
179        section: section.to_string(),
180        added,
181        removed,
182        changed: Vec::new(),
183    }
184}
185
186/// Detect field-level changes in equations present in both old and new contracts
187fn diff_equation_changes(old: &Contract, new: &Contract) -> Vec<String> {
188    let mut changed = Vec::new();
189    for (name, old_eq) in &old.equations {
190        if let Some(new_eq) = new.equations.get(name) {
191            if old_eq.formula != new_eq.formula {
192                changed.push(format!("{name}: formula changed"));
193            }
194            if old_eq.domain != new_eq.domain {
195                changed.push(format!("{name}: domain changed"));
196            }
197            if old_eq.codomain != new_eq.codomain {
198                changed.push(format!("{name}: codomain changed"));
199            }
200            if old_eq.invariants != new_eq.invariants {
201                changed.push(format!("{name}: invariants changed"));
202            }
203        }
204    }
205    changed
206}
207
208/// Determine bump for a section: removals/changes → `on_break`, additions → `on_add`.
209fn section_bump(section: &SectionDiff, on_break: SemverBump, on_add: SemverBump) -> SemverBump {
210    if !section.removed.is_empty() || !section.changed.is_empty() {
211        on_break
212    } else if !section.added.is_empty() {
213        on_add
214    } else {
215        SemverBump::None
216    }
217}
218
219/// Return the higher-priority semver bump between current and candidate
220fn bump_max(current: SemverBump, candidate: SemverBump) -> SemverBump {
221    let rank = |b: SemverBump| match b {
222        SemverBump::None => 0,
223        SemverBump::Patch => 1,
224        SemverBump::Minor => 2,
225        SemverBump::Major => 3,
226    };
227    if rank(candidate) > rank(current) {
228        candidate
229    } else {
230        current
231    }
232}
233
234/// True if the diff contains no changes at all.
235pub fn is_identical(diff: &ContractDiff) -> bool {
236    diff.sections.iter().all(SectionDiff::is_empty) && diff.suggested_bump == SemverBump::None
237}
238
239#[cfg(test)]
240mod tests {
241    include!("diff_tests.rs");
242}