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