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