Skip to main content

ought_analysis/
bisect.rs

1use std::path::Path;
2
3use chrono::{DateTime, Utc};
4use ought_run::Runner;
5use ought_spec::{ClauseId, SpecGraph};
6
7use crate::types::{BisectResult, CommitInfo};
8
9/// Options for the bisect command.
10pub struct BisectOptions {
11    /// Limit the search to a git revision range (e.g. "abc123..def456").
12    pub range: Option<String>,
13    /// Regenerate tests at each commit instead of using the current manifest.
14    pub regenerate: bool,
15}
16
17/// Binary search through git history to find the commit that broke a clause.
18///
19/// Always restores the working tree to its original state after completion.
20pub fn bisect(
21    clause_id: &ClauseId,
22    _specs: &SpecGraph,
23    runner: &dyn Runner,
24    options: &BisectOptions,
25) -> anyhow::Result<BisectResult> {
26    // 1. Record the current branch/HEAD so we can restore it.
27    let original_ref = get_current_ref()?;
28
29    // 2. Get the list of commits in the range.
30    let commits = get_commit_range(options)?;
31
32    if commits.is_empty() {
33        anyhow::bail!("No commits found in the specified range");
34    }
35
36    if commits.len() == 1 {
37        // Only one commit -- it must be the breaking one.
38        restore_working_tree(&original_ref);
39        return Ok(BisectResult {
40            clause_id: clause_id.clone(),
41            breaking_commit: commits.into_iter().next().unwrap(),
42            diff_summary: "Only one commit in range".to_string(),
43        });
44    }
45
46    // 3. Binary search: find the first commit where the test fails.
47    let result = run_bisect(clause_id, &commits, runner, &original_ref);
48
49    // 4. Always restore working tree.
50    restore_working_tree(&original_ref);
51
52    match result {
53        Ok(breaking_idx) => {
54            let breaking_commit = commits[breaking_idx].clone();
55            let diff_summary = get_commit_diff_summary(&breaking_commit.hash);
56
57            Ok(BisectResult {
58                clause_id: clause_id.clone(),
59                breaking_commit,
60                diff_summary,
61            })
62        }
63        Err(e) => Err(e),
64    }
65}
66
67/// Run the binary search across commits.
68fn run_bisect(
69    clause_id: &ClauseId,
70    commits: &[CommitInfo],
71    runner: &dyn Runner,
72    original_ref: &str,
73) -> anyhow::Result<usize> {
74    // Commits are ordered newest first. We want to find the first (oldest) failing commit.
75    // So we reverse to get oldest first for binary search.
76    let n = commits.len();
77
78    // Binary search: lo is the oldest, hi is the newest.
79    // We assume commits[n-1] (newest) fails, and search for the first failure.
80    let mut lo: usize = 0;
81    let mut hi: usize = n - 1;
82    let mut last_fail: usize = hi;
83
84    while lo < hi {
85        let mid = lo + (hi - lo) / 2;
86
87        // Checkout the commit at index `mid` (in reversed order, so commits[n-1-mid] is older).
88        // Actually, commits are newest first from git log. So commits[0] is newest.
89        let commit_idx = mid;
90        let commit = &commits[commit_idx];
91
92        match checkout_and_test(commit, clause_id, runner) {
93            Ok(passed) => {
94                if passed {
95                    // This commit passes, so the break is between mid and last_fail.
96                    // Move towards newer commits.
97                    // Since commits[0] is newest: lower index = newer.
98                    // If mid passes and hi fails, break is between lo..mid (newer side).
99                    hi = mid;
100                } else {
101                    // This commit fails, search older.
102                    last_fail = mid;
103                    lo = mid + 1;
104                }
105            }
106            Err(_) => {
107                // If test execution fails, treat as failing.
108                last_fail = mid;
109                lo = mid + 1;
110            }
111        }
112
113        // Restore to original ref between checkouts.
114        restore_working_tree(original_ref);
115    }
116
117    Ok(last_fail)
118}
119
120/// Checkout a commit, run the test for the clause, and return whether it passed.
121fn checkout_and_test(
122    commit: &CommitInfo,
123    clause_id: &ClauseId,
124    runner: &dyn Runner,
125) -> anyhow::Result<bool> {
126    // Stash any uncommitted changes.
127    let _ = std::process::Command::new("git")
128        .args(["stash", "--include-untracked"])
129        .output();
130
131    // Checkout the target commit.
132    let checkout = std::process::Command::new("git")
133        .args(["checkout", &commit.hash])
134        .output()?;
135
136    if !checkout.status.success() {
137        anyhow::bail!(
138            "Failed to checkout commit {}: {}",
139            commit.hash,
140            String::from_utf8_lossy(&checkout.stderr)
141        );
142    }
143
144    // Find test files in the current working directory.
145    let test_dir = find_test_dir();
146
147    // Run tests using the runner.
148    let tests = collect_test_files_for_clause(clause_id, &test_dir);
149
150    if tests.is_empty() {
151        // No test file found for this clause at this commit.
152        return Ok(true); // Assume passing if test doesn't exist yet.
153    }
154
155    let result = runner.run(&tests, &test_dir)?;
156
157    // Check if the specific clause passed.
158    let clause_passed = result
159        .results
160        .iter()
161        .any(|r| r.clause_id == *clause_id && r.status == ought_run::TestStatus::Passed);
162
163    // If no results matched the clause, consider it passing (test may not exist at this commit).
164    let relevant_results: Vec<_> = result
165        .results
166        .iter()
167        .filter(|r| r.clause_id == *clause_id)
168        .collect();
169
170    if relevant_results.is_empty() {
171        return Ok(true);
172    }
173
174    Ok(clause_passed)
175}
176
177/// Get the current git ref (branch name or HEAD hash).
178fn get_current_ref() -> anyhow::Result<String> {
179    // Try to get branch name.
180    let output = std::process::Command::new("git")
181        .args(["symbolic-ref", "--short", "HEAD"])
182        .output();
183
184    if let Ok(output) = output
185        && output.status.success() {
186            return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
187        }
188
189    // Fall back to HEAD hash.
190    let output = std::process::Command::new("git")
191        .args(["rev-parse", "HEAD"])
192        .output()?;
193
194    if !output.status.success() {
195        anyhow::bail!("Not in a git repository");
196    }
197
198    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
199}
200
201/// Get the list of commits in the given range.
202fn get_commit_range(options: &BisectOptions) -> anyhow::Result<Vec<CommitInfo>> {
203    let range = options
204        .range
205        .as_deref()
206        .unwrap_or("HEAD~20..HEAD");
207
208    let output = std::process::Command::new("git")
209        .args([
210            "log",
211            range,
212            "--format=%H|%s|%an <%ae>|%aI",
213            "--reverse",
214        ])
215        .output()?;
216
217    if !output.status.success() {
218        // If the range is invalid (e.g., not enough history), try a smaller range.
219        let output = std::process::Command::new("git")
220            .args([
221                "log",
222                "--max-count=20",
223                "--format=%H|%s|%an <%ae>|%aI",
224                "--reverse",
225            ])
226            .output()?;
227
228        if !output.status.success() {
229            anyhow::bail!("Failed to get git log");
230        }
231
232        return parse_git_log(&String::from_utf8_lossy(&output.stdout));
233    }
234
235    parse_git_log(&String::from_utf8_lossy(&output.stdout))
236}
237
238fn parse_git_log(output: &str) -> anyhow::Result<Vec<CommitInfo>> {
239    let commits: Vec<CommitInfo> = output
240        .lines()
241        .filter_map(|line| {
242            let parts: Vec<&str> = line.splitn(4, '|').collect();
243            if parts.len() < 4 {
244                return None;
245            }
246            let date: DateTime<Utc> = parts[3].parse().ok()?;
247            Some(CommitInfo {
248                hash: parts[0].to_string(),
249                message: parts[1].to_string(),
250                author: parts[2].to_string(),
251                date,
252            })
253        })
254        .collect();
255
256    Ok(commits)
257}
258
259/// Restore the working tree to the given ref.
260fn restore_working_tree(ref_name: &str) {
261    let _ = std::process::Command::new("git")
262        .args(["checkout", ref_name])
263        .output();
264
265    // Pop stash if there was one.
266    let _ = std::process::Command::new("git")
267        .args(["stash", "pop"])
268        .output();
269}
270
271/// Get a diff summary for a specific commit.
272fn get_commit_diff_summary(hash: &str) -> String {
273    let output = std::process::Command::new("git")
274        .args(["diff-tree", "--no-commit-id", "-r", "--stat", hash])
275        .output();
276
277    match output {
278        Ok(output) if output.status.success() => {
279            String::from_utf8_lossy(&output.stdout).trim().to_string()
280        }
281        _ => "Unable to retrieve diff summary".to_string(),
282    }
283}
284
285/// Find the test directory in the current working directory.
286fn find_test_dir() -> std::path::PathBuf {
287    // Try common locations.
288    for candidate in &["ought/ought-gen", "tests", "test", "ought-gen"] {
289        let path = std::path::PathBuf::from(candidate);
290        if path.is_dir() {
291            return path;
292        }
293    }
294    std::path::PathBuf::from(".")
295}
296
297/// Collect generated test files for a specific clause.
298fn collect_test_files_for_clause(
299    clause_id: &ClauseId,
300    test_dir: &Path,
301) -> Vec<ought_gen::GeneratedTest> {
302    // Derive the expected file path from the clause ID.
303    let path_component = clause_id.0.replace("::", "/");
304
305    // Look for test files matching common patterns.
306    let extensions = ["_test.rs", "_test.py", ".test.ts", ".test.js", "_test.go"];
307
308    for ext in &extensions {
309        let file_path = test_dir.join(format!("{}{}", path_component, ext));
310        if file_path.is_file()
311            && let Ok(code) = std::fs::read_to_string(&file_path) {
312                let language = match *ext {
313                    "_test.rs" => ought_gen::generator::Language::Rust,
314                    "_test.py" => ought_gen::generator::Language::Python,
315                    ".test.ts" => ought_gen::generator::Language::TypeScript,
316                    ".test.js" => ought_gen::generator::Language::JavaScript,
317                    "_test.go" => ought_gen::generator::Language::Go,
318                    _ => ought_gen::generator::Language::Rust,
319                };
320                return vec![ought_gen::GeneratedTest {
321                    clause_id: clause_id.clone(),
322                    code,
323                    language,
324                    file_path,
325                }];
326            }
327    }
328
329    Vec::new()
330}