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