use std::path::Path;
use chrono::{DateTime, Utc};
use ought_run::Runner;
use ought_spec::{ClauseId, SpecGraph};
use crate::types::{BisectResult, CommitInfo};
pub struct BisectOptions {
pub range: Option<String>,
pub regenerate: bool,
}
pub fn bisect(
clause_id: &ClauseId,
_specs: &SpecGraph,
runner: &dyn Runner,
options: &BisectOptions,
) -> anyhow::Result<BisectResult> {
let original_ref = get_current_ref()?;
let commits = get_commit_range(options)?;
if commits.is_empty() {
anyhow::bail!("No commits found in the specified range");
}
if commits.len() == 1 {
restore_working_tree(&original_ref);
return Ok(BisectResult {
clause_id: clause_id.clone(),
breaking_commit: commits.into_iter().next().unwrap(),
diff_summary: "Only one commit in range".to_string(),
});
}
let result = run_bisect(clause_id, &commits, runner, &original_ref);
restore_working_tree(&original_ref);
match result {
Ok(breaking_idx) => {
let breaking_commit = commits[breaking_idx].clone();
let diff_summary = get_commit_diff_summary(&breaking_commit.hash);
Ok(BisectResult {
clause_id: clause_id.clone(),
breaking_commit,
diff_summary,
})
}
Err(e) => Err(e),
}
}
fn run_bisect(
clause_id: &ClauseId,
commits: &[CommitInfo],
runner: &dyn Runner,
original_ref: &str,
) -> anyhow::Result<usize> {
let n = commits.len();
let mut lo: usize = 0;
let mut hi: usize = n - 1;
let mut last_fail: usize = hi;
while lo < hi {
let mid = lo + (hi - lo) / 2;
let commit_idx = mid;
let commit = &commits[commit_idx];
match checkout_and_test(commit, clause_id, runner) {
Ok(passed) => {
if passed {
hi = mid;
} else {
last_fail = mid;
lo = mid + 1;
}
}
Err(_) => {
last_fail = mid;
lo = mid + 1;
}
}
restore_working_tree(original_ref);
}
Ok(last_fail)
}
fn checkout_and_test(
commit: &CommitInfo,
clause_id: &ClauseId,
runner: &dyn Runner,
) -> anyhow::Result<bool> {
let _ = std::process::Command::new("git")
.args(["stash", "--include-untracked"])
.output();
let checkout = std::process::Command::new("git")
.args(["checkout", &commit.hash])
.output()?;
if !checkout.status.success() {
anyhow::bail!(
"Failed to checkout commit {}: {}",
commit.hash,
String::from_utf8_lossy(&checkout.stderr)
);
}
let test_dir = find_test_dir();
let tests = collect_test_files_for_clause(clause_id, &test_dir);
if tests.is_empty() {
return Ok(true); }
let result = runner.run(&tests, &test_dir)?;
let clause_passed = result
.results
.iter()
.any(|r| r.clause_id == *clause_id && r.status == ought_run::TestStatus::Passed);
let relevant_results: Vec<_> = result
.results
.iter()
.filter(|r| r.clause_id == *clause_id)
.collect();
if relevant_results.is_empty() {
return Ok(true);
}
Ok(clause_passed)
}
fn get_current_ref() -> anyhow::Result<String> {
let output = std::process::Command::new("git")
.args(["symbolic-ref", "--short", "HEAD"])
.output();
if let Ok(output) = output
&& output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
}
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.output()?;
if !output.status.success() {
anyhow::bail!("Not in a git repository");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn get_commit_range(options: &BisectOptions) -> anyhow::Result<Vec<CommitInfo>> {
let range = options
.range
.as_deref()
.unwrap_or("HEAD~20..HEAD");
let output = std::process::Command::new("git")
.args([
"log",
range,
"--format=%H|%s|%an <%ae>|%aI",
"--reverse",
])
.output()?;
if !output.status.success() {
let output = std::process::Command::new("git")
.args([
"log",
"--max-count=20",
"--format=%H|%s|%an <%ae>|%aI",
"--reverse",
])
.output()?;
if !output.status.success() {
anyhow::bail!("Failed to get git log");
}
return parse_git_log(&String::from_utf8_lossy(&output.stdout));
}
parse_git_log(&String::from_utf8_lossy(&output.stdout))
}
fn parse_git_log(output: &str) -> anyhow::Result<Vec<CommitInfo>> {
let commits: Vec<CommitInfo> = output
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() < 4 {
return None;
}
let date: DateTime<Utc> = parts[3].parse().ok()?;
Some(CommitInfo {
hash: parts[0].to_string(),
message: parts[1].to_string(),
author: parts[2].to_string(),
date,
})
})
.collect();
Ok(commits)
}
fn restore_working_tree(ref_name: &str) {
let _ = std::process::Command::new("git")
.args(["checkout", ref_name])
.output();
let _ = std::process::Command::new("git")
.args(["stash", "pop"])
.output();
}
fn get_commit_diff_summary(hash: &str) -> String {
let output = std::process::Command::new("git")
.args(["diff-tree", "--no-commit-id", "-r", "--stat", hash])
.output();
match output {
Ok(output) if output.status.success() => {
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
_ => "Unable to retrieve diff summary".to_string(),
}
}
fn find_test_dir() -> std::path::PathBuf {
for candidate in &["ought/ought-gen", "tests", "test", "ought-gen"] {
let path = std::path::PathBuf::from(candidate);
if path.is_dir() {
return path;
}
}
std::path::PathBuf::from(".")
}
fn collect_test_files_for_clause(
clause_id: &ClauseId,
test_dir: &Path,
) -> Vec<ought_gen::GeneratedTest> {
let path_component = clause_id.0.replace("::", "/");
let extensions = ["_test.rs", "_test.py", ".test.ts", ".test.js", "_test.go"];
for ext in &extensions {
let file_path = test_dir.join(format!("{}{}", path_component, ext));
if file_path.is_file()
&& let Ok(code) = std::fs::read_to_string(&file_path) {
let language = match *ext {
"_test.rs" => ought_gen::generator::Language::Rust,
"_test.py" => ought_gen::generator::Language::Python,
".test.ts" => ought_gen::generator::Language::TypeScript,
".test.js" => ought_gen::generator::Language::JavaScript,
"_test.go" => ought_gen::generator::Language::Go,
_ => ought_gen::generator::Language::Rust,
};
return vec![ought_gen::GeneratedTest {
clause_id: clause_id.clone(),
code,
language,
file_path,
}];
}
}
Vec::new()
}