use crate::review::parse_diff;
use crate::CodememEngine;
use codemem_core::{Edge, GraphNode, NodeKind, RelationshipType};
use std::collections::HashMap;
fn make_sym_node(id: &str, file_path: &str, line_start: u32, line_end: u32) -> GraphNode {
let mut payload = HashMap::new();
payload.insert("file_path".to_string(), serde_json::json!(file_path));
payload.insert("line_start".to_string(), serde_json::json!(line_start));
payload.insert("line_end".to_string(), serde_json::json!(line_end));
GraphNode {
id: format!("sym:{id}"),
kind: NodeKind::Function,
label: id.to_string(),
payload,
centrality: 0.0,
memory_id: None,
namespace: None,
valid_from: None,
valid_to: None,
}
}
fn make_edge(src: &str, dst: &str, rel: RelationshipType) -> Edge {
Edge {
id: format!("{src}->{dst}"),
src: src.to_string(),
dst: dst.to_string(),
relationship: rel,
weight: 1.0,
properties: HashMap::new(),
created_at: chrono::Utc::now(),
valid_from: None,
valid_to: None,
}
}
const SAMPLE_DIFF: &str = r#"diff --git a/src/auth.rs b/src/auth.rs
index abc1234..def5678 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -10,6 +10,7 @@ fn validate_token(token: &str) -> bool {
let decoded = decode(token);
if decoded.is_expired() {
return false;
+ log::warn!("expired token");
}
true
}
"#;
#[test]
fn parse_diff_extracts_file_and_lines() {
let hunks = parse_diff(SAMPLE_DIFF);
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].file_path, "src/auth.rs");
assert_eq!(hunks[0].added_lines, vec![13]); }
#[test]
fn parse_diff_multiple_files() {
let diff = r#"diff --git a/src/a.rs b/src/a.rs
--- a/src/a.rs
+++ b/src/a.rs
@@ -1,3 +1,4 @@
fn foo() {
+ bar();
}
diff --git a/src/b.rs b/src/b.rs
--- a/src/b.rs
+++ b/src/b.rs
@@ -5,3 +5,4 @@
fn baz() {
+ qux();
}
"#;
let hunks = parse_diff(diff);
assert_eq!(hunks.len(), 2);
assert_eq!(hunks[0].file_path, "src/a.rs");
assert_eq!(hunks[1].file_path, "src/b.rs");
}
#[test]
fn parse_diff_empty() {
let hunks = parse_diff("");
assert!(hunks.is_empty());
}
#[test]
fn diff_to_symbols_finds_changed_symbol() {
let engine = CodememEngine::for_testing();
{
let mut graph = engine.lock_graph().unwrap();
let node = make_sym_node("auth::validate_token", "src/auth.rs", 10, 16);
graph.add_node(node).unwrap();
}
let mapping = engine.diff_to_symbols(SAMPLE_DIFF).unwrap();
assert!(
mapping
.changed_symbols
.contains(&"sym:auth::validate_token".to_string()),
"Should find validate_token as changed"
);
assert!(
mapping
.changed_files
.contains(&"file:src/auth.rs".to_string()),
"Should include the changed file"
);
}
#[test]
fn diff_to_symbols_skips_unrelated_symbol() {
let engine = CodememEngine::for_testing();
{
let mut graph = engine.lock_graph().unwrap();
let node = make_sym_node("auth::other_func", "src/auth.rs", 50, 60);
graph.add_node(node).unwrap();
}
let mapping = engine.diff_to_symbols(SAMPLE_DIFF).unwrap();
assert!(
!mapping
.changed_symbols
.contains(&"sym:auth::other_func".to_string()),
"Should not include unrelated symbol"
);
}
#[test]
fn diff_to_symbols_finds_containing_parent() {
let engine = CodememEngine::for_testing();
{
let mut graph = engine.lock_graph().unwrap();
let child = make_sym_node("auth::validate_token", "src/auth.rs", 10, 16);
graph.add_node(child).unwrap();
let mut parent = make_sym_node("auth", "src/auth.rs", 1, 50);
parent.kind = NodeKind::Module;
graph.add_node(parent).unwrap();
let edge = make_edge(
"sym:auth",
"sym:auth::validate_token",
RelationshipType::Contains,
);
graph.add_edge(edge).unwrap();
}
let mapping = engine.diff_to_symbols(SAMPLE_DIFF).unwrap();
assert!(
mapping.changed_symbols.contains(&"sym:auth".to_string())
|| mapping.containing_symbols.contains(&"sym:auth".to_string()),
"Should find parent module as changed or containing symbol"
);
}
#[test]
fn blast_radius_finds_direct_dependents() {
let engine = CodememEngine::for_testing();
{
let mut graph = engine.lock_graph().unwrap();
let changed = make_sym_node("auth::validate_token", "src/auth.rs", 10, 16);
graph.add_node(changed).unwrap();
let caller = make_sym_node("api::handler", "src/api.rs", 20, 30);
graph.add_node(caller).unwrap();
let edge = make_edge(
"sym:api::handler",
"sym:auth::validate_token",
RelationshipType::Calls,
);
graph.add_edge(edge).unwrap();
}
let report = engine.blast_radius(SAMPLE_DIFF, 2).unwrap();
assert!(
!report.changed_symbols.is_empty(),
"Should have changed symbols"
);
assert!(
report
.direct_dependents
.iter()
.any(|d| d.id == "sym:api::handler"),
"Should find api::handler as direct dependent"
);
assert!(
report.affected_files.contains(&"src/api.rs".to_string()),
"Should include dependent's file in affected files"
);
assert!(
report.risk_score >= 0.0,
"Risk score should be non-negative"
);
}
#[test]
fn blast_radius_empty_diff() {
let engine = CodememEngine::for_testing();
let report = engine.blast_radius("", 2).unwrap();
assert!(report.changed_symbols.is_empty());
assert!(report.direct_dependents.is_empty());
assert_eq!(report.risk_score, 0.0);
}
fn make_file_node(file_path: &str) -> GraphNode {
GraphNode {
id: format!("file:{file_path}"),
kind: NodeKind::File,
label: file_path.to_string(),
payload: HashMap::new(),
centrality: 0.0,
memory_id: None,
namespace: None,
valid_from: None,
valid_to: None,
}
}
fn make_cochanged_edge(src_file: &str, dst_file: &str, weight: f64) -> Edge {
Edge {
id: format!("cochanged:{src_file}->{dst_file}"),
src: format!("file:{src_file}"),
dst: format!("file:{dst_file}"),
relationship: RelationshipType::CoChanged,
weight,
properties: HashMap::new(),
created_at: chrono::Utc::now(),
valid_from: None,
valid_to: None,
}
}
#[test]
fn blast_radius_detects_missing_co_changes() {
let engine = CodememEngine::for_testing();
{
let mut graph = engine.lock_graph().unwrap();
graph.add_node(make_file_node("src/auth.rs")).unwrap();
graph.add_node(make_file_node("src/auth_test.rs")).unwrap();
graph.add_node(make_file_node("src/config.rs")).unwrap();
let sym = make_sym_node("auth::validate_token", "src/auth.rs", 10, 16);
graph.add_node(sym).unwrap();
graph
.add_edge(make_cochanged_edge("src/auth.rs", "src/auth_test.rs", 0.9))
.unwrap();
graph
.add_edge(make_cochanged_edge("src/auth.rs", "src/config.rs", 0.5))
.unwrap();
}
let report = engine.blast_radius(SAMPLE_DIFF, 1).unwrap();
assert!(
!report.missing_co_changes.is_empty(),
"Should detect missing co-changes"
);
let auth_test = report
.missing_co_changes
.iter()
.find(|m| m.file_path == "src/auth_test.rs");
assert!(
auth_test.is_some(),
"Should flag auth_test.rs as missing co-change"
);
let auth_test = auth_test.unwrap();
assert!(
(auth_test.strength - 0.9).abs() < 0.01,
"Strength should match the edge weight"
);
assert!(
auth_test.coupled_with.contains(&"src/auth.rs".to_string()),
"Should record which changed file it's coupled with"
);
assert!(
report
.missing_co_changes
.iter()
.any(|m| m.file_path == "src/config.rs"),
"Should flag config.rs as missing co-change"
);
if report.missing_co_changes.len() >= 2 {
assert!(
report.missing_co_changes[0].strength >= report.missing_co_changes[1].strength,
"Missing co-changes should be sorted by strength descending"
);
}
}
#[test]
fn blast_radius_no_missing_co_changes_when_all_present() {
let engine = CodememEngine::for_testing();
let diff = r#"diff --git a/src/auth.rs b/src/auth.rs
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -10,6 +10,7 @@ fn validate_token(token: &str) -> bool {
let decoded = decode(token);
+ log::info!("validating");
true
}
diff --git a/src/auth_test.rs b/src/auth_test.rs
--- a/src/auth_test.rs
+++ b/src/auth_test.rs
@@ -1,3 +1,4 @@
fn test_validate() {
+ assert!(true);
}
"#;
{
let mut graph = engine.lock_graph().unwrap();
graph.add_node(make_file_node("src/auth.rs")).unwrap();
graph.add_node(make_file_node("src/auth_test.rs")).unwrap();
let sym = make_sym_node("auth::validate_token", "src/auth.rs", 10, 16);
graph.add_node(sym).unwrap();
graph
.add_edge(make_cochanged_edge("src/auth.rs", "src/auth_test.rs", 0.9))
.unwrap();
}
let report = engine.blast_radius(diff, 1).unwrap();
assert!(
report.missing_co_changes.is_empty(),
"No missing co-changes when all coupled files are in the diff"
);
}