1use chrono::{DateTime, Utc};
2use ought_run::{RunResult, TestStatus};
3use ought_spec::{ClauseId, Section, SpecGraph};
4
5use crate::types::{BlameResult, CommitInfo};
6
7pub fn blame(
12 clause_id: &ClauseId,
13 specs: &SpecGraph,
14 results: &RunResult,
15) -> anyhow::Result<BlameResult> {
16 let test_result = results
18 .results
19 .iter()
20 .find(|r| r.clause_id == *clause_id);
21
22 let test_result = match test_result {
24 Some(r) => r,
25 None => {
26 return Ok(BlameResult {
27 clause_id: clause_id.clone(),
28 last_passed: None,
29 first_failed: None,
30 likely_commit: None,
31 narrative: format!(
32 "Clause {} was not found in the test results. It may not have a generated test yet.",
33 clause_id
34 ),
35 suggested_fix: Some("Run `ought generate` to create tests for this clause".to_string()),
36 });
37 }
38 };
39
40 if test_result.status == TestStatus::Passed {
42 return Ok(BlameResult {
43 clause_id: clause_id.clone(),
44 last_passed: Some(Utc::now()),
45 first_failed: None,
46 likely_commit: None,
47 narrative: format!("Clause {} is currently passing.", clause_id),
48 suggested_fix: None,
49 });
50 }
51
52 let source_files = collect_source_files_for_clause(clause_id, specs);
54 let recent_commits = get_recent_commits(20);
55 let recent_diff = get_recent_diff(&source_files, 5);
56
57 let failure_msg = test_result
59 .details
60 .failure_message
61 .as_deref()
62 .or(test_result.message.as_deref())
63 .unwrap_or("(no failure message)");
64
65 let mut narrative = format!(
66 "Clause {} is failing with status {:?}.\n\nFailure: {}\n",
67 clause_id, test_result.status, failure_msg
68 );
69
70 let likely_commit = if let Some(ref commits) = recent_commits {
71 if !commits.is_empty() {
72 narrative.push_str("\nRecent commits:\n");
73 for commit in commits.iter().take(10) {
74 narrative.push_str(&format!(
75 " {} {} ({})\n",
76 &commit.hash[..7.min(commit.hash.len())],
77 commit.message,
78 commit.author
79 ));
80 }
81
82 Some(commits[0].clone())
84 } else {
85 narrative.push_str("\nNo recent commits found.\n");
86 None
87 }
88 } else {
89 narrative.push_str("\nUnable to retrieve git history (not a git repository?).\n");
90 None
91 };
92
93 if let Some(ref diff) = recent_diff
94 && !diff.is_empty() {
95 narrative.push_str(&format!("\nRecent changes to related source files:\n{}\n", diff));
96 }
97
98 let suggested_fix = likely_commit.as_ref().map(|commit| format!(
100 "Investigate commit {} ({}) for changes that may have broken this clause",
101 &commit.hash[..7.min(commit.hash.len())],
102 commit.message
103 ));
104
105 Ok(BlameResult {
106 clause_id: clause_id.clone(),
107 last_passed: None, first_failed: Some(Utc::now()),
109 likely_commit,
110 narrative,
111 suggested_fix,
112 })
113}
114
115fn collect_source_files_for_clause(clause_id: &ClauseId, specs: &SpecGraph) -> Vec<String> {
117 let mut source_files = Vec::new();
118 for spec in specs.specs() {
119 if section_contains_clause(&spec.sections, clause_id) {
121 for src in &spec.metadata.sources {
122 source_files.push(src.clone());
123 }
124 }
125 }
126 source_files
127}
128
129fn section_contains_clause(sections: &[Section], clause_id: &ClauseId) -> bool {
130 for section in sections {
131 for clause in §ion.clauses {
132 if clause.id == *clause_id {
133 return true;
134 }
135 for ow in &clause.otherwise {
136 if ow.id == *clause_id {
137 return true;
138 }
139 }
140 }
141 if section_contains_clause(§ion.subsections, clause_id) {
142 return true;
143 }
144 }
145 false
146}
147
148fn get_recent_commits(count: usize) -> Option<Vec<CommitInfo>> {
150 let output = std::process::Command::new("git")
151 .args([
152 "log",
153 &format!("--max-count={}", count),
154 "--format=%H|%s|%an <%ae>|%aI",
155 ])
156 .output()
157 .ok()?;
158
159 if !output.status.success() {
160 return None;
161 }
162
163 let stdout = String::from_utf8_lossy(&output.stdout);
164 let commits: Vec<CommitInfo> = stdout
165 .lines()
166 .filter_map(|line| {
167 let parts: Vec<&str> = line.splitn(4, '|').collect();
168 if parts.len() < 4 {
169 return None;
170 }
171 let date: DateTime<Utc> = parts[3].parse().ok()?;
172 Some(CommitInfo {
173 hash: parts[0].to_string(),
174 message: parts[1].to_string(),
175 author: parts[2].to_string(),
176 date,
177 })
178 })
179 .collect();
180
181 Some(commits)
182}
183
184fn get_recent_diff(source_files: &[String], depth: usize) -> Option<String> {
186 if source_files.is_empty() {
187 let output = std::process::Command::new("git")
189 .args([
190 "diff",
191 &format!("HEAD~{}..HEAD", depth),
192 "--stat",
193 ])
194 .output()
195 .ok()?;
196
197 if !output.status.success() {
198 return None;
199 }
200 return Some(String::from_utf8_lossy(&output.stdout).to_string());
201 }
202
203 let mut args = vec![
204 "diff".to_string(),
205 format!("HEAD~{}..HEAD", depth),
206 "--stat".to_string(),
207 "--".to_string(),
208 ];
209 args.extend(source_files.iter().cloned());
210
211 let output = std::process::Command::new("git")
212 .args(&args)
213 .output()
214 .ok()?;
215
216 if !output.status.success() {
217 return None;
218 }
219
220 Some(String::from_utf8_lossy(&output.stdout).to_string())
221}