Skip to main content

ucm_ingest/
diff_parser.rs

1//! Git diff parser — compares before/after source to emit ChangeDetected events.
2//!
3//! In production, this would use git2 (libgit2 bindings) and tree-sitter
4//! to compare two ASTs. This mock parser compares source strings and
5//! classifies changes semantically.
6
7use ucm_graph_core::entity::*;
8use ucm_graph_core::event::*;
9
10/// Compare before/after source code and emit change events.
11///
12/// Classifies changes semantically:
13/// - Signature changes (function parameters, return types)
14/// - Body changes (implementation details)
15/// - New/removed entities
16/// - Import changes
17pub fn parse_diff(file_path: &str, before: &str, after: &str) -> Vec<UcmEvent> {
18    let mut events = Vec::new();
19
20    let before_fns = extract_function_signatures(before);
21    let after_fns = extract_function_signatures(after);
22
23    // Detect signature changes
24    for (name, sig) in &after_fns {
25        if let Some(old_sig) = before_fns.get(name.as_str()) {
26            if sig != old_sig {
27                events.push(UcmEvent::new(EventPayload::ChangeDetected {
28                    file_path: file_path.to_string(),
29                    change_type: ChangeType::SignatureChange,
30                    affected_entities: vec![EntityId::local(file_path, name)],
31                    before_snapshot: Some(old_sig.clone()),
32                    after_snapshot: Some(sig.clone()),
33                }));
34            }
35        } else {
36            // New function
37            events.push(UcmEvent::new(EventPayload::ChangeDetected {
38                file_path: file_path.to_string(),
39                change_type: ChangeType::EntityAdded,
40                affected_entities: vec![EntityId::local(file_path, name)],
41                before_snapshot: None,
42                after_snapshot: Some(sig.clone()),
43            }));
44        }
45    }
46
47    // Detect removed functions
48    for (name, sig) in &before_fns {
49        if !after_fns.contains_key(name.as_str()) {
50            events.push(UcmEvent::new(EventPayload::ChangeDetected {
51                file_path: file_path.to_string(),
52                change_type: ChangeType::EntityDeleted,
53                affected_entities: vec![EntityId::local(file_path, name)],
54                before_snapshot: Some(sig.clone()),
55                after_snapshot: None,
56            }));
57        }
58    }
59
60    // Detect import changes
61    let before_imports = extract_import_lines(before);
62    let after_imports = extract_import_lines(after);
63    if before_imports != after_imports {
64        events.push(UcmEvent::new(EventPayload::ChangeDetected {
65            file_path: file_path.to_string(),
66            change_type: ChangeType::ImportChange,
67            affected_entities: vec![],
68            before_snapshot: Some(before_imports.join("\n")),
69            after_snapshot: Some(after_imports.join("\n")),
70        }));
71    }
72
73    // If no specific changes detected but content differs, emit body change
74    if events.is_empty() && before != after {
75        events.push(UcmEvent::new(EventPayload::ChangeDetected {
76            file_path: file_path.to_string(),
77            change_type: ChangeType::BodyChange,
78            affected_entities: vec![],
79            before_snapshot: None,
80            after_snapshot: None,
81        }));
82    }
83
84    events
85}
86
87/// Extract function signatures (name → signature string).
88fn extract_function_signatures(source: &str) -> std::collections::HashMap<String, String> {
89    let mut sigs = std::collections::HashMap::new();
90
91    for line in source.lines() {
92        let trimmed = line.trim();
93        if trimmed.contains("function ") && trimmed.contains('(') {
94            let parts: Vec<&str> = trimmed.split("function ").collect();
95            if parts.len() >= 2 {
96                let after = parts.last().unwrap();
97                let name: String = after
98                    .chars()
99                    .take_while(|c| c.is_alphanumeric() || *c == '_')
100                    .collect();
101                if !name.is_empty() {
102                    // Extract up to the closing paren
103                    let sig = if let Some(end) = trimmed.find('{') {
104                        trimmed[..end].trim().to_string()
105                    } else {
106                        trimmed.to_string()
107                    };
108                    sigs.insert(name, sig);
109                }
110            }
111        }
112    }
113
114    sigs
115}
116
117/// Extract import lines for comparison.
118fn extract_import_lines(source: &str) -> Vec<String> {
119    source
120        .lines()
121        .filter(|line| line.trim().starts_with("import "))
122        .map(|line| line.trim().to_string())
123        .collect()
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn test_detect_signature_change() {
132        let before = r#"
133function validateToken(token: string): boolean {
134    return jwt.verify(token);
135}
136"#;
137        let after = r#"
138function validateToken(token: string): Result<Claims, AuthError> {
139    return jwt.verify(token);
140}
141"#;
142
143        let events = parse_diff("src/auth/service.ts", before, after);
144        assert!(!events.is_empty());
145
146        let change = &events[0];
147        match &change.payload {
148            EventPayload::ChangeDetected { change_type, .. } => {
149                assert!(matches!(change_type, ChangeType::SignatureChange));
150            }
151            _ => panic!("Expected ChangeDetected event"),
152        }
153    }
154
155    #[test]
156    fn test_detect_new_function() {
157        let before = "function existing() {}";
158        let after = "function existing() {}\nfunction newFunction() {}";
159
160        let events = parse_diff("src/main.ts", before, after);
161        let added: Vec<_> = events
162            .iter()
163            .filter(|e| {
164                matches!(
165                    &e.payload,
166                    EventPayload::ChangeDetected {
167                        change_type: ChangeType::EntityAdded,
168                        ..
169                    }
170                )
171            })
172            .collect();
173
174        assert!(!added.is_empty());
175    }
176
177    #[test]
178    fn test_detect_removed_function() {
179        let before = "function toRemove() {}\nfunction toKeep() {}";
180        let after = "function toKeep() {}";
181
182        let events = parse_diff("src/main.ts", before, after);
183        let removed: Vec<_> = events
184            .iter()
185            .filter(|e| {
186                matches!(
187                    &e.payload,
188                    EventPayload::ChangeDetected {
189                        change_type: ChangeType::EntityDeleted,
190                        ..
191                    }
192                )
193            })
194            .collect();
195
196        assert!(!removed.is_empty());
197    }
198}