use std::collections::{HashMap, HashSet};
use similar::{ChangeTag, TextDiff};
use crate::capture::snapshot::{
FileAttributionResult, FileEditHistory, LineAttribution, LineSource,
};
pub const DEFAULT_SIMILARITY_THRESHOLD: f64 = 0.6;
const CONTEXT_CONFIDENCE: f64 = 0.85;
const CONTEXT_SIMILARITY_FALLBACK: f64 = 0.5;
const MAX_CONTEXT_ITERATIONS: usize = 5;
fn normalize_line(line: &str) -> String {
line.trim_end().to_string()
}
fn normalize_for_key(line: &str) -> String {
normalize_line(line)
}
pub struct ThreeWayAnalyzer;
impl ThreeWayAnalyzer {
pub fn analyze(history: &FileEditHistory, final_content: &str) -> FileAttributionResult {
let final_lines: Vec<&str> = final_content.lines().collect();
let original_lines = build_line_set(&history.original.content);
let ai_line_sources = build_ai_line_map(history);
let mut attributions = Vec::with_capacity(final_lines.len());
for (idx, line) in final_lines.iter().enumerate() {
let line_number = (idx + 1) as u32;
let attribution = attribute_line(
line,
line_number,
&original_lines,
&ai_line_sources,
history,
DEFAULT_SIMILARITY_THRESHOLD,
);
attributions.push(attribution);
}
improve_attributions_with_context(&mut attributions, history, final_content);
let summary = FileAttributionResult::compute_summary(&attributions);
FileAttributionResult {
path: history.path.clone(),
lines: attributions,
summary,
}
}
pub fn analyze_with_diff(
history: &FileEditHistory,
final_content: &str,
) -> FileAttributionResult {
Self::analyze_with_diff_with_threshold(history, final_content, DEFAULT_SIMILARITY_THRESHOLD)
}
pub fn analyze_with_diff_with_threshold(
history: &FileEditHistory,
final_content: &str,
similarity_threshold: f64,
) -> FileAttributionResult {
let final_lines: Vec<&str> = final_content.lines().collect();
let mut attributions = Vec::with_capacity(final_lines.len());
if history.edits.is_empty() {
for (idx, line) in final_lines.iter().enumerate() {
let line_number = (idx + 1) as u32;
let source = if line_in_content(line, &history.original.content) {
LineSource::Original
} else {
LineSource::Human
};
attributions.push(LineAttribution {
line_number,
content: line.to_string(),
source,
edit_id: None,
prompt_index: None,
confidence: 1.0,
});
}
let summary = FileAttributionResult::compute_summary(&attributions);
return FileAttributionResult {
path: history.path.clone(),
lines: attributions,
summary,
};
}
let latest_ai = history.latest_ai_content();
let original_lines = build_line_set(&history.original.content);
let ai_line_map = build_ai_line_map(history);
let ai_to_final_mapping = diff_map_lines(&latest_ai.content, final_content);
let original_to_final_mapping = diff_map_lines(&history.original.content, final_content);
let mut final_line_sources: HashMap<usize, (LineSource, Option<String>, Option<u32>)> =
HashMap::new();
for (_, final_idx) in &original_to_final_mapping {
final_line_sources.insert(*final_idx, (LineSource::Original, None, None));
}
for (ai_idx, final_idx) in &ai_to_final_mapping {
if final_line_sources.contains_key(final_idx) {
continue;
}
let ai_line = latest_ai.lines().get(*ai_idx).copied().unwrap_or("");
let normalized = normalize_for_key(ai_line);
if let Some((edit_id, prompt_idx)) = ai_line_map.get(&normalized) {
final_line_sources.insert(
*final_idx,
(
LineSource::AI {
edit_id: edit_id.clone(),
},
Some(edit_id.clone()),
Some(*prompt_idx),
),
);
}
}
for (idx, line) in final_lines.iter().enumerate() {
if final_line_sources.contains_key(&idx) {
continue;
}
let normalized = normalize_for_key(line);
if original_lines.contains(&normalized) {
final_line_sources.insert(idx, (LineSource::Original, None, None));
continue;
}
if let Some((edit_id, prompt_idx)) = ai_line_map.get(&normalized) {
final_line_sources.insert(
idx,
(
LineSource::AI {
edit_id: edit_id.clone(),
},
Some(edit_id.clone()),
Some(*prompt_idx),
),
);
continue;
}
if let Some((edit_id, prompt_idx, similarity)) =
find_similar_ai_line(line, &ai_line_map, similarity_threshold)
{
final_line_sources.insert(
idx,
(
LineSource::AIModified {
edit_id: edit_id.clone(),
similarity,
},
Some(edit_id),
Some(prompt_idx),
),
);
continue;
}
final_line_sources.insert(idx, (LineSource::Human, None, None));
}
for (idx, line) in final_lines.iter().enumerate() {
let line_number = (idx + 1) as u32;
let (source, edit_id, prompt_index) = final_line_sources
.get(&idx)
.cloned()
.unwrap_or((LineSource::Unknown, None, None));
let confidence = match &source {
LineSource::Original => 1.0,
LineSource::AI { .. } => 1.0,
LineSource::AIModified { similarity, .. } => *similarity,
LineSource::Human => 0.9,
LineSource::Unknown => 0.5,
};
attributions.push(LineAttribution {
line_number,
content: line.to_string(),
source,
edit_id,
prompt_index,
confidence,
});
}
improve_attributions_with_context(&mut attributions, history, final_content);
let summary = FileAttributionResult::compute_summary(&attributions);
FileAttributionResult {
path: history.path.clone(),
lines: attributions,
summary,
}
}
}
fn build_line_set(content: &str) -> HashSet<String> {
content.lines().map(normalize_for_key).collect()
}
fn build_ai_line_map(history: &FileEditHistory) -> HashMap<String, (String, u32)> {
let mut map = HashMap::new();
for edit in &history.edits {
for line in edit.after.content.lines() {
map.insert(
normalize_for_key(line),
(edit.edit_id.clone(), edit.prompt_index),
);
}
}
map
}
fn line_in_content(line: &str, content: &str) -> bool {
let normalized = normalize_for_key(line);
content.lines().any(|l| normalize_for_key(l) == normalized)
}
fn diff_map_lines(source: &str, target: &str) -> Vec<(usize, usize)> {
let diff = TextDiff::from_lines(source, target);
let mut mappings = Vec::new();
let mut source_idx = 0usize;
let mut target_idx = 0usize;
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Equal => {
mappings.push((source_idx, target_idx));
source_idx += 1;
target_idx += 1;
}
ChangeTag::Delete => {
source_idx += 1;
}
ChangeTag::Insert => {
target_idx += 1;
}
}
}
mappings
}
fn attribute_line(
line: &str,
line_number: u32,
original_lines: &HashSet<String>,
ai_line_sources: &HashMap<String, (String, u32)>,
_history: &FileEditHistory,
similarity_threshold: f64,
) -> LineAttribution {
let normalized = normalize_for_key(line);
let in_original = original_lines.contains(&normalized);
let in_ai = ai_line_sources.get(&normalized);
if in_original && in_ai.is_some() {
return LineAttribution {
line_number,
content: line.to_string(),
source: LineSource::Original,
edit_id: None,
prompt_index: None,
confidence: 1.0,
};
}
if in_original {
return LineAttribution {
line_number,
content: line.to_string(),
source: LineSource::Original,
edit_id: None,
prompt_index: None,
confidence: 1.0,
};
}
if let Some((edit_id, prompt_idx)) = in_ai {
return LineAttribution {
line_number,
content: line.to_string(),
source: LineSource::AI {
edit_id: edit_id.clone(),
},
edit_id: Some(edit_id.clone()),
prompt_index: Some(*prompt_idx),
confidence: 1.0,
};
}
if let Some((edit_id, prompt_idx, similarity)) =
find_similar_ai_line(line, ai_line_sources, similarity_threshold)
{
return LineAttribution {
line_number,
content: line.to_string(),
source: LineSource::AIModified {
edit_id: edit_id.clone(),
similarity,
},
edit_id: Some(edit_id),
prompt_index: Some(prompt_idx),
confidence: similarity,
};
}
LineAttribution {
line_number,
content: line.to_string(),
source: LineSource::Human,
edit_id: None,
prompt_index: None,
confidence: 0.9,
}
}
fn find_similar_ai_line(
line: &str,
ai_lines: &HashMap<String, (String, u32)>,
threshold: f64,
) -> Option<(String, u32, f64)> {
let line_trimmed = line.trim();
if line_trimmed.is_empty() {
return None;
}
let mut best_match: Option<(String, u32, f64)> = None;
for (ai_line, (edit_id, prompt_idx)) in ai_lines {
let ai_trimmed = ai_line.trim();
if ai_trimmed.is_empty() {
continue;
}
let similarity = compute_similarity(line_trimmed, ai_trimmed);
if similarity >= threshold
&& (best_match.is_none() || similarity > best_match.as_ref().unwrap().2)
{
best_match = Some((edit_id.clone(), *prompt_idx, similarity));
}
}
best_match
}
fn compute_similarity(a: &str, b: &str) -> f64 {
if a == b {
return 1.0;
}
if a.is_empty() || b.is_empty() {
return 0.0;
}
let lcs_len = longest_common_subsequence(a, b);
let max_len = a.len().max(b.len()) as f64;
lcs_len as f64 / max_len
}
fn longest_common_subsequence(a: &str, b: &str) -> usize {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let m = a_chars.len();
let n = b_chars.len();
if (m as f64 / n as f64) < 0.5 || (n as f64 / m as f64) < 0.5 {
return 0;
}
let mut dp = vec![vec![0usize; n + 1]; m + 1];
for i in 1..=m {
for j in 1..=n {
if a_chars[i - 1] == b_chars[j - 1] {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
}
}
}
dp[m][n]
}
fn improve_attributions_with_context(
attributions: &mut [LineAttribution],
history: &FileEditHistory,
_final_content: &str,
) {
let len = attributions.len();
if len < 2 {
return;
}
for i in 1..len - 1 {
if attributions[i].source == LineSource::Unknown {
let prev_edit = attributions[i - 1].edit_id.clone();
let next_edit = attributions[i + 1].edit_id.clone();
if prev_edit.is_some() && prev_edit == next_edit {
attributions[i].source = LineSource::AIModified {
edit_id: prev_edit.clone().unwrap(),
similarity: CONTEXT_SIMILARITY_FALLBACK,
};
attributions[i].edit_id = prev_edit;
attributions[i].prompt_index = attributions[i - 1].prompt_index;
attributions[i].confidence = CONTEXT_SIMILARITY_FALLBACK;
}
}
}
improve_attributions_with_block_matching(attributions, history);
improve_attributions_with_surrounding_context(attributions);
}
fn looks_like_fragment(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty() {
return false;
}
let starts_continuation = trimmed.starts_with('.')
|| trimmed.starts_with(',')
|| trimmed.starts_with(')')
|| trimmed.starts_with(']')
|| trimmed.starts_with('}')
|| trimmed.starts_with("&&")
|| trimmed.starts_with("||");
let ends_continuation = trimmed.ends_with('(')
|| trimmed.ends_with('[')
|| trimmed.ends_with('{')
|| trimmed.ends_with(',')
|| trimmed.ends_with('=')
|| trimmed.ends_with("&&")
|| trimmed.ends_with("||");
let is_common_fragment = trimmed == ");"
|| trimmed == ")"
|| trimmed == "};"
|| trimmed == "}"
|| trimmed == "];"
|| trimmed == "]"
|| trimmed.starts_with(".unwrap(")
|| trimmed.starts_with(".context(")
|| trimmed.starts_with(".ok_or")
|| trimmed.starts_with(".expect(")
|| trimmed.starts_with(".map(")
|| trimmed.starts_with(".and_then(")
|| trimmed.starts_with(".or_else(");
starts_continuation || ends_continuation || is_common_fragment
}
fn improve_attributions_with_surrounding_context(attributions: &mut [LineAttribution]) {
let len = attributions.len();
if len < 3 {
return;
}
let mut changed = true;
let mut iterations = 0;
while changed && iterations < MAX_CONTEXT_ITERATIONS {
changed = false;
iterations += 1;
for i in 1..len - 1 {
let is_unattributed = matches!(
&attributions[i].source,
LineSource::Human | LineSource::AIModified { .. }
);
if !is_unattributed {
continue;
}
let prev_edit = &attributions[i - 1].edit_id;
let next_edit = &attributions[i + 1].edit_id;
let prev_is_ai = matches!(
&attributions[i - 1].source,
LineSource::AI { .. } | LineSource::AIModified { .. }
);
let next_is_ai = matches!(
&attributions[i + 1].source,
LineSource::AI { .. } | LineSource::AIModified { .. }
);
if prev_is_ai
&& next_is_ai
&& prev_edit.is_some()
&& prev_edit == next_edit
&& looks_like_fragment(&attributions[i].content)
{
let edit_id = prev_edit.clone().unwrap();
let prompt_index = attributions[i - 1].prompt_index;
attributions[i].source = LineSource::AI {
edit_id: edit_id.clone(),
};
attributions[i].edit_id = Some(edit_id);
attributions[i].prompt_index = prompt_index;
attributions[i].confidence = CONTEXT_CONFIDENCE; changed = true;
}
}
}
}
fn normalize_for_block_comparison(s: &str) -> String {
let collapsed = s.split_whitespace().collect::<Vec<_>>().join(" ");
collapsed
.replace(" .", ".")
.replace(" ,", ",")
.replace(" ;", ";")
.replace(" )", ")")
.replace("( ", "(")
}
fn improve_attributions_with_block_matching(
attributions: &mut [LineAttribution],
history: &FileEditHistory,
) {
if attributions.is_empty() || history.edits.is_empty() {
return;
}
let mut ai_normalized_lines: Vec<(String, String, u32)> = Vec::new();
for edit in &history.edits {
for line in edit.after.content.lines() {
let normalized = normalize_for_block_comparison(line);
if !normalized.is_empty() {
ai_normalized_lines.push((normalized, edit.edit_id.clone(), edit.prompt_index));
}
}
}
for edit in &history.edits {
let lines: Vec<&str> = edit.after.content.lines().collect();
for window_size in 2..=8.min(lines.len()) {
for start in 0..=lines.len().saturating_sub(window_size) {
let joined: String = lines[start..start + window_size]
.iter()
.map(|l| normalize_for_block_comparison(l))
.collect::<Vec<_>>()
.join(" ");
if !joined.is_empty() {
ai_normalized_lines.push((joined, edit.edit_id.clone(), edit.prompt_index));
}
}
}
}
let is_unmatched = |attr: &LineAttribution| -> bool {
match &attr.source {
LineSource::Human => true,
LineSource::Unknown => true,
LineSource::AIModified { similarity, .. } => *similarity < 0.85,
_ => false,
}
};
let mut i = 0;
while i < attributions.len() {
if !is_unmatched(&attributions[i]) {
i += 1;
continue;
}
let block_start = i;
let mut block_end = i;
while block_end < attributions.len() && is_unmatched(&attributions[block_end]) {
block_end += 1;
}
let block_len = block_end - block_start;
if (1..=8).contains(&block_len) {
let block_content: String = attributions[block_start..block_end]
.iter()
.map(|a| normalize_for_block_comparison(&a.content))
.collect::<Vec<_>>()
.join(" ");
let mut best_match: Option<(f64, String, u32)> = None;
for (ai_normalized, edit_id, prompt_idx) in &ai_normalized_lines {
let similarity = compute_similarity(&block_content, ai_normalized);
let threshold = match block_len {
1 => 0.75, 2 => 0.70, 3..=4 => 0.65,
_ => 0.60,
};
if similarity >= threshold
&& (best_match.is_none() || similarity > best_match.as_ref().unwrap().0)
{
best_match = Some((similarity, edit_id.clone(), *prompt_idx));
}
}
if let Some((similarity, edit_id, prompt_idx)) = best_match {
for attr in attributions.iter_mut().take(block_end).skip(block_start) {
attr.source = LineSource::AI {
edit_id: edit_id.clone(),
};
attr.edit_id = Some(edit_id.clone());
attr.prompt_index = Some(prompt_idx);
attr.confidence = similarity;
}
}
}
i = block_end;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capture::snapshot::AIEdit;
#[test]
fn test_simple_ai_addition() {
let mut history = FileEditHistory::new("test.rs", Some("line1\nline2\n"));
history.add_edit(AIEdit::new(
"Add line3",
0,
"Edit",
"line1\nline2\n",
"line1\nline2\nline3\n",
));
let result = ThreeWayAnalyzer::analyze(&history, "line1\nline2\nline3\n");
assert_eq!(result.summary.ai_lines, 1);
assert_eq!(result.summary.original_lines, 2);
assert_eq!(result.summary.human_lines, 0);
}
#[test]
fn test_human_modification_after_ai() {
let mut history = FileEditHistory::new("test.rs", Some("line1\n"));
history.add_edit(AIEdit::new(
"Add lines",
0,
"Edit",
"line1\n",
"line1\nAI line\n",
));
let final_content = "line1\nAI line modified\nhuman line\n";
let result = ThreeWayAnalyzer::analyze(&history, final_content);
assert_eq!(result.summary.original_lines, 1);
assert_eq!(
result.summary.ai_modified_lines + result.summary.human_lines,
2,
"Should have 2 changed lines (modified + human)"
);
}
#[test]
fn test_line_shift() {
let mut history = FileEditHistory::new("test.rs", Some("line1\nline2\n"));
history.add_edit(AIEdit::new(
"Add AI content",
0,
"Edit",
"line1\nline2\n",
"line1\nline2\nAI added\n",
));
let final_content = "new first line\nline1\nline2\nAI added\n";
let result = ThreeWayAnalyzer::analyze_with_diff(&history, final_content);
assert_eq!(result.summary.human_lines, 1);
assert_eq!(result.summary.ai_lines, 1);
assert_eq!(result.summary.original_lines, 2);
}
#[test]
fn test_similarity_computation() {
assert_eq!(compute_similarity("hello", "hello"), 1.0);
assert!(compute_similarity("abc", "xyz") < 0.3);
assert!(compute_similarity("println(hello)", "println(world)") > 0.6);
assert!(
compute_similarity(
" println!(\"hello\");",
" println!(\"hello, world!\");"
) > 0.6
);
}
#[test]
fn test_multiple_ai_edits() {
let mut history = FileEditHistory::new("test.rs", Some("original\n"));
history.add_edit(AIEdit::new(
"First prompt",
0,
"Edit",
"original\n",
"original\nfirst AI\n",
));
history.add_edit(AIEdit::new(
"Second prompt",
1,
"Edit",
"original\nfirst AI\n",
"original\nfirst AI\nsecond AI\n",
));
let result = ThreeWayAnalyzer::analyze(&history, "original\nfirst AI\nsecond AI\n");
assert_eq!(result.summary.original_lines, 1);
assert_eq!(result.summary.ai_lines, 2);
let first_ai = result
.lines
.iter()
.find(|l| l.content == "first AI")
.unwrap();
assert_eq!(first_ai.prompt_index, Some(1));
let second_ai = result
.lines
.iter()
.find(|l| l.content == "second AI")
.unwrap();
assert_eq!(second_ai.prompt_index, Some(1));
}
#[test]
fn test_only_original_no_ai_edits() {
let history = FileEditHistory::new("test.rs", Some("line1\nline2\n"));
let result = ThreeWayAnalyzer::analyze(&history, "line1\nline2\nline3\n");
assert_eq!(result.summary.original_lines, 2);
assert_eq!(result.summary.human_lines, 1);
assert_eq!(result.summary.ai_lines, 0);
}
#[test]
fn test_whitespace_normalization() {
let mut history = FileEditHistory::new("test.rs", Some(""));
history.add_edit(AIEdit::new(
"Generate code",
0,
"Write",
"",
"fn main() { \n println!(\"hello\"); \n}\n",
));
let final_content = "fn main() {\n println!(\"hello\");\n}\n";
let result = ThreeWayAnalyzer::analyze(&history, final_content);
assert_eq!(result.summary.ai_lines, 3, "All lines should be AI");
assert_eq!(result.summary.human_lines, 0, "No human lines expected");
}
#[test]
fn test_empty_line_attribution() {
let mut history = FileEditHistory::new("test.rs", Some(""));
history.add_edit(AIEdit::new(
"Generate code with spacing",
0,
"Write",
"",
"fn main() {\n\n println!(\"hello\");\n\n}\n",
));
let final_content = "fn main() {\n\n println!(\"hello\");\n\n}\n";
let result = ThreeWayAnalyzer::analyze(&history, final_content);
assert_eq!(result.summary.ai_lines, 5, "All 5 lines should be AI");
assert_eq!(result.summary.human_lines, 0, "No human lines expected");
}
#[test]
fn test_tabs_vs_spaces() {
let mut history = FileEditHistory::new("test.rs", Some(""));
history.add_edit(AIEdit::new(
"Generate code",
0,
"Write",
"",
"fn main() {\n code();\n}\n",
));
let final_content = "fn main() {\n code();\n}\n";
let result = ThreeWayAnalyzer::analyze(&history, final_content);
assert_eq!(result.summary.ai_lines, 3);
assert_eq!(result.summary.human_lines, 0);
}
#[test]
fn test_diff_unmapped_lines_still_attributed_correctly() {
let original = "fn foo() {\n old_code();\n}\n";
let mut history = FileEditHistory::new("test.rs", Some(original));
let ai_output = "fn foo() {\n new_code();\n more_code();\n}\n";
history.add_edit(AIEdit::new(
"Rewrite function",
0,
"Edit",
original,
ai_output,
));
let result = ThreeWayAnalyzer::analyze_with_diff(&history, ai_output);
assert_eq!(result.summary.ai_lines, 2, "2 lines only in AI output");
assert_eq!(result.summary.human_lines, 0, "No human lines expected");
assert_eq!(
result.summary.original_lines, 2,
"2 lines unchanged from original (fn foo and closing brace)"
);
let closing_brace = result.lines.iter().find(|l| l.content == "}").unwrap();
assert!(
matches!(closing_brace.source, LineSource::Original),
"Closing brace should be Original (exists in both), got {:?}",
closing_brace.source
);
}
#[test]
fn test_debug_attribution_flow() {
let original = "line1\nline2\nline3\nline4\nline5\n";
let mut history = FileEditHistory::new("test.rs", Some(original));
let after1 = "line1\nline2\nline3\nline4\nline5\nline6\nline7\n";
history.add_edit(AIEdit::new("prompt1", 0, "Edit", original, after1));
let after2 = "line1\nline2\nLINE3_MODIFIED\nline4\nline5\nline6\nline7\nline8\n";
history.add_edit(AIEdit::new("prompt2", 1, "Edit", after1, after2));
let result = ThreeWayAnalyzer::analyze_with_diff(&history, after2);
println!("\nAttribution results:");
println!(" AI lines: {}", result.summary.ai_lines);
println!(" AI modified lines: {}", result.summary.ai_modified_lines);
println!(" Original lines: {}", result.summary.original_lines);
println!(" Human lines: {}", result.summary.human_lines);
for line in &result.lines {
println!(
" Line {}: {:?} - '{}'",
line.line_number, line.source, line.content
);
}
assert_eq!(result.summary.ai_lines, 4, "4 lines actually changed by AI");
assert_eq!(
result.summary.original_lines, 4,
"4 lines unchanged from original"
);
assert_eq!(result.summary.human_lines, 0, "No human lines expected");
}
#[test]
fn test_duplicate_lines_in_ai_output() {
let original = r#"fn foo() {
code();
}
"#;
let mut history = FileEditHistory::new("test.rs", Some(original));
let after1 = r#"fn foo() {
code();
}
fn bar() {
more_code();
}
"#;
history.add_edit(AIEdit::new("Add bar function", 0, "Edit", original, after1));
let result = ThreeWayAnalyzer::analyze_with_diff(&history, after1);
println!("\nDuplicate lines test:");
for line in &result.lines {
let source_str = match &line.source {
LineSource::AI { .. } => "AI",
LineSource::Original => "Orig",
LineSource::Human => "Human",
_ => "Other",
};
println!(
" Line {}: {} - '{}'",
line.line_number, source_str, line.content
);
}
assert_eq!(
result.summary.human_lines, 0,
"No human lines - all are either original or AI"
);
assert_eq!(
result.summary.original_lines, 3,
"3 lines unchanged from original (fn foo, code, first closing brace)"
);
assert_eq!(
result.summary.ai_lines, 4,
"4 lines added by AI (empty, fn bar, more_code, second closing brace)"
);
let closing_braces: Vec<_> = result.lines.iter().filter(|l| l.content == "}").collect();
assert_eq!(closing_braces.len(), 2, "Should have 2 closing braces");
assert!(
matches!(closing_braces[0].source, LineSource::Original),
"First closing brace (line {}) should be Original, got {:?}",
closing_braces[0].line_number,
closing_braces[0].source
);
assert!(
matches!(closing_braces[1].source, LineSource::AI { .. }),
"Second closing brace (line {}) should be AI, got {:?}",
closing_braces[1].line_number,
closing_braces[1].source
);
}
#[test]
fn test_common_patterns_attributed_to_ai() {
let original = "";
let mut history = FileEditHistory::new("test.rs", Some(original));
let ai_output = r#"/// A test function
#[test]
fn test() {
assert!(true);
}
"#;
history.add_edit(AIEdit::new(
"Generate test",
0,
"Write",
original,
ai_output,
));
let result = ThreeWayAnalyzer::analyze_with_diff(&history, ai_output);
assert_eq!(result.summary.ai_lines, 5, "All 5 lines should be AI");
assert_eq!(result.summary.human_lines, 0, "No human lines");
for line in &result.lines {
assert!(
matches!(line.source, LineSource::AI { .. }),
"Line '{}' should be AI, got {:?}",
line.content,
line.source
);
}
}
#[test]
fn test_block_matching_reformatted_method_chain() {
let original = "";
let mut history = FileEditHistory::new("test.rs", Some(original));
let ai_output = "let result = foo.bar().baz().qux().unwrap();\n";
history.add_edit(AIEdit::new(
"Generate code",
0,
"Write",
original,
ai_output,
));
let final_content = r#"let result = foo
.bar()
.baz()
.qux()
.unwrap();
"#;
let result = ThreeWayAnalyzer::analyze_with_diff(&history, final_content);
println!("\nBlock matching test:");
for line in &result.lines {
let source_str = match &line.source {
LineSource::AI { .. } => "AI",
LineSource::Human => "Human",
_ => "Other",
};
println!(
" Line {}: {} - '{}'",
line.line_number, source_str, line.content
);
}
assert_eq!(
result.summary.human_lines, 0,
"No human lines - block matching should attribute all to AI"
);
assert!(
result.summary.ai_lines >= 4,
"Most lines should be AI (got {})",
result.summary.ai_lines
);
}
#[test]
fn test_block_matching_split_assignment() {
let original = "";
let mut history = FileEditHistory::new("test.rs", Some(original));
let ai_output =
"let commit_time = DateTime::from_timestamp(commit.time().seconds(), 0).unwrap();\n";
history.add_edit(AIEdit::new(
"Generate code",
0,
"Write",
original,
ai_output,
));
let final_content = r#"let commit_time =
DateTime::from_timestamp(commit.time().seconds(), 0).unwrap();
"#;
let result = ThreeWayAnalyzer::analyze_with_diff(&history, final_content);
println!("\nSplit assignment test:");
for line in &result.lines {
let source_str = match &line.source {
LineSource::AI { .. } => "AI",
LineSource::Human => "Human",
LineSource::Original => "Original",
LineSource::AIModified { .. } => "AIModified",
LineSource::Unknown => "Unknown",
};
println!(
" Line {}: {} - '{}'",
line.line_number, source_str, line.content
);
}
assert_eq!(
result.summary.human_lines, 0,
"Both lines should be AI via block matching"
);
assert_eq!(result.summary.ai_lines, 2, "Both lines should be AI");
}
#[test]
fn test_block_matching_closure_formatting() {
let original = "";
let mut history = FileEditHistory::new("test.rs", Some(original));
let ai_output = ".map(|t| { t.with_timezone(&Utc).format(\"%Y-%m-%d\").to_string() })\n";
history.add_edit(AIEdit::new(
"Generate code",
0,
"Write",
original,
ai_output,
));
let final_content = r#".map(|t| {
t.with_timezone(&Utc)
.format("%Y-%m-%d")
.to_string()
})
"#;
let result = ThreeWayAnalyzer::analyze_with_diff(&history, final_content);
println!("\nClosure formatting test:");
for line in &result.lines {
let source_str = match &line.source {
LineSource::AI { .. } => "AI",
LineSource::Human => "Human",
_ => "Other",
};
println!(
" Line {}: {} - '{}'",
line.line_number, source_str, line.content
);
}
assert_eq!(
result.summary.human_lines, 0,
"All lines should be AI via block matching"
);
}
#[test]
fn test_block_matching_ok_or_else_chain() {
let original = "";
let mut history = FileEditHistory::new("test.rs", Some(original));
let ai_output = r#"let hooks_dir = claude_hooks_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
"#;
history.add_edit(AIEdit::new(
"Generate code",
0,
"Write",
original,
ai_output,
));
let final_content = r#"let hooks_dir =
claude_hooks_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
"#;
let result = ThreeWayAnalyzer::analyze_with_diff(&history, final_content);
println!("\nok_or_else chain test:");
for line in &result.lines {
let source_str = match &line.source {
LineSource::AI { .. } => "AI",
LineSource::Human => "Human",
LineSource::AIModified { similarity, .. } => {
&format!("AIModified({:.2})", similarity)
}
_ => "Other",
};
println!(
" Line {}: {} - '{}'",
line.line_number, source_str, line.content
);
}
assert_eq!(
result.summary.human_lines, 0,
"Both lines should be AI via block matching"
);
assert_eq!(result.summary.ai_lines, 2, "Both lines should be AI");
}
#[test]
fn test_block_matching_sync_all_context() {
let original = "";
let mut history = FileEditHistory::new("test.rs", Some(original));
let ai_output = r#"file.sync_all().context("Failed to sync audit log to disk")?;
"#;
history.add_edit(AIEdit::new(
"Generate code",
0,
"Write",
original,
ai_output,
));
let final_content = r#"file.sync_all()
.context("Failed to sync audit log to disk")?;
"#;
let result = ThreeWayAnalyzer::analyze_with_diff(&history, final_content);
println!("\nsync_all().context() test:");
for line in &result.lines {
let source_str = match &line.source {
LineSource::AI { .. } => "AI",
LineSource::Human => "Human",
LineSource::AIModified { similarity, .. } => {
&format!("AIModified({:.2})", similarity)
}
_ => "Other",
};
println!(
" Line {}: {} - '{}'",
line.line_number, source_str, line.content
);
}
assert_eq!(
result.summary.human_lines, 0,
"Both lines should be AI via block matching"
);
assert_eq!(result.summary.ai_lines, 2, "Both lines should be AI");
}
#[test]
fn test_block_matching_assert_multiline() {
let original = "";
let mut history = FileEditHistory::new("test.rs", Some(original));
let ai_output = r#"assert!(names.len() >= 22, "Expected at least 22 builtin patterns, got {}", names.len());
"#;
history.add_edit(AIEdit::new(
"Generate code",
0,
"Write",
original,
ai_output,
));
let final_content = r#"assert!(
names.len() >= 22,
"Expected at least 22 builtin patterns, got {}",
names.len()
);
"#;
let result = ThreeWayAnalyzer::analyze_with_diff(&history, final_content);
println!("\nmultiline assert test:");
for line in &result.lines {
let source_str = match &line.source {
LineSource::AI { .. } => "AI",
LineSource::Human => "Human",
LineSource::AIModified { similarity, .. } => {
&format!("AIModified({:.2})", similarity)
}
_ => "Other",
};
println!(
" Line {}: {} - '{}'",
line.line_number, source_str, line.content
);
}
assert_eq!(
result.summary.human_lines, 0,
"All lines should be AI via block matching"
);
assert_eq!(result.summary.ai_lines, 5, "All 5 lines should be AI");
}
}