Skip to main content

harn_cli/
test_runner.rs

1use std::collections::{BTreeMap, HashSet};
2use std::fs;
3use std::future::Future;
4use std::path::{Path, PathBuf};
5use std::pin::Pin;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Condvar, Mutex};
8use std::thread;
9use std::time::Instant;
10
11use crate::env_guard::ScopedEnvVar;
12use crate::test_timing::DurationSummary;
13use crate::CLI_RUNTIME_STACK_SIZE;
14use harn_lexer::Lexer;
15use harn_parser::const_eval::{const_eval, ConstEnv, ConstValue};
16use harn_parser::{Attribute, Node, Parser, SNode, TypedParam};
17use harn_vm::VmValue;
18
19mod execution;
20mod reporting;
21mod session;
22mod skill_context;
23#[cfg(test)]
24mod tests;
25
26use execution::execute_case;
27pub use reporting::{
28    AggregateTimings, PhaseTimings, TestPhase, TestResult, TestSummary, TestTimeout,
29};
30pub use session::{TestRunSession, TestRunSessionStats};
31use skill_context::PreparedSkillContexts;
32
33#[derive(Clone, Debug)]
34pub enum TestRunEvent {
35    SuiteDiscovered {
36        total_tests: usize,
37        total_files: usize,
38        parallel: bool,
39        workers: usize,
40    },
41    LargeSequentialSuite {
42        total_tests: usize,
43        total_files: usize,
44    },
45    TestStarted {
46        name: String,
47        file: String,
48        test_index: usize,
49        total_tests: usize,
50    },
51    TestFinished(TestResult),
52}
53
54pub type TestRunProgress = Arc<dyn Fn(TestRunEvent) + Send + Sync>;
55
56const LARGE_SEQUENTIAL_TEST_THRESHOLD: usize = 50;
57const LARGE_SEQUENTIAL_FILE_THRESHOLD: usize = 10;
58const DEFAULT_PARALLEL_JOBS_CAP: usize = 8;
59const TIMINGS_CACHE_RELATIVE_PATH: &str = ".harn/test-timings.json";
60const HARN_TEST_JOBS_ENV: &str = "HARN_TEST_JOBS";
61const HARN_TEST_MAX_MS_ENV: &str = "HARN_TEST_MAX_MS";
62const HARN_TEST_MAX_EXECUTE_MS_ENV: &str = "HARN_TEST_MAX_EXECUTE_MS";
63
64/// Per-worker memory budget (MiB) used to cap *auto-detected* parallelism on
65/// memory-constrained or oversubscribed hosts. Overridable via
66/// `HARN_TEST_WORKER_MEMORY_MB`. A worker runs a full VM and may drive nested
67/// agent loops, so this is a deliberately conservative estimate. The cap only
68/// ever *lowers* the core-based default — it never raises it, and an explicit
69/// `--jobs` / `HARN_TEST_JOBS` always wins.
70const DEFAULT_WORKER_MEMORY_MB: u64 = 1024;
71const HARN_TEST_WORKER_MEMORY_MB_ENV: &str = "HARN_TEST_WORKER_MEMORY_MB";
72
73/// Memory (MiB) held back for the OS, the CI runner agent, and any co-tenant
74/// job, so an auto-sized suite cannot consume the last scrap of RAM and starve
75/// the runner's heartbeat. This is the failure mode behind the self-hosted
76/// "The operation was canceled" runner-loss cancellations: two runner agents
77/// share one box, two heavy jobs overcommit RAM + swap, and the kernel never
78/// fires the OOM-killer — instead a starved runner agent stops phoning home
79/// and the control plane declares the job lost.
80const RESERVED_SYSTEM_MEMORY_MB: u64 = 1024;
81
82/// Options that shape how a user-test suite is discovered and executed.
83///
84/// Held separately from the positional path so call sites (one-shot run,
85/// `--watch`, persona doctor) can share the same scheduler without
86/// keyword-argument explosion at the call sites.
87#[derive(Clone, Default)]
88pub struct RunOptions {
89    pub filter: Option<String>,
90    pub timeout_ms: u64,
91    /// Optional hard budget for a passing test's total wall-clock duration.
92    /// Exceeding it converts the result to a failure without changing the
93    /// actual per-test timeout behavior.
94    pub max_test_ms: Option<u64>,
95    /// Optional hard budget for a passing test's `vm.execute` phase. This
96    /// catches tests whose assertions accidentally drive full agent loops or
97    /// other slow runtime behavior while ignoring setup/compile cold-start.
98    pub max_execute_ms: Option<u64>,
99    /// When false, the scheduler runs with a single worker, preserving the
100    /// historical "everything sequential" semantics that `harn test`
101    /// defaulted to before `--parallel` was introduced.
102    pub parallel: bool,
103    /// Stop claiming new cases after the first discovery or execution failure.
104    /// Cases already running in parallel finish and retain their results.
105    pub fail_fast: bool,
106    /// Explicit worker limit (`-j`/`--jobs`). `None` defaults to the
107    /// available parallelism, capped by a small constant when running in
108    /// parallel mode. Ignored when `parallel = false`.
109    pub jobs: Option<usize>,
110    /// Optional 1-based shard selection for CI matrix fan-out. Sharding
111    /// happens after discovery/filtering and before execution.
112    pub shard: Option<TestShard>,
113    pub cli_skill_dirs: Vec<PathBuf>,
114    /// Optional progress callback. When set, the runner emits events as
115    /// the suite progresses; consumers (CLI, dev mode) render output.
116    pub progress: Option<TestRunProgress>,
117    /// Emit per-test phase timings (setup / compile / execute /
118    /// teardown) to stderr. Also honored via `HARN_TEST_DIAGNOSE=1` so
119    /// users can flip the flag without restarting their shell.
120    pub diagnose: bool,
121}
122
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub struct TestShard {
125    index: usize,
126    total: usize,
127}
128
129impl TestShard {
130    pub fn new(index: usize, total: usize) -> Result<Self, String> {
131        if total == 0 {
132            return Err("test shard total must be at least 1".to_string());
133        }
134        if index == 0 {
135            return Err("test shard index must be at least 1".to_string());
136        }
137        if index > total {
138            return Err(format!(
139                "test shard index {index} exceeds shard total {total}"
140            ));
141        }
142        Ok(Self { index, total })
143    }
144
145    pub fn index(self) -> usize {
146        self.index
147    }
148
149    pub fn total(self) -> usize {
150        self.total
151    }
152}
153
154impl RunOptions {
155    pub fn new(timeout_ms: u64) -> Self {
156        Self {
157            timeout_ms,
158            ..Default::default()
159        }
160    }
161}
162
163/// A single executable test discovered during scan. Workers compile and
164/// run each case in isolation; the parsed program is shared by `Arc` so
165/// large suites parse exactly once.
166#[derive(Clone)]
167struct TestCase {
168    file: PathBuf,
169    name: String,
170    pipeline_name: String,
171    source: Arc<String>,
172    program: Arc<Vec<SNode>>,
173    /// Optional serial group — tests with the same group never run
174    /// concurrently with each other, even if workers are idle. Used for
175    /// shared fixtures.
176    serial_group: Option<String>,
177    /// Number of workers this test reserves while running. Capped at the
178    /// pool size during discovery so heavy tests still get scheduled.
179    weight: usize,
180    /// Parameter bindings supplied by one `@test(cases: [...])` row.
181    bindings: Vec<(String, VmValue)>,
182}
183
184fn canonicalize_existing_path(path: &Path) -> PathBuf {
185    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
186}
187
188fn test_execution_cwd() -> PathBuf {
189    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
190}
191
192fn emit_progress(progress: &Option<TestRunProgress>, event: TestRunEvent) {
193    if let Some(callback) = progress {
194        callback(event);
195    }
196}
197
198fn should_warn_large_sequential_suite(total_tests: usize, total_files: usize) -> bool {
199    total_tests >= LARGE_SEQUENTIAL_TEST_THRESHOLD || total_files >= LARGE_SEQUENTIAL_FILE_THRESHOLD
200}
201
202/// Discover and run tests in a file or directory.
203pub async fn run_tests(
204    path: &Path,
205    filter: Option<&str>,
206    timeout_ms: u64,
207    parallel: bool,
208    cli_skill_dirs: &[PathBuf],
209) -> TestSummary {
210    let options = RunOptions {
211        filter: filter.map(str::to_owned),
212        timeout_ms,
213        max_test_ms: test_budget_ms_via_env(HARN_TEST_MAX_MS_ENV),
214        max_execute_ms: test_budget_ms_via_env(HARN_TEST_MAX_EXECUTE_MS_ENV),
215        parallel,
216        fail_fast: false,
217        jobs: None,
218        shard: None,
219        cli_skill_dirs: cli_skill_dirs.to_vec(),
220        progress: None,
221        diagnose: diagnose_enabled_via_env(),
222    };
223    run_tests_with_options(path, &options).await
224}
225
226/// Backwards-compatible progress-emitting entry point.
227pub async fn run_tests_with_progress(
228    path: &Path,
229    filter: Option<&str>,
230    timeout_ms: u64,
231    parallel: bool,
232    cli_skill_dirs: &[PathBuf],
233    progress: Option<TestRunProgress>,
234) -> TestSummary {
235    let options = RunOptions {
236        filter: filter.map(str::to_owned),
237        timeout_ms,
238        max_test_ms: test_budget_ms_via_env(HARN_TEST_MAX_MS_ENV),
239        max_execute_ms: test_budget_ms_via_env(HARN_TEST_MAX_EXECUTE_MS_ENV),
240        parallel,
241        fail_fast: false,
242        jobs: None,
243        shard: None,
244        cli_skill_dirs: cli_skill_dirs.to_vec(),
245        progress,
246        diagnose: diagnose_enabled_via_env(),
247    };
248    run_tests_with_options(path, &options).await
249}
250
251fn diagnose_enabled_via_env() -> bool {
252    let Ok(raw) = std::env::var("HARN_TEST_DIAGNOSE") else {
253        return false;
254    };
255    matches!(
256        raw.to_ascii_lowercase().as_str(),
257        "1" | "true" | "yes" | "on"
258    )
259}
260
261fn test_budget_ms_via_env(name: &str) -> Option<u64> {
262    std::env::var(name)
263        .ok()
264        .and_then(|raw| raw.trim().parse::<u64>().ok())
265        .filter(|&value| value >= 1)
266}
267
268/// Run tests with full control over scheduling, worker count, and
269/// progress reporting. Workers and scheduling mode are reported via
270/// `TestRunEvent::SuiteDiscovered` so consumers can render their own
271/// banner instead of the runner printing to stdout directly.
272pub async fn run_tests_with_options(path: &Path, options: &RunOptions) -> TestSummary {
273    run_tests_with_session(path, options, &TestRunSession::default()).await
274}
275
276/// Run tests while retaining immutable prepared-module artifacts in `session`.
277///
278/// Callers that execute only once should use [`run_tests_with_options`]. Watch
279/// mode and long-lived hosts should retain one session for their desired cache
280/// lifetime and inspect [`TestRunSession::stats`] for reuse receipts.
281pub fn run_tests_with_session<'a>(
282    path: &'a Path,
283    options: &'a RunOptions,
284    session: &'a TestRunSession,
285) -> Pin<Box<dyn Future<Output = TestSummary> + 'a>> {
286    Box::pin(run_tests_with_session_impl(path, options, session))
287}
288
289async fn run_tests_with_session_impl(
290    path: &Path,
291    options: &RunOptions,
292    session: &TestRunSession,
293) -> TestSummary {
294    // Default LLM provider to "mock" in test mode unless caller overrides.
295    let _default_llm_provider = ScopedEnvVar::set_if_unset("HARN_LLM_PROVIDER", "mock");
296    let _disable_llm_calls = ScopedEnvVar::set(harn_vm::llm::LLM_CALLS_DISABLED_ENV, "1");
297
298    let start = Instant::now();
299
300    let collection_start = Instant::now();
301    let canonical_target = canonicalize_existing_path(path);
302    let files = if canonical_target.is_dir() {
303        discover_test_files(&canonical_target)
304    } else {
305        vec![canonical_target.clone()]
306    };
307
308    let workers = resolve_workers(options);
309    let timings_path = timings_cache_path(&canonical_target);
310    let timings = timings_path
311        .as_deref()
312        .map(load_timings_cache)
313        .unwrap_or_default();
314
315    let mut discovery = discover_test_cases(&files, options.filter.as_deref(), workers);
316    if let Some(shard) = options.shard {
317        discovery.cases = select_shard_cases(discovery.cases, &timings, shard);
318        if shard.index() > 1 {
319            discovery.discovery_errors.clear();
320        }
321    }
322    let skill_contexts = PreparedSkillContexts::prepare(&discovery.cases, &options.cli_skill_dirs);
323    let collection_ms = collection_start.elapsed().as_millis() as u64;
324    let selected_files_with_tests = if options.shard.is_some() {
325        count_files_with_cases(&discovery.cases)
326    } else {
327        discovery.files_with_tests
328    };
329
330    emit_progress(
331        &options.progress,
332        TestRunEvent::SuiteDiscovered {
333            total_tests: discovery.cases.len(),
334            total_files: selected_files_with_tests,
335            parallel: options.parallel,
336            workers,
337        },
338    );
339    if workers == 1
340        && should_warn_large_sequential_suite(discovery.cases.len(), selected_files_with_tests)
341    {
342        emit_progress(
343            &options.progress,
344            TestRunEvent::LargeSequentialSuite {
345                total_tests: discovery.cases.len(),
346                total_files: selected_files_with_tests,
347            },
348        );
349    }
350
351    let mut cases = discovery.cases;
352    sort_cases_longest_first(&mut cases, &timings);
353
354    let mut all_results = discovery.discovery_errors;
355    let total_tests = cases.len();
356    let execution = if !options.fail_fast || all_results.is_empty() {
357        execute_cases(
358            cases,
359            workers,
360            options,
361            total_tests,
362            session,
363            skill_contexts,
364        )
365        .await
366    } else {
367        CaseExecutionResults::default()
368    };
369
370    let timing = DurationSummary::from_samples(
371        &execution
372            .cases
373            .iter()
374            .map(|result| result.duration_ms)
375            .collect::<Vec<_>>(),
376    );
377    if let Some(path) = timings_path.as_deref() {
378        update_timings_cache(path, timings, &execution.cases);
379    }
380    all_results.extend(execution.cases);
381    all_results.extend(execution.infrastructure_errors);
382    let total = all_results.len();
383    let passed = all_results.iter().filter(|result| result.passed).count();
384    let failed = total - passed;
385    let aggregate = AggregateTimings::from_results(collection_ms, &all_results);
386
387    TestSummary {
388        results: all_results,
389        passed,
390        failed,
391        total,
392        duration_ms: start.elapsed().as_millis() as u64,
393        timing,
394        aggregate,
395    }
396}
397
398/// Backwards-compatible single-file API used by `harn dev`.
399///
400/// Runs every test in one file on the current thread. The new scheduler
401/// uses per-test worker threads, but `harn dev` re-runs a single module
402/// in the foreground after each rebuild — the queueing machinery would
403/// add latency without parallelism to gain back, so we keep this path
404/// minimal.
405pub async fn run_test_file(
406    path: &Path,
407    filter: Option<&str>,
408    timeout_ms: u64,
409    execution_cwd: Option<&Path>,
410    cli_skill_dirs: &[PathBuf],
411) -> Result<Vec<TestResult>, String> {
412    run_test_file_with_session(
413        path,
414        filter,
415        timeout_ms,
416        execution_cwd,
417        cli_skill_dirs,
418        &TestRunSession::default(),
419    )
420    .await
421}
422
423/// Single-file test API that retains prepared artifacts across invocations.
424pub fn run_test_file_with_session<'a>(
425    path: &'a Path,
426    filter: Option<&'a str>,
427    timeout_ms: u64,
428    execution_cwd: Option<&'a Path>,
429    cli_skill_dirs: &'a [PathBuf],
430    session: &'a TestRunSession,
431) -> Pin<Box<dyn Future<Output = Result<Vec<TestResult>, String>> + 'a>> {
432    Box::pin(run_test_file_with_session_impl(
433        path,
434        filter,
435        timeout_ms,
436        execution_cwd,
437        cli_skill_dirs,
438        session,
439    ))
440}
441
442async fn run_test_file_with_session_impl(
443    path: &Path,
444    filter: Option<&str>,
445    timeout_ms: u64,
446    execution_cwd: Option<&Path>,
447    cli_skill_dirs: &[PathBuf],
448    session: &TestRunSession,
449) -> Result<Vec<TestResult>, String> {
450    let source =
451        fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
452    let program = parse_program(&source)?;
453    let source = Arc::new(source);
454    let program = Arc::new(program);
455
456    let cases = extract_cases_from_program(path, &source, &program, filter, usize::MAX)?;
457    let skill_contexts = PreparedSkillContexts::prepare(&cases, cli_skill_dirs);
458
459    let mut results = Vec::with_capacity(cases.len());
460    let execution_cwd = execution_cwd
461        .map(Path::to_path_buf)
462        .unwrap_or_else(test_execution_cwd);
463    let prepared_module_cache = session.prepared_module_cache(0);
464    for case in cases {
465        let loaded_skills = skill_contexts.for_case(&case);
466        results.push(
467            execute_case(
468                &case,
469                &execution_cwd,
470                timeout_ms,
471                loaded_skills,
472                &prepared_module_cache,
473                session.stdio_available(),
474            )
475            .await,
476        );
477    }
478    Ok(results)
479}
480
481fn resolve_workers(options: &RunOptions) -> usize {
482    if !options.parallel {
483        return 1;
484    }
485    if let Some(jobs) = options.jobs {
486        return jobs.max(1);
487    }
488    if let Ok(raw) = std::env::var(HARN_TEST_JOBS_ENV) {
489        if let Ok(parsed) = raw.trim().parse::<usize>() {
490            if parsed >= 1 {
491                return parsed;
492            }
493        }
494    }
495    let detected = thread::available_parallelism()
496        .map(|n| n.get())
497        .unwrap_or(1);
498    let core_cap = detected.clamp(1, DEFAULT_PARALLEL_JOBS_CAP);
499    apply_memory_cap(core_cap)
500}
501
502/// Lower `core_cap` to what currently-available system memory can hold, so an
503/// auto-sized parallel suite backs off on a loaded or small host instead of
504/// overcommitting RAM. Returns `core_cap` unchanged when memory is plentiful
505/// or cannot be measured. Emits a one-line notice when the cap bites so CI
506/// logs explain the reduced parallelism.
507fn apply_memory_cap(core_cap: usize) -> usize {
508    let Some(available_mb) = available_memory_mb() else {
509        return core_cap;
510    };
511    let budget = per_worker_memory_mb();
512    let mem_cap = memory_worker_cap(available_mb, budget, RESERVED_SYSTEM_MEMORY_MB);
513    if mem_cap < core_cap {
514        eprintln!(
515            "harn test: capping workers {core_cap} -> {mem_cap} \
516             (~{available_mb} MiB available, {budget} MiB/worker; \
517             override with --jobs / HARN_TEST_JOBS)"
518        );
519        return mem_cap;
520    }
521    core_cap
522}
523
524/// Pure worker-count-from-memory math, factored out so it is unit-testable
525/// without touching the host. Always yields at least one worker.
526fn memory_worker_cap(available_mb: u64, per_worker_mb: u64, reserved_mb: u64) -> usize {
527    let usable = available_mb.saturating_sub(reserved_mb);
528    let per_worker = per_worker_mb.max(1);
529    ((usable / per_worker).max(1)) as usize
530}
531
532/// Per-worker memory budget, honoring the `HARN_TEST_WORKER_MEMORY_MB`
533/// override (values `>= 1`), else [`DEFAULT_WORKER_MEMORY_MB`].
534fn per_worker_memory_mb() -> u64 {
535    std::env::var(HARN_TEST_WORKER_MEMORY_MB_ENV)
536        .ok()
537        .and_then(|raw| raw.trim().parse::<u64>().ok())
538        .filter(|&n| n >= 1)
539        .unwrap_or(DEFAULT_WORKER_MEMORY_MB)
540}
541
542/// Best-effort "memory available for new work" in MiB: the lesser of the
543/// host's available memory and (on Linux) this process's cgroup-v2 headroom.
544///
545/// Host memory comes from `sysinfo`, so it is correct on Linux, macOS, and
546/// Windows. The cgroup min means a container or a systemd-sliced CI runner
547/// sizes to its *slice* rather than the whole host — the key to stopping two
548/// runner agents on one box from each sizing to ~100% and collectively
549/// overcommitting RAM (the "thundering herd" behind the self-hosted
550/// runner-loss cancellations). Returns `None` when nothing can be measured,
551/// leaving the core-based cap in force.
552fn available_memory_mb() -> Option<u64> {
553    let mut sys = sysinfo::System::new();
554    sys.refresh_memory();
555    let host_mb = match sys.available_memory() {
556        0 => None, // unsupported / detection failed — don't over-throttle
557        bytes => Some(bytes / (1024 * 1024)),
558    };
559    match (host_mb, cgroup_v2_headroom_mb()) {
560        (Some(h), Some(c)) => Some(h.min(c)),
561        (Some(h), None) => Some(h),
562        (None, c) => c,
563    }
564}
565
566/// cgroup-v2 memory headroom (MiB) for this process's own cgroup, or `None`
567/// when not on cgroup v2, no limit is set, or the files cannot be read.
568#[cfg(target_os = "linux")]
569fn cgroup_v2_headroom_mb() -> Option<u64> {
570    let dir = own_cgroup_v2_dir()?;
571    let max_raw = fs::read_to_string(dir.join("memory.max")).ok()?;
572    let current_raw = fs::read_to_string(dir.join("memory.current")).ok()?;
573    cgroup_headroom_mb(&max_raw, &current_raw)
574}
575
576#[cfg(not(target_os = "linux"))]
577fn cgroup_v2_headroom_mb() -> Option<u64> {
578    None
579}
580
581/// Resolve this process's own cgroup-v2 directory under `/sys/fs/cgroup` from
582/// the unified-hierarchy line (`0::<path>`) in `/proc/self/cgroup`. A limit
583/// set directly on a systemd service slice or on a container's namespaced
584/// root lives here; ancestor-only limits are not chased (the host min still
585/// backstops those). `None` on cgroup v1 / hybrid (no `0::` line).
586#[cfg(target_os = "linux")]
587fn own_cgroup_v2_dir() -> Option<PathBuf> {
588    let content = fs::read_to_string("/proc/self/cgroup").ok()?;
589    let rel = content
590        .lines()
591        .find_map(|line| line.strip_prefix("0::"))?
592        .trim();
593    let rel = rel.strip_prefix('/').unwrap_or(rel);
594    Some(Path::new("/sys/fs/cgroup").join(rel))
595}
596
597/// Pure headroom math from raw `memory.max` / `memory.current` file contents
598/// (both bytes; `memory.max` may be the literal `"max"` sentinel = unlimited).
599/// `memory.current` counts reclaimable page cache, so the result is a
600/// conservative (under-)estimate of true headroom — the safe direction for
601/// OOM avoidance.
602#[cfg(any(target_os = "linux", test))]
603fn cgroup_headroom_mb(memory_max: &str, memory_current: &str) -> Option<u64> {
604    let max = memory_max.trim();
605    if max == "max" {
606        return None;
607    }
608    let max: u64 = max.parse().ok()?;
609    let current: u64 = memory_current.trim().parse().ok()?;
610    Some(max.saturating_sub(current) / (1024 * 1024))
611}
612
613struct Discovery {
614    cases: Vec<TestCase>,
615    files_with_tests: usize,
616    discovery_errors: Vec<TestResult>,
617}
618
619fn discover_test_cases(files: &[PathBuf], filter: Option<&str>, workers: usize) -> Discovery {
620    let mut cases = Vec::new();
621    let mut files_with_tests = 0usize;
622    let mut discovery_errors = Vec::new();
623
624    for file in files {
625        let source = match fs::read_to_string(file) {
626            Ok(s) => s,
627            Err(e) => {
628                discovery_errors.push(TestResult {
629                    name: "<file error>".to_string(),
630                    file: file.display().to_string(),
631                    passed: false,
632                    error: Some(format!("Failed to read {}: {e}", file.display())),
633                    captured_output: None,
634                    timeout: None,
635                    duration_ms: 0,
636                    phases: None,
637                });
638                continue;
639            }
640        };
641
642        let program = match parse_program(&source) {
643            Ok(p) => p,
644            Err(e) => {
645                discovery_errors.push(TestResult {
646                    name: "<file error>".to_string(),
647                    file: file.display().to_string(),
648                    passed: false,
649                    error: Some(e),
650                    captured_output: None,
651                    timeout: None,
652                    duration_ms: 0,
653                    phases: None,
654                });
655                continue;
656            }
657        };
658
659        let source = Arc::new(source);
660        let program = Arc::new(program);
661        match extract_cases_from_program(file, &source, &program, filter, workers) {
662            Ok(file_cases) => {
663                if !file_cases.is_empty() {
664                    files_with_tests += 1;
665                    cases.extend(file_cases);
666                }
667            }
668            Err(error) => discovery_errors.push(TestResult {
669                name: "<file error>".to_string(),
670                file: file.display().to_string(),
671                passed: false,
672                error: Some(error),
673                captured_output: None,
674                timeout: None,
675                duration_ms: 0,
676                phases: None,
677            }),
678        }
679    }
680
681    Discovery {
682        cases,
683        files_with_tests,
684        discovery_errors,
685    }
686}
687
688fn parse_program(source: &str) -> Result<Vec<SNode>, String> {
689    let mut lexer = Lexer::new(source);
690    let tokens = lexer.tokenize().map_err(|e| format!("{e}"))?;
691    let mut parser = Parser::new(tokens);
692    parser.parse().map_err(|e| format!("{e}"))
693}
694
695fn extract_cases_from_program(
696    file: &Path,
697    source: &Arc<String>,
698    program: &Arc<Vec<SNode>>,
699    filter: Option<&str>,
700    workers: usize,
701) -> Result<Vec<TestCase>, String> {
702    let mut cases = Vec::new();
703    for snode in program.iter() {
704        let Some(meta) = inspect_test_pipeline(snode)? else {
705            continue;
706        };
707        // Cap heavy weight so a single annotated test never deadlocks
708        // when the pool is smaller than the requested concurrency.
709        let weight = meta.weight.min(workers).max(1);
710        if meta.rows.is_empty() {
711            if filter.is_some_and(|pattern| !meta.name.contains(pattern)) {
712                continue;
713            }
714            cases.push(TestCase {
715                file: file.to_path_buf(),
716                name: meta.name.clone(),
717                pipeline_name: meta.name,
718                source: Arc::clone(source),
719                program: Arc::clone(program),
720                serial_group: meta.serial_group,
721                weight,
722                bindings: Vec::new(),
723            });
724        } else {
725            for row in meta.rows {
726                let case_name = format!("{}[{}]", meta.name, row.name);
727                if filter.is_some_and(|pattern| !case_name.contains(pattern)) {
728                    continue;
729                }
730                cases.push(TestCase {
731                    file: file.to_path_buf(),
732                    name: case_name,
733                    pipeline_name: meta.name.clone(),
734                    source: Arc::clone(source),
735                    program: Arc::clone(program),
736                    serial_group: meta.serial_group.clone(),
737                    weight,
738                    bindings: meta.params.iter().cloned().zip(row.args).collect(),
739                });
740            }
741        }
742    }
743    Ok(cases)
744}
745
746struct PipelineMeta {
747    name: String,
748    params: Vec<String>,
749    serial_group: Option<String>,
750    weight: usize,
751    rows: Vec<ParameterizedRow>,
752}
753
754struct ParameterizedRow {
755    name: String,
756    args: Vec<VmValue>,
757}
758
759fn inspect_test_pipeline(snode: &SNode) -> Result<Option<PipelineMeta>, String> {
760    // Pipelines marked `@test`, or named `test_*`, are user tests. The
761    // companion attributes `@serial` and `@heavy` only tune the scheduler
762    // and never make a non-test pipeline discoverable on their own.
763    let (attributes, inner) = match &snode.node {
764        Node::AttributedDecl { attributes, inner } => (attributes.as_slice(), inner.as_ref()),
765        _ => (&[][..], snode),
766    };
767    let (name, params) = match &inner.node {
768        Node::Pipeline { name, params, .. } => (name.clone(), TypedParam::names(params)),
769        _ => return Ok(None),
770    };
771    let has_test_attr = attributes.iter().any(|a| a.name == "test");
772    if !has_test_attr && !name.starts_with("test_") {
773        return Ok(None);
774    }
775    let serial_group = attributes
776        .iter()
777        .find(|a| a.name == "serial")
778        .map(serial_group_for);
779    let weight = attributes
780        .iter()
781        .find(|a| a.name == "heavy")
782        .and_then(heavy_weight_for)
783        .unwrap_or(1);
784    let rows = match attributes.iter().find(|a| a.name == "test") {
785        Some(attribute) => parameterized_rows(attribute, &name, params.len())?,
786        None => Vec::new(),
787    };
788    Ok(Some(PipelineMeta {
789        name,
790        params,
791        serial_group,
792        weight,
793        rows,
794    }))
795}
796
797fn parameterized_rows(
798    attribute: &Attribute,
799    pipeline_name: &str,
800    parameter_count: usize,
801) -> Result<Vec<ParameterizedRow>, String> {
802    let Some(cases) = attribute.named_arg("cases") else {
803        return Ok(Vec::new());
804    };
805    let Node::ListLiteral(items) = &cases.node else {
806        return Err(format!(
807            "@test cases for `{pipeline_name}` must be a list of {{name, args}} rows"
808        ));
809    };
810    if items.is_empty() {
811        return Err(format!(
812            "@test cases for `{pipeline_name}` must not be empty"
813        ));
814    }
815
816    let mut rows = Vec::with_capacity(items.len());
817    let mut names = HashSet::new();
818    for item in items {
819        let Node::DictLiteral(entries) = &item.node else {
820            return Err(format!(
821                "@test case in `{pipeline_name}` must be a {{name, args}} dict"
822            ));
823        };
824        let name_node = dict_entry(entries, "name").ok_or_else(|| {
825            format!("@test case in `{pipeline_name}` is missing string field `name`")
826        })?;
827        let name = match &name_node.node {
828            Node::StringLiteral(value) | Node::RawStringLiteral(value) => value.trim().to_string(),
829            _ => {
830                return Err(format!(
831                    "@test case name in `{pipeline_name}` must be a string literal"
832                ));
833            }
834        };
835        if name.is_empty() || !names.insert(name.clone()) {
836            return Err(format!(
837                "@test case names in `{pipeline_name}` must be non-empty and unique: `{name}`"
838            ));
839        }
840        let args_node = dict_entry(entries, "args").ok_or_else(|| {
841            format!("@test case `{name}` in `{pipeline_name}` is missing list field `args`")
842        })?;
843        let Node::ListLiteral(args) = &args_node.node else {
844            return Err(format!(
845                "@test case `{name}` in `{pipeline_name}` must provide `args` as a list"
846            ));
847        };
848        if args.len() != parameter_count {
849            return Err(format!(
850                "@test case `{name}` in `{pipeline_name}` has {} arguments; expected {parameter_count}",
851                args.len()
852            ));
853        }
854        let args = args
855            .iter()
856            .map(attribute_value)
857            .collect::<Result<Vec<_>, _>>()?;
858        rows.push(ParameterizedRow { name, args });
859    }
860    Ok(rows)
861}
862
863fn dict_entry<'a>(entries: &'a [harn_parser::DictEntry], key: &str) -> Option<&'a SNode> {
864    entries.iter().find_map(|entry| {
865        let matches = match &entry.key.node {
866            Node::Identifier(value) | Node::StringLiteral(value) => value == key,
867            _ => false,
868        };
869        matches.then_some(&entry.value)
870    })
871}
872
873fn attribute_value(node: &SNode) -> Result<VmValue, String> {
874    let value = const_eval(node, &ConstEnv::new())
875        .map_err(|error| format!("@test case arguments must be compile-time values: {error:?}"))?;
876    Ok(const_value_to_vm(value))
877}
878
879fn const_value_to_vm(value: ConstValue) -> VmValue {
880    match value {
881        ConstValue::Int(value) => VmValue::Int(value),
882        ConstValue::Float(value) => VmValue::Float(value),
883        ConstValue::Bool(value) => VmValue::Bool(value),
884        ConstValue::String(value) => VmValue::String(value.into()),
885        ConstValue::Nil => VmValue::Nil,
886        ConstValue::List(items) => {
887            VmValue::List(Arc::new(items.into_iter().map(const_value_to_vm).collect()))
888        }
889        ConstValue::Dict(entries) => VmValue::dict(
890            entries
891                .into_iter()
892                .map(|(key, value)| (key, const_value_to_vm(value)))
893                .collect::<Vec<(String, VmValue)>>(),
894        ),
895    }
896}
897
898fn serial_group_for(attr: &Attribute) -> String {
899    attr.string_arg("group")
900        .unwrap_or_else(|| "__default__".to_string())
901}
902
903fn heavy_weight_for(attr: &Attribute) -> Option<usize> {
904    attr.args
905        .iter()
906        .find(|a| a.name.as_deref() == Some("threads"))
907        .and_then(|a| match &a.value.node {
908            Node::IntLiteral(n) if *n >= 1 => Some(*n as usize),
909            _ => None,
910        })
911}
912
913fn sort_cases_longest_first(cases: &mut [TestCase], timings: &BTreeMap<String, u64>) {
914    // Sort ascending so the slowest tests sit at the tail and get popped
915    // first by workers. New (unmeasured) tests share the bottom of the
916    // queue alongside the fastest known ones — they'll appear in stable
917    // file/name order, and once they get their first timing they'll
918    // float up to where they belong.
919    cases.sort_by(|a, b| {
920        let key_a = timings_key(&a.file, &a.name);
921        let key_b = timings_key(&b.file, &b.name);
922        let dur_a = timings.get(&key_a).copied().unwrap_or(0);
923        let dur_b = timings.get(&key_b).copied().unwrap_or(0);
924        dur_a
925            .cmp(&dur_b)
926            .then_with(|| a.file.cmp(&b.file))
927            .then_with(|| a.name.cmp(&b.name))
928    });
929}
930
931fn select_shard_cases(
932    cases: Vec<TestCase>,
933    timings: &BTreeMap<String, u64>,
934    shard: TestShard,
935) -> Vec<TestCase> {
936    if shard.total() <= 1 {
937        return cases;
938    }
939
940    let mut ranked = cases.into_iter().collect::<Vec<_>>();
941    ranked.sort_by(|a, b| {
942        estimated_case_cost_ms(b, timings)
943            .cmp(&estimated_case_cost_ms(a, timings))
944            .then_with(|| a.file.cmp(&b.file))
945            .then_with(|| a.name.cmp(&b.name))
946    });
947
948    let mut buckets = (0..shard.total()).map(|_| Vec::new()).collect::<Vec<_>>();
949    let mut costs = vec![0u64; shard.total()];
950    let mut counts = vec![0usize; shard.total()];
951
952    for case in ranked {
953        let bucket_index = (0..shard.total())
954            .min_by_key(|&index| (costs[index], counts[index], index))
955            .unwrap_or(0);
956        costs[bucket_index] =
957            costs[bucket_index].saturating_add(estimated_case_cost_ms(&case, timings));
958        counts[bucket_index] += 1;
959        buckets[bucket_index].push(case);
960    }
961
962    buckets.swap_remove(shard.index() - 1)
963}
964
965fn estimated_case_cost_ms(case: &TestCase, timings: &BTreeMap<String, u64>) -> u64 {
966    timings
967        .get(&timings_key(&case.file, &case.name))
968        .copied()
969        .unwrap_or(case.weight as u64)
970        .max(1)
971}
972
973fn count_files_with_cases(cases: &[TestCase]) -> usize {
974    let mut files = HashSet::new();
975    for case in cases {
976        files.insert(case.file.as_path());
977    }
978    files.len()
979}
980
981fn timings_key(file: &Path, name: &str) -> String {
982    format!("{}::{}", file.display(), name)
983}
984
985fn timings_cache_path(target: &Path) -> Option<PathBuf> {
986    // Anchor the cache at the project root if discoverable, otherwise at
987    // the directory the suite was launched from. The cache is shared
988    // across runs in the same workspace, so a per-suite cache would
989    // fragment timings whenever a user runs a subset.
990    let probe_root = if target.is_dir() {
991        target.to_path_buf()
992    } else {
993        target.parent()?.to_path_buf()
994    };
995    let root = harn_vm::stdlib::process::find_project_root(&probe_root)
996        .unwrap_or_else(|| probe_root.clone());
997    Some(root.join(TIMINGS_CACHE_RELATIVE_PATH))
998}
999
1000fn load_timings_cache(path: &Path) -> BTreeMap<String, u64> {
1001    let Ok(contents) = fs::read_to_string(path) else {
1002        return BTreeMap::new();
1003    };
1004    serde_json::from_str::<BTreeMap<String, u64>>(&contents).unwrap_or_default()
1005}
1006
1007fn update_timings_cache(path: &Path, mut existing: BTreeMap<String, u64>, results: &[TestResult]) {
1008    for result in results {
1009        existing.insert(
1010            timings_key(Path::new(&result.file), &result.name),
1011            result.duration_ms,
1012        );
1013    }
1014    if let Some(parent) = path.parent() {
1015        let _ = fs::create_dir_all(parent);
1016    }
1017    if let Ok(serialized) = serde_json::to_string(&existing) {
1018        let _ = fs::write(path, serialized);
1019    }
1020}
1021
1022#[derive(Default)]
1023struct CaseExecutionResults {
1024    cases: Vec<TestResult>,
1025    infrastructure_errors: Vec<TestResult>,
1026}
1027
1028async fn execute_cases(
1029    cases: Vec<TestCase>,
1030    workers: usize,
1031    options: &RunOptions,
1032    total_tests: usize,
1033    session: &TestRunSession,
1034    skill_contexts: PreparedSkillContexts,
1035) -> CaseExecutionResults {
1036    if cases.is_empty() {
1037        return CaseExecutionResults::default();
1038    }
1039    let completed = Arc::new(Mutex::new(0usize));
1040    if workers <= 1 {
1041        let prepared_module_cache = session.prepared_module_cache(0);
1042        let mut results = Vec::with_capacity(cases.len());
1043        for case in cases {
1044            let loaded_skills = skill_contexts.for_case(&case);
1045            let cwd = case_execution_cwd(&case);
1046            let test_index = next_test_index(&completed);
1047            emit_progress(
1048                &options.progress,
1049                TestRunEvent::TestStarted {
1050                    name: case.name.clone(),
1051                    file: case.file.display().to_string(),
1052                    test_index,
1053                    total_tests,
1054                },
1055            );
1056            let result = execute_case(
1057                &case,
1058                &cwd,
1059                options.timeout_ms,
1060                loaded_skills,
1061                &prepared_module_cache,
1062                session.stdio_available(),
1063            )
1064            .await;
1065            let result = enforce_case_budgets(result, options.max_test_ms, options.max_execute_ms);
1066            if options.diagnose {
1067                result.emit_diagnose();
1068            }
1069            emit_progress(
1070                &options.progress,
1071                TestRunEvent::TestFinished(result.clone()),
1072            );
1073            results.push(result);
1074            if options.fail_fast && !results.last().is_some_and(|result| result.passed) {
1075                break;
1076            }
1077        }
1078        return CaseExecutionResults {
1079            cases: results,
1080            infrastructure_errors: Vec::new(),
1081        };
1082    }
1083
1084    let queue = Arc::new(Mutex::new(cases));
1085    let skill_contexts = Arc::new(skill_contexts);
1086    let gate = Arc::new(ResourceGate::new(workers));
1087    let results: Arc<Mutex<Vec<TestResult>>> = Arc::new(Mutex::new(Vec::new()));
1088    let infrastructure_errors: Arc<Mutex<Vec<TestResult>>> = Arc::new(Mutex::new(Vec::new()));
1089    let cancelled = Arc::new(AtomicBool::new(false));
1090
1091    let mut handles = Vec::with_capacity(workers);
1092    for worker_idx in 0..workers {
1093        let queue = Arc::clone(&queue);
1094        let skill_contexts = Arc::clone(&skill_contexts);
1095        let gate = Arc::clone(&gate);
1096        let results = Arc::clone(&results);
1097        let infrastructure_errors = Arc::clone(&infrastructure_errors);
1098        let completed = Arc::clone(&completed);
1099        let timeout_ms = options.timeout_ms;
1100        let max_test_ms = options.max_test_ms;
1101        let max_execute_ms = options.max_execute_ms;
1102        let progress = options.progress.clone();
1103        let diagnose = options.diagnose;
1104        let fail_fast = options.fail_fast;
1105        let cancelled = Arc::clone(&cancelled);
1106        let prepared_module_cache = session.prepared_module_cache(worker_idx);
1107        let stdio_available = session.stdio_available();
1108        let handle = thread::Builder::new()
1109            .name(format!("harn-test-worker-{worker_idx}"))
1110            .stack_size(CLI_RUNTIME_STACK_SIZE)
1111            .spawn(move || {
1112                let runtime = match tokio::runtime::Builder::new_current_thread()
1113                    .enable_all()
1114                    .build()
1115                {
1116                    Ok(rt) => rt,
1117                    Err(error) => {
1118                        infrastructure_errors.lock().unwrap().push(TestResult {
1119                            name: "<worker error>".to_string(),
1120                            file: String::new(),
1121                            passed: false,
1122                            error: Some(format!("failed to start test runtime: {error}")),
1123                            captured_output: None,
1124                            timeout: None,
1125                            duration_ms: 0,
1126                            phases: None,
1127                        });
1128                        return;
1129                    }
1130                };
1131                // Cases are sorted ascending by historical duration; popping
1132                // from the tail gives this worker the slowest unclaimed
1133                // test, which front-loads long poles and prevents workers
1134                // from stranding on quick tests at the end of the run.
1135                loop {
1136                    let case = claim_next_case(&queue, &cancelled, fail_fast);
1137                    let Some(case) = case else { break };
1138                    let _guard = gate.acquire(case.weight, case.serial_group.as_deref());
1139                    let cwd = case_execution_cwd(&case);
1140                    let loaded_skills = skill_contexts.for_case(&case);
1141                    let test_index = next_test_index(&completed);
1142                    emit_progress(
1143                        &progress,
1144                        TestRunEvent::TestStarted {
1145                            name: case.name.clone(),
1146                            file: case.file.display().to_string(),
1147                            test_index,
1148                            total_tests,
1149                        },
1150                    );
1151                    let result = runtime.block_on(execute_case(
1152                        &case,
1153                        &cwd,
1154                        timeout_ms,
1155                        loaded_skills,
1156                        &prepared_module_cache,
1157                        stdio_available,
1158                    ));
1159                    let result = enforce_case_budgets(result, max_test_ms, max_execute_ms);
1160                    if fail_fast && !result.passed {
1161                        cancelled.store(true, Ordering::Release);
1162                    }
1163                    if diagnose {
1164                        result.emit_diagnose();
1165                    }
1166                    emit_progress(&progress, TestRunEvent::TestFinished(result.clone()));
1167                    results.lock().unwrap().push(result);
1168                }
1169            })
1170            .expect("spawning a harn-test worker thread should succeed");
1171        handles.push(handle);
1172    }
1173
1174    for handle in handles {
1175        let _ = handle.join();
1176    }
1177
1178    // All workers have joined, so this Arc holds the only remaining
1179    // reference. The lock-and-clone fallback survives the unlikely case
1180    // where a panic-unwind kept an extra reference alive.
1181    let cases = Arc::try_unwrap(results)
1182        .map(|m| m.into_inner().unwrap_or_default())
1183        .unwrap_or_else(|arc| arc.lock().unwrap().clone());
1184    let infrastructure_errors = Arc::try_unwrap(infrastructure_errors)
1185        .map(|mutex| mutex.into_inner().unwrap_or_default())
1186        .unwrap_or_else(|arc| arc.lock().unwrap().clone());
1187    CaseExecutionResults {
1188        cases,
1189        infrastructure_errors,
1190    }
1191}
1192
1193fn claim_next_case(
1194    queue: &Mutex<Vec<TestCase>>,
1195    cancelled: &AtomicBool,
1196    fail_fast: bool,
1197) -> Option<TestCase> {
1198    let mut queue = queue.lock().unwrap();
1199    if fail_fast && cancelled.load(Ordering::Acquire) {
1200        None
1201    } else {
1202        queue.pop()
1203    }
1204}
1205
1206fn enforce_case_budgets(
1207    mut result: TestResult,
1208    max_test_ms: Option<u64>,
1209    max_execute_ms: Option<u64>,
1210) -> TestResult {
1211    if !result.passed {
1212        return result;
1213    }
1214
1215    let phases = result
1216        .phases
1217        .expect("passed test results always carry measured phases");
1218    let mut violations = Vec::new();
1219    if let Some(max_ms) = max_test_ms {
1220        if result.duration_ms > max_ms {
1221            violations.push(format!(
1222                "exceeded test wall-clock budget: {}ms > {}ms",
1223                result.duration_ms, max_ms
1224            ));
1225        }
1226    }
1227    if let Some(max_ms) = max_execute_ms {
1228        if phases.execute_ms > max_ms {
1229            violations.push(format!(
1230                "exceeded test execute budget: {}ms > {}ms",
1231                phases.execute_ms, max_ms
1232            ));
1233        }
1234    }
1235
1236    if violations.is_empty() {
1237        return result;
1238    }
1239
1240    violations.push(format!(
1241        "phase timings: setup={}ms compile={}ms execute={}ms teardown={}ms total={}ms",
1242        phases.setup_ms,
1243        phases.compile_ms,
1244        phases.execute_ms,
1245        phases.teardown_ms,
1246        result.duration_ms
1247    ));
1248    result.passed = false;
1249    result.error = Some(violations.join("\n"));
1250    result
1251}
1252
1253fn next_test_index(counter: &Mutex<usize>) -> usize {
1254    let mut guard = counter.lock().unwrap();
1255    *guard += 1;
1256    *guard
1257}
1258
1259fn case_execution_cwd(case: &TestCase) -> PathBuf {
1260    case.file
1261        .parent()
1262        .filter(|p| !p.as_os_str().is_empty())
1263        .map(Path::to_path_buf)
1264        .unwrap_or_else(test_execution_cwd)
1265}
1266
1267/// Coordinates worker permits and serial-group exclusivity without
1268/// requiring an async lock — workers are dedicated OS threads, so a
1269/// classic Mutex+Condvar gate keeps everything off the tokio scheduler.
1270struct ResourceGate {
1271    state: Mutex<GateState>,
1272    cond: Condvar,
1273    capacity: usize,
1274}
1275
1276struct GateState {
1277    available: usize,
1278    busy_groups: HashSet<String>,
1279}
1280
1281struct GateGuard<'a> {
1282    gate: &'a ResourceGate,
1283    weight: usize,
1284    group: Option<String>,
1285}
1286
1287impl ResourceGate {
1288    fn new(capacity: usize) -> Self {
1289        Self {
1290            state: Mutex::new(GateState {
1291                available: capacity,
1292                busy_groups: HashSet::new(),
1293            }),
1294            cond: Condvar::new(),
1295            capacity,
1296        }
1297    }
1298
1299    fn acquire(&self, weight: usize, group: Option<&str>) -> GateGuard<'_> {
1300        let weight = weight.min(self.capacity).max(1);
1301        let mut state = self.state.lock().unwrap();
1302        loop {
1303            if let Some(guard) = self.try_grab_locked(&mut state, weight, group) {
1304                return guard;
1305            }
1306            state = self.cond.wait(state).unwrap();
1307        }
1308    }
1309
1310    /// Grab a permit if one is immediately available, holding the already-locked
1311    /// state. Returns `None` without blocking when the pool is exhausted or the
1312    /// group is busy. Shared by `acquire` (which retries) and `try_acquire`.
1313    fn try_grab_locked<'a>(
1314        &'a self,
1315        state: &mut GateState,
1316        weight: usize,
1317        group: Option<&str>,
1318    ) -> Option<GateGuard<'a>> {
1319        let group_free = group.is_none_or(|g| !state.busy_groups.contains(g));
1320        if state.available >= weight && group_free {
1321            state.available -= weight;
1322            if let Some(g) = group {
1323                state.busy_groups.insert(g.to_string());
1324            }
1325            return Some(GateGuard {
1326                gate: self,
1327                weight,
1328                group: group.map(str::to_owned),
1329            });
1330        }
1331        None
1332    }
1333
1334    /// Non-blocking variant of `acquire` used by tests to assert gate state
1335    /// deterministically (in-process, no threads or wall-clock sleeps).
1336    #[cfg(test)]
1337    fn try_acquire(&self, weight: usize, group: Option<&str>) -> Option<GateGuard<'_>> {
1338        let weight = weight.min(self.capacity).max(1);
1339        let mut state = self.state.lock().unwrap();
1340        self.try_grab_locked(&mut state, weight, group)
1341    }
1342}
1343
1344impl Drop for GateGuard<'_> {
1345    fn drop(&mut self) {
1346        let mut state = self.gate.state.lock().unwrap();
1347        state.available += self.weight;
1348        if let Some(group) = self.group.as_deref() {
1349            state.busy_groups.remove(group);
1350        }
1351        self.gate.cond.notify_all();
1352    }
1353}
1354
1355fn discover_test_files(dir: &Path) -> Vec<PathBuf> {
1356    let mut files = Vec::new();
1357    if let Ok(entries) = fs::read_dir(dir) {
1358        for entry in entries.flatten() {
1359            let path = entry.path();
1360            if path.is_dir() {
1361                files.extend(discover_test_files(&path));
1362            } else if path.extension().is_some_and(|e| e == "harn") {
1363                if let Ok(content) = fs::read_to_string(&path) {
1364                    if content.contains("test_") || content.contains("@test") {
1365                        files.push(canonicalize_existing_path(&path));
1366                    }
1367                }
1368            }
1369        }
1370    }
1371    files.sort();
1372    files
1373}