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 = if line_count > old_count {
54                line_count - old_count
55            } else {
56                0
57            };
58            let deleted_count = if old_count > line_count {
59                old_count - line_count
60            } else {
61                0
62            };
63
64            let changed_lines_total: u32 =
65                changed.iter().map(|r| r.end - r.start + 1).sum();
66
67            let (ai_lines, human_lines) = if is_ai_authored {
68                (changed, vec![])
69            } else {
70                (vec![], changed)
71            };
72
73            FileAttribution {
74                path: path.to_string(),
75                lines_added: added_count + changed_lines_total,
76                lines_deleted: deleted_count,
77                ai_lines,
78                human_lines,
79                mixed_lines: vec![],
80            }
81        }
82    }
83}
84
85/// Compute summary across all file attributions.
86pub fn compute_attribution_summary(files: &[FileAttribution]) -> AttributionSummary {
87    let total_ai: u32 = files
88        .iter()
89        .flat_map(|f| &f.ai_lines)
90        .map(|r| r.end - r.start + 1)
91        .sum();
92
93    let total_human: u32 = files
94        .iter()
95        .flat_map(|f| &f.human_lines)
96        .map(|r| r.end - r.start + 1)
97        .sum();
98
99    let total_mixed: u32 = files
100        .iter()
101        .flat_map(|f| &f.mixed_lines)
102        .map(|r| r.end - r.start + 1)
103        .sum();
104
105    let total = total_ai + total_human + total_mixed;
106    let total_added: u32 = files.iter().map(|f| f.lines_added).sum();
107    let total_deleted: u32 = files.iter().map(|f| f.lines_deleted).sum();
108
109    let ai_pct = if total > 0 {
110        (total_ai as f32 / total as f32) * 100.0
111    } else {
112        0.0
113    };
114
115    AttributionSummary {
116        total_lines_added: total_added,
117        total_lines_deleted: total_deleted,
118        ai_percentage: ai_pct,
119        human_percentage: 100.0 - ai_pct,
120    }
121}
122
123/// Simple line-based diff: returns ranges of lines in `new` that differ from `old` (1-indexed).
124fn find_changed_lines(old: &[&str], new: &[&str]) -> Vec<LineRange> {
125    let mut ranges = vec![];
126    let mut range_start: Option<u32> = None;
127
128    for (i, new_line) in new.iter().enumerate() {
129        let is_changed = old.get(i).map_or(true, |old_line| old_line != new_line);
130
131        if is_changed {
132            if range_start.is_none() {
133                range_start = Some(i as u32 + 1); // 1-indexed
134            }
135        } else if let Some(start) = range_start.take() {
136            ranges.push(LineRange {
137                start,
138                end: i as u32, // previous line was the end
139            });
140        }
141    }
142
143    // Close any open range
144    if let Some(start) = range_start {
145        ranges.push(LineRange {
146            start,
147            end: new.len() as u32,
148        });
149    }
150
151    ranges
152}