Skip to main content

ought_analysis/
blame.rs

1use chrono::{DateTime, Utc};
2use ought_run::{RunResult, TestStatus};
3use ought_spec::{ClauseId, Section, SpecGraph};
4
5use crate::types::{BlameResult, CommitInfo};
6
7/// Explain why a clause is failing by correlating with git history.
8///
9/// Finds when the clause last passed, what commits changed since,
10/// and produces a causal narrative using structural analysis from git history.
11pub fn blame(
12    clause_id: &ClauseId,
13    specs: &SpecGraph,
14    results: &RunResult,
15) -> anyhow::Result<BlameResult> {
16    // 1. Find the clause in the test results.
17    let test_result = results
18        .results
19        .iter()
20        .find(|r| r.clause_id == *clause_id);
21
22    // If the clause isn't found in results at all.
23    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 the clause is currently passing.
41    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    // 2. The clause is failing. Gather git history to find the likely cause.
53    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    // 3. Build the narrative.
58    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            // The most recent commit is the most likely culprit.
83            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    // 4. Build suggested fix.
99    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, // Would need historical run data to populate.
108        first_failed: Some(Utc::now()),
109        likely_commit,
110        narrative,
111        suggested_fix,
112    })
113}
114
115/// Collect source file paths that might be relevant to a clause.
116fn 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        // Check if this spec contains the clause.
120        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 &section.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(&section.subsections, clause_id) {
142            return true;
143        }
144    }
145    false
146}
147
148/// Get recent git commits.
149fn 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
184/// Get recent diff for the specified source files.
185fn get_recent_diff(source_files: &[String], depth: usize) -> Option<String> {
186    if source_files.is_empty() {
187        // If no source files specified, diff all files.
188        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}