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 crate::seed::seed(s.repo, &s.repo.join(s.prefix), dir, project);
289 if !s.worktree.prepare || !crate::prepare::needed(project) {
290 return Some(runner);
291 }
292 crate::prepare::prepare(&crate::prepare::Prepare {
293 worktree: dir,
294 project,
295 config: s.worktree,
296 runner: &runner,
297 state: s.state,
298 })
299 .ok()?;
300 Some(runner)
301}
302
303#[must_use]
306pub fn search(s: &Search<'_>) -> Found {
307 let (ticks, base) = ticks(s.repo, s.feature);
308 search_with(&ticks, base.as_deref(), &mut |c, _| probe_at(s, c))
309}
310
311pub fn search_with(
315 ticks: &[(String, String)],
316 base: Option<&str>,
317 probe: &mut dyn FnMut(&str, u32) -> Probe,
318) -> Found {
319 let short = |c: &str| c.chars().take(7).collect::<String>();
320 let none = |runs| Found {
321 implemented_by: None,
322 runs,
323 };
324 if ticks.is_empty() {
325 return Found {
327 implemented_by: Some(ImplementedBy::BeforeFeature {
328 before_feature: true,
329 }),
330 runs: 0,
331 };
332 }
333 let budget = budget(ticks.len());
334 let mut runs = 0u32;
335 let mut seen: Vec<(usize, bool)> = Vec::new();
336 let (mut lo, mut hi) = (0usize, ticks.len() - 1);
338 while lo < hi {
339 if runs >= budget {
340 return none(runs);
341 }
342 let mid = lo + (hi - lo) / 2;
343 runs += 1;
344 let ok = match probe(&ticks[mid].0, runs) {
345 Probe::Pass => true,
346 Probe::Fail => false,
347 Probe::Undetermined => return none(runs),
348 };
349 seen.push((mid, ok));
350 if ok {
351 hi = mid;
352 } else {
353 lo = mid + 1;
354 }
355 }
356 if !seen.contains(&(lo, true)) {
360 if runs >= budget {
361 return none(runs);
362 }
363 runs += 1;
364 if probe(&ticks[lo].0, runs) != Probe::Pass {
365 return none(runs);
366 }
367 }
368 if lo == 0 {
369 let Some(base) = base.filter(|_| runs < budget) else {
371 return none(runs);
372 };
373 runs += 1;
374 match probe(base, runs) {
375 Probe::Pass => {
376 return Found {
377 implemented_by: Some(ImplementedBy::BeforeFeature {
378 before_feature: true,
379 }),
380 runs,
381 };
382 }
383 Probe::Fail => {}
384 Probe::Undetermined => return none(runs),
385 }
386 }
387 let (commit, task) = &ticks[lo];
388 Found {
389 implemented_by: Some(ImplementedBy::Task {
390 task: task.clone(),
391 commit: short(commit),
392 }),
393 runs,
394 }
395}
396
397fn alive(pid: u32) -> bool {
399 i32::try_from(pid).is_ok_and(|p| {
400 !matches!(
402 nix::sys::signal::kill(nix::unistd::Pid::from_raw(p), None),
403 Err(nix::errno::Errno::ESRCH)
404 )
405 })
406}
407
408pub fn prune(repo: &Path, scratch: &Path) {
412 let leftover: Vec<PathBuf> = std::fs::read_dir(scratch)
413 .into_iter()
414 .flatten()
415 .flatten()
416 .filter_map(|e| {
417 let name = e.file_name().to_string_lossy().into_owned();
418 let pid: u32 = name.strip_prefix(PREFIX)?.split('-').next()?.parse().ok()?;
419 (!alive(pid)).then(|| e.path())
420 })
421 .collect();
422 for d in &leftover {
423 let wt = d.join("wt");
424 crate::prepare::forget(&wt);
425 crate::include::forget(&wt);
426 if let Some(p) = wt.to_str() {
427 let _setup = super::lanes::LANE_SETUP.lock();
428 let _ = Git::at(repo).run(&["worktree", "remove", "--force", p]);
429 }
430 let _ = std::fs::remove_dir_all(d);
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 fn ticks(n: usize) -> Vec<(String, String)> {
439 (1..=n)
440 .map(|i| (format!("{i:07}abcdef"), format!("T{i:03}")))
441 .collect()
442 }
443
444 fn of(pass: bool) -> Probe {
445 if pass { Probe::Pass } else { Probe::Fail }
446 }
447
448 #[test]
449 fn the_budget_grows_with_the_log_and_stops_at_five() {
450 assert_eq!(budget(1), 2);
451 assert_eq!(budget(3), 3);
452 assert_eq!(budget(6), 4);
453 assert_eq!(budget(14), 5);
454 assert_eq!(budget(1000), 5);
455 }
456
457 #[test]
458 fn the_first_passing_tick_is_found_within_budget() {
459 let t = ticks(6);
460 let mut calls = 0;
461 let found = search_with(&t, Some("base"), &mut |c, _| {
462 calls += 1;
463 of(c >= t[2].0.as_str())
464 });
465 assert_eq!(
466 found.implemented_by,
467 Some(ImplementedBy::Task {
468 task: "T003".to_owned(),
469 commit: "0000003".to_owned()
470 })
471 );
472 assert!(found.runs <= budget(6) && calls == found.runs as usize);
473 }
474
475 #[test]
476 fn passing_at_the_base_is_before_the_feature_and_no_ticks_is_too() {
477 let t = ticks(6);
478 let found = search_with(&t, Some("base"), &mut |_, _| Probe::Pass);
479 assert_eq!(
480 found.implemented_by,
481 Some(ImplementedBy::BeforeFeature {
482 before_feature: true
483 })
484 );
485 assert!(found.runs <= 5);
486 assert_eq!(search_with(&[], None, &mut |_, _| panic!("no run")).runs, 0);
487 }
488
489 #[test]
490 fn a_failing_base_names_the_first_tick() {
491 let t = ticks(2);
492 let found = search_with(&t, Some("base"), &mut |c, _| of(c != "base"));
493 assert_eq!(
494 found.implemented_by,
495 Some(ImplementedBy::Task {
496 task: "T001".to_owned(),
497 commit: "0000001".to_owned()
498 })
499 );
500 }
501
502 #[test]
503 fn a_tip_that_does_not_pass_where_red_said_it_did_is_unknown() {
504 let t = ticks(6);
505 let found = search_with(&t, Some("base"), &mut |_, _| Probe::Fail);
506 assert_eq!(found.implemented_by, None);
507 assert!(found.runs <= budget(6));
508 }
509
510 #[test]
512 fn an_undetermined_probe_ends_the_search_with_no_name() {
513 let t = ticks(6);
514 let found = search_with(&t, Some("base"), &mut |c, n| {
516 if n == 1 {
517 Probe::Undetermined
518 } else {
519 of(c >= t[1].0.as_str())
520 }
521 });
522 assert_eq!(found.implemented_by, None);
523 let found = search_with(&ticks(1), Some("base"), &mut |c, _| {
524 if c == "base" {
525 Probe::Undetermined
526 } else {
527 Probe::Pass
528 }
529 });
530 assert_eq!(
531 found.implemented_by, None,
532 "an unreadable base is not 'implemented by T001'"
533 );
534 }
535
536 #[test]
537 fn an_exhausted_budget_or_no_base_cannot_decide() {
538 let t = ticks(1);
539 assert_eq!(
540 search_with(&t, None, &mut |_, _| Probe::Pass).implemented_by,
541 None
542 );
543 let t = ticks(30);
544 let found = search_with(&t, Some("base"), &mut |_, n| of(n % 2 == 0));
545 assert!(found.runs <= 5);
546 }
547
548 #[test]
550 fn a_section_runs_only_its_own_tests() {
551 let argv: Vec<String> = ["cargo", "test", "--test", "acceptance_us1"]
552 .map(str::to_owned)
553 .to_vec();
554 assert_eq!(
555 section_command(
556 &argv,
557 "tests/acceptance_us1.rs",
558 "// T006\n#[test]\nfn t006_a() {}\n#[test]\nasync fn t006_b() {}\n"
559 )
560 .unwrap(),
561 [
562 "cargo",
563 "test",
564 "--test",
565 "acceptance_us1",
566 "--",
567 "t006_a",
568 "t006_b",
569 "--exact"
570 ]
571 );
572 let py: Vec<String> = ["python3", "-m", "pytest", "tests/test_acceptance_us1.py"]
573 .map(str::to_owned)
574 .to_vec();
575 assert_eq!(
576 section_command(
577 &py,
578 "tests/test_acceptance_us1.py",
579 "def test_t006_a():\n pass\n"
580 )
581 .unwrap()
582 .last()
583 .unwrap(),
584 "test_t006_a"
585 );
586 assert_eq!(
587 section_command(&argv, "src/acceptance-us1.test.ts", "it('a', () => {})"),
588 None
589 );
590 }
591
592 #[test]
594 fn prune_leaves_a_live_search_s_scratch() {
595 let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
596 let live = d
597 .path()
598 .join(format!("{PREFIX}{}-T005-x", std::process::id()));
599 let dead = d.path().join(format!("{PREFIX}{}-T006-y", u32::MAX - 7));
600 std::fs::create_dir_all(&live).unwrap_or_else(|e| panic!("{e}"));
601 std::fs::create_dir_all(&dead).unwrap_or_else(|e| panic!("{e}"));
602 prune(d.path(), d.path());
603 assert!(live.exists() && !dead.exists());
604 }
605}