1use std::path::{Path, PathBuf};
15
16use speccoder_core::collection::Toolchain;
17use speccoder_core::regression::ImplementedBy;
18use speccoder_core::verify::{CheckStatus, RedClass, classify_red};
19
20use crate::git::Git;
21use crate::sandbox;
22use crate::verify::process::{CommandRunner, Invocation, OUTPUT_CAP, scrubbed_env};
23
24pub struct Search<'a> {
26 pub repo: &'a Path,
28 pub prefix: &'a str,
30 pub feature: &'a str,
32 pub task: &'a str,
34 pub files: &'a [(String, String)],
36 pub argv: &'a [String],
39 pub scratch: &'a Path,
41 pub sandbox: &'a sandbox::Config,
43 pub timeout: std::time::Duration,
45 pub worktree: &'a crate::prepare::Config,
48 pub checkout: &'a Path,
50 pub state: Option<&'a Path>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Found {
57 pub implemented_by: Option<ImplementedBy>,
59 pub runs: u32,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum Probe {
66 Pass,
68 Fail,
70 Undetermined,
72}
73
74const PREFIX: &str = "bisect-";
76
77#[must_use]
79pub fn budget(n: usize) -> u32 {
80 let mut bits = 0u32;
81 while (1usize << bits) < n + 1 {
82 bits += 1;
83 }
84 (bits + 1).min(5)
85}
86
87#[must_use]
91pub fn section_command(argv: &[String], path: &str, added: &str) -> Option<Vec<String>> {
92 let t = speccoder_core::collection::toolchain_of(path)?;
93 let code = speccoder_core::collection::strip_comments(added);
94 let after = |kw: &str| -> Vec<String> {
95 code.lines()
96 .filter_map(|l| {
97 let l = l.trim_start();
98 let rest = l
99 .strip_prefix("pub ")
100 .unwrap_or(l)
101 .strip_prefix("async ")
102 .unwrap_or_else(|| l.strip_prefix("pub ").unwrap_or(l));
103 let rest = rest.strip_prefix(kw)?;
104 let name: String = rest
105 .chars()
106 .take_while(|c| c.is_alphanumeric() || *c == '_')
107 .collect();
108 (!name.is_empty()).then_some(name)
109 })
110 .collect()
111 };
112 let mut out = argv.to_vec();
113 match t {
114 Toolchain::Rust => {
115 let names = after("fn ");
116 if names.is_empty() {
117 return None;
118 }
119 if !out.iter().any(|a| a == "--") {
120 out.push("--".to_owned());
121 }
122 out.extend(names);
123 out.push("--exact".to_owned());
124 }
125 Toolchain::Python => {
126 let names: Vec<String> = after("def ")
127 .into_iter()
128 .filter(|n| n.starts_with("test"))
129 .collect();
130 if names.is_empty() {
131 return None;
132 }
133 out.extend(["-k".to_owned(), names.join(" or ")]);
134 }
135 Toolchain::Go => {
136 let names = speccoder_core::collection::go_declared_tests(added);
137 if names.is_empty() {
138 return None;
139 }
140 out.extend(["-run".to_owned(), format!("^({})$", names.join("|"))]);
141 }
142 Toolchain::JavaScript => return None,
144 }
145 Some(out)
146}
147
148fn ticks(repo: &Path, feature: &str) -> (Vec<(String, String)>, Option<String>) {
151 let log = Git::at(repo)
152 .run(&[
153 "log",
154 "--first-parent",
155 "--reverse",
156 "--format=%H%x1f%(trailers:key=Spec-Task,valueonly)%x1e",
157 "HEAD",
158 ])
159 .map(|b| String::from_utf8_lossy(&b).into_owned())
160 .unwrap_or_default();
161 let ticks: Vec<(String, String)> = log
162 .split('\u{1e}')
163 .filter_map(|r| {
164 let (commit, task) = r.trim().split_once('\u{1f}')?;
165 let task = task.trim().strip_prefix(&format!("{feature}/"))?;
166 Some((commit.to_owned(), task.to_owned()))
167 })
168 .collect();
169 let base = ticks.first().and_then(|(c, _)| {
170 Git::at(repo)
171 .line(&["rev-parse", "--verify", "-q", &format!("{c}^")])
172 .ok()
173 .map(|l| l.trim().to_owned())
174 });
175 (ticks, base)
176}
177
178fn probe_at(s: &Search<'_>, commit: &str) -> Probe {
181 if std::fs::create_dir_all(s.scratch).is_err() {
182 return Probe::Undetermined;
183 }
184 let safe: String = s.task.chars().filter(char::is_ascii_alphanumeric).collect();
185 let Ok(holder) = tempfile::Builder::new()
186 .prefix(&format!("{PREFIX}{}-{safe}-", std::process::id()))
187 .tempdir_in(s.scratch)
188 else {
189 return Probe::Undetermined;
190 };
191 let dir = holder.path().join("wt");
192 let Some(path) = dir.to_str() else {
193 return Probe::Undetermined;
194 };
195 let git = Git::at(s.repo);
196 let added = {
198 let _setup = super::lanes::LANE_SETUP.lock();
199 git.run(&["worktree", "add", "--detach", "-q", path, commit])
200 .is_ok()
201 };
202 if !added {
203 return Probe::Undetermined;
204 }
205 let project = if s.prefix.is_empty() {
206 dir.clone()
207 } else {
208 dir.join(s.prefix)
209 };
210 let overlaid = s.files.iter().all(|(rel, content)| {
211 let f = project.join(rel);
212 f.parent()
213 .is_none_or(|p| std::fs::create_dir_all(p).is_ok())
214 && std::fs::write(&f, content).is_ok()
215 });
216 let probe = if !overlaid {
217 Probe::Undetermined
218 } else {
219 match sandbox::runner(&dir, s.sandbox).map(|(r, _)| prepared(s, &dir, &project, r)) {
220 Err(_) | Ok(None) => Probe::Undetermined,
221 Ok(Some(runner)) => {
222 let out = runner.run(&Invocation {
223 argv: s.argv.to_vec(),
224 cwd: project.clone(),
225 env: scrubbed_env(std::env::vars()),
226 timeout: s.timeout,
227 output_cap: OUTPUT_CAP,
228 });
229 let status = if out.spawn_error.is_some() {
230 CheckStatus::Error
231 } else if out.timed_out {
232 CheckStatus::TimedOut
233 } else if out.exit_code == Some(0) {
234 CheckStatus::Passed
235 } else {
236 CheckStatus::Failed
237 };
238 let summary = crate::verify::summary::parse(
239 &String::from_utf8_lossy(&out.stdout.kept),
240 &String::from_utf8_lossy(&out.stderr.kept),
241 out.exit_code,
242 );
243 let result = speccoder_core::verify::CheckResult {
244 name: "acceptance".to_owned(),
245 argv: s.argv.to_vec(),
246 status,
247 exit_code: out.exit_code,
248 duration_ms: 0,
249 junit: false,
250 tests: None,
251 stdout_tail: String::new(),
252 stderr_tail: String::new(),
253 truncated: false,
254 note: None,
255 flaky: false,
256 };
257 match (status, classify_red(&result, summary.as_ref())) {
258 (CheckStatus::Error | CheckStatus::TimedOut, _) => Probe::Undetermined,
259 (_, RedClass::Passed) => Probe::Pass,
260 (_, RedClass::Assertion | RedClass::Absent | RedClass::BuildError) => {
261 Probe::Fail
262 }
263 _ => Probe::Undetermined,
264 }
265 }
266 }
267 };
268 crate::prepare::forget(&dir);
269 crate::include::forget(&dir);
270 {
271 let _setup = super::lanes::LANE_SETUP.lock();
272 let _ = git.run(&["worktree", "remove", "--force", path]);
273 }
274 drop(holder);
275 probe
276}
277
278fn prepared(
282 s: &Search<'_>,
283 dir: &Path,
284 project: &Path,
285 runner: sandbox::Runner,
286) -> Option<sandbox::Runner> {
287 crate::include::copy(dir, project, s.checkout, &s.worktree.include).ok()?;
288 if !s.worktree.prepare || !crate::prepare::needed(project) {
289 return Some(runner);
290 }
291 crate::prepare::prepare(&crate::prepare::Prepare {
292 worktree: dir,
293 project,
294 config: s.worktree,
295 runner: &runner,
296 state: s.state,
297 })
298 .ok()?;
299 Some(runner)
300}
301
302#[must_use]
305pub fn search(s: &Search<'_>) -> Found {
306 let (ticks, base) = ticks(s.repo, s.feature);
307 search_with(&ticks, base.as_deref(), &mut |c, _| probe_at(s, c))
308}
309
310pub fn search_with(
314 ticks: &[(String, String)],
315 base: Option<&str>,
316 probe: &mut dyn FnMut(&str, u32) -> Probe,
317) -> Found {
318 let short = |c: &str| c.chars().take(7).collect::<String>();
319 let none = |runs| Found {
320 implemented_by: None,
321 runs,
322 };
323 if ticks.is_empty() {
324 return Found {
326 implemented_by: Some(ImplementedBy::BeforeFeature {
327 before_feature: true,
328 }),
329 runs: 0,
330 };
331 }
332 let budget = budget(ticks.len());
333 let mut runs = 0u32;
334 let mut seen: Vec<(usize, bool)> = Vec::new();
335 let (mut lo, mut hi) = (0usize, ticks.len() - 1);
337 while lo < hi {
338 if runs >= budget {
339 return none(runs);
340 }
341 let mid = lo + (hi - lo) / 2;
342 runs += 1;
343 let ok = match probe(&ticks[mid].0, runs) {
344 Probe::Pass => true,
345 Probe::Fail => false,
346 Probe::Undetermined => return none(runs),
347 };
348 seen.push((mid, ok));
349 if ok {
350 hi = mid;
351 } else {
352 lo = mid + 1;
353 }
354 }
355 if !seen.contains(&(lo, true)) {
359 if runs >= budget {
360 return none(runs);
361 }
362 runs += 1;
363 if probe(&ticks[lo].0, runs) != Probe::Pass {
364 return none(runs);
365 }
366 }
367 if lo == 0 {
368 let Some(base) = base.filter(|_| runs < budget) else {
370 return none(runs);
371 };
372 runs += 1;
373 match probe(base, runs) {
374 Probe::Pass => {
375 return Found {
376 implemented_by: Some(ImplementedBy::BeforeFeature {
377 before_feature: true,
378 }),
379 runs,
380 };
381 }
382 Probe::Fail => {}
383 Probe::Undetermined => return none(runs),
384 }
385 }
386 let (commit, task) = &ticks[lo];
387 Found {
388 implemented_by: Some(ImplementedBy::Task {
389 task: task.clone(),
390 commit: short(commit),
391 }),
392 runs,
393 }
394}
395
396fn alive(pid: u32) -> bool {
398 i32::try_from(pid).is_ok_and(|p| {
399 !matches!(
401 nix::sys::signal::kill(nix::unistd::Pid::from_raw(p), None),
402 Err(nix::errno::Errno::ESRCH)
403 )
404 })
405}
406
407pub fn prune(repo: &Path, scratch: &Path) {
411 let leftover: Vec<PathBuf> = std::fs::read_dir(scratch)
412 .into_iter()
413 .flatten()
414 .flatten()
415 .filter_map(|e| {
416 let name = e.file_name().to_string_lossy().into_owned();
417 let pid: u32 = name.strip_prefix(PREFIX)?.split('-').next()?.parse().ok()?;
418 (!alive(pid)).then(|| e.path())
419 })
420 .collect();
421 for d in &leftover {
422 let wt = d.join("wt");
423 crate::prepare::forget(&wt);
424 crate::include::forget(&wt);
425 if let Some(p) = wt.to_str() {
426 let _setup = super::lanes::LANE_SETUP.lock();
427 let _ = Git::at(repo).run(&["worktree", "remove", "--force", p]);
428 }
429 let _ = std::fs::remove_dir_all(d);
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 fn ticks(n: usize) -> Vec<(String, String)> {
438 (1..=n)
439 .map(|i| (format!("{i:07}abcdef"), format!("T{i:03}")))
440 .collect()
441 }
442
443 fn of(pass: bool) -> Probe {
444 if pass { Probe::Pass } else { Probe::Fail }
445 }
446
447 #[test]
448 fn the_budget_grows_with_the_log_and_stops_at_five() {
449 assert_eq!(budget(1), 2);
450 assert_eq!(budget(3), 3);
451 assert_eq!(budget(6), 4);
452 assert_eq!(budget(14), 5);
453 assert_eq!(budget(1000), 5);
454 }
455
456 #[test]
457 fn the_first_passing_tick_is_found_within_budget() {
458 let t = ticks(6);
459 let mut calls = 0;
460 let found = search_with(&t, Some("base"), &mut |c, _| {
461 calls += 1;
462 of(c >= t[2].0.as_str())
463 });
464 assert_eq!(
465 found.implemented_by,
466 Some(ImplementedBy::Task {
467 task: "T003".to_owned(),
468 commit: "0000003".to_owned()
469 })
470 );
471 assert!(found.runs <= budget(6) && calls == found.runs as usize);
472 }
473
474 #[test]
475 fn passing_at_the_base_is_before_the_feature_and_no_ticks_is_too() {
476 let t = ticks(6);
477 let found = search_with(&t, Some("base"), &mut |_, _| Probe::Pass);
478 assert_eq!(
479 found.implemented_by,
480 Some(ImplementedBy::BeforeFeature {
481 before_feature: true
482 })
483 );
484 assert!(found.runs <= 5);
485 assert_eq!(search_with(&[], None, &mut |_, _| panic!("no run")).runs, 0);
486 }
487
488 #[test]
489 fn a_failing_base_names_the_first_tick() {
490 let t = ticks(2);
491 let found = search_with(&t, Some("base"), &mut |c, _| of(c != "base"));
492 assert_eq!(
493 found.implemented_by,
494 Some(ImplementedBy::Task {
495 task: "T001".to_owned(),
496 commit: "0000001".to_owned()
497 })
498 );
499 }
500
501 #[test]
502 fn a_tip_that_does_not_pass_where_red_said_it_did_is_unknown() {
503 let t = ticks(6);
504 let found = search_with(&t, Some("base"), &mut |_, _| Probe::Fail);
505 assert_eq!(found.implemented_by, None);
506 assert!(found.runs <= budget(6));
507 }
508
509 #[test]
511 fn an_undetermined_probe_ends_the_search_with_no_name() {
512 let t = ticks(6);
513 let found = search_with(&t, Some("base"), &mut |c, n| {
515 if n == 1 {
516 Probe::Undetermined
517 } else {
518 of(c >= t[1].0.as_str())
519 }
520 });
521 assert_eq!(found.implemented_by, None);
522 let found = search_with(&ticks(1), Some("base"), &mut |c, _| {
523 if c == "base" {
524 Probe::Undetermined
525 } else {
526 Probe::Pass
527 }
528 });
529 assert_eq!(
530 found.implemented_by, None,
531 "an unreadable base is not 'implemented by T001'"
532 );
533 }
534
535 #[test]
536 fn an_exhausted_budget_or_no_base_cannot_decide() {
537 let t = ticks(1);
538 assert_eq!(
539 search_with(&t, None, &mut |_, _| Probe::Pass).implemented_by,
540 None
541 );
542 let t = ticks(30);
543 let found = search_with(&t, Some("base"), &mut |_, n| of(n % 2 == 0));
544 assert!(found.runs <= 5);
545 }
546
547 #[test]
549 fn a_section_runs_only_its_own_tests() {
550 let argv: Vec<String> = ["cargo", "test", "--test", "acceptance_us1"]
551 .map(str::to_owned)
552 .to_vec();
553 assert_eq!(
554 section_command(
555 &argv,
556 "tests/acceptance_us1.rs",
557 "// T006\n#[test]\nfn t006_a() {}\n#[test]\nasync fn t006_b() {}\n"
558 )
559 .unwrap(),
560 [
561 "cargo",
562 "test",
563 "--test",
564 "acceptance_us1",
565 "--",
566 "t006_a",
567 "t006_b",
568 "--exact"
569 ]
570 );
571 let py: Vec<String> = ["python3", "-m", "pytest", "tests/test_acceptance_us1.py"]
572 .map(str::to_owned)
573 .to_vec();
574 assert_eq!(
575 section_command(
576 &py,
577 "tests/test_acceptance_us1.py",
578 "def test_t006_a():\n pass\n"
579 )
580 .unwrap()
581 .last()
582 .unwrap(),
583 "test_t006_a"
584 );
585 assert_eq!(
586 section_command(&argv, "src/acceptance-us1.test.ts", "it('a', () => {})"),
587 None
588 );
589 }
590
591 #[test]
593 fn prune_leaves_a_live_search_s_scratch() {
594 let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
595 let live = d
596 .path()
597 .join(format!("{PREFIX}{}-T005-x", std::process::id()));
598 let dead = d.path().join(format!("{PREFIX}{}-T006-y", u32::MAX - 7));
599 std::fs::create_dir_all(&live).unwrap_or_else(|e| panic!("{e}"));
600 std::fs::create_dir_all(&dead).unwrap_or_else(|e| panic!("{e}"));
601 prune(d.path(), d.path());
602 assert!(live.exists() && !dead.exists());
603 }
604}