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}