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
9pub struct BisectOptions {
11 pub range: Option<String>,
13 pub regenerate: bool,
15}
16
17pub fn bisect(
21 clause_id: &ClauseId,
22 _specs: &SpecGraph,
23 runner: &dyn Runner,
24 options: &BisectOptions,
25) -> anyhow::Result<BisectResult> {
26 let original_ref = get_current_ref()?;
28
29 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 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 let result = run_bisect(clause_id, &commits, runner, &original_ref);
48
49 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
67fn run_bisect(
69 clause_id: &ClauseId,
70 commits: &[CommitInfo],
71 runner: &dyn Runner,
72 original_ref: &str,
73) -> anyhow::Result<usize> {
74 let n = commits.len();
77
78 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 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 hi = mid;
100 } else {
101 last_fail = mid;
103 lo = mid + 1;
104 }
105 }
106 Err(_) => {
107 last_fail = mid;
109 lo = mid + 1;
110 }
111 }
112
113 restore_working_tree(original_ref);
115 }
116
117 Ok(last_fail)
118}
119
120fn checkout_and_test(
122 commit: &CommitInfo,
123 clause_id: &ClauseId,
124 runner: &dyn Runner,
125) -> anyhow::Result<bool> {
126 let _ = std::process::Command::new("git")
128 .args(["stash", "--include-untracked"])
129 .output();
130
131 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 let test_dir = find_test_dir();
146
147 let tests = collect_test_files_for_clause(clause_id, &test_dir);
149
150 if tests.is_empty() {
151 return Ok(true); }
154
155 let result = runner.run(&tests, &test_dir)?;
156
157 let clause_passed = result
159 .results
160 .iter()
161 .any(|r| r.clause_id == *clause_id && r.status == ought_run::TestStatus::Passed);
162
163 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
177fn get_current_ref() -> anyhow::Result<String> {
179 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 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
201fn 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 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
259fn restore_working_tree(ref_name: &str) {
261 let _ = std::process::Command::new("git")
262 .args(["checkout", ref_name])
263 .output();
264
265 let _ = std::process::Command::new("git")
267 .args(["stash", "pop"])
268 .output();
269}
270
271fn 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
285fn find_test_dir() -> std::path::PathBuf {
287 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
297fn collect_test_files_for_clause(
299 clause_id: &ClauseId,
300 test_dir: &Path,
301) -> Vec<ought_gen::GeneratedTest> {
302 let path_component = clause_id.0.replace("::", "/");
304
305 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}