ktstr_macros/
lib.rs

1use proc_macro::TokenStream;
2use quote::quote;
3use syn::{DeriveInput, parse_macro_input};
4
5#[allow(dead_code)]
6mod kernel_path;
7
8mod claim;
9mod common;
10mod json;
11mod ktstr_test;
12mod payload;
13mod scheduler;
14
15/// Attribute macro that registers a function as a ktstr integration test.
16///
17/// The annotated function must have signature `fn(&ktstr::scenario::Ctx) ->
18/// anyhow::Result<ktstr::assert::AssertResult>`. The macro:
19///
20/// 1. Renames the original function to `__ktstr_inner_{name}`.
21/// 2. Registers it in the `KTSTR_TESTS` distributed slice via linkme.
22/// 3. Emits a `#[test]` wrapper that boots a VM and runs the function
23///    inside it.
24///
25/// Every attribute is optional. Most take a `key = value` form; the
26/// sixteen boolean attributes (`auto_repro`, `expect_auto_repro`,
27/// `not_starved`, `isolation`, `performance_mode`, `pci`, `no_perf_mode`,
28/// `requires_smt`, `expect_err`, `survives_storm`, `allow_inconclusive`,
29/// `fail_on_stall`, `host_only`, `ignore`, `kaslr`, `wprof`) also accept a
30/// bare form as shorthand for `= true` — e.g.
31/// `#[ktstr_test(host_only)]` is equivalent to
32/// `#[ktstr_test(host_only = true)]`. Of the sixteen, `auto_repro`
33/// and `kaslr` are the two whose default is `true`, so the bare form
34/// is a no-op; `auto_repro = false` / `kaslr = false` are the only
35/// way to disable each. The other fourteen default to `false`, so the
36/// bare form is the meaningful shorthand.
37///
38/// The accepted attributes and their defaults are the fields of
39/// `ktstr::test_support::KtstrTestEntry` (runtime metadata) and
40/// `ktstr::assert::Assert` (checking thresholds). A few are
41/// worth calling out because their names differ from the underlying
42/// field or because they have nontrivial defaults:
43///
44///   - `llcs = N` — number of LLCs (default: inherited from
45///     scheduler, or 1).
46///   - `cores = N` (default: inherited from scheduler, or 2)
47///   - `threads = N` (default: inherited from scheduler, or 1)
48///   - `numa_nodes = N` (default: inherited from scheduler, or 1)
49///   - `memory_mib = N` — per-test minimum memory in MiB (default:
50///     2048). The framework picks `max(total_cpus * 64, 256,
51///     memory_mib)` MiB at VM-launch time, so for tests with more
52///     than 32 vCPUs the cpu-based floor dominates the macro
53///     default. Below ~4 vCPUs the absolute 256-MiB floor wins if
54///     `memory_mib` is also below it. Setting `memory_mib` above
55///     the cpu-based floor is only meaningful when the test needs
56///     more headroom than the per-cpu budget. The unit is binary
57///     mebibytes; the conversion at VM-launch is `value << 20`
58///     bytes, not decimal megabytes.
59///   - `duration_s = N` — scenario run duration in seconds; maps
60///     onto `KtstrTestEntry::duration`
61///   - `watchdog_timeout_s = N` — watchdog fire threshold in
62///     seconds; maps onto `KtstrTestEntry::watchdog_timeout`
63///   - `cleanup_budget_ms = N` — sub-watchdog cap on host-side VM
64///     teardown wall time; maps onto `KtstrTestEntry::cleanup_budget`
65///     as `Duration::from_millis(N)`. Default: `None` (unenforced).
66///   - `num_snapshots = N` — fire `N` periodic
67///     `freeze_and_dispatch(FreezeMode::Capture { gate_on_exit_kind: false })` boundaries inside the workload's
68///     10 %–90 % window, stored on the host
69///     `SnapshotBridge` under `periodic_NNN`. `0` (default)
70///     disables periodic capture entirely. Maps onto
71///     `KtstrTestEntry::num_snapshots`; runtime
72///     `KtstrTestEntry::validate` rejects values past the bridge
73///     cap (`MAX_STORED_SNAPSHOTS`), `host_only = true`, and
74///     duration / `N` settings that would land boundaries closer
75///     than 100 ms apart.
76///   - `scheduler = PATH` — path to a `const Scheduler` (typically
77///     produced by `declare_scheduler!(...)`). Maps onto
78///     `KtstrTestEntry::scheduler`, which is typed
79///     `&'static Scheduler`. Default: `&Scheduler::EEVDF`, the
80///     no-scx placeholder that runs under the kernel's default
81///     scheduler.
82///   - `payload = PATH` — path to a `const Payload` used as the
83///     primary binary workload (must be `PayloadKind::Binary`;
84///     runtime-enforced). Default: `None` (scheduler-only test).
85///     Coexists with `scheduler = PATH` — the payload runs *under*
86///     the selected scheduler.
87///   - `workloads = [PATH, PATH, ...]` — additional `const Payload`
88///     references composed with the primary via `Ctx::payload` in
89///     the test body. Default: `&[]`. Must not contain the same
90///     path as `payload` — reject at expansion time to catch the
91///     common "fio as primary AND workload" slip.
92///   - `auto_repro = bool` (default: `true`)
93///   - `wprof = bool` (default: `false`; requires the `wprof`
94///     cargo feature on ktstr) — attach `/bin/wprof` to the
95///     test's VM(s) and ship the Perfetto trace to the host.
96///   - `wprof_args = "..."` (requires the `wprof` cargo feature)
97///     — override `WprofConfig::default_args`. Only meaningful
98///     with `wprof = true`. Parsed as space-separated tokens.
99///   - `host_only = bool` (default: `false`) — run the test function
100///     on the host instead of inside a VM
101///   - `no_perf_mode = bool` (default: `false`) — decouple the
102///     virtual topology from host hardware. The VM is built with
103///     the declared `numa_nodes` / `llcs` / `cores` / `threads`
104///     even on smaller hosts; vCPU pinning, hugepages, NUMA mbind,
105///     RT scheduling, and KVM exit suppression are skipped, and
106///     gauntlet preset filtering relaxes host-topology checks
107///     to the single "host has enough total CPUs" inequality.
108///     Mutually exclusive with `performance_mode = true`. Maps onto
109///     `KtstrTestEntry::no_perf_mode`.
110///   - `post_vm = PATH` — host-side callback invoked after
111///     `vm.run()` returns, with access to the full `VmResult`.
112///     Use for assertions that need host-side state — e.g.
113///     draining `VmResult.snapshot_bridge` after a snapshot
114///     capture pipeline fires inside the guest. The function
115///     must have signature
116///     `fn(&ktstr::vmm::VmResult) -> anyhow::Result<()>`. PATH
117///     accepts any Rust path-expression that resolves to a value
118///     of that fn type — both free-function refs
119///     (`my_post_vm_check`) AND UFCS method refs
120///     (`VmResult::assert_wprof_pb_landed`) work via Rust's
121///     function-item-to-fn-pointer coercion, so a method that
122///     already has the right `&self -> Result<()>` shape can be
123///     pointed at directly without wrapping in a one-line
124///     delegating free fn.
125///     SUPPRESSED on guest-reported fail — see
126///     `post_vm_unconditional` for the always-runs sibling.
127///     Default: `None` (no callback).
128///   - `post_vm_unconditional = PATH` — host-side callback that
129///     always runs after `vm.run()` returns, bypassing the
130///     guest-fail suppression that gates `post_vm`. Same
131///     signature as `post_vm` and PATH accepts the same form:
132///     any Rust path-expression resolving to a value of that fn
133///     type — both free-function refs AND UFCS method refs
134///     (`VmResult::assert_wprof_pb_landed`-style) work via
135///     function-item-to-fn-pointer coercion. Use when the
136///     callback must observe host-side state regardless of
137///     guest-side outcome (e.g. verifying a sidecar artifact
138///     landed even when the guest reported a deliberate fail).
139///     The callback is responsible for guarding against missing
140///     state when the scheduler crashed before producing it —
141///     the canonical guard is
142///     `if !result.success { return Ok(()); }` at the top of the
143///     callback body. Setting `post_vm_unconditional` does NOT
144///     invert the test verdict — a guest-reported fail still
145///     fails the test even if the unconditional callback returns
146///     Ok. Both attributes may be set on the same entry (both
147///     errors surface via `combine_post_vm_errs` when both
148///     fire). Default: `None` (no callback).
149///   - `disk = PATH` — path to a `const DiskConfig` attached to the
150///     VM as a virtio-blk device at `/dev/vda`. Construct via
151///     `DiskConfig::DEFAULT.with_name("data")` or similar const-fn
152///     chain (the `with_name` builder takes `&'static str` so the
153///     full expression is const-evaluable). Maps onto
154///     `KtstrTestEntry::disk`. Default: `None` (no disk).
155///     Mutually exclusive with `host_only = true` —
156///     `host_only` skips the VM boot that owns the device lifecycle,
157///     so a `disk` attached under `host_only` would never bind;
158///     `KtstrTestEntry::validate` rejects the pairing at runtime.
159///   - `networks = [PATH, ...]` — array of `const NetConfig` paths, one
160///     virtio-net device per element (in-VMM loopback backend). On x86_64
161///     each lands on its own virtio-pci function (PCI slots 1..=N, one INTx
162///     GSI apiece); aarch64 takes a single virtio-MMIO NIC (build() errors
163///     on more than one). Construct
164///     each via `NetConfig::DEFAULT.mac(...)` or `NetConfig::DEFAULT`
165///     (const-fn chain). Maps onto `KtstrTestEntry::networks`. Default: `[]`
166///     (no NIC). Like `disk`, mutually exclusive with `host_only`.
167///   - `config = EXPR` — inline scheduler config content, written
168///     into the guest at the path declared by the scheduler's
169///     `config_file_def`. `EXPR` is either a string literal or a
170///     path to a `const &'static str` (e.g. `LAYERED_CONFIG`).
171///     Maps onto `KtstrTestEntry::config_content`. Required when
172///     the scheduler declares `config_file_def`; rejected when the
173///     scheduler does not. The pairing is enforced at compile time
174///     via a `const` assertion against `Scheduler::config_file_def`,
175///     and again at runtime by `KtstrTestEntry::validate` so direct
176///     programmatic-entry construction sees the same gate.
177///   - `expect_scx_bpf_error_contains = EXPR` — literal-substring
178///     matcher applied to the captured `scx_bpf_error` text in
179///     reproducer mode. `EXPR` is either a string literal or a path
180///     to a `const &'static str`. Maps onto
181///     `Assert::expect_scx_bpf_error_contains`. Requires
182///     `expect_err = true` (rejected at construction otherwise by
183///     `KtstrTestEntry::validate`). Empty strings panic at
184///     construction. When both `_contains` and `_matches` are set,
185///     the evaluator ANDs them — every set matcher must hit.
186///   - `expect_scx_bpf_error_matches = EXPR` — regex matcher with
187///     the same accepted forms and gating as `_contains`. Maps onto
188///     `Assert::expect_scx_bpf_error_matches`. Validated at
189///     construction: empty patterns, invalid regex syntax, and any
190///     pattern satisfying `is_match("")` all panic immediately. The
191///     `is_match("")` predicate catches two no-op classes with one
192///     check: patterns that match every position (e.g. `a?`, `.*`,
193///     `(?:)`) trivially pass against any corpus, and patterns that
194///     match only the empty string (e.g. `^$`) trivially fail
195///     against any non-empty corpus — both are equally useless pins.
196///     Bare `\b` slips the gate (no word characters in `""`); see
197///     `Assert::expect_scx_bpf_error_matches` for the operator
198///     direction.
199///   - `survives_storm` — assert the scx scheduler SURVIVES the run
200///     (does not die or get ejected during any hold); the positive
201///     inverse of `expect_err`. Requires an active scheduler and is
202///     mutually exclusive with both `expect_err` and `expect_auto_repro`
203///     (rejected at macro-parse and by `KtstrTestEntry::validate`).
204///     Enforced on scenarios driven through `execute_defs` /
205///     `execute_steps` / `execute_scenario` (which run the scheduler
206///     liveness probe); a survival violation surfaces as a failing exit
207///     with a survival-specific explainer.
208///   - `extra_include_files = ["PATH", "PATH", ...]` — host-side
209///     file paths to bundle into the guest initramfs beyond what
210///     the entry's `scheduler` / `payload` / `workloads` already
211///     declare via their own `include_files`. Use this for
212///     test-level dependencies that don't belong on a specific
213///     Payload: auxiliary data files, per-test helper scripts,
214///     fixtures. Each element must be a string literal (no
215///     expressions). Maps onto
216///     `KtstrTestEntry::extra_include_files` and is unioned with
217///     the per-payload specs at `run_ktstr_test` time via
218///     `KtstrTestEntry::all_include_files`. Default: `[]`.
219///     Path resolution: bare names (no `/`) search `PATH`; paths
220///     containing `/` are absolute or relative to the test process
221///     current directory; directories are walked recursively at
222///     test-run time (rejected by `cargo ktstr export` since the
223///     `.run` packager handles regular files only — recursive
224///     directory packaging is a v2 enhancement); a missing file
225///     fails loudly at setup with an actionable error naming the
226///     missing path.
227///
228/// Duplicate keys: each attribute KEY may appear at most once per
229/// `#[ktstr_test]` invocation; duplicate keys (whether the values
230/// match or differ) fail at expansion rather than silently letting
231/// the later value win. `#[ktstr_test(host_only = false,
232/// host_only)]` and `#[ktstr_test(llcs = 4, llcs = 8)]` both fail.
233/// The bare form (`host_only`) and explicit form (`host_only =
234/// true`) of the same attribute collide as well — they refer to
235/// the same slot. List values like `workloads = [FIO, FIO]` are
236/// NOT affected by this rule; the duplicate check is on attribute
237/// keys, not on values within an array. `payload = ...` and
238/// `workloads = [..]` keep their tailored messages directing the
239/// author to the right home for extras; `config = ...` and
240/// `expect_scx_bpf_error_{contains,matches} = ...` likewise have
241/// tailored wording; every other attribute uses a uniform
242/// "duplicate attribute" diagnostic.
243///
244/// Path / list forms: `#[ktstr_test(crate::host_only)]` (a
245/// multi-segment path, whether bare or as a key in
246/// `crate::host_only = true`) is rejected with a targeted message
247/// naming both valid forms with concrete examples — the macro only
248/// accepts bare single-segment idents because routing dispatches on
249/// the ident string against `BOOL_ATTR_NAMES` or the value-attr match
250/// arms (enumerated in `VALUE_ATTR_NAMES`).
251/// `#[ktstr_test(host_only(false))]` (parenthesised
252/// arguments) is rejected with a separate targeted message naming
253/// the attribute and the two valid forms (`= value` or bare); the
254/// same diagnostic fires for `crate::host_only(false)` so the
255/// operator sees one combined error rather than chasing two.
256#[proc_macro_attribute]
257pub fn ktstr_test(attr: TokenStream, item: TokenStream) -> TokenStream {
258    match ktstr_test::ktstr_test_impl(attr.into(), item.into()) {
259        Ok(ts) => ts.into(),
260        Err(e) => e.to_compile_error().into(),
261    }
262}
263
264/// Function-style macro that registers a `Scheduler` const.
265///
266/// # Syntax
267///
268/// ```rust,ignore
269/// use ktstr::prelude::*;
270///
271/// declare_scheduler!(MITOSIS, {
272///     name = "mitosis",
273///     binary = "scx_mitosis",
274///     cgroup_parent = "/ktstr",
275///     sched_args = ["--exit-dump-len", "1048576"],
276///     kernels = ["6.14", "7.0..=7.2"],
277///     constraints = TopologyConstraints {
278///         min_llcs: 1, max_llcs: Some(8), max_cpus: Some(64),
279///         ..TopologyConstraints::DEFAULT
280///     },
281/// });
282/// ```
283///
284/// # Generated items
285///
286/// Given `declare_scheduler!(MITOSIS, { ... })`:
287///
288/// - `pub static MITOSIS: ::ktstr::test_support::Scheduler` — the declared
289///   scheduler value. No `_PAYLOAD` suffix; the const IS the
290///   `Scheduler`.
291/// - A hidden `static __KTSTR_SCHED_REG_MITOSIS: &'static Scheduler`
292///   registered in `KTSTR_SCHEDULERS` (`ktstr::test_support::KTSTR_SCHEDULERS`)
293///   via linkme so the verifier can discover the declaration by
294///   spawning the test binary with `--ktstr-list-schedulers`.
295///
296/// # Visibility prefix
297///
298/// An optional Rust visibility prefix may precede the const name:
299///
300/// ```rust,ignore
301/// declare_scheduler!(MY_SCHED, { ... });             // defaults to `pub`
302/// declare_scheduler!(pub MY_SCHED, { ... });          // explicit `pub`
303/// declare_scheduler!(pub(crate) MY_SCHED, { ... });   // crate-local
304/// declare_scheduler!(pub(super) MY_SCHED, { ... });   // parent-module
305/// declare_scheduler!(pub(in crate::test_support) MY_SCHED, { ... });
306/// ```
307///
308/// Omitting the prefix defaults to `pub` — schedulers are normally
309/// public so the verifier and other crates can reference them; an
310/// explicit prefix is needed only when the declaration sits inside
311/// a module that wants to narrow the exposed name. (Field content
312/// shown above as `{ ... }` is elided; consult the Syntax example
313/// for the required fields.) The hidden registry static (see
314/// Generated items above) is always `static` (private) regardless
315/// of the user-facing const's visibility — `linkme` gathers it via
316/// link-section walking, not Rust name resolution, so the slice
317/// mechanism works at every visibility level.
318///
319/// # Accepted fields
320///
321/// Exactly one scheduler-source must be declared: `binary`,
322/// `binary_path`, or the `kernel_builtin_enable` + `kernel_builtin_disable`
323/// pair. The three options select between the matching
324/// `SchedulerSpec` variants. To run under the kernel default
325/// instead, reference `ktstr::test_support::Scheduler::EEVDF`
326/// directly rather than declaring a new scheduler.
327///
328/// | Field | Required | Description |
329/// |---|---|---|
330/// | `name = "..."` | yes | Scheduler name (sidecar / logs). |
331/// | `binary = "..."` | one source | Binary name → `SchedulerSpec::Discover(...)`. Matched against `[[bin]]` names in `target/{debug,release}/`, the test binary's directory, or `KTSTR_SCHEDULER` env var. Often equal to the cargo package name but not required to be. |
332/// | `binary_path = "/abs/path"` | one source | Absolute filesystem path → `SchedulerSpec::Path(...)`. The runtime does not auto-build this variant: the file must already exist at the path when the test runs. Use for prebuilt binaries that live outside the cargo discovery cascade. Macro-time validation rejects empty strings, relative paths, and `~`-prefixed paths (no compile-time tilde expansion); existence is the runtime's job. |
333/// | `kernel_builtin_enable = [..]` + `kernel_builtin_disable = [..]` | one source | Two string-array literals that together select `SchedulerSpec::KernelBuiltin { enable: &[..], disable: &[..] }`. The framework writes the enable commands to the guest's `/sched_enable` and the disable commands to `/sched_disable` (see `src/vmm/initramfs.rs`), and the guest interpreter runs each entry once at scenario start / teardown. Both fields must be set together — setting only one is rejected. The interpreter (`src/vmm/rust_init/dump.rs`) accepts EXACTLY ONE shell-line shape: `echo VALUE > /path` (plus blank lines and `#` comments). Pipes, `>>`, `;`, variable expansion, and any other syntax silently no-ops at runtime, so the macro rejects entries that don't match `echo … > /…` at expand time. At least one of the two arrays must be non-empty: a pair that supplies neither enable nor disable commands is equivalent to the EEVDF baseline — reference `Scheduler::EEVDF` for that. Note: `cargo ktstr export` currently bails on KernelBuiltin schedulers (`src/export.rs`); declarations using this variant cannot be reproduced via the export-to-shar workflow until that limitation is lifted. |
334/// | `topology = (numa, llcs, cores, threads)` | no | Default VM topology. Default: `(1, 1, 2, 1)` (from `Scheduler::named`). Validated at compile time: each value must be non-zero, and `llcs` must be a multiple of `numa`. |
335/// | `cgroup_parent = "..."` | no | Cgroup parent path (must begin with `/`). |
336/// | `sched_args = [..]` | no | Scheduler CLI args prepended before per-test `extra_sched_args`. |
337/// | `sysctls = [Sysctl::new("k", "v"), ..]` | no | Guest sysctls. |
338/// | `kargs = [..]` | no | Extra guest kernel cmdline args. |
339/// | `kernels = ["6.14", "7.0..=7.2", ..]` | no | Kernel specs the verifier sweeps. Same parser as the `--kernel` CLI flag — accepts exact versions, ranges (`..` or `..=`, both inclusive), git refs (`git+URL#tag=NAME`), paths, and cache keys. Each entry is validated at macro-expand time via the same `KernelId::parse` + `validate` the verifier uses at runtime; empty entries, inverted ranges, and `..`-containing strings whose endpoints aren't version-shaped (e.g. `"abc..def"`) are rejected. |
340/// | `constraints = TopologyConstraints { .. }` | no | Gauntlet preset constraints — maps directly onto `Scheduler::constraints`. Filters which gauntlet topology presets exercise this scheduler. When given as a struct literal, the macro additionally cross-checks each literal field against the effective topology (explicit `topology` field if present, otherwise the `(1, 1, 2, 1)` default from `Scheduler::named`) and rejects infeasible pairings; non-struct-literal forms (e.g. `OTHER::CONST_CONSTRAINTS`) skip that check. |
341/// | `assert = Assert::NO_OVERRIDES.method().chain()` | no | Scheduler-wide assertion overrides — maps directly onto `Scheduler::assert`. Merged with `Assert::default_checks()` and the per-test `assert` at runtime (`default ← scheduler ← per-test`). Accepts any const-evaluable expression: a const path like `Assert::NO_OVERRIDES`, a const-fn call like `Assert::default_checks()`, or a chain of const-fn setters like `Assert::NO_OVERRIDES.check_not_starved().max_gap_ms(50)`. The macro accepts MethodCall chains and Path-rooted (type/module-prefixed) Calls — only bare single-segment lowercase Calls like `helper()` are rejected as non-const free-fn patterns; non-const methods on a Path receiver slip through and surface as a deep const-eval failure at the spread site. |
342/// | `config_file = "..."` | no | Host-side config file path. |
343/// | `config_file_def = ("--config {file}", "/include-files/cfg.json")` | no | Inline-config plumbing — maps directly onto `Scheduler::config_file_def`. 2-tuple of string literals: arg_template (CLI arg with `{file}` placeholder substituted at run time) and guest_path (absolute path where the framework writes the JSON inside the guest). Distinct from `config_file` (which references a pre-existing host file). The macro validates: tuple-arity = 2, both elements non-empty string literals, `{file}` placeholder present in arg_template, guest_path absolute. |
344///
345/// # Const naming rules
346///
347/// The first argument must be a SCREAMING_SNAKE_CASE identifier and
348/// must NOT be one of the reserved built-in names (`EEVDF`,
349/// `KERNEL_DEFAULT`). The macro emits a `compile_error!` if either rule
350/// is violated.
351#[proc_macro]
352pub fn declare_scheduler(input: TokenStream) -> TokenStream {
353    match scheduler::declare_scheduler_inner(input.into()) {
354        Ok(ts) => ts.into(),
355        Err(e) => e.to_compile_error().into(),
356    }
357}
358
359/// Derive macro that generates a `Payload` const from an annotated
360/// struct for a userspace binary workload (stress-ng, fio, and
361/// similar tools test authors compose under a scheduler).
362///
363/// # Required struct-level attributes (`#[payload(...)]`)
364///
365/// - `binary = "..."` — the binary name resolved by the guest's
366///   include-files infrastructure (required). Becomes
367///   `PayloadKind::Binary(name)` (`ktstr::test_support::PayloadKind::Binary`),
368///   and is also auto-prepended to the emitted `include_files` slice
369///   so the binary is packaged into the initramfs without needing a
370///   separate `#[include_files("...")]` entry. Extra auxiliary files
371///   (helpers, configs, fixtures) still go on `#[include_files(...)]`.
372///
373/// # Optional struct-level attributes
374///
375/// - `name = "..."` — short name used in logs and sidecar records.
376///   Defaults to the binary name.
377/// - `output = Json | ExitCode` — how the framework extracts
378///   metrics from the payload's stdout. The variant names match the
379///   `OutputFormat` enum and the `Polarity` kwarg grammar. Defaults
380///   to `ExitCode`.
381///
382/// # Optional outer attributes
383///
384/// - `#[default_args("--a", "--b", ...)]` — variadic string
385///   literals appended to the binary's argv when the payload runs.
386///   May repeat across multiple `#[default_args(...)]` attrs; entries
387///   accumulate in source order.
388/// - `#[default_check(...)]` — one `MetricCheck` (`ktstr::test_support::MetricCheck`)
389///   construction expression (e.g. `min("iops", 1000.0)`,
390///   `exit_code_eq(0)`). May repeat; entries accumulate in source
391///   order. Both `min(...)` and `MetricCheck::min(...)` are accepted: the
392///   macro prepends `::ktstr::test_support::MetricCheck::` when the
393///   expression doesn't already spell `MetricCheck::` on its callee path,
394///   so bare constructors work without an import and qualified
395///   constructors read naturally in modules that already have
396///   `MetricCheck` in scope.
397/// - `#[metric(name = "...", polarity = ..., unit = "...")]` —
398///   kwarg form. `polarity` is one of `HigherBetter`, `LowerBetter`,
399///   `TargetValue(f64)`, `Unknown`. May repeat; entries accumulate.
400/// - `#[include_files("helper", "config.json", ...)]` — variadic
401///   string literals appended to the emitted `include_files` slice
402///   after the auto-injected binary entry. Each entry passes through
403///   the same resolver used by the CLI `-i` flag (bare names search
404///   host `PATH`; explicit paths must exist; directories are walked).
405///   The primary binary is already packaged automatically, so this
406///   attribute is only needed for auxiliary files the payload
407///   depends on.
408///
409/// # Const name derivation
410///
411/// Strip trailing `"Payload"` suffix (if present), then convert to
412/// `SCREAMING_SNAKE_CASE`. `FioPayload` → `FIO`,
413/// `StressNgPayload` → `STRESS_NG`, `Fio` (no suffix) → `FIO`.
414///
415/// # Example
416///
417/// ```rust,ignore
418/// use ktstr::prelude::*;
419///
420/// #[derive(Payload)]
421/// #[payload(binary = "fio", output = Json)]
422/// #[default_args("--output-format=json", "--minimal")]
423/// #[default_check(exit_code_eq(0))]
424/// #[metric(name = "jobs.0.read.iops", polarity = HigherBetter, unit = "iops")]
425/// struct FioPayload;
426/// ```
427#[proc_macro_derive(
428    Payload,
429    attributes(payload, default_args, default_check, metric, include_files)
430)]
431pub fn derive_payload(input: TokenStream) -> TokenStream {
432    let input = parse_macro_input!(input as DeriveInput);
433    match payload::derive_payload_inner(input) {
434        Ok(ts) => ts.into(),
435        Err(e) => e.to_compile_error().into(),
436    }
437}
438
439/// Generate per-field claim accessors on a stats struct.
440///
441/// See the `claim` module docs for the dispatch rules and label
442/// invariant. Reject non-struct inputs and tuple-struct inputs — the
443/// claim API is keyed on field names, which tuple structs do not have.
444#[proc_macro_derive(Claim, attributes(claim))]
445pub fn derive_claim(input: TokenStream) -> TokenStream {
446    let input = parse_macro_input!(input as DeriveInput);
447    match claim::derive_claim_inner(input) {
448        Ok(ts) => ts.into(),
449        Err(e) => e.to_compile_error().into(),
450    }
451}
452
453/// Convert JSON-like Rust tokens into a `&'static str` at compile time.
454///
455/// Accepts a superset of JSON syntax using Rust token trees:
456/// - Objects: `{ "key": value, ... }`
457/// - Arrays: `[value, ...]`
458/// - Strings: `"hello"`
459/// - Numbers: `42`, `3.14`, `-1`
460/// - Booleans: `true`, `false`
461/// - Null: `null`
462/// - Trailing commas are stripped
463///
464/// ```rust,ignore
465/// const CFG: &str = ktstr::json!({
466///     "layers": [{
467///         "name": "batch",
468///         "kind": { "Grouped": { "cpus_range": [0, 4] } },
469///     }],
470/// });
471/// ```
472#[proc_macro]
473pub fn json(input: TokenStream) -> TokenStream {
474    let mut out = String::new();
475    json::tokens_to_json(&mut out, proc_macro2::TokenStream::from(input));
476    let lit = syn::LitStr::new(&out, proc_macro2::Span::call_site());
477    TokenStream::from(quote! { #lit })
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use quote::quote;
484
485    #[test]
486    fn camel_to_screaming_snake_acronym_run() {
487        assert_eq!(
488            payload::camel_to_screaming_snake("HTTPServer"),
489            "HTTP_SERVER"
490        );
491    }
492
493    #[test]
494    fn camel_to_screaming_snake_single_word() {
495        assert_eq!(payload::camel_to_screaming_snake("Llc"), "LLC");
496    }
497
498    #[test]
499    fn camel_to_screaming_snake_all_caps_passthrough() {
500        assert_eq!(payload::camel_to_screaming_snake("LLC"), "LLC");
501    }
502
503    #[test]
504    fn option_tokens_some_int() {
505        let opt: Option<u32> = Some(42);
506        let ts = ktstr_test::option_tokens(&opt);
507        assert_eq!(ts.to_string(), quote! { Some(42u32) }.to_string());
508    }
509
510    #[test]
511    fn option_tokens_none_int() {
512        let opt: Option<u32> = None;
513        let ts = ktstr_test::option_tokens(&opt);
514        assert_eq!(ts.to_string(), quote! { None }.to_string());
515    }
516
517    #[test]
518    fn option_tokens_some_bool() {
519        let opt: Option<bool> = Some(true);
520        let ts = ktstr_test::option_tokens(&opt);
521        assert_eq!(ts.to_string(), quote! { Some(true) }.to_string());
522    }
523
524    /// Pins the two attribute-name registries the unknown-attribute diagnostic
525    /// is derived from: they must be internally duplicate-free, disjoint (a
526    /// name is a bool flag XOR a value attr, never both), and sum to the full
527    /// accepted set. A maintainer adding an attribute to the wrong slice — or
528    /// to both — silently shifts the user-facing "expected:" list; this catches
529    /// it. (The complementary direction — a name in a slice with no handling
530    /// match arm — is guarded at parse time by the unknown-attribute catch-all
531    /// `assert!`.)
532    #[test]
533    fn attr_name_registries_disjoint_and_complete() {
534        use std::collections::HashSet;
535        let bool_names = ktstr_test::BOOL_ATTR_NAMES;
536        let value_names = ktstr_test::VALUE_ATTR_NAMES;
537        let bool_set: HashSet<&str> = bool_names.iter().copied().collect();
538        let value_set: HashSet<&str> = value_names.iter().copied().collect();
539        // No duplicates within either slice.
540        assert_eq!(
541            bool_set.len(),
542            bool_names.len(),
543            "BOOL_ATTR_NAMES has duplicate entries",
544        );
545        assert_eq!(
546            value_set.len(),
547            value_names.len(),
548            "VALUE_ATTR_NAMES has duplicate entries",
549        );
550        // Disjoint: an attribute is a bool flag XOR a value attr.
551        let overlap: Vec<&str> = bool_set.intersection(&value_set).copied().collect();
552        assert!(
553            overlap.is_empty(),
554            "BOOL_ATTR_NAMES and VALUE_ATTR_NAMES overlap: {overlap:?}",
555        );
556        // Cardinality pin: 16 bool + 50 value = 66 accepted attributes.
557        assert_eq!(bool_names.len(), 16, "bool attribute count changed");
558        assert_eq!(value_names.len(), 50, "value attribute count changed");
559    }
560
561    /// Contract pin: `ktstr_test::AttrValues::default()` is the single source of
562    /// truth for every `#[ktstr_test]` macro default since step 2/4 of
563    /// the parse-loop refactor. Without a field-by-field positive
564    /// assertion a maintainer editing the [`Default`] impl can shift
565    /// any user-visible default (auto_repro, kaslr, memory_mib, the
566    /// gauntlet caps, etc.) with zero test feedback. Same precedent
567    /// as `resolve_host_cgroup_parent_env_unset_returns_default` in
568    /// src/test_support/dispatch_tests.rs pinning a runtime const
569    /// against production source.
570    #[test]
571    fn attr_values_default_matches_documented_macro_defaults() {
572        let d = ktstr_test::AttrValues::default();
573
574        // -- Topology --
575        assert_eq!(d.llcs, ktstr_test::DEFAULT_LLCS);
576        assert_eq!(d.cores, ktstr_test::DEFAULT_CORES);
577        assert_eq!(d.threads, ktstr_test::DEFAULT_THREADS);
578        assert_eq!(d.numa_nodes, 1);
579        assert!(!d.llcs_set);
580        assert!(!d.cores_set);
581        assert!(!d.threads_set);
582        assert!(!d.numa_nodes_set);
583
584        // -- Memory + duration --
585        assert_eq!(d.memory_mib, ktstr_test::DEFAULT_MEMORY_MIB);
586        assert!(!d.memory_mib_set);
587        assert_eq!(d.duration_s, 2);
588        assert!(!d.duration_s_set);
589        assert_eq!(d.cleanup_budget_ms, None);
590        assert_eq!(d.watchdog_timeout_s, 4);
591        assert!(!d.watchdog_timeout_s_set);
592
593        // -- Scheduler refs --
594        assert!(d.scheduler.is_none());
595        assert!(d.payload.is_none());
596        assert!(d.workloads.is_none());
597        assert!(d.staged_schedulers.is_none());
598        assert!(d.bpf_map_write.is_none());
599        assert!(d.post_vm.is_none());
600        assert!(d.post_vm_unconditional.is_none());
601        assert!(d.disk.is_none());
602        assert!(d.networks.is_none());
603
604        // -- Assert overrides --
605        assert_eq!(d.not_starved, None);
606        assert_eq!(d.isolation, None);
607        assert_eq!(d.max_gap_ms, None);
608        assert_eq!(d.max_spread_pct, None);
609        assert_eq!(d.max_imbalance_ratio, None);
610        assert_eq!(d.max_local_dsq_depth, None);
611        assert_eq!(d.fail_on_stall, None);
612        assert_eq!(d.sustained_samples, None);
613        assert_eq!(d.max_throughput_cv, None);
614        assert_eq!(d.min_work_rate, None);
615        assert_eq!(d.max_fallback_rate, None);
616        assert_eq!(d.max_keep_last_rate, None);
617        assert_eq!(d.max_p99_wake_latency_ns, None);
618        assert_eq!(d.max_wake_latency_cv, None);
619        assert_eq!(d.min_iteration_rate, None);
620        assert_eq!(d.max_migration_ratio, None);
621        assert_eq!(d.min_page_locality, None);
622        assert_eq!(d.max_cross_node_migration_ratio, None);
623        assert_eq!(d.max_slow_tier_ratio, None);
624
625        // -- TopologyConstraints --
626        assert_eq!(d.min_numa_nodes, 1);
627        assert!(!d.min_numa_nodes_set);
628        assert_eq!(d.min_llcs, 1);
629        assert!(!d.min_llcs_set);
630        assert!(!d.requires_smt);
631        assert!(!d.requires_smt_set);
632        assert_eq!(d.min_cpus, 1);
633        assert!(!d.min_cpus_set);
634        assert_eq!(d.max_llcs, Some(12));
635        assert!(!d.max_llcs_set);
636        assert_eq!(d.max_numa_nodes, Some(1));
637        assert!(!d.max_numa_nodes_set);
638        assert_eq!(d.max_cpus, Some(192));
639        assert!(!d.max_cpus_set);
640        assert_eq!(d.cpu_budget, None);
641
642        // -- Bool attrs (auto_repro + kaslr default TRUE; others false) --
643        assert!(d.auto_repro);
644        assert!(!d.auto_repro_set);
645        assert!(!d.expect_auto_repro);
646        assert!(!d.expect_auto_repro_set);
647        assert!(!d.performance_mode);
648        assert!(!d.performance_mode_set);
649        assert!(!d.no_perf_mode);
650        assert!(!d.no_perf_mode_set);
651        assert!(!d.expect_err);
652        assert!(!d.expect_err_set);
653        assert!(!d.allow_inconclusive);
654        assert!(!d.allow_inconclusive_set);
655        assert!(!d.host_only);
656        assert!(!d.host_only_set);
657        assert!(!d.ignore_test);
658        assert!(d.kaslr);
659        assert!(!d.kaslr_set);
660        assert!(!d.wprof);
661        assert!(!d.wprof_set);
662        assert_eq!(d.num_snapshots, 0);
663        assert!(!d.num_snapshots_set);
664
665        // -- Strings + tokens --
666        assert!(d.extra_sched_args.is_empty());
667        assert!(d.extra_include_files.is_empty());
668        assert_eq!(d.workload_root_cgroup, None);
669        assert!(d.wprof_args.is_none());
670        assert!(d.expect_scx_bpf_error_contains_tokens.is_none());
671        assert!(d.expect_scx_bpf_error_matches_tokens.is_none());
672        assert!(d.config_expr.is_none());
673        assert!(!d.config_set);
674    }
675
676    // -- expect_auto_repro macro-parse positive tests --
677    //
678    // Synthesize each attribute spelling, invoke ktstr_test_impl
679    // directly (bypassing proc_macro::TokenStream which panics outside
680    // a procedural-macro invocation), parse the output back into a
681    // syn AST, locate the `static __KTSTR_ENTRY_*: KtstrTestEntry =
682    // KtstrTestEntry { ... };` registration emitted by the macro, and
683    // assert the `expect_auto_repro` field is either:
684    //   - absent (omitted spelling — DEFAULT spread carries false), or
685    //   - present with a `Lit::Bool { value: true/false }` value.
686    //
687    // The AST round-trip (rather than substring matching on the
688    // output's `.to_string()`) guards against two failure modes:
689    //   1. proc_macro2 version drift in colon/whitespace formatting —
690    //      a future proc_macro2 release that emits `field: true` (no
691    //      space) vs `field : true` (current) would silently flip a
692    //      substring-matching test from PASS to FAIL or vice versa.
693    //   2. structural defects that a substring check cannot detect —
694    //      wrong outer struct name, wrong field value type (e.g. a
695    //      String literal where a bool is expected), or a phantom
696    //      sub-literal elsewhere in the output that happens to
697    //      contain the same substring.
698    //
699    // For spellings that set expect_auto_repro = true, the cross-
700    // attribute validation pass (added at the same time as the field)
701    // requires a scheduler attribute + wprof attribute to be present.
702    // The fixture inputs satisfy those preconditions so the parser
703    // reaches codegen without rejection.
704
705    /// Type-erased extraction from a `syn::Expr` for the
706    /// [`field_value_in_static_entry`] helper. Each impl panics with
707    /// a descriptive error if the expression shape doesn't match the
708    /// expected literal kind — same wrong-type-rejection contract as
709    /// the single-purpose helper this generalization replaces.
710    trait ExtractFromExpr: Sized {
711        fn extract_or_panic(field_name: &str, expr: &syn::Expr) -> Self;
712    }
713
714    impl ExtractFromExpr for bool {
715        fn extract_or_panic(field_name: &str, expr: &syn::Expr) -> Self {
716            match expr {
717                syn::Expr::Lit(syn::ExprLit {
718                    lit: syn::Lit::Bool(b),
719                    ..
720                }) => b.value,
721                other => panic!("{field_name} field value must be a Lit::Bool; got {other:?}"),
722            }
723        }
724    }
725
726    impl ExtractFromExpr for u32 {
727        fn extract_or_panic(field_name: &str, expr: &syn::Expr) -> Self {
728            match expr {
729                syn::Expr::Lit(syn::ExprLit {
730                    lit: syn::Lit::Int(i),
731                    ..
732                }) => i
733                    .base10_parse::<u32>()
734                    .unwrap_or_else(|e| panic!("{field_name} field value parse as u32: {e}")),
735                other => panic!("{field_name} field value must be a Lit::Int (u32); got {other:?}"),
736            }
737        }
738    }
739
740    impl ExtractFromExpr for u64 {
741        fn extract_or_panic(field_name: &str, expr: &syn::Expr) -> Self {
742            match expr {
743                syn::Expr::Lit(syn::ExprLit {
744                    lit: syn::Lit::Int(i),
745                    ..
746                }) => i
747                    .base10_parse::<u64>()
748                    .unwrap_or_else(|e| panic!("{field_name} field value parse as u64: {e}")),
749                other => panic!("{field_name} field value must be a Lit::Int (u64); got {other:?}"),
750            }
751        }
752    }
753
754    impl ExtractFromExpr for String {
755        fn extract_or_panic(field_name: &str, expr: &syn::Expr) -> Self {
756            match expr {
757                syn::Expr::Lit(syn::ExprLit {
758                    lit: syn::Lit::Str(s),
759                    ..
760                }) => s.value(),
761                other => panic!("{field_name} field value must be a Lit::Str; got {other:?}"),
762            }
763        }
764    }
765
766    impl ExtractFromExpr for syn::Path {
767        fn extract_or_panic(field_name: &str, expr: &syn::Expr) -> Self {
768            match expr {
769                syn::Expr::Path(p) => p.path.clone(),
770                other => panic!("{field_name} field value must be an Expr::Path; got {other:?}"),
771            }
772        }
773    }
774
775    /// Locate the `__KTSTR_ENTRY_*` static the macro emits and return
776    /// the value of its `field_name` field if explicitly set, or
777    /// `None` if the field is absent from the struct literal
778    /// (omitted spellings fall through to the
779    /// `..KtstrTestEntry::DEFAULT` spread).
780    ///
781    /// Generalized from the single-field `expect_auto_repro` helper
782    /// to read any field whose value-type implements
783    /// [`ExtractFromExpr`] (bool, u32, u64, String, syn::Path).
784    /// The wrong-value-type rejection moves into the per-type
785    /// `extract_or_panic` impl — a field whose expression doesn't
786    /// match the requested type panics with a message naming both
787    /// the field and the expected type.
788    ///
789    /// Verifies along the way:
790    /// - exactly one `__KTSTR_ENTRY_*` static is emitted (panics on
791    ///   zero or multiple — a future codegen change that emitted
792    ///   two prefixed statics would silently key off the first
793    ///   without the count assertion),
794    /// - its declared type's last path segment is `KtstrTestEntry`,
795    /// - its initializer is a struct literal whose path's last
796    ///   segment is `KtstrTestEntry` (catches a regression that
797    ///   wrapped the literal in a different outer struct),
798    /// - any present `field_name` field's value matches the
799    ///   requested type (panics via [`ExtractFromExpr::extract_or_panic`]
800    ///   otherwise).
801    fn field_value_in_static_entry<T: ExtractFromExpr>(
802        output: &proc_macro2::TokenStream,
803        field_name: &str,
804    ) -> Option<T> {
805        let file: syn::File =
806            syn::parse2(output.clone()).expect("macro output must parse as a syn::File");
807        let static_candidates: Vec<&syn::ItemStatic> = file
808            .items
809            .iter()
810            .filter_map(|item| match item {
811                syn::Item::Static(s) if s.ident.to_string().starts_with("__KTSTR_ENTRY_") => {
812                    Some(s)
813                }
814                _ => None,
815            })
816            .collect();
817        assert_eq!(
818            static_candidates.len(),
819            1,
820            "macro must emit exactly one __KTSTR_ENTRY_* static; found {}",
821            static_candidates.len()
822        );
823        let static_item = static_candidates[0];
824        let static_type_last = match static_item.ty.as_ref() {
825            syn::Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()),
826            _ => None,
827        };
828        assert_eq!(
829            static_type_last.as_deref(),
830            Some("KtstrTestEntry"),
831            "static type's last path segment must be KtstrTestEntry"
832        );
833        let expr_struct = match static_item.expr.as_ref() {
834            syn::Expr::Struct(s) => s,
835            other => panic!("static initializer must be a struct literal; got {other:?}"),
836        };
837        let struct_last = expr_struct
838            .path
839            .segments
840            .last()
841            .map(|s| s.ident.to_string());
842        assert_eq!(
843            struct_last.as_deref(),
844            Some("KtstrTestEntry"),
845            "struct-literal path's last segment must be KtstrTestEntry"
846        );
847        for field in &expr_struct.fields {
848            let ident_matches = matches!(
849                &field.member,
850                syn::Member::Named(ident) if ident == field_name
851            );
852            if !ident_matches {
853                continue;
854            }
855            return Some(T::extract_or_panic(field_name, &field.expr));
856        }
857        None
858    }
859
860    /// `#[ktstr_test(expect_auto_repro)]` (bare form) emits
861    /// `expect_auto_repro: true` as a field on the
862    /// `KtstrTestEntry` struct literal. Pins the bare-flag arm of
863    /// the macro's bool-slot parser. Gated on the `wprof` feature: the
864    /// attr pairs `wprof` with `expect_auto_repro` (the latter requires the
865    /// former), and the macro only accepts `wprof` when the feature is on —
866    /// so this case can only parse successfully under `--features wprof`.
867    #[cfg(feature = "wprof")]
868    #[test]
869    fn macro_parses_expect_auto_repro_bare_to_true() {
870        let attr = quote! { scheduler = SCHED, wprof, expect_auto_repro };
871        let item = quote! {
872            fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
873                Ok(ktstr::assert::AssertResult::pass())
874            }
875        };
876        let out = ktstr_test::ktstr_test_impl(attr, item)
877            .expect("bare attribute must parse successfully");
878        assert_eq!(
879            field_value_in_static_entry::<bool>(&out, "expect_auto_repro"),
880            Some(true),
881            "bare `expect_auto_repro` must emit a `expect_auto_repro: true` field"
882        );
883    }
884
885    /// `#[ktstr_test(expect_auto_repro = true)]` emits
886    /// `expect_auto_repro: true`. Pins the explicit-true arm. Gated on the
887    /// `wprof` feature (the attr requires `wprof`, accepted only with the
888    /// feature on).
889    #[cfg(feature = "wprof")]
890    #[test]
891    fn macro_parses_expect_auto_repro_explicit_true() {
892        let attr = quote! { scheduler = SCHED, wprof, expect_auto_repro = true };
893        let item = quote! {
894            fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
895                Ok(ktstr::assert::AssertResult::pass())
896            }
897        };
898        let out = ktstr_test::ktstr_test_impl(attr, item)
899            .expect("explicit-true attribute must parse successfully");
900        assert_eq!(
901            field_value_in_static_entry::<bool>(&out, "expect_auto_repro"),
902            Some(true),
903            "explicit `expect_auto_repro = true` must emit a `expect_auto_repro: true` field"
904        );
905    }
906
907    /// `#[ktstr_test(expect_auto_repro = false)]` emits
908    /// `expect_auto_repro: false`. Pins the explicit-false arm
909    /// against a regression that conflated explicit-false with
910    /// omission (which would silently leave DEFAULT untouched and
911    /// lose the user's negative declaration). No cross-attribute
912    /// gates apply when expect_auto_repro is false — the only
913    /// rejection arms trigger on the true value.
914    #[test]
915    fn macro_parses_expect_auto_repro_explicit_false() {
916        let attr = quote! { expect_auto_repro = false };
917        let item = quote! {
918            fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
919                Ok(ktstr::assert::AssertResult::pass())
920            }
921        };
922        let out = ktstr_test::ktstr_test_impl(attr, item)
923            .expect("explicit-false attribute must parse successfully");
924        assert_eq!(
925            field_value_in_static_entry::<bool>(&out, "expect_auto_repro"),
926            Some(false),
927            "explicit `expect_auto_repro = false` must emit a `expect_auto_repro: false` field"
928        );
929    }
930
931    /// Omitting the attribute entirely emits NO
932    /// `expect_auto_repro` field — the generated struct literal
933    /// uses the `..KtstrTestEntry::DEFAULT` spread to inherit the
934    /// false default. Pins backward-compat: an existing
935    /// `#[ktstr_test(...)]` with no expect_auto_repro must not
936    /// gain a phantom field that flips the entry's behavior.
937    #[test]
938    fn macro_parses_omitted_expect_auto_repro_leaves_field_unemitted() {
939        let attr = quote! {};
940        let item = quote! {
941            fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
942                Ok(ktstr::assert::AssertResult::pass())
943            }
944        };
945        let out = ktstr_test::ktstr_test_impl(attr, item)
946            .expect("attribute-less invocation must parse successfully");
947        assert_eq!(
948            field_value_in_static_entry::<bool>(&out, "expect_auto_repro"),
949            None,
950            "omitted attribute must NOT emit any `expect_auto_repro` field — DEFAULT spread carries the false"
951        );
952    }
953
954    /// `#[ktstr_test(scheduler = SCHED, survives_storm)]` emits
955    /// `survives_storm: true`. Pins BOOL_ATTR_NAMES + assign_bool + codegen.
956    /// A scheduler token is present so the mutex does not reject.
957    #[test]
958    fn macro_parses_survives_storm_bare_to_true() {
959        let attr = quote! { scheduler = SCHED, survives_storm };
960        let item = quote! {
961            fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
962                Ok(ktstr::assert::AssertResult::pass())
963            }
964        };
965        let out = ktstr_test::ktstr_test_impl(attr, item)
966            .expect("bare survives_storm with a scheduler must parse");
967        assert_eq!(
968            field_value_in_static_entry::<bool>(&out, "survives_storm"),
969            Some(true),
970            "bare `survives_storm` must emit `survives_storm: true`"
971        );
972    }
973
974    /// `#[ktstr_test(survives_storm = false)]` emits `survives_storm: false`
975    /// and needs no scheduler (the mutex only fires on the true value).
976    #[test]
977    fn macro_parses_survives_storm_explicit_false() {
978        let attr = quote! { survives_storm = false };
979        let item = quote! {
980            fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
981                Ok(ktstr::assert::AssertResult::pass())
982            }
983        };
984        let out = ktstr_test::ktstr_test_impl(attr, item)
985            .expect("explicit-false survives_storm must parse");
986        assert_eq!(
987            field_value_in_static_entry::<bool>(&out, "survives_storm"),
988            Some(false),
989            "explicit `survives_storm = false` must emit `survives_storm: false`"
990        );
991    }
992
993    /// `survives_storm` + `expect_err` is rejected at macro parse —
994    /// contradictory polarity. Pins the `validate_survives_storm_mutex`
995    /// expect_err arm.
996    #[test]
997    fn macro_rejects_survives_storm_with_expect_err() {
998        let attr = quote! { scheduler = SCHED, survives_storm, expect_err = true };
999        let item = quote! {
1000            fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
1001                Ok(ktstr::assert::AssertResult::pass())
1002            }
1003        };
1004        let err = ktstr_test::ktstr_test_impl(attr, item).unwrap_err();
1005        assert!(
1006            err.to_string().contains("survives_storm") && err.to_string().contains("expect_err"),
1007            "diagnostic must name both survives_storm and expect_err: {err}"
1008        );
1009    }
1010
1011    /// `survives_storm` without a scheduler is rejected at macro parse —
1012    /// the kernel default has no scx scheduler to die or be ejected.
1013    #[test]
1014    fn macro_rejects_survives_storm_without_scheduler() {
1015        let attr = quote! { survives_storm };
1016        let item = quote! {
1017            fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
1018                Ok(ktstr::assert::AssertResult::pass())
1019            }
1020        };
1021        let err = ktstr_test::ktstr_test_impl(attr, item).unwrap_err();
1022        assert!(
1023            err.to_string().contains("survives_storm") && err.to_string().contains("scheduler"),
1024            "diagnostic must name survives_storm and the missing scheduler: {err}"
1025        );
1026    }
1027
1028    /// `survives_storm` + `expect_auto_repro` is rejected at macro parse —
1029    /// both are inversion intents (survives_storm forces a death fail to
1030    /// EXIT_FAIL; expect_auto_repro inverts a crash-with-repro fail to
1031    /// PASS). Feature-agnostic: `validate_cross_attr` runs the
1032    /// survives_storm mutex (which fires on the `expect_auto_repro` arm)
1033    /// BEFORE the `#[cfg(not(feature = "wprof"))]` wprof-required rejection,
1034    /// so the diagnostic names both regardless of the `wprof` feature —
1035    /// unlike the positive parse tests, which need `--features wprof` to
1036    /// reach codegen successfully. The runtime twin is
1037    /// `validate_rejects_survives_storm_with_expect_auto_repro`.
1038    #[test]
1039    fn macro_rejects_survives_storm_with_expect_auto_repro() {
1040        let attr = quote! { scheduler = SCHED, wprof, survives_storm, expect_auto_repro };
1041        let item = quote! {
1042            fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
1043                Ok(ktstr::assert::AssertResult::pass())
1044            }
1045        };
1046        let err = ktstr_test::ktstr_test_impl(attr, item).unwrap_err();
1047        assert!(
1048            err.to_string().contains("survives_storm")
1049                && err.to_string().contains("expect_auto_repro"),
1050            "diagnostic must name both survives_storm and expect_auto_repro: {err}"
1051        );
1052    }
1053
1054    // -- check_visible_lit (commit f4018278) --------------------------
1055
1056    #[test]
1057    fn check_visible_lit_visible_string_passes() {
1058        let expr: syn::Expr = syn::parse_quote!("hello");
1059        scheduler::check_visible_lit("hello", &expr, "name")
1060            .expect("non-empty visible string must pass");
1061    }
1062
1063    #[test]
1064    fn check_visible_lit_empty_string_rejected() {
1065        let expr: syn::Expr = syn::parse_quote!("");
1066        let err = scheduler::check_visible_lit("", &expr, "name").unwrap_err();
1067        assert!(
1068            err.to_string()
1069                .contains("`name` must contain at least one visible character"),
1070            "expected `name` visible-empty diagnostic, got: {err}"
1071        );
1072    }
1073
1074    #[test]
1075    fn check_visible_lit_whitespace_only_rejected() {
1076        let expr: syn::Expr = syn::parse_quote!("   ");
1077        let err = scheduler::check_visible_lit("   ", &expr, "binary").unwrap_err();
1078        assert!(
1079            err.to_string()
1080                .contains("`binary` must contain at least one visible character"),
1081            "expected `binary` visible-empty diagnostic, got: {err}"
1082        );
1083    }
1084
1085    #[test]
1086    fn check_visible_lit_invisible_only_rejected() {
1087        let expr: syn::Expr = syn::parse_quote!("zwsp");
1088        let err = scheduler::check_visible_lit("\u{200B}", &expr, "binary_path").unwrap_err();
1089        assert!(
1090            err.to_string()
1091                .contains("`binary_path` must contain at least one visible character"),
1092            "expected `binary_path` visible-empty diagnostic, got: {err}"
1093        );
1094    }
1095
1096    // -- validate_kernel_builtin_pair (commit 753ecf9e) ---------------
1097
1098    #[test]
1099    fn validate_kernel_builtin_pair_both_set_passes() {
1100        let span = proc_macro2::Span::call_site();
1101        scheduler::validate_kernel_builtin_pair(Some(span), Some(span))
1102            .expect("both set is valid (KernelBuiltin)");
1103    }
1104
1105    #[test]
1106    fn validate_kernel_builtin_pair_neither_set_passes() {
1107        scheduler::validate_kernel_builtin_pair(None, None)
1108            .expect("neither set is valid (not KernelBuiltin)");
1109    }
1110
1111    #[test]
1112    fn validate_kernel_builtin_pair_enable_only_rejected() {
1113        let span = proc_macro2::Span::call_site();
1114        let err = scheduler::validate_kernel_builtin_pair(Some(span), None).unwrap_err();
1115        assert!(
1116            err.to_string()
1117                .contains("`kernel_builtin_enable` set without `kernel_builtin_disable`"),
1118            "expected enable-without-disable diagnostic, got: {err}"
1119        );
1120    }
1121
1122    #[test]
1123    fn validate_kernel_builtin_pair_disable_only_rejected() {
1124        let span = proc_macro2::Span::call_site();
1125        let err = scheduler::validate_kernel_builtin_pair(None, Some(span)).unwrap_err();
1126        assert!(
1127            err.to_string()
1128                .contains("`kernel_builtin_disable` set without `kernel_builtin_enable`"),
1129            "expected disable-without-enable diagnostic, got: {err}"
1130        );
1131    }
1132
1133    // -- validate_exactly_one_source (commit 7c796939) ----------------
1134
1135    #[test]
1136    fn validate_exactly_one_source_none_rejected() {
1137        let span = proc_macro2::Span::call_site();
1138        let err = scheduler::validate_exactly_one_source(false, false, false, span).unwrap_err();
1139        assert!(
1140            err.to_string().contains("no scheduler source declared"),
1141            "expected no-source diagnostic, got: {err}"
1142        );
1143    }
1144
1145    #[test]
1146    fn validate_exactly_one_source_only_binary_passes() {
1147        let span = proc_macro2::Span::call_site();
1148        scheduler::validate_exactly_one_source(true, false, false, span)
1149            .expect("binary-only is valid");
1150    }
1151
1152    #[test]
1153    fn validate_exactly_one_source_only_binary_path_passes() {
1154        let span = proc_macro2::Span::call_site();
1155        scheduler::validate_exactly_one_source(false, true, false, span)
1156            .expect("binary_path-only is valid");
1157    }
1158
1159    #[test]
1160    fn validate_exactly_one_source_only_kernel_builtin_passes() {
1161        let span = proc_macro2::Span::call_site();
1162        scheduler::validate_exactly_one_source(false, false, true, span)
1163            .expect("kernel_builtin-only is valid");
1164    }
1165
1166    #[test]
1167    fn validate_exactly_one_source_binary_and_path_rejected() {
1168        let span = proc_macro2::Span::call_site();
1169        let err = scheduler::validate_exactly_one_source(true, true, false, span).unwrap_err();
1170        assert!(
1171            err.to_string()
1172                .contains("more than one scheduler source declared"),
1173            "expected multi-source diagnostic, got: {err}"
1174        );
1175    }
1176
1177    #[test]
1178    fn validate_exactly_one_source_all_three_rejected() {
1179        let span = proc_macro2::Span::call_site();
1180        let err = scheduler::validate_exactly_one_source(true, true, true, span).unwrap_err();
1181        assert!(
1182            err.to_string()
1183                .contains("more than one scheduler source declared"),
1184            "expected multi-source diagnostic, got: {err}"
1185        );
1186    }
1187
1188    // -- validate_kernel_name_collision (commit 7480df1c) -------------
1189
1190    #[test]
1191    fn validate_kernel_name_collision_non_kernel_passes() {
1192        let expr: syn::Expr = syn::parse_quote!("scx_mitosis");
1193        scheduler::validate_kernel_name_collision(true, "scx_mitosis", Some(&expr))
1194            .expect("non-`kernel` name is valid");
1195    }
1196
1197    #[test]
1198    fn validate_kernel_name_collision_not_kernel_builtin_passes() {
1199        let expr: syn::Expr = syn::parse_quote!("kernel");
1200        scheduler::validate_kernel_name_collision(false, "kernel", Some(&expr))
1201            .expect("`kernel` name is valid when not KernelBuiltin variant");
1202    }
1203
1204    #[test]
1205    fn validate_kernel_name_collision_exact_kernel_rejected() {
1206        let expr: syn::Expr = syn::parse_quote!("kernel");
1207        let err =
1208            scheduler::validate_kernel_name_collision(true, "kernel", Some(&expr)).unwrap_err();
1209        assert!(
1210            err.to_string()
1211                .contains("collides with the KernelBuiltin variant's display_name"),
1212            "expected collision diagnostic, got: {err}"
1213        );
1214    }
1215
1216    #[test]
1217    fn validate_kernel_name_collision_case_insensitive_rejected() {
1218        let expr: syn::Expr = syn::parse_quote!("Kernel");
1219        let err =
1220            scheduler::validate_kernel_name_collision(true, "Kernel", Some(&expr)).unwrap_err();
1221        assert!(
1222            err.to_string()
1223                .contains("collides with the KernelBuiltin variant's display_name"),
1224            "expected case-insensitive collision diagnostic, got: {err}"
1225        );
1226    }
1227
1228    #[test]
1229    fn validate_kernel_name_collision_whitespace_padded_rejected() {
1230        let expr: syn::Expr = syn::parse_quote!("  Kernel  ");
1231        let err =
1232            scheduler::validate_kernel_name_collision(true, "  Kernel  ", Some(&expr)).unwrap_err();
1233        assert!(
1234            err.to_string()
1235                .contains("collides with the KernelBuiltin variant's display_name"),
1236            "expected whitespace-insensitive collision diagnostic, got: {err}"
1237        );
1238    }
1239
1240    // -- validate_payload_workloads_dedup (commit 26352fd7) -----------
1241
1242    #[test]
1243    fn validate_payload_workloads_dedup_empty_workloads_passes() {
1244        let payload: Option<syn::Path> = Some(syn::parse_quote!(FIO));
1245        ktstr_test::validate_payload_workloads_dedup(&payload, &[])
1246            .expect("empty workloads is valid");
1247    }
1248
1249    #[test]
1250    fn validate_payload_workloads_dedup_disjoint_passes() {
1251        let payload: Option<syn::Path> = Some(syn::parse_quote!(FIO));
1252        let workloads: Vec<syn::Path> =
1253            vec![syn::parse_quote!(STRESS_NG), syn::parse_quote!(NETPERF)];
1254        ktstr_test::validate_payload_workloads_dedup(&payload, &workloads)
1255            .expect("disjoint workloads is valid");
1256    }
1257
1258    #[test]
1259    fn validate_payload_workloads_dedup_primary_in_workloads_rejected() {
1260        let payload: Option<syn::Path> = Some(syn::parse_quote!(FIO));
1261        let workloads: Vec<syn::Path> = vec![syn::parse_quote!(STRESS_NG), syn::parse_quote!(FIO)];
1262        let err = ktstr_test::validate_payload_workloads_dedup(&payload, &workloads).unwrap_err();
1263        assert!(
1264            err.to_string().contains("appears in both"),
1265            "expected payload-in-workloads diagnostic, got: {err}"
1266        );
1267    }
1268
1269    #[test]
1270    fn validate_payload_workloads_dedup_pairwise_duplicate_rejected() {
1271        let payload: Option<syn::Path> = None;
1272        let workloads: Vec<syn::Path> = vec![syn::parse_quote!(FIO), syn::parse_quote!(FIO)];
1273        let err = ktstr_test::validate_payload_workloads_dedup(&payload, &workloads).unwrap_err();
1274        assert!(
1275            err.to_string().contains("appears twice"),
1276            "expected pairwise-dupe diagnostic, got: {err}"
1277        );
1278    }
1279}