Skip to main content

harn_cli/commands/
test_bench.rs

1//! `harn test-bench` runner.
2//!
3//! Wraps [`crate::commands::run::execute_run`] in a
4//! [`harn_vm::testbench::TestbenchSession`] so a script runs against a
5//! pinned clock, an optional LLM/process tape, and an optional
6//! filesystem overlay — all with deny-by-default network egress.
7//!
8//! The CLI flag names map onto [`harn_vm::testbench::Testbench`] one-for-one.
9//!
10//! # Runtime modes
11//!
12//! `--runtime paused-tokio` (default): multi-threaded Tokio runtime. Tasks
13//! from concurrent Harn agents run in parallel across worker threads. The
14//! paused mock clock keeps virtual time stable, but task-interleaving order
15//! varies between runs.
16//!
17//! `--runtime des`: single-threaded `current_thread` Tokio runtime. All
18//! tasks, I/O completions, and timer callbacks share one OS thread. Combined
19//! with the paused mock clock this produces bit-exact event tapes across
20//! reruns for scripts that stay within the DES-safe primitive set (no real
21//! network, no real subprocess, no real clock). See `docs/src/dev/des-mode.md`.
22
23use std::collections::HashSet;
24use std::fs;
25use std::path::{Path, PathBuf};
26use std::process;
27use std::thread;
28
29use harn_vm::testbench::annotations::{
30    annotations_for_record, validate_against_tape, AnnotationKind, AnnotationTape,
31};
32use harn_vm::testbench::fidelity::{compare, FidelityMode, FidelityReport};
33use harn_vm::testbench::overlay_fs::{render_unified_diff, DiffEntry, DiffKind};
34use harn_vm::testbench::tape::EventTape;
35use harn_vm::testbench::{
36    ClockConfig, FilesystemConfig, LlmConfig, NetworkConfig, SubprocessConfig, TapeConfig,
37    Testbench,
38};
39
40use crate::cli::{
41    TestBenchCommand, TestBenchExportAnnotationsArgs, TestBenchFidelityArgs, TestBenchReplayArgs,
42    TestBenchRunArgs, TestBenchValidateAnnotationsArgs,
43};
44use crate::commands::run::{execute_run, CliLlmMockMode, RunOutcome, RunProfileOptions};
45use crate::CLI_RUNTIME_STACK_SIZE;
46
47/// Default starting point for `--clock paused` runs. Picked to be
48/// stable, RFC-3339-friendly, and after every prerequisite Y2K38
49/// boundary so date-of-birth math doesn't underflow:
50/// 2026-01-01T00:00:00Z.
51const DEFAULT_TESTBENCH_START_MS: i64 = 1_767_225_600_000;
52
53/// Where the replay tape used by `harn test-bench fidelity` came from.
54enum ReplaySource {
55    /// Re-run the script under `--against` and emit a fresh tape.
56    ReRun,
57    /// Load an existing tape from disk.
58    Tape(String),
59}
60
61pub(crate) async fn run(command: TestBenchCommand) {
62    let outcome = match command {
63        TestBenchCommand::Run(args) => run_args(args).await,
64        TestBenchCommand::Replay(args) => replay_args(args).await,
65        TestBenchCommand::Fidelity(args) => fidelity_args(args).await,
66        TestBenchCommand::ValidateAnnotations(args) => validate_annotations_args(args),
67        TestBenchCommand::ExportAnnotations(args) => export_annotations_args(args),
68    };
69    flush_outcome(outcome);
70}
71
72async fn run_args(args: TestBenchRunArgs) -> RunOutcome {
73    let bench = match build_testbench(&args) {
74        Ok(bench) => bench,
75        Err(message) => return error_outcome(message),
76    };
77    let llm_mode = match build_llm_mode(&args) {
78        Ok(mode) => mode,
79        Err(message) => return error_outcome(message),
80    };
81    match args.runtime.as_str() {
82        "paused-tokio" | "" => run_with_bench(args, bench, llm_mode).await,
83        "des" => run_with_des_runtime(args, bench, llm_mode).await,
84        other => error_outcome(format!(
85            "--runtime must be `paused-tokio` or `des`, got `{other}`"
86        )),
87    }
88}
89
90/// Execute the script under a standard multi-thread Tokio runtime with the
91/// testbench mocks already active on the calling async task.
92async fn run_with_bench(
93    args: TestBenchRunArgs,
94    bench: Testbench,
95    llm_mode: CliLlmMockMode,
96) -> RunOutcome {
97    let session = match bench.activate() {
98        Ok(session) => session,
99        Err(error) => return error_outcome(format!("activate testbench: {error}")),
100    };
101    let outcome = execute_run(
102        &args.file,
103        false,
104        HashSet::new(),
105        args.argv.clone(),
106        Vec::new(),
107        llm_mode,
108        None,
109        RunProfileOptions::default(),
110    )
111    .await;
112    finalize_session(outcome, session, &args)
113}
114
115/// Execute the script under a **single-threaded** `current_thread` Tokio
116/// runtime for maximum inter-task scheduling determinism.
117///
118/// Spawns a fresh OS thread so we can call `Runtime::block_on` without
119/// nesting inside the caller's multi-thread runtime. The stack size is
120/// matched to the main CLI thread so deep recursion in scripts works.
121/// Thread-local testbench mocks (clock, overlay, process tape, recorder)
122/// are installed inside the new thread so they are visible to every task
123/// that runs there.
124///
125/// The `current_thread` scheduler cooperatively multiplexes all tasks on one
126/// OS thread, eliminating the inter-thread wake-up races that cause tape
127/// records to appear in different orders between runs. Combined with the
128/// paused mock clock this yields bit-exact event tapes for DES-safe scripts.
129async fn run_with_des_runtime(
130    args: TestBenchRunArgs,
131    bench: Testbench,
132    llm_mode: CliLlmMockMode,
133) -> RunOutcome {
134    let (tx, rx) = std::sync::mpsc::channel();
135    thread::Builder::new()
136        .name("harn-des".to_string())
137        .stack_size(CLI_RUNTIME_STACK_SIZE)
138        .spawn(move || {
139            let rt = tokio::runtime::Builder::new_current_thread()
140                .enable_all()
141                .build()
142                .unwrap_or_else(|e| panic!("failed to build DES runtime: {e}"));
143            let outcome = rt.block_on(async move {
144                harn_vm::reset_thread_local_state();
145                let session = match bench.activate() {
146                    Ok(s) => s,
147                    Err(e) => return error_outcome(format!("activate testbench: {e}")),
148                };
149                let outcome = execute_run(
150                    &args.file,
151                    false,
152                    HashSet::new(),
153                    args.argv.clone(),
154                    Vec::new(),
155                    llm_mode,
156                    None,
157                    RunProfileOptions::default(),
158                )
159                .await;
160                finalize_session(outcome, session, &args)
161            });
162            let _ = tx.send(outcome);
163        })
164        .expect("spawn DES thread");
165    tokio::task::spawn_blocking(move || {
166        rx.recv()
167            .unwrap_or_else(|_| error_outcome("DES runtime thread panicked".to_string()))
168    })
169    .await
170    .unwrap_or_else(|e| error_outcome(format!("DES runtime blocking task failed: {e:?}")))
171}
172
173fn build_llm_mode(args: &TestBenchRunArgs) -> Result<CliLlmMockMode, String> {
174    match (&args.llm_fixture, &args.llm_record) {
175        (Some(_), Some(_)) => Err("--llm-fixture and --llm-record are mutually exclusive".into()),
176        (Some(path), None) => Ok(CliLlmMockMode::Replay {
177            fixture_path: PathBuf::from(path),
178        }),
179        (None, Some(path)) => Ok(CliLlmMockMode::Record {
180            fixture_path: PathBuf::from(path),
181        }),
182        (None, None) => Ok(CliLlmMockMode::Off),
183    }
184}
185
186fn finalize_session(
187    outcome: RunOutcome,
188    session: harn_vm::testbench::TestbenchSession,
189    args: &TestBenchRunArgs,
190) -> RunOutcome {
191    let finalize = match session.finalize() {
192        Ok(f) => f,
193        Err(error) => return append_error(outcome, format!("finalize testbench: {error}")),
194    };
195    let mut outcome = outcome;
196    if matches!(args.network.as_str(), "deny") {
197        outcome
198            .stderr
199            .push_str("[testbench] network=deny applied for the duration of the run.\n");
200    }
201    if let Some(diff_path) = args.emit_diff.as_ref() {
202        if let Err(error) = persist_overlay_diff(&finalize.fs_diff, &PathBuf::from(diff_path)) {
203            outcome.stderr.push_str(&format!(
204                "warning: failed to write fs diff to {diff_path}: {error}\n"
205            ));
206        }
207    } else if !finalize.fs_diff.is_empty() {
208        outcome
209            .stderr
210            .push_str(&render_diff_summary(&finalize.fs_diff));
211    }
212    if let Some(record_path) = args.process_record.as_ref() {
213        outcome.stderr.push_str(&format!(
214            "[testbench] recorded {} subprocess invocation(s) to {record_path}.\n",
215            finalize.recorded_subprocesses.len()
216        ));
217    }
218    if let Some(toolchain_dir) = args.process_wasi.as_ref() {
219        outcome.stderr.push_str(&format!(
220            "[testbench] subprocess invocations resolved against WASI toolchain at {toolchain_dir}.\n"
221        ));
222    }
223    if let Some(tape) = finalize.tape.as_ref() {
224        outcome.stderr.push_str(&format!(
225            "[testbench] emitted unified tape with {} record(s) to {}.\n",
226            tape.records,
227            tape.path.display(),
228        ));
229    }
230    for leak in &finalize.clock_leaks {
231        outcome.stderr.push_str(&format!(
232            "[testbench] clock leak: {} (count={})\n",
233            leak.capability_id, leak.count,
234        ));
235    }
236    outcome
237}
238
239async fn replay_args(args: TestBenchReplayArgs) -> RunOutcome {
240    // Load + pre-validate annotations before running so a malformed
241    // sidecar fails fast and the run output stays focused on the script.
242    let annotations_loaded = match args.annotations.as_deref() {
243        None => None,
244        Some(path) => match AnnotationTape::load(Path::new(path)) {
245            Ok(tape) => Some((path.to_string(), tape)),
246            Err(error) => {
247                return error_outcome(format!("load annotations {path}: {error}"));
248            }
249        },
250    };
251
252    // Surfacing annotations during replay requires the emitted tape so
253    // we can resolve `event_id` → record. When the caller did not ask
254    // for `--emit-tape`, allocate a temp file and persist the tape
255    // there for the duration of the call.
256    let tape_temp = if annotations_loaded.is_some() && args.emit_tape.is_none() {
257        match tempfile::tempdir() {
258            Ok(dir) => Some(dir),
259            Err(error) => return error_outcome(format!("tempdir for replay tape: {error}")),
260        }
261    } else {
262        None
263    };
264    let emit_tape_path = match (&args.emit_tape, tape_temp.as_ref()) {
265        (Some(path), _) => Some(path.clone()),
266        (None, Some(dir)) => Some(dir.path().join("run.tape").to_string_lossy().into_owned()),
267        (None, None) => None,
268    };
269
270    let derived = TestBenchRunArgs {
271        file: args.file.clone(),
272        start_at_ms: args.start_at_ms,
273        clock: "paused".to_string(),
274        llm_fixture: args.llm_fixture.clone(),
275        llm_record: None,
276        hypothesis_scenario: None,
277        fs_overlay: args.fs_overlay.clone(),
278        process_replay: Some(args.process_tape.clone()),
279        process_record: None,
280        process_wasi: None,
281        network: "deny".to_string(),
282        allow_host: Vec::new(),
283        emit_diff: None,
284        emit_tape: emit_tape_path.clone(),
285        runtime: "paused-tokio".to_string(),
286        argv: args.argv.clone(),
287    };
288    let mut outcome = run_args(derived).await;
289
290    if let (Some((annotations_path, annotations)), Some(tape_path)) =
291        (annotations_loaded, emit_tape_path)
292    {
293        match EventTape::load(Path::new(&tape_path)) {
294            Ok(tape) => {
295                let report = validate_against_tape(&annotations, &tape);
296                outcome.stderr.push_str(&render_annotations_block(
297                    &annotations_path,
298                    &annotations,
299                    &tape,
300                ));
301                if !report.is_ok() {
302                    outcome.stderr.push_str(&format!(
303                        "[testbench] annotations validation failed with {} problem(s); see `harn test-bench validate-annotations` for the structured report.\n",
304                        report.problems.len()
305                    ));
306                    outcome.exit_code = outcome.exit_code.max(2);
307                }
308            }
309            Err(error) => {
310                outcome.stderr.push_str(&format!(
311                    "warning: failed to load tape for annotation surfacing: {error}\n"
312                ));
313            }
314        }
315    }
316    outcome
317}
318
319/// Render a "[annotations]" stderr block grouping every annotation by
320/// the tape event it targets. Output is deterministic (sorted by `seq`)
321/// so it diffs cleanly across reruns.
322fn render_annotations_block(
323    annotations_path: &str,
324    annotations: &AnnotationTape,
325    tape: &EventTape,
326) -> String {
327    let mut out = String::new();
328    out.push_str(&format!(
329        "[annotations] loaded {} annotation(s) from {annotations_path}\n",
330        annotations.annotations.len()
331    ));
332    let mut sorted_records: Vec<_> = tape.records.iter().collect();
333    sorted_records.sort_by_key(|record| record.seq);
334    for record in sorted_records {
335        let matches = annotations_for_record(annotations, record);
336        if matches.is_empty() {
337            continue;
338        }
339        out.push_str(&format!(
340            "  event seq={} virtual_time_ms={} kind={}\n",
341            record.seq,
342            record.virtual_time_ms,
343            record.kind.label(),
344        ));
345        for annotation in matches {
346            let label = annotation.kind.as_str();
347            let evidence = annotation
348                .evidence
349                .as_deref()
350                .unwrap_or("(no evidence)")
351                .lines()
352                .next()
353                .unwrap_or("(no evidence)");
354            let id = if annotation.id.is_empty() {
355                "(no id)".to_string()
356            } else {
357                annotation.id.clone()
358            };
359            out.push_str(&format!("    [{label}] {id}: {evidence}\n"));
360        }
361    }
362    out
363}
364
365fn validate_annotations_args(args: TestBenchValidateAnnotationsArgs) -> RunOutcome {
366    let tape = match EventTape::load(Path::new(&args.tape)) {
367        Ok(tape) => tape,
368        Err(error) => return error_outcome(format!("load tape {}: {error}", args.tape)),
369    };
370    let annotations = match AnnotationTape::load(Path::new(&args.annotations)) {
371        Ok(tape) => tape,
372        Err(error) => {
373            return error_outcome(format!("load annotations {}: {error}", args.annotations));
374        }
375    };
376    let report = validate_against_tape(&annotations, &tape);
377    let json = match serde_json::to_string_pretty(&report) {
378        Ok(json) => json,
379        Err(error) => return error_outcome(format!("serialize validation report: {error}")),
380    };
381    let mut outcome = RunOutcome::default();
382    if let Some(path) = args.report.as_deref() {
383        if let Err(error) = persist_text(&json, Path::new(path)) {
384            return error_outcome(format!("write validation report: {error}"));
385        }
386        outcome.stderr.push_str(&format!(
387            "[testbench] annotations validation: checked={} problems={} ({})\n",
388            report.annotations_checked,
389            report.problems.len(),
390            path,
391        ));
392    } else {
393        outcome.stdout.push_str(&json);
394        outcome.stdout.push('\n');
395    }
396    if !report.is_ok() {
397        outcome.exit_code = 2;
398    }
399    outcome
400}
401
402fn export_annotations_args(args: TestBenchExportAnnotationsArgs) -> RunOutcome {
403    let annotations = match AnnotationTape::load(Path::new(&args.annotations)) {
404        Ok(tape) => tape,
405        Err(error) => {
406            return error_outcome(format!("load annotations {}: {error}", args.annotations));
407        }
408    };
409
410    let kinds: Vec<AnnotationKind> = if args.kind.is_empty() {
411        Vec::new()
412    } else {
413        let mut parsed = Vec::with_capacity(args.kind.len());
414        for raw in &args.kind {
415            match AnnotationKind::parse_cli(raw) {
416                Ok(kind) => parsed.push(kind),
417                Err(error) => return error_outcome(error),
418            }
419        }
420        parsed
421    };
422
423    let selected: Vec<_> = annotations
424        .annotations
425        .iter()
426        .filter(|annotation| kinds.is_empty() || kinds.contains(&annotation.kind))
427        .collect();
428
429    let body = match args.format.as_str() {
430        "jsonl" | "" => {
431            let mut out = String::new();
432            for annotation in &selected {
433                match serde_json::to_string(annotation) {
434                    Ok(line) => {
435                        out.push_str(&line);
436                        out.push('\n');
437                    }
438                    Err(error) => {
439                        return error_outcome(format!("serialize annotation: {error}"));
440                    }
441                }
442            }
443            out
444        }
445        "friction" => {
446            let mut out = String::new();
447            for annotation in &selected {
448                if let Some(event) = harn_vm::testbench::annotations::annotation_to_friction_event(
449                    annotation,
450                    &annotations.header,
451                ) {
452                    match serde_json::to_string(&event) {
453                        Ok(line) => {
454                            out.push_str(&line);
455                            out.push('\n');
456                        }
457                        Err(error) => {
458                            return error_outcome(format!("serialize friction event: {error}"));
459                        }
460                    }
461                }
462            }
463            out
464        }
465        other => {
466            return error_outcome(format!(
467                "--format must be `jsonl` or `friction`, got `{other}`"
468            ));
469        }
470    };
471
472    let mut outcome = RunOutcome::default();
473    if let Some(path) = args.output.as_deref() {
474        if let Err(error) = persist_text(&body, Path::new(path)) {
475            return error_outcome(format!("write export: {error}"));
476        }
477        outcome.stderr.push_str(&format!(
478            "[testbench] exported {} annotation(s) to {} (format={})\n",
479            selected.len(),
480            path,
481            args.format,
482        ));
483    } else {
484        outcome.stdout.push_str(&body);
485    }
486    outcome
487}
488
489async fn fidelity_args(args: TestBenchFidelityArgs) -> RunOutcome {
490    let mode = match FidelityMode::parse(&args.mode) {
491        Ok(mode) => mode,
492        Err(error) => return error_outcome(error),
493    };
494
495    let (recorded_path, replay_source) = match (&args.against, &args.replay) {
496        (Some(recorded), _) => (recorded.clone(), ReplaySource::ReRun),
497        (None, Some(replay)) => (args.primary.clone(), ReplaySource::Tape(replay.clone())),
498        (None, None) => {
499            return error_outcome(
500                "expected either two tape paths or `--against <tape> <script>`".to_string(),
501            )
502        }
503    };
504
505    let recorded = match EventTape::load(Path::new(&recorded_path)) {
506        Ok(tape) => tape,
507        Err(error) => return error_outcome(format!("load recorded tape: {error}")),
508    };
509
510    let (replay, mut prelude) = match replay_source {
511        ReplaySource::ReRun => {
512            let temp = match tempfile::tempdir() {
513                Ok(dir) => dir,
514                Err(error) => return error_outcome(format!("create temp tape dir: {error}")),
515            };
516            let replay_tape_path = temp.path().join("replay.tape");
517            let start_at = args
518                .start_at_ms
519                .or(recorded.header.started_at_unix_ms)
520                .unwrap_or(DEFAULT_TESTBENCH_START_MS);
521            let derived = TestBenchRunArgs {
522                file: args.primary.clone(),
523                start_at_ms: Some(start_at),
524                clock: "paused".to_string(),
525                llm_fixture: None,
526                llm_record: None,
527                hypothesis_scenario: None,
528                fs_overlay: args.fs_overlay.clone(),
529                process_replay: None,
530                process_record: None,
531                process_wasi: None,
532                network: "deny".to_string(),
533                allow_host: Vec::new(),
534                emit_diff: None,
535                emit_tape: Some(replay_tape_path.to_string_lossy().into_owned()),
536                runtime: "paused-tokio".to_string(),
537                argv: args.argv.clone(),
538            };
539            let inner = run_args(derived).await;
540            match EventTape::load(&replay_tape_path) {
541                Ok(tape) => (tape, inner),
542                Err(error) => return append_error(inner, format!("load replay tape: {error}")),
543            }
544        }
545        ReplaySource::Tape(path) => match EventTape::load(Path::new(&path)) {
546            Ok(tape) => (tape, RunOutcome::default()),
547            Err(error) => return error_outcome(format!("load replay tape: {error}")),
548        },
549    };
550
551    let report = compare(&recorded, &replay, mode);
552    let json = match serde_json::to_string_pretty(&report) {
553        Ok(json) => json,
554        Err(error) => return append_error(prelude, format!("serialize fidelity report: {error}")),
555    };
556    if let Some(path) = args.report.as_ref() {
557        if let Err(error) = persist_fidelity_report(&json, Path::new(path)) {
558            return append_error(prelude, format!("write fidelity report: {error}"));
559        }
560        prelude.stderr.push_str(&format!(
561            "[testbench] fidelity report written to {path} (mode={:?}, score={:.4}, divergences={})\n",
562            report.mode,
563            report.score,
564            report.divergences.len(),
565        ));
566    } else {
567        prelude.stdout.push_str(&json);
568        prelude.stdout.push('\n');
569    }
570    if !report.divergences.is_empty() {
571        prelude.exit_code = prelude.exit_code.max(report_exit_code(&report));
572    }
573    prelude
574}
575
576fn report_exit_code(report: &FidelityReport) -> i32 {
577    // Exit non-zero on any divergence so CI gates can rely on the
578    // status code without parsing JSON.
579    if report.divergences.is_empty() {
580        0
581    } else {
582        2
583    }
584}
585
586fn persist_fidelity_report(json: &str, path: &Path) -> Result<(), String> {
587    persist_text(json, path)
588}
589
590fn persist_text(body: &str, path: &Path) -> Result<(), String> {
591    if let Some(parent) = path.parent() {
592        if !parent.as_os_str().is_empty() {
593            fs::create_dir_all(parent)
594                .map_err(|error| format!("mkdir {}: {error}", parent.display()))?;
595        }
596    }
597    fs::write(path, body).map_err(|error| format!("write {}: {error}", path.display()))
598}
599
600fn build_testbench(args: &TestBenchRunArgs) -> Result<Testbench, String> {
601    let clock = match args.clock.as_str() {
602        "paused" => ClockConfig::Paused {
603            starting_at_ms: args.start_at_ms.unwrap_or(DEFAULT_TESTBENCH_START_MS),
604        },
605        "real" => ClockConfig::Real,
606        other => return Err(format!("--clock must be `paused` or `real`, got `{other}`")),
607    };
608
609    let llm = if let Some(fixture) = &args.llm_fixture {
610        LlmConfig::Replay {
611            fixture: PathBuf::from(fixture),
612        }
613    } else if let Some(record) = &args.llm_record {
614        LlmConfig::Record {
615            fixture: PathBuf::from(record),
616        }
617    } else {
618        LlmConfig::Real
619    };
620
621    let filesystem = match &args.fs_overlay {
622        None => FilesystemConfig::Real,
623        Some(root) => FilesystemConfig::Overlay {
624            worktree: PathBuf::from(root),
625        },
626    };
627
628    let subprocess = if let Some(record) = &args.process_record {
629        SubprocessConfig::Record {
630            tape: PathBuf::from(record),
631        }
632    } else if let Some(replay) = &args.process_replay {
633        SubprocessConfig::Replay {
634            tape: PathBuf::from(replay),
635        }
636    } else if let Some(toolchain) = &args.process_wasi {
637        SubprocessConfig::WasiToolchain {
638            dir: PathBuf::from(toolchain),
639        }
640    } else {
641        SubprocessConfig::Real
642    };
643
644    let network = match args.network.as_str() {
645        "deny" => NetworkConfig::DenyByDefault {
646            allow: args.allow_host.clone(),
647        },
648        "real" => NetworkConfig::Real,
649        other => return Err(format!("--network must be `deny` or `real`, got `{other}`")),
650    };
651
652    let tape = match &args.emit_tape {
653        None => TapeConfig::Off,
654        Some(path) => TapeConfig::Emit {
655            path: PathBuf::from(path),
656            argv: args.argv.clone(),
657            script_path: Some(args.file.clone()),
658        },
659    };
660
661    let hypothesis = args
662        .hypothesis_scenario
663        .as_deref()
664        .map(harn_vm::testbench::hypothesis::HypothesisScenario::parse)
665        .transpose()?;
666
667    Ok(Testbench {
668        clock,
669        llm,
670        filesystem,
671        subprocess,
672        network,
673        tape,
674        hypothesis,
675    })
676}
677
678fn persist_overlay_diff(diff: &[DiffEntry], path: &PathBuf) -> Result<(), String> {
679    if let Some(parent) = path.parent() {
680        if !parent.as_os_str().is_empty() {
681            fs::create_dir_all(parent)
682                .map_err(|err| format!("mkdir {}: {err}", parent.display()))?;
683        }
684    }
685    let body = render_unified_diff(diff);
686    fs::write(path, body).map_err(|err| format!("write {}: {err}", path.display()))
687}
688
689fn render_diff_summary(diff: &[DiffEntry]) -> String {
690    let mut out = String::new();
691    out.push_str(&format!(
692        "[testbench] overlay fs diff: {} change(s)\n",
693        diff.len()
694    ));
695    for entry in diff {
696        let label = match &entry.kind {
697            DiffKind::Added { .. } => "added",
698            DiffKind::Modified { .. } => "modified",
699            DiffKind::Deleted => "deleted",
700        };
701        out.push_str(&format!("  {label} {}\n", entry.path.display()));
702    }
703    out
704}
705
706fn error_outcome(message: String) -> RunOutcome {
707    RunOutcome {
708        stdout: String::new(),
709        stderr: format!("error: {message}\n"),
710        exit_code: 1,
711    }
712}
713
714fn append_error(mut outcome: RunOutcome, message: String) -> RunOutcome {
715    outcome.stderr.push_str(&format!("error: {message}\n"));
716    outcome.exit_code = outcome.exit_code.max(1);
717    outcome
718}
719
720fn flush_outcome(outcome: RunOutcome) {
721    use std::io::Write;
722    let _ = std::io::stderr().write_all(outcome.stderr.as_bytes());
723    let _ = std::io::stdout().write_all(outcome.stdout.as_bytes());
724    if outcome.exit_code != 0 {
725        process::exit(outcome.exit_code);
726    }
727}