1use std::path::Path;
6
7use objects::object::{ChangeImportance, ModificationKind};
8
9use super::analysis_similarity::{SimilarityMethod, compute_similarity};
10use crate::parser::{Language, ParsedFile, walk_non_comment_leaves};
11
12pub type ClassificationResult = (ModificationKind, ChangeImportance, f64);
14
15pub fn classify_modification(
23 path: &Path,
24 old_content: &str,
25 new_content: &str,
26) -> (ModificationKind, ChangeImportance) {
27 let (kind, importance, _confidence) =
28 classify_modification_with_confidence(path, old_content, new_content);
29 (kind, importance)
30}
31
32pub fn classify_modification_with_confidence(
34 path: &Path,
35 old_content: &str,
36 new_content: &str,
37) -> ClassificationResult {
38 let token_sim = classify_common_prefix(old_content, new_content);
39 if let Some(result) = token_sim.result {
40 return result;
41 }
42
43 let language = Language::from_path(path);
44
45 let old_parsed = ParsedFile::parse(old_content, language);
47 let new_parsed = ParsedFile::parse(new_content, language);
48
49 match (old_parsed.as_ref(), new_parsed.as_ref()) {
50 (Some(old_ast), Some(new_ast)) => {
51 classify_with_parsed(old_content, new_content, old_ast, new_ast)
52 }
53 _ => classify_without_ast(old_content, new_content, token_sim.value),
54 }
55}
56
57pub(crate) fn classify_with_parsed(
59 old_content: &str,
60 new_content: &str,
61 old_ast: &ParsedFile,
62 new_ast: &ParsedFile,
63) -> ClassificationResult {
64 let token_sim = classify_common_prefix(old_content, new_content);
65 if let Some(result) = token_sim.result {
66 return result;
67 }
68
69 classify_with_ast(old_content, new_content, old_ast, new_ast)
70}
71
72struct TokenSimilarityCheck {
73 value: f64,
74 result: Option<ClassificationResult>,
75}
76
77fn classify_common_prefix(old_content: &str, new_content: &str) -> TokenSimilarityCheck {
78 if old_content == new_content {
80 return TokenSimilarityCheck {
81 value: 1.0,
82 result: Some((
83 ModificationKind::WhitespaceOnly,
84 ChangeImportance::Noise,
85 1.0,
86 )),
87 };
88 }
89
90 let token_sim = compute_similarity(old_content, new_content, SimilarityMethod::Tokens);
92 if token_sim >= 1.0 {
93 return TokenSimilarityCheck {
96 value: token_sim,
97 result: Some((
98 ModificationKind::FormattingOnly,
99 ChangeImportance::Noise,
100 0.95,
101 )),
102 };
103 }
104
105 TokenSimilarityCheck {
106 value: token_sim,
107 result: None,
108 }
109}
110
111fn classify_with_ast(
113 old_content: &str,
114 new_content: &str,
115 old_ast: &ParsedFile,
116 new_ast: &ParsedFile,
117) -> ClassificationResult {
118 let old_funcs = old_ast.extract_functions();
119 let new_funcs = new_ast.extract_functions();
120 let old_imports = old_ast.extract_imports();
121 let new_imports = new_ast.extract_imports();
122
123 let funcs_identical = are_functions_identical(&old_funcs, &new_funcs);
124 let imports_identical = old_imports.len() == new_imports.len()
125 && old_imports
126 .iter()
127 .zip(new_imports.iter())
128 .all(|(a, b)| a.raw == b.raw);
129
130 let old_stripped = strip_comments(old_ast);
132 let new_stripped = strip_comments(new_ast);
133 let non_comment_identical = old_stripped == new_stripped;
134
135 if non_comment_identical {
136 return (ModificationKind::CommentsOnly, ChangeImportance::Low, 0.92);
137 }
138
139 if funcs_identical && !imports_identical {
140 let old_body = strip_imports_and_functions(old_ast);
143 let new_body = strip_imports_and_functions(new_ast);
144 if old_body == new_body {
145 return (ModificationKind::ImportsOnly, ChangeImportance::Low, 0.93);
146 }
147 }
148
149 let token_sim = compute_similarity(old_content, new_content, SimilarityMethod::Tokens);
151 if token_sim >= 1.0 {
152 return (
153 ModificationKind::FormattingOnly,
154 ChangeImportance::Noise,
155 0.97,
156 );
157 }
158
159 let line_sim = compute_similarity(old_content, new_content, SimilarityMethod::Lines);
162 if token_sim > 0.9 && line_sim < 0.7 {
163 return (ModificationKind::Mixed, ChangeImportance::Medium, 0.75);
165 }
166
167 (ModificationKind::Logic, ChangeImportance::High, 0.85)
169}
170
171fn classify_without_ast(
173 old_content: &str,
174 new_content: &str,
175 token_sim: f64,
176) -> ClassificationResult {
177 if token_sim >= 1.0 {
178 return (
179 ModificationKind::FormattingOnly,
180 ChangeImportance::Noise,
181 0.9,
182 );
183 }
184
185 let line_sim = compute_similarity(old_content, new_content, SimilarityMethod::Lines);
186
187 if token_sim > 0.95 && line_sim < 0.8 {
189 return (
190 ModificationKind::FormattingOnly,
191 ChangeImportance::Noise,
192 0.7,
193 );
194 }
195
196 if token_sim > 0.9 {
197 return (ModificationKind::Mixed, ChangeImportance::Medium, 0.6);
198 }
199
200 (ModificationKind::Logic, ChangeImportance::High, 0.5)
202}
203
204fn are_functions_identical(
206 old_funcs: &[crate::parser::FunctionDef],
207 new_funcs: &[crate::parser::FunctionDef],
208) -> bool {
209 if old_funcs.len() != new_funcs.len() {
210 return false;
211 }
212 let mut old_sorted: Vec<_> = old_funcs.iter().collect();
214 let mut new_sorted: Vec<_> = new_funcs.iter().collect();
215 old_sorted.sort_by_key(|f| &f.name);
216 new_sorted.sort_by_key(|f| &f.name);
217
218 old_sorted
219 .iter()
220 .zip(new_sorted.iter())
221 .all(|(a, b)| a.name == b.name && a.content == b.content)
222}
223
224fn strip_comments(parsed: &ParsedFile) -> String {
226 let mut result = String::new();
227 collect_non_comment_text(parsed.root_node(), &parsed.source, &mut result);
228 result
229}
230
231fn collect_non_comment_text(node: tree_sitter::Node<'_>, source: &str, out: &mut String) {
232 walk_non_comment_leaves(node, |leaf| {
233 out.push_str(&source[leaf.byte_range()]);
234 out.push(' ');
235 });
236}
237
238fn strip_imports_and_functions(parsed: &ParsedFile) -> String {
240 let mut result = String::new();
241 let root = parsed.root_node();
242 for i in 0..root.child_count() {
243 if let Some(child) = root.child(i as u32) {
244 let kind = child.kind();
245 if matches!(
247 kind,
248 "use_declaration"
249 | "extern_crate_declaration"
250 | "import_statement"
251 | "import_from_statement"
252 | "import_declaration"
253 ) {
254 continue;
255 }
256 if ParsedFile::is_function_kind(kind, parsed.language) {
258 continue;
259 }
260 result.push_str(&parsed.source[child.byte_range()]);
261 result.push('\n');
262 }
263 }
264 result
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn test_whitespace_only() {
273 let old = "fn foo() {\n bar();\n}\n";
274 let new = "fn foo() {\n bar();\n}\n";
275 let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
276 assert_eq!(kind, ModificationKind::FormattingOnly);
277 assert_eq!(importance, ChangeImportance::Noise);
278 }
279
280 #[test]
281 fn test_logic_change() {
282 let old = "fn foo() -> i32 {\n 42\n}\n";
283 let new = "fn foo() -> i32 {\n 43\n}\n";
284 let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
285 assert_eq!(kind, ModificationKind::Logic);
286 assert_eq!(importance, ChangeImportance::High);
287 }
288
289 #[test]
290 fn test_comments_only() {
291 let old = "// old comment\nfn foo() {\n bar();\n}\n";
292 let new = "// new comment\nfn foo() {\n bar();\n}\n";
293 let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
294 assert_eq!(kind, ModificationKind::CommentsOnly);
295 assert_eq!(importance, ChangeImportance::Low);
296 }
297
298 #[test]
299 fn test_imports_only() {
300 let old = "use std::io;\n\nfn foo() {\n bar();\n}\n";
301 let new = "use std::io;\nuse std::fs;\n\nfn foo() {\n bar();\n}\n";
302 let (kind, importance) = classify_modification(Path::new("test.rs"), old, new);
303 assert_eq!(kind, ModificationKind::ImportsOnly);
304 assert_eq!(importance, ChangeImportance::Low);
305 }
306
307 #[test]
308 fn test_parse_error_fallback() {
309 let old = "some content here\n";
311 let new = "some content here\nwith additions\n";
312 let (kind, importance) = classify_modification(Path::new("test.xyz"), old, new);
313 assert_eq!(kind, ModificationKind::Logic);
315 assert_eq!(importance, ChangeImportance::High);
316 }
317
318 #[test]
319 fn test_formatting_only_unknown_lang() {
320 let old = "foo bar baz\n";
322 let new = "foo bar baz\n";
323 let (kind, importance) = classify_modification(Path::new("test.xyz"), old, new);
324 assert_eq!(kind, ModificationKind::FormattingOnly);
325 assert_eq!(importance, ChangeImportance::Noise);
326 }
327
328 #[test]
329 fn test_classify_with_parsed_matches_direct_classifier() {
330 let fixtures = [
331 (
332 "formatting",
333 "fn compute() -> i32 { 1 }\n",
334 "fn compute() -> i32 {\n 1\n}\n",
335 ),
336 (
337 "comments",
338 "// before\nfn compute() -> i32 { 1 }\n",
339 "// after\nfn compute() -> i32 { 1 }\n",
340 ),
341 (
342 "imports",
343 "use std::io;\n\nfn compute() -> i32 {\n 1\n}\n",
344 "use std::io;\nuse std::fs;\n\nfn compute() -> i32 {\n 1\n}\n",
345 ),
346 (
347 "logic",
348 "fn compute(input: i32) -> i32 { input + 1 }\n",
349 "fn compute(input: i32) -> i32 { input * 2 }\n",
350 ),
351 ];
352
353 for (name, old, new) in fixtures {
354 let old_ast = ParsedFile::parse(old, Language::Rust).expect("old Rust should parse");
355 let new_ast = ParsedFile::parse(new, Language::Rust).expect("new Rust should parse");
356 let direct = classify_modification_with_confidence(Path::new("test.rs"), old, new);
357 let cached = classify_with_parsed(old, new, &old_ast, &new_ast);
358
359 assert_eq!(cached, direct, "classification drift for {name}");
360 }
361 }
362}