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