Skip to main content

airl_project/
diff.rs

1//! Semantic diff between two AIRL modules.
2//!
3//! Unlike a text diff, this operates on the IR graph structure:
4//! - Added / removed / modified functions
5//! - Changed effects
6//! - Signature changes (parameters, return type)
7//! - Body structural changes (node count delta)
8
9use airl_ir::module::{FuncDef, Module};
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12
13/// A semantic diff between two modules.
14///
15/// Produced by [`diff`]. Use [`ModuleDiff::is_empty`] to check for any changes
16/// and [`ModuleDiff::summary`] for a short human-readable overview.
17#[derive(Clone, Debug, Serialize, Deserialize)]
18pub struct ModuleDiff {
19    /// Names of functions present in `new` but not `old`.
20    pub added_functions: Vec<String>,
21    /// Names of functions present in `old` but not `new`.
22    pub removed_functions: Vec<String>,
23    /// Functions present in both with any change (signature, effects, body).
24    pub modified_functions: Vec<FunctionDiff>,
25    /// Imports added in `new` (formatted as `"module::item1,item2"`).
26    pub added_imports: Vec<String>,
27    /// Imports removed in `new` (formatted as `"module::item1,item2"`).
28    pub removed_imports: Vec<String>,
29}
30
31/// A function-level diff showing what changed between two versions.
32#[derive(Clone, Debug, Serialize, Deserialize)]
33pub struct FunctionDiff {
34    /// Function name (same in both versions).
35    pub name: String,
36    /// Whether parameters or return type changed.
37    pub signature_changed: bool,
38    /// Signature rendering from the old version, e.g. `"fn foo(x: I64) -> Unit"`.
39    pub old_signature: String,
40    /// Signature rendering from the new version.
41    pub new_signature: String,
42    /// Whether declared effects changed.
43    pub effects_changed: bool,
44    /// Effects declared in the old version, as strings.
45    pub old_effects: Vec<String>,
46    /// Effects declared in the new version, as strings.
47    pub new_effects: Vec<String>,
48    /// Difference in body node count: `new - old`. Negative values indicate shrinkage.
49    pub body_node_count_delta: i64,
50    /// Total body node count in the old version.
51    pub old_node_count: u32,
52    /// Total body node count in the new version.
53    pub new_node_count: u32,
54}
55
56impl ModuleDiff {
57    /// True if the two modules are semantically identical (no changes).
58    pub fn is_empty(&self) -> bool {
59        self.added_functions.is_empty()
60            && self.removed_functions.is_empty()
61            && self.modified_functions.is_empty()
62            && self.added_imports.is_empty()
63            && self.removed_imports.is_empty()
64    }
65
66    /// Short human-readable summary.
67    pub fn summary(&self) -> String {
68        let mut parts = Vec::new();
69        if !self.added_functions.is_empty() {
70            parts.push(format!("+{} fn", self.added_functions.len()));
71        }
72        if !self.removed_functions.is_empty() {
73            parts.push(format!("-{} fn", self.removed_functions.len()));
74        }
75        if !self.modified_functions.is_empty() {
76            parts.push(format!("~{} fn", self.modified_functions.len()));
77        }
78        if !self.added_imports.is_empty() {
79            parts.push(format!("+{} import", self.added_imports.len()));
80        }
81        if !self.removed_imports.is_empty() {
82            parts.push(format!("-{} import", self.removed_imports.len()));
83        }
84        if parts.is_empty() {
85            "no changes".to_string()
86        } else {
87            parts.join(", ")
88        }
89    }
90}
91
92/// Compute a semantic diff from `old` to `new`.
93pub fn diff(old: &Module, new: &Module) -> ModuleDiff {
94    let old_funcs: HashMap<&str, &FuncDef> = old
95        .functions()
96        .iter()
97        .map(|f| (f.name.as_str(), f))
98        .collect();
99    let new_funcs: HashMap<&str, &FuncDef> = new
100        .functions()
101        .iter()
102        .map(|f| (f.name.as_str(), f))
103        .collect();
104
105    let old_names: HashSet<&str> = old_funcs.keys().copied().collect();
106    let new_names: HashSet<&str> = new_funcs.keys().copied().collect();
107
108    let added_functions: Vec<String> = new_names
109        .difference(&old_names)
110        .map(|s| s.to_string())
111        .collect();
112    let removed_functions: Vec<String> = old_names
113        .difference(&new_names)
114        .map(|s| s.to_string())
115        .collect();
116
117    let mut modified_functions = Vec::new();
118    for name in old_names.intersection(&new_names) {
119        let old_f = old_funcs[name];
120        let new_f = new_funcs[name];
121        if let Some(fd) = diff_function(old_f, new_f) {
122            modified_functions.push(fd);
123        }
124    }
125
126    // Imports diff
127    let old_imports: HashSet<String> = old
128        .module
129        .imports
130        .iter()
131        .map(|i| format!("{}::{}", i.module, i.items.join(",")))
132        .collect();
133    let new_imports: HashSet<String> = new
134        .module
135        .imports
136        .iter()
137        .map(|i| format!("{}::{}", i.module, i.items.join(",")))
138        .collect();
139
140    let added_imports: Vec<String> = new_imports.difference(&old_imports).cloned().collect();
141    let removed_imports: Vec<String> = old_imports.difference(&new_imports).cloned().collect();
142
143    // Sort for deterministic output
144    let mut added_functions = added_functions;
145    added_functions.sort();
146    let mut removed_functions = removed_functions;
147    removed_functions.sort();
148    modified_functions.sort_by(|a, b| a.name.cmp(&b.name));
149    let mut added_imports = added_imports;
150    added_imports.sort();
151    let mut removed_imports = removed_imports;
152    removed_imports.sort();
153
154    ModuleDiff {
155        added_functions,
156        removed_functions,
157        modified_functions,
158        added_imports,
159        removed_imports,
160    }
161}
162
163fn diff_function(old: &FuncDef, new: &FuncDef) -> Option<FunctionDiff> {
164    let old_sig = function_signature(old);
165    let new_sig = function_signature(new);
166    let signature_changed = old_sig != new_sig;
167
168    let old_effects: Vec<String> = old.effects.iter().map(|e| e.to_effect_str()).collect();
169    let new_effects: Vec<String> = new.effects.iter().map(|e| e.to_effect_str()).collect();
170    let effects_changed = old_effects != new_effects;
171
172    let old_nodes = count_nodes(&old.body);
173    let new_nodes = count_nodes(&new.body);
174    let body_node_count_delta = new_nodes as i64 - old_nodes as i64;
175
176    let body_structurally_same = old.body == new.body;
177
178    if !signature_changed && !effects_changed && body_structurally_same {
179        return None;
180    }
181
182    Some(FunctionDiff {
183        name: old.name.clone(),
184        signature_changed,
185        old_signature: old_sig,
186        new_signature: new_sig,
187        effects_changed,
188        old_effects,
189        new_effects,
190        body_node_count_delta,
191        old_node_count: old_nodes,
192        new_node_count: new_nodes,
193    })
194}
195
196fn function_signature(func: &FuncDef) -> String {
197    let params: Vec<String> = func
198        .params
199        .iter()
200        .map(|p| format!("{}: {}", p.name, p.param_type.to_type_str()))
201        .collect();
202    format!(
203        "fn {}({}) -> {}",
204        func.name,
205        params.join(", "),
206        func.returns.to_type_str()
207    )
208}
209
210fn count_nodes(node: &airl_ir::node::Node) -> u32 {
211    use airl_ir::node::Node;
212    1 + match node {
213        Node::Let { value, body, .. } => count_nodes(value) + count_nodes(body),
214        Node::If {
215            cond,
216            then_branch,
217            else_branch,
218            ..
219        } => count_nodes(cond) + count_nodes(then_branch) + count_nodes(else_branch),
220        Node::Call { args, .. } => args.iter().map(count_nodes).sum(),
221        Node::Return { value, .. } => count_nodes(value),
222        Node::BinOp { lhs, rhs, .. } => count_nodes(lhs) + count_nodes(rhs),
223        Node::UnaryOp { operand, .. } => count_nodes(operand),
224        Node::Block {
225            statements, result, ..
226        } => statements.iter().map(count_nodes).sum::<u32>() + count_nodes(result),
227        Node::Loop { body, .. } => count_nodes(body),
228        Node::Match {
229            scrutinee, arms, ..
230        } => count_nodes(scrutinee) + arms.iter().map(|a| count_nodes(&a.body)).sum::<u32>(),
231        Node::ArrayLiteral { elements, .. } => elements.iter().map(count_nodes).sum(),
232        Node::IndexAccess { array, index, .. } => count_nodes(array) + count_nodes(index),
233        Node::StructLiteral { fields, .. } => fields.iter().map(|(_, n)| count_nodes(n)).sum(),
234        Node::FieldAccess { object, .. } => count_nodes(object),
235        _ => 0,
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    fn load(json: &str) -> Module {
244        serde_json::from_str(json).unwrap()
245    }
246
247    fn hello_v1() -> Module {
248        load(
249            r#"{
250            "format_version":"0.1.0",
251            "module":{"id":"m","name":"main",
252                "metadata":{"version":"1","description":"","author":"","created_at":""},
253                "imports":[],"exports":[],"types":[],
254                "functions":[{
255                    "id":"f","name":"main","params":[],"returns":"Unit","effects":["IO"],
256                    "body":{"id":"n1","kind":"Call","type":"Unit","target":"std::io::println",
257                        "args":[{"id":"n2","kind":"Literal","type":"String","value":"hello"}]}
258                }]
259            }
260        }"#,
261        )
262    }
263
264    fn hello_v2_changed_body() -> Module {
265        load(
266            r#"{
267            "format_version":"0.1.0",
268            "module":{"id":"m","name":"main",
269                "metadata":{"version":"1","description":"","author":"","created_at":""},
270                "imports":[],"exports":[],"types":[],
271                "functions":[{
272                    "id":"f","name":"main","params":[],"returns":"Unit","effects":["IO"],
273                    "body":{"id":"n1","kind":"Call","type":"Unit","target":"std::io::println",
274                        "args":[{"id":"n2","kind":"Literal","type":"String","value":"world"}]}
275                }]
276            }
277        }"#,
278        )
279    }
280
281    #[test]
282    fn test_diff_empty_when_identical() {
283        let m = hello_v1();
284        let d = diff(&m, &m);
285        assert!(d.is_empty());
286        assert_eq!(d.summary(), "no changes");
287    }
288
289    #[test]
290    fn test_diff_modified_body() {
291        let v1 = hello_v1();
292        let v2 = hello_v2_changed_body();
293        let d = diff(&v1, &v2);
294        assert!(!d.is_empty());
295        assert_eq!(d.modified_functions.len(), 1);
296        assert_eq!(d.modified_functions[0].name, "main");
297        // Signature unchanged
298        assert!(!d.modified_functions[0].signature_changed);
299    }
300
301    #[test]
302    fn test_diff_added_function() {
303        let v1 = hello_v1();
304        let v2 = load(
305            r#"{
306            "format_version":"0.1.0",
307            "module":{"id":"m","name":"main",
308                "metadata":{"version":"1","description":"","author":"","created_at":""},
309                "imports":[],"exports":[],"types":[],
310                "functions":[
311                    {"id":"f","name":"main","params":[],"returns":"Unit","effects":["IO"],
312                        "body":{"id":"n1","kind":"Literal","type":"Unit","value":null}},
313                    {"id":"g","name":"helper","params":[],"returns":"I64","effects":["Pure"],
314                        "body":{"id":"n2","kind":"Literal","type":"I64","value":42}}
315                ]
316            }
317        }"#,
318        );
319        let d = diff(&v1, &v2);
320        assert_eq!(d.added_functions, vec!["helper".to_string()]);
321        assert!(d.removed_functions.is_empty());
322    }
323
324    #[test]
325    fn test_diff_summary() {
326        let v1 = hello_v1();
327        let v2 = hello_v2_changed_body();
328        let d = diff(&v1, &v2);
329        assert_eq!(d.summary(), "~1 fn");
330    }
331}