1use crate::git_tools::{CodeDiffChangeType, CodeDiffResult};
8use crate::schema::{CodeEdgePredicate, CodeNode, CodeNodeKind, extract_file_path, node_col};
9use crate::search::{callers, tests_for, transitive_callers};
10use arrow::array::{Array, RecordBatch, StringArray};
11use std::collections::HashSet;
12
13#[derive(Debug, Clone)]
15pub struct ImpactReport {
16 pub directly_changed: Vec<ChangedNode>,
18 pub direct_callers: Vec<CodeNode>,
20 pub transitive_callers: Vec<CodeNode>,
22 pub affected_tests: Vec<CodeNode>,
24 pub stats: ImpactStats,
26}
27
28#[derive(Debug, Clone)]
30pub struct ChangedNode {
31 pub node: CodeNode,
32 pub change_type: CodeDiffChangeType,
33}
34
35#[derive(Debug, Clone, Default)]
37pub struct ImpactStats {
38 pub directly_changed: usize,
39 pub direct_callers: usize,
40 pub transitive_callers: usize,
41 pub affected_tests: usize,
42 pub total_blast_radius: usize,
43 pub files_affected: usize,
44}
45
46impl std::fmt::Display for ImpactStats {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 write!(
49 f,
50 "{} changed, {} direct callers, {} transitive callers, {} tests affected ({} total across {} files)",
51 self.directly_changed,
52 self.direct_callers,
53 self.transitive_callers,
54 self.affected_tests,
55 self.total_blast_radius,
56 self.files_affected,
57 )
58 }
59}
60
61pub fn impact_analysis(
68 diff: &CodeDiffResult,
69 nodes_batch: &RecordBatch,
70 edges_batch: &RecordBatch,
71 max_depth: usize,
72) -> ImpactReport {
73 let changed_ids: HashSet<String> = diff.entries.iter().map(|e| e.node_id.clone()).collect();
75
76 let node_map = build_node_map(nodes_batch);
78 let directly_changed: Vec<ChangedNode> = diff
79 .entries
80 .iter()
81 .filter_map(|entry| {
82 let node = node_map.get(entry.node_id.as_str()).cloned().or_else(|| {
83 if entry.change_type == CodeDiffChangeType::Removed {
85 Some(CodeNode {
86 id: entry.node_id.clone(),
87 kind: CodeNodeKind::parse(&entry.kind).unwrap_or(CodeNodeKind::Function),
88 parent_id: None,
89 name: entry.name.clone(),
90 signature: None,
91 docstring: None,
92 body_hash: None,
93 body: None,
94 loc: None,
95 cyclomatic_complexity: None,
96 coverage_pct: None,
97 last_modified: None,
98 ..Default::default()
99 })
100 } else {
101 None
102 }
103 })?;
104 Some(ChangedNode {
105 node,
106 change_type: entry.change_type,
107 })
108 })
109 .collect();
110
111 let mut direct_caller_ids: HashSet<String> = HashSet::new();
113 for entry in &diff.entries {
114 if entry.change_type == CodeDiffChangeType::Removed {
115 continue; }
117 for caller in callers(&entry.node_id, nodes_batch, edges_batch) {
118 if !changed_ids.contains(&caller.id) {
119 direct_caller_ids.insert(caller.id.clone());
120 }
121 }
122 }
123
124 let mut transitive_caller_ids: HashSet<String> = HashSet::new();
126 for entry in &diff.entries {
127 if entry.change_type == CodeDiffChangeType::Removed {
128 continue;
129 }
130 for tc in transitive_callers(
131 &entry.node_id,
132 CodeEdgePredicate::Calls,
133 max_depth,
134 nodes_batch,
135 edges_batch,
136 ) {
137 if !changed_ids.contains(&tc.id) && !direct_caller_ids.contains(&tc.id) {
138 transitive_caller_ids.insert(tc.id.clone());
139 }
140 }
141 }
142
143 let mut test_ids: HashSet<String> = HashSet::new();
145 let all_affected: HashSet<&str> = changed_ids
146 .iter()
147 .chain(direct_caller_ids.iter())
148 .chain(transitive_caller_ids.iter())
149 .map(|s| s.as_str())
150 .collect();
151
152 for node_id in &all_affected {
153 for test in tests_for(node_id, nodes_batch, edges_batch) {
154 test_ids.insert(test.id.clone());
155 }
156 }
157
158 let direct_callers: Vec<CodeNode> = direct_caller_ids
160 .iter()
161 .filter_map(|id| node_map.get(id.as_str()).cloned())
162 .collect();
163 let transitive_callers_nodes: Vec<CodeNode> = transitive_caller_ids
164 .iter()
165 .filter_map(|id| node_map.get(id.as_str()).cloned())
166 .collect();
167 let affected_tests: Vec<CodeNode> = test_ids
168 .iter()
169 .filter_map(|id| node_map.get(id.as_str()).cloned())
170 .collect();
171
172 let mut all_files: HashSet<String> = HashSet::new();
174 for cn in &directly_changed {
175 if let Some(fp) = extract_file_path(&cn.node.id) {
176 all_files.insert(fp);
177 }
178 }
179 for n in direct_callers.iter().chain(transitive_callers_nodes.iter()) {
180 if let Some(fp) = extract_file_path(&n.id) {
181 all_files.insert(fp);
182 }
183 }
184
185 let stats = ImpactStats {
186 directly_changed: directly_changed.len(),
187 direct_callers: direct_callers.len(),
188 transitive_callers: transitive_callers_nodes.len(),
189 affected_tests: affected_tests.len(),
190 total_blast_radius: directly_changed.len()
191 + direct_callers.len()
192 + transitive_callers_nodes.len(),
193 files_affected: all_files.len(),
194 };
195
196 ImpactReport {
197 directly_changed,
198 direct_callers,
199 transitive_callers: transitive_callers_nodes,
200 affected_tests,
201 stats,
202 }
203}
204
205pub fn format_impact_report(report: &ImpactReport) -> String {
207 let mut out = String::new();
208 out.push_str(&format!("## Impact Analysis\n\n{}\n\n", report.stats));
209
210 if !report.directly_changed.is_empty() {
211 out.push_str("### Directly Changed\n\n");
212 for cn in &report.directly_changed {
213 out.push_str(&format!(
214 " {} {} `{}`\n",
215 match cn.change_type {
216 CodeDiffChangeType::Added => "+",
217 CodeDiffChangeType::Removed => "-",
218 CodeDiffChangeType::Modified => "~",
219 },
220 cn.node.kind,
221 cn.node.name,
222 ));
223 }
224 out.push('\n');
225 }
226
227 if !report.direct_callers.is_empty() {
228 out.push_str("### Direct Callers (may need updating)\n\n");
229 for n in &report.direct_callers {
230 out.push_str(&format!(" {} `{}`\n", n.kind, n.name));
231 }
232 out.push('\n');
233 }
234
235 if !report.transitive_callers.is_empty() {
236 out.push_str("### Transitive Callers (ripple effect)\n\n");
237 for n in &report.transitive_callers {
238 out.push_str(&format!(" {} `{}`\n", n.kind, n.name));
239 }
240 out.push('\n');
241 }
242
243 if !report.affected_tests.is_empty() {
244 out.push_str("### Affected Tests (should be run)\n\n");
245 for t in &report.affected_tests {
246 out.push_str(&format!(" {} `{}`\n", t.kind, t.name));
247 }
248 out.push('\n');
249 }
250
251 out
252}
253
254fn build_node_map(batch: &RecordBatch) -> std::collections::HashMap<String, CodeNode> {
256 use crate::schema::CodeNodeKind;
257 use arrow::array::{Float64Array, Int32Array};
258
259 let mut map = std::collections::HashMap::new();
260 if batch.num_rows() == 0 {
261 return map;
262 }
263
264 let ids = batch
265 .column(node_col::ID)
266 .as_any()
267 .downcast_ref::<StringArray>()
268 .expect("id");
269 let names = batch
270 .column(node_col::NAME)
271 .as_any()
272 .downcast_ref::<StringArray>()
273 .expect("name");
274 let parent_ids = batch
275 .column(node_col::PARENT_ID)
276 .as_any()
277 .downcast_ref::<StringArray>()
278 .expect("parent_id");
279 let signatures = batch
280 .column(node_col::SIGNATURE)
281 .as_any()
282 .downcast_ref::<StringArray>()
283 .expect("signature");
284 let docstrings = batch
285 .column(node_col::DOCSTRING)
286 .as_any()
287 .downcast_ref::<StringArray>()
288 .expect("docstring");
289 let body_hashes = batch
290 .column(node_col::BODY_HASH)
291 .as_any()
292 .downcast_ref::<StringArray>()
293 .expect("body_hash");
294 let locs = batch
295 .column(node_col::LOC)
296 .as_any()
297 .downcast_ref::<Int32Array>()
298 .expect("loc");
299 let complexities = batch
300 .column(node_col::CYCLOMATIC_COMPLEXITY)
301 .as_any()
302 .downcast_ref::<Int32Array>()
303 .expect("complexity");
304 let coverages = batch
305 .column(node_col::COVERAGE_PCT)
306 .as_any()
307 .downcast_ref::<Float64Array>()
308 .expect("coverage");
309 let kind_dict = batch
310 .column(node_col::KIND)
311 .as_any()
312 .downcast_ref::<arrow::array::Int8DictionaryArray>()
313 .expect("kind dict");
314 let kind_values = kind_dict
315 .values()
316 .as_any()
317 .downcast_ref::<StringArray>()
318 .expect("kind values");
319
320 for i in 0..batch.num_rows() {
321 let kind_key = kind_dict.keys().value(i) as usize;
322 let kind_str = kind_values.value(kind_key);
323 let node = CodeNode {
324 id: ids.value(i).to_string(),
325 kind: CodeNodeKind::parse(kind_str).unwrap_or(CodeNodeKind::Function),
326 parent_id: if parent_ids.is_null(i) {
327 None
328 } else {
329 Some(parent_ids.value(i).to_string())
330 },
331 name: names.value(i).to_string(),
332 signature: if signatures.is_null(i) {
333 None
334 } else {
335 Some(signatures.value(i).to_string())
336 },
337 docstring: if docstrings.is_null(i) {
338 None
339 } else {
340 Some(docstrings.value(i).to_string())
341 },
342 body_hash: if body_hashes.is_null(i) {
343 None
344 } else {
345 Some(body_hashes.value(i).to_string())
346 },
347 body: None, loc: if locs.is_null(i) {
349 None
350 } else {
351 Some(locs.value(i))
352 },
353 cyclomatic_complexity: if complexities.is_null(i) {
354 None
355 } else {
356 Some(complexities.value(i))
357 },
358 coverage_pct: if coverages.is_null(i) {
359 None
360 } else {
361 Some(coverages.value(i))
362 },
363 last_modified: None,
364 ..Default::default()
365 };
366 map.insert(node.id.clone(), node);
367 }
368
369 map
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use crate::git_tools::codegraph_diff;
376 use crate::schema::{
377 CodeEdge, CodeEdgePredicate, CodeNode, CodeNodeKind, build_code_edges_batch,
378 build_code_nodes_batch,
379 };
380
381 fn sample_nodes() -> Vec<CodeNode> {
382 vec![
383 CodeNode {
384 id: "func:a.py::entry".into(),
385 kind: CodeNodeKind::Function,
386 parent_id: None,
387 name: "entry".into(),
388 signature: Some("def entry()".into()),
389 docstring: None,
390 body_hash: Some("entry_v1".into()),
391 body: None,
392 loc: Some(20),
393 cyclomatic_complexity: Some(3),
394 coverage_pct: None,
395 last_modified: None,
396 ..Default::default()
397 },
398 CodeNode {
399 id: "func:a.py::process".into(),
400 kind: CodeNodeKind::Function,
401 parent_id: None,
402 name: "process".into(),
403 signature: Some("def process(data)".into()),
404 docstring: None,
405 body_hash: Some("proc_v1".into()),
406 body: None,
407 loc: Some(40),
408 cyclomatic_complexity: Some(7),
409 coverage_pct: None,
410 last_modified: None,
411 ..Default::default()
412 },
413 CodeNode {
414 id: "func:b.py::helper".into(),
415 kind: CodeNodeKind::Function,
416 parent_id: None,
417 name: "helper".into(),
418 signature: Some("def helper(x)".into()),
419 docstring: None,
420 body_hash: Some("help_v1".into()),
421 body: None,
422 loc: Some(10),
423 cyclomatic_complexity: Some(1),
424 coverage_pct: None,
425 last_modified: None,
426 ..Default::default()
427 },
428 CodeNode {
429 id: "func:b.py::unrelated".into(),
430 kind: CodeNodeKind::Function,
431 parent_id: None,
432 name: "unrelated".into(),
433 signature: None,
434 docstring: None,
435 body_hash: Some("unrel_v1".into()),
436 body: None,
437 loc: Some(5),
438 cyclomatic_complexity: Some(1),
439 coverage_pct: None,
440 last_modified: None,
441 ..Default::default()
442 },
443 CodeNode {
444 id: "test:tests/test_a.py::test_entry".into(),
445 kind: CodeNodeKind::Test,
446 parent_id: None,
447 name: "test_entry".into(),
448 signature: None,
449 docstring: None,
450 body_hash: Some("te_v1".into()),
451 body: None,
452 loc: Some(8),
453 cyclomatic_complexity: Some(1),
454 coverage_pct: None,
455 last_modified: None,
456 ..Default::default()
457 },
458 ]
459 }
460
461 fn sample_edges() -> Vec<CodeEdge> {
462 vec![
463 CodeEdge {
465 source_id: "func:a.py::entry".into(),
466 target_id: "func:a.py::process".into(),
467 predicate: CodeEdgePredicate::Calls,
468 weight: Some(1.0),
469 commit_id: None,
470 },
471 CodeEdge {
472 source_id: "func:a.py::process".into(),
473 target_id: "func:b.py::helper".into(),
474 predicate: CodeEdgePredicate::Calls,
475 weight: Some(2.0),
476 commit_id: None,
477 },
478 CodeEdge {
480 source_id: "test:tests/test_a.py::test_entry".into(),
481 target_id: "func:a.py::entry".into(),
482 predicate: CodeEdgePredicate::Tests,
483 weight: Some(1.0),
484 commit_id: None,
485 },
486 ]
487 }
488
489 #[test]
490 fn test_impact_modified_leaf() {
491 let base = build_code_nodes_batch(&sample_nodes()).unwrap();
493 let mut head_nodes = sample_nodes();
494 head_nodes[2].body_hash = Some("help_v2".into()); let head = build_code_nodes_batch(&head_nodes).unwrap();
496 let edges = build_code_edges_batch(&sample_edges()).unwrap();
497
498 let diff = codegraph_diff(&base, &head).unwrap();
499 let report = impact_analysis(&diff, &head, &edges, 3);
500
501 assert_eq!(report.stats.directly_changed, 1);
502 assert_eq!(report.directly_changed[0].node.name, "helper");
503
504 assert_eq!(report.stats.direct_callers, 1);
506 assert_eq!(report.direct_callers[0].name, "process");
507
508 assert!(report.stats.transitive_callers >= 1);
511
512 assert_eq!(report.stats.affected_tests, 1);
513 assert_eq!(report.affected_tests[0].name, "test_entry");
514 }
515
516 #[test]
517 fn test_impact_no_changes() {
518 let nodes = build_code_nodes_batch(&sample_nodes()).unwrap();
519 let edges = build_code_edges_batch(&sample_edges()).unwrap();
520 let diff = codegraph_diff(&nodes, &nodes).unwrap();
521
522 let report = impact_analysis(&diff, &nodes, &edges, 3);
523 assert_eq!(report.stats.directly_changed, 0);
524 assert_eq!(report.stats.direct_callers, 0);
525 assert_eq!(report.stats.affected_tests, 0);
526 }
527
528 #[test]
529 fn test_impact_unrelated_change() {
530 let base = build_code_nodes_batch(&sample_nodes()).unwrap();
532 let mut head_nodes = sample_nodes();
533 head_nodes[3].body_hash = Some("unrel_v2".into());
534 let head = build_code_nodes_batch(&head_nodes).unwrap();
535 let edges = build_code_edges_batch(&sample_edges()).unwrap();
536
537 let diff = codegraph_diff(&base, &head).unwrap();
538 let report = impact_analysis(&diff, &head, &edges, 3);
539
540 assert_eq!(report.stats.directly_changed, 1);
541 assert_eq!(report.stats.direct_callers, 0);
542 assert_eq!(report.stats.transitive_callers, 0);
543 assert_eq!(report.stats.affected_tests, 0);
544 }
545
546 #[test]
547 fn test_impact_depth_limits_transitive() {
548 let base = build_code_nodes_batch(&sample_nodes()).unwrap();
549 let mut head_nodes = sample_nodes();
550 head_nodes[2].body_hash = Some("help_v2".into());
551 let head = build_code_nodes_batch(&head_nodes).unwrap();
552 let edges = build_code_edges_batch(&sample_edges()).unwrap();
553
554 let diff = codegraph_diff(&base, &head).unwrap();
556 let report = impact_analysis(&diff, &head, &edges, 1);
557 assert_eq!(report.stats.direct_callers, 1); assert_eq!(report.stats.transitive_callers, 0); }
560
561 #[test]
562 fn test_impact_removed_node() {
563 let base = build_code_nodes_batch(&sample_nodes()).unwrap();
564 let head_nodes: Vec<CodeNode> = sample_nodes()
566 .into_iter()
567 .filter(|n| n.name != "helper")
568 .collect();
569 let head = build_code_nodes_batch(&head_nodes).unwrap();
570 let edges = build_code_edges_batch(&sample_edges()).unwrap();
571
572 let diff = codegraph_diff(&base, &head).unwrap();
573 let report = impact_analysis(&diff, &head, &edges, 3);
574
575 assert_eq!(report.stats.directly_changed, 1);
576 assert_eq!(report.directly_changed[0].node.name, "helper");
577 assert_eq!(
578 report.directly_changed[0].change_type,
579 CodeDiffChangeType::Removed
580 );
581 }
582
583 #[test]
584 fn test_impact_files_affected() {
585 let base = build_code_nodes_batch(&sample_nodes()).unwrap();
586 let mut head_nodes = sample_nodes();
587 head_nodes[2].body_hash = Some("help_v2".into()); let head = build_code_nodes_batch(&head_nodes).unwrap();
589 let edges = build_code_edges_batch(&sample_edges()).unwrap();
590
591 let diff = codegraph_diff(&base, &head).unwrap();
592 let report = impact_analysis(&diff, &head, &edges, 3);
593
594 assert_eq!(report.stats.files_affected, 2);
596 }
597
598 #[test]
599 fn test_format_impact_report() {
600 let base = build_code_nodes_batch(&sample_nodes()).unwrap();
601 let mut head_nodes = sample_nodes();
602 head_nodes[2].body_hash = Some("help_v2".into());
603 let head = build_code_nodes_batch(&head_nodes).unwrap();
604 let edges = build_code_edges_batch(&sample_edges()).unwrap();
605
606 let diff = codegraph_diff(&base, &head).unwrap();
607 let report = impact_analysis(&diff, &head, &edges, 3);
608 let formatted = format_impact_report(&report);
609
610 assert!(formatted.contains("## Impact Analysis"));
611 assert!(formatted.contains("helper"));
612 assert!(formatted.contains("Direct Callers"));
613 assert!(formatted.contains("test_entry"));
614 }
615}