Skip to main content

tracevault_core/
attribution_engine.rs

1use crate::attribution::{AttributionSummary, FileAttribution, LineRange};
2
3/// Compute attribution for a single file.
4///
5/// `old_content`: previous content (None if new file)
6/// `new_content`: current content
7/// `is_ai_authored`: whether the AI agent wrote/edited this file
8pub fn compute_file_attribution(
9    path: &str,
10    old_content: Option<&str>,
11    new_content: &str,
12    is_ai_authored: bool,
13) -> FileAttribution {
14    let new_lines: Vec<&str> = new_content.lines().collect();
15    let line_count = new_lines.len() as u32;
16
17    if line_count == 0 {
18        return FileAttribution {
19            path: path.to_string(),
20            lines_added: 0,
21            lines_deleted: 0,
22            ai_lines: vec![],
23            human_lines: vec![],
24            mixed_lines: vec![],
25        };
26    }
27
28    match old_content {
29        None => {
30            // New file: all lines attributed to whoever created it
31            let range = vec![LineRange {
32                start: 1,
33                end: line_count,
34            }];
35            FileAttribution {
36                path: path.to_string(),
37                lines_added: line_count,
38                lines_deleted: 0,
39                ai_lines: if is_ai_authored {
40                    range.clone()
41                } else {
42                    vec![]
43                },
44                human_lines: if is_ai_authored { vec![] } else { range },
45                mixed_lines: vec![],
46            }
47        }
48        Some(old) => {
49            let old_lines: Vec<&str> = old.lines().collect();
50            let old_count = old_lines.len() as u32;
51
52            let changed = find_changed_lines(&old_lines, &new_lines);
53            let added_count = line_count.saturating_sub(old_count);
54            let deleted_count = old_count.saturating_sub(line_count);
55
56            let changed_lines_total: u32 = changed.iter().map(|r| r.end - r.start + 1).sum();
57
58            let (ai_lines, human_lines) = if is_ai_authored {
59                (changed, vec![])
60            } else {
61                (vec![], changed)
62            };
63
64            FileAttribution {
65                path: path.to_string(),
66                lines_added: added_count + changed_lines_total,
67                lines_deleted: deleted_count,
68                ai_lines,
69                human_lines,
70                mixed_lines: vec![],
71            }
72        }
73    }
74}
75
76/// Compute summary across all file attributions.
77pub fn compute_attribution_summary(files: &[FileAttribution]) -> AttributionSummary {
78    let total_ai: u32 = files
79        .iter()
80        .flat_map(|f| &f.ai_lines)
81        .map(|r| r.end - r.start + 1)
82        .sum();
83
84    let total_human: u32 = files
85        .iter()
86        .flat_map(|f| &f.human_lines)
87        .map(|r| r.end - r.start + 1)
88        .sum();
89
90    let total_mixed: u32 = files
91        .iter()
92        .flat_map(|f| &f.mixed_lines)
93        .map(|r| r.end - r.start + 1)
94        .sum();
95
96    let total = total_ai + total_human + total_mixed;
97    let total_added: u32 = files.iter().map(|f| f.lines_added).sum();
98    let total_deleted: u32 = files.iter().map(|f| f.lines_deleted).sum();
99
100    let ai_pct = if total > 0 {
101        (total_ai as f32 / total as f32) * 100.0
102    } else {
103        0.0
104    };
105
106    AttributionSummary {
107        total_lines_added: total_added,
108        total_lines_deleted: total_deleted,
109        ai_percentage: ai_pct,
110        human_percentage: 100.0 - ai_pct,
111    }
112}
113
114/// Simple line-based diff: returns ranges of lines in `new` that differ from `old` (1-indexed).
115fn find_changed_lines(old: &[&str], new: &[&str]) -> Vec<LineRange> {
116    let mut ranges = vec![];
117    let mut range_start: Option<u32> = None;
118
119    for (i, new_line) in new.iter().enumerate() {
120        let is_changed = old.get(i) != Some(new_line);
121
122        if is_changed {
123            if range_start.is_none() {
124                range_start = Some(i as u32 + 1); // 1-indexed
125            }
126        } else if let Some(start) = range_start.take() {
127            ranges.push(LineRange {
128                start,
129                end: i as u32, // previous line was the end
130            });
131        }
132    }
133
134    // Close any open range
135    if let Some(start) = range_start {
136        ranges.push(LineRange {
137            start,
138            end: new.len() as u32,
139        });
140    }
141
142    ranges
143}