1use crate::attribution::{Attribution, FileAttribution, LineRange};
2use crate::diff::{DiffLineKind, FileDiff};
3
4#[derive(Debug, Clone)]
6pub struct GitAiAuthorshipLog {
7 pub files: Vec<GitAiFileEntry>,
8 pub metadata: Option<serde_json::Value>,
9}
10
11#[derive(Debug, Clone)]
13pub struct GitAiFileEntry {
14 pub path: String,
15 pub ai_line_ranges: Vec<(u32, u32)>,
17}
18
19pub fn parse_gitai_note(note: &str) -> Option<GitAiAuthorshipLog> {
32 let note = note.trim();
33 if note.is_empty() {
34 return None;
35 }
36
37 let separator_pos = note.find("\n---\n").or_else(|| note.find("\n---"))?;
38 let attestation = ¬e[..separator_pos];
39 let metadata_str = note[separator_pos..]
40 .trim_start_matches('\n')
41 .strip_prefix("---")?;
42 let metadata_str = metadata_str.trim();
43
44 let metadata: Option<serde_json::Value> = if metadata_str.is_empty() {
45 None
46 } else {
47 serde_json::from_str(metadata_str).ok()
48 };
49
50 let files = parse_attestation(attestation);
51 if files.is_empty() {
52 return None;
53 }
54
55 Some(GitAiAuthorshipLog { files, metadata })
56}
57
58fn parse_attestation(text: &str) -> Vec<GitAiFileEntry> {
59 let mut files: Vec<GitAiFileEntry> = Vec::new();
60 let mut current_path: Option<String> = None;
61 let mut current_ranges: Vec<(u32, u32)> = Vec::new();
62
63 for line in text.lines() {
64 if line.is_empty() {
65 continue;
66 }
67
68 if line.starts_with(' ') || line.starts_with('\t') {
70 let tokens: Vec<&str> = line.split_whitespace().collect();
72 if tokens.len() >= 2 {
74 for spec in tokens[1].split(',') {
75 if let Some((start, end)) = parse_line_range(spec) {
76 current_ranges.push((start, end));
77 }
78 }
79 }
80 } else {
81 if let Some(path) = current_path.take() {
83 files.push(GitAiFileEntry {
84 path,
85 ai_line_ranges: std::mem::take(&mut current_ranges),
86 });
87 }
88 current_path = Some(line.to_string());
89 }
90 }
91
92 if let Some(path) = current_path {
94 files.push(GitAiFileEntry {
95 path,
96 ai_line_ranges: current_ranges,
97 });
98 }
99
100 files
101}
102
103fn parse_line_range(s: &str) -> Option<(u32, u32)> {
105 if let Some((start_str, end_str)) = s.split_once('-') {
106 let start = start_str.parse().ok()?;
107 let end = end_str.parse().ok()?;
108 Some((start, end))
109 } else {
110 let n = s.parse().ok()?;
111 Some((n, n))
112 }
113}
114
115pub fn gitai_to_attribution(log: &GitAiAuthorshipLog, diff_files: &[FileDiff]) -> Attribution {
119 use std::collections::{HashMap, HashSet};
120
121 let ai_lines_by_file: HashMap<&str, HashSet<u32>> = log
123 .files
124 .iter()
125 .map(|entry| {
126 let mut lines = HashSet::new();
127 for &(start, end) in &entry.ai_line_ranges {
128 for n in start..=end {
129 lines.insert(n);
130 }
131 }
132 (entry.path.as_str(), lines)
133 })
134 .collect();
135
136 let mut files: Vec<FileAttribution> = Vec::new();
137
138 for diff_file in diff_files {
139 let mut added_lines: Vec<u32> = Vec::new();
141 let mut deleted_count: u32 = 0;
142
143 for hunk in &diff_file.hunks {
144 for line in &hunk.lines {
145 match line.kind {
146 DiffLineKind::Add => {
147 if let Some(n) = line.new_line_number {
148 added_lines.push(n);
149 }
150 }
151 DiffLineKind::Delete => {
152 deleted_count += 1;
153 }
154 DiffLineKind::Context => {}
155 }
156 }
157 }
158
159 let ai_set = ai_lines_by_file.get(diff_file.path.as_str());
160
161 let mut ai_line_nums: Vec<u32> = Vec::new();
163 let mut human_line_nums: Vec<u32> = Vec::new();
164
165 for n in &added_lines {
166 if ai_set.is_some_and(|s| s.contains(n)) {
167 ai_line_nums.push(*n);
168 } else {
169 human_line_nums.push(*n);
170 }
171 }
172
173 let ai_lines = collapse_to_ranges(&mut ai_line_nums);
174 let human_lines = collapse_to_ranges(&mut human_line_nums);
175
176 files.push(FileAttribution {
177 path: diff_file.path.clone(),
178 lines_added: added_lines.len() as u32,
179 lines_deleted: deleted_count,
180 ai_lines,
181 human_lines,
182 mixed_lines: vec![],
183 });
184 }
185
186 let summary = crate::attribution_engine::compute_attribution_summary(&files);
187
188 Attribution { files, summary }
189}
190
191fn collapse_to_ranges(nums: &mut [u32]) -> Vec<LineRange> {
193 if nums.is_empty() {
194 return vec![];
195 }
196 nums.sort_unstable();
197
198 let mut ranges = Vec::new();
199 let mut start = nums[0];
200 let mut end = nums[0];
201
202 for &n in &nums[1..] {
203 if n == end + 1 {
204 end = n;
205 } else {
206 ranges.push(LineRange { start, end });
207 start = n;
208 end = n;
209 }
210 }
211 ranges.push(LineRange { start, end });
212 ranges
213}