use proc_macro::TokenStream;
use quote::{ToTokens, format_ident, quote};
use syn::{
    Data, DeriveInput, Fields, ItemFn, Meta, MetaNameValue, parse::Parser, parse_macro_input,
};

/// Emit `Some(value)` or `None` as token streams.
fn option_tokens<T: ToTokens>(opt: &Option<T>) -> proc_macro2::TokenStream {
    match opt {
        Some(v) => quote! { Some(#v) },
        None => quote! { None },
    }
}

/// Default topology and memory for ktstr_test-annotated functions.
const DEFAULT_LLCS: u32 = 1;
const DEFAULT_CORES: u32 = 2;
const DEFAULT_THREADS: u32 = 1;
const DEFAULT_MEMORY_MB: u32 = 2048;

/// Attribute macro that registers a function as a ktstr integration test.
///
/// The annotated function must have signature `fn(&ktstr::scenario::Ctx) ->
/// anyhow::Result<ktstr::assert::AssertResult>`. The macro:
///
/// 1. Renames the original function to `__ktstr_inner_{name}`.
/// 2. Registers it in the `KTSTR_TESTS` distributed slice via linkme.
/// 3. Emits a `#[test]` wrapper that boots a VM and runs the function
///    inside it.
///
/// Every key=value attribute is optional. The accepted attributes and
/// their defaults are the fields of
/// [`ktstr::test_support::KtstrTestEntry`] (runtime metadata) and
/// [`ktstr::assert::Assert`] (checking thresholds). A few are
/// worth calling out because their names differ from the underlying
/// field or because they have nontrivial defaults:
///
///   - `llcs = N` — number of LLCs (default: inherited from
///     scheduler, or 1).
///   - `cores = N` (default: inherited from scheduler, or 2)
///   - `threads = N` (default: inherited from scheduler, or 1)
///   - `numa_nodes = N` (default: inherited from scheduler, or 1)
///   - `memory_mb = N` (default: 2048)
///   - `duration_s = N` — scenario run duration in seconds; maps
///     onto `KtstrTestEntry::duration`
///   - `watchdog_timeout_s = N` — watchdog fire threshold in
///     seconds; maps onto `KtstrTestEntry::watchdog_timeout`
///   - `cleanup_budget_ms = N` — sub-watchdog cap on host-side VM
///     teardown wall time; maps onto `KtstrTestEntry::cleanup_budget`
///     as `Duration::from_millis(N)`. Default: `None` (unenforced).
///   - `scheduler = PATH` — path to a `const Payload` (typically
///     produced by `Payload::from_scheduler(&...)`). Maps onto
///     `KtstrTestEntry::scheduler`, which is typed
///     `&'static Payload`. Default: `&Payload::KERNEL_DEFAULT`,
///     the no-scx placeholder that runs under the kernel's
///     default scheduler.
///   - `payload = PATH` — path to a `const Payload` used as the
///     primary binary workload (must be `PayloadKind::Binary`;
///     runtime-enforced). Default: `None` (scheduler-only test).
///     Coexists with `scheduler = PATH` — the payload runs *under*
///     the selected scheduler.
///   - `workloads = [PATH, PATH, ...]` — additional `const Payload`
///     references composed with the primary via `Ctx::payload` in
///     the test body. Default: `&[]`. Must not contain the same
///     path as `payload` — reject at expansion time to catch the
///     common "fio as primary AND workload" slip.
///   - `auto_repro = bool` (default: `true`)
///   - `host_only = bool` (default: `false`) — run the test function
///     on the host instead of inside a VM
///   - `post_vm = PATH` — host-side callback invoked after
///     `vm.run()` returns, with access to the full `VmResult`.
///     Use for assertions that need host-side state — e.g.
///     draining `VmResult.snapshot_bridge` after a snapshot
///     capture pipeline fires inside the guest. The function
///     must have signature
///     `fn(&ktstr::vmm::VmResult) -> anyhow::Result<()>`.
///     Default: `None` (no callback).
#[proc_macro_attribute]
pub fn ktstr_test(attr: TokenStream, item: TokenStream) -> TokenStream {
    let input = parse_macro_input!(item as ItemFn);
    let orig_name = &input.sig.ident;
    let inner_name = format_ident!("__ktstr_inner_{}", orig_name);
    let entry_name = format_ident!("__KTSTR_ENTRY_{}", orig_name.to_string().to_uppercase());
    let name_str = orig_name.to_string();

    // Parse attributes
    let mut llcs = DEFAULT_LLCS;
    let mut cores = DEFAULT_CORES;
    let mut threads = DEFAULT_THREADS;
    let mut numa_nodes: u32 = 1;
    let mut llcs_set = false;
    let mut cores_set = false;
    let mut threads_set = false;
    let mut numa_nodes_set = false;
    let mut memory_mb = DEFAULT_MEMORY_MB;
    let mut memory_mb_set = false;
    let mut scheduler: Option<syn::Path> = None;
    let mut payload: Option<syn::Path> = None;
    let mut payload_set = false;
    let mut workloads: Vec<syn::Path> = Vec::new();
    let mut workloads_set = false;
    let mut auto_repro = true;
    let mut auto_repro_set = false;
    let mut not_starved: Option<bool> = None;
    let mut isolation: Option<bool> = None;
    let mut max_gap_ms: Option<u64> = None;
    let mut max_spread_pct: Option<f64> = None;
    let mut max_imbalance_ratio: Option<f64> = None;
    let mut max_local_dsq_depth: Option<u32> = None;
    let mut fail_on_stall: Option<bool> = None;
    let mut sustained_samples: Option<usize> = None;
    let mut max_throughput_cv: Option<f64> = None;
    let mut min_work_rate: Option<f64> = None;
    let mut max_fallback_rate: Option<f64> = None;
    let mut max_keep_last_rate: Option<f64> = None;
    let mut max_p99_wake_latency_ns: Option<u64> = None;
    let mut max_wake_latency_cv: Option<f64> = None;
    let mut min_iteration_rate: Option<f64> = None;
    let mut max_migration_ratio: Option<f64> = None;
    let mut min_page_locality: Option<f64> = None;
    let mut max_cross_node_migration_ratio: Option<f64> = None;
    let mut max_slow_tier_ratio: Option<f64> = None;
    let mut extra_sched_args: Vec<String> = Vec::new();
    let mut required_flags: Vec<proc_macro2::TokenStream> = Vec::new();
    let mut excluded_flags: Vec<proc_macro2::TokenStream> = Vec::new();
    let mut extra_include_files: Vec<String> = Vec::new();
    let mut min_numa_nodes: u32 = 1;
    let mut min_numa_nodes_set = false;
    let mut min_llcs: u32 = 1;
    let mut min_llcs_set = false;
    let mut requires_smt: bool = false;
    let mut requires_smt_set = false;
    let mut min_cpus: u32 = 1;
    let mut min_cpus_set = false;
    let mut max_llcs: Option<u32> = Some(12);
    let mut max_llcs_set = false;
    let mut max_numa_nodes: Option<u32> = Some(1);
    let mut max_numa_nodes_set = false;
    let mut max_cpus: Option<u32> = Some(192);
    let mut max_cpus_set = false;
    let mut watchdog_timeout_s: u64 = 4;
    let mut watchdog_timeout_s_set = false;
    let mut performance_mode: bool = false;
    let mut performance_mode_set = false;
    let mut duration_s: u64 = 2;
    let mut duration_s_set = false;
    let mut workers_per_cgroup: u32 = 2;
    let mut workers_per_cgroup_set = false;
    let mut bpf_map_write: Option<syn::Path> = None;
    let mut expect_err: bool = false;
    let mut expect_err_set = false;
    let mut host_only: bool = false;
    let mut host_only_set = false;
    let mut cleanup_budget_ms: Option<u64> = None;
    let mut post_vm: Option<syn::Path> = None;

    let attr_parser = syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated;
    let parsed_attrs = match attr_parser.parse(attr) {
        Ok(p) => p,
        Err(e) => return e.to_compile_error().into(),
    };

    for meta in &parsed_attrs {
        match meta {
            Meta::NameValue(MetaNameValue { path, value, .. }) => {
                let ident = match path.get_ident() {
                    Some(id) => id.to_string(),
                    None => {
                        return syn::Error::new_spanned(path, "expected identifier")
                            .to_compile_error()
                            .into();
                    }
                };
                match ident.as_str() {
                    "scheduler" => {
                        let p = match value {
                            syn::Expr::Path(ep) => ep.path.clone(),
                            _ => {
                                return syn::Error::new_spanned(
                                    value,
                                    "expected path for scheduler (e.g. MITOSIS or crate::MITOSIS)",
                                )
                                .to_compile_error()
                                .into();
                            }
                        };
                        scheduler = Some(p);
                    }
                    "payload" => {
                        if payload_set {
                            return syn::Error::new_spanned(
                                path,
                                "duplicate `payload = ...` — each test declares at \
                                 most one primary payload; extras belong in \
                                 `workloads = [..]`",
                            )
                            .to_compile_error()
                            .into();
                        }
                        let p = match value {
                            syn::Expr::Path(ep) => ep.path.clone(),
                            _ => {
                                return syn::Error::new_spanned(
                                    value,
                                    "expected path for payload (e.g. FIO or crate::FIO)",
                                )
                                .to_compile_error()
                                .into();
                            }
                        };
                        payload = Some(p);
                        payload_set = true;
                    }
                    "workloads" => {
                        if workloads_set {
                            return syn::Error::new_spanned(
                                path,
                                "duplicate `workloads = [...]` — combine all \
                                 entries into a single array",
                            )
                            .to_compile_error()
                            .into();
                        }
                        let arr = match value {
                            syn::Expr::Array(ea) => ea,
                            _ => {
                                return syn::Error::new_spanned(
                                    value,
                                    "expected array of Payload paths for workloads \
                                     (e.g. [FIO, STRESS_NG])",
                                )
                                .to_compile_error()
                                .into();
                            }
                        };
                        for elem in &arr.elems {
                            match elem {
                                syn::Expr::Path(ep) => workloads.push(ep.path.clone()),
                                _ => {
                                    return syn::Error::new_spanned(
                                        elem,
                                        "expected Payload path in workloads array",
                                    )
                                    .to_compile_error()
                                    .into();
                                }
                            }
                        }
                        workloads_set = true;
                    }
                    "bpf_map_write" => {
                        let p = match value {
                            syn::Expr::Path(ep) => ep.path.clone(),
                            _ => {
                                return syn::Error::new_spanned(
                                    value,
                                    "expected path for bpf_map_write (e.g. BPF_CRASH)",
                                )
                                .to_compile_error()
                                .into();
                            }
                        };
                        bpf_map_write = Some(p);
                    }
                    "post_vm" => {
                        let p = match value {
                            syn::Expr::Path(ep) => ep.path.clone(),
                            _ => {
                                return syn::Error::new_spanned(
                                    value,
                                    "expected path for post_vm (e.g. my_post_vm_check)",
                                )
                                .to_compile_error()
                                .into();
                            }
                        };
                        post_vm = Some(p);
                    }
                    "auto_repro" | "not_starved" | "isolation" | "performance_mode"
                    | "requires_smt" | "expect_err" | "fail_on_stall" | "host_only" => {
                        let lit_bool = match value {
                            syn::Expr::Lit(syn::ExprLit {
                                lit: syn::Lit::Bool(lb),
                                ..
                            }) => lb,
                            _ => {
                                return syn::Error::new_spanned(
                                    value,
                                    format!("expected bool literal for {ident}"),
                                )
                                .to_compile_error()
                                .into();
                            }
                        };
                        match ident.as_str() {
                            "auto_repro" => {
                                auto_repro = lit_bool.value();
                                auto_repro_set = true;
                            }
                            "not_starved" => not_starved = Some(lit_bool.value()),
                            "isolation" => isolation = Some(lit_bool.value()),
                            "performance_mode" => {
                                performance_mode = lit_bool.value();
                                performance_mode_set = true;
                            }
                            "requires_smt" => {
                                requires_smt = lit_bool.value();
                                requires_smt_set = true;
                            }
                            "expect_err" => {
                                expect_err = lit_bool.value();
                                expect_err_set = true;
                            }
                            "fail_on_stall" => fail_on_stall = Some(lit_bool.value()),
                            "host_only" => {
                                host_only = lit_bool.value();
                                host_only_set = true;
                            }
                            _ => unreachable!(),
                        }
                    }
                    "llcs"
                    | "cores"
                    | "threads"
                    | "numa_nodes"
                    | "memory_mb"
                    | "sustained_samples"
                    | "max_gap_ms"
                    | "watchdog_timeout_s"
                    | "duration_s"
                    | "workers_per_cgroup"
                    | "max_local_dsq_depth"
                    | "min_numa_nodes"
                    | "min_llcs"
                    | "min_cpus"
                    | "max_llcs"
                    | "max_numa_nodes"
                    | "max_cpus"
                    | "max_p99_wake_latency_ns"
                    | "cleanup_budget_ms" => {
                        let lit_int = match value {
                            syn::Expr::Lit(syn::ExprLit {
                                lit: syn::Lit::Int(li),
                                ..
                            }) => li,
                            _ => {
                                return syn::Error::new_spanned(value, "expected integer literal")
                                    .to_compile_error()
                                    .into();
                            }
                        };
                        match ident.as_str() {
                            "llcs" => {
                                llcs = lit_int
                                    .base10_parse::<u32>()
                                    .unwrap_or_else(|e| panic!("{e}"));
                                llcs_set = true;
                            }
                            "numa_nodes" => {
                                numa_nodes = lit_int
                                    .base10_parse::<u32>()
                                    .unwrap_or_else(|e| panic!("{e}"));
                                numa_nodes_set = true;
                            }
                            "cores" => {
                                cores = lit_int
                                    .base10_parse::<u32>()
                                    .unwrap_or_else(|e| panic!("{e}"));
                                cores_set = true;
                            }
                            "threads" => {
                                threads = lit_int
                                    .base10_parse::<u32>()
                                    .unwrap_or_else(|e| panic!("{e}"));
                                threads_set = true;
                            }
                            "memory_mb" => {
                                memory_mb = lit_int
                                    .base10_parse::<u32>()
                                    .unwrap_or_else(|e| panic!("{e}"));
                                memory_mb_set = true;
                            }
                            "sustained_samples" => {
                                sustained_samples = Some(
                                    lit_int
                                        .base10_parse::<usize>()
                                        .unwrap_or_else(|e| panic!("{e}")),
                                )
                            }
                            "max_gap_ms" => {
                                max_gap_ms = Some(
                                    lit_int
                                        .base10_parse::<u64>()
                                        .unwrap_or_else(|e| panic!("{e}")),
                                )
                            }
                            "cleanup_budget_ms" => {
                                cleanup_budget_ms = Some(
                                    lit_int
                                        .base10_parse::<u64>()
                                        .unwrap_or_else(|e| panic!("{e}")),
                                )
                            }
                            "watchdog_timeout_s" => {
                                watchdog_timeout_s = lit_int
                                    .base10_parse::<u64>()
                                    .unwrap_or_else(|e| panic!("{e}"));
                                watchdog_timeout_s_set = true;
                            }
                            "duration_s" => {
                                duration_s = lit_int
                                    .base10_parse::<u64>()
                                    .unwrap_or_else(|e| panic!("{e}"));
                                duration_s_set = true;
                            }
                            "workers_per_cgroup" => {
                                workers_per_cgroup = lit_int
                                    .base10_parse::<u32>()
                                    .unwrap_or_else(|e| panic!("{e}"));
                                workers_per_cgroup_set = true;
                            }
                            "max_local_dsq_depth" => {
                                max_local_dsq_depth = Some(
                                    lit_int
                                        .base10_parse::<u32>()
                                        .unwrap_or_else(|e| panic!("{e}")),
                                )
                            }
                            "min_numa_nodes" => {
                                min_numa_nodes = lit_int
                                    .base10_parse::<u32>()
                                    .unwrap_or_else(|e| panic!("{e}"));
                                min_numa_nodes_set = true;
                            }
                            "min_llcs" => {
                                min_llcs = lit_int
                                    .base10_parse::<u32>()
                                    .unwrap_or_else(|e| panic!("{e}"));
                                min_llcs_set = true;
                            }
                            "min_cpus" => {
                                min_cpus = lit_int
                                    .base10_parse::<u32>()
                                    .unwrap_or_else(|e| panic!("{e}"));
                                min_cpus_set = true;
                            }
                            "max_llcs" => {
                                max_llcs = Some(
                                    lit_int
                                        .base10_parse::<u32>()
                                        .unwrap_or_else(|e| panic!("{e}")),
                                );
                                max_llcs_set = true;
                            }
                            "max_numa_nodes" => {
                                max_numa_nodes = Some(
                                    lit_int
                                        .base10_parse::<u32>()
                                        .unwrap_or_else(|e| panic!("{e}")),
                                );
                                max_numa_nodes_set = true;
                            }
                            "max_cpus" => {
                                max_cpus = Some(
                                    lit_int
                                        .base10_parse::<u32>()
                                        .unwrap_or_else(|e| panic!("{e}")),
                                );
                                max_cpus_set = true;
                            }
                            "max_p99_wake_latency_ns" => {
                                max_p99_wake_latency_ns = Some(
                                    lit_int
                                        .base10_parse::<u64>()
                                        .unwrap_or_else(|e| panic!("{e}")),
                                )
                            }
                            _ => unreachable!(),
                        }
                    }
                    "max_imbalance_ratio"
                    | "max_fallback_rate"
                    | "max_keep_last_rate"
                    | "max_spread_pct"
                    | "max_throughput_cv"
                    | "min_work_rate"
                    | "max_wake_latency_cv"
                    | "min_iteration_rate"
                    | "max_migration_ratio"
                    | "min_page_locality"
                    | "max_cross_node_migration_ratio"
                    | "max_slow_tier_ratio" => {
                        let lit_float = match value {
                            syn::Expr::Lit(syn::ExprLit {
                                lit: syn::Lit::Float(lf),
                                ..
                            }) => lf,
                            _ => {
                                return syn::Error::new_spanned(
                                    value,
                                    format!("expected float literal for {ident}"),
                                )
                                .to_compile_error()
                                .into();
                            }
                        };
                        let v = lit_float
                            .base10_parse::<f64>()
                            .unwrap_or_else(|e| panic!("{e}"));
                        match ident.as_str() {
                            "max_imbalance_ratio" => max_imbalance_ratio = Some(v),
                            "max_fallback_rate" => max_fallback_rate = Some(v),
                            "max_keep_last_rate" => max_keep_last_rate = Some(v),
                            "max_spread_pct" => max_spread_pct = Some(v),
                            "max_throughput_cv" => max_throughput_cv = Some(v),
                            "min_work_rate" => min_work_rate = Some(v),
                            "max_wake_latency_cv" => max_wake_latency_cv = Some(v),
                            "min_iteration_rate" => min_iteration_rate = Some(v),
                            "max_migration_ratio" => max_migration_ratio = Some(v),
                            "min_page_locality" => min_page_locality = Some(v),
                            "max_cross_node_migration_ratio" => {
                                max_cross_node_migration_ratio = Some(v)
                            }
                            "max_slow_tier_ratio" => max_slow_tier_ratio = Some(v),
                            _ => unreachable!(),
                        }
                    }
                    "extra_sched_args" => {
                        let arr = match value {
                            syn::Expr::Array(ea) => ea,
                            _ => {
                                return syn::Error::new_spanned(
                                    value,
                                    "expected array of string literals for extra_sched_args",
                                )
                                .to_compile_error()
                                .into();
                            }
                        };
                        for elem in &arr.elems {
                            match elem {
                                syn::Expr::Lit(syn::ExprLit {
                                    lit: syn::Lit::Str(ls),
                                    ..
                                }) => extra_sched_args.push(ls.value()),
                                _ => {
                                    return syn::Error::new_spanned(
                                        elem,
                                        "expected string literal in extra_sched_args",
                                    )
                                    .to_compile_error()
                                    .into();
                                }
                            }
                        }
                    }
                    "extra_include_files" => {
                        let arr = match value {
                            syn::Expr::Array(ea) => ea,
                            _ => {
                                return syn::Error::new_spanned(
                                    value,
                                    "expected array of string literals for extra_include_files",
                                )
                                .to_compile_error()
                                .into();
                            }
                        };
                        for elem in &arr.elems {
                            match elem {
                                syn::Expr::Lit(syn::ExprLit {
                                    lit: syn::Lit::Str(ls),
                                    ..
                                }) => extra_include_files.push(ls.value()),
                                _ => {
                                    return syn::Error::new_spanned(
                                        elem,
                                        "expected string literal in extra_include_files",
                                    )
                                    .to_compile_error()
                                    .into();
                                }
                            }
                        }
                    }
                    "required_flags" | "excluded_flags" => {
                        let arr = match value {
                            syn::Expr::Array(ea) => ea,
                            _ => {
                                return syn::Error::new_spanned(
                                    value,
                                    format!("expected array for {ident}"),
                                )
                                .to_compile_error()
                                .into();
                            }
                        };
                        let target = match ident.as_str() {
                            "required_flags" => &mut required_flags,
                            "excluded_flags" => &mut excluded_flags,
                            _ => unreachable!(),
                        };
                        for elem in &arr.elems {
                            match elem {
                                syn::Expr::Lit(syn::ExprLit {
                                    lit: syn::Lit::Str(ls),
                                    ..
                                }) => {
                                    let val = ls.value();
                                    target.push(quote! { #val });
                                }
                                syn::Expr::Path(_) => {
                                    target.push(quote! { #elem });
                                }
                                _ => {
                                    return syn::Error::new_spanned(
                                        elem,
                                        format!(
                                            "expected string literal or path expression in {ident}"
                                        ),
                                    )
                                    .to_compile_error()
                                    .into();
                                }
                            }
                        }
                    }
                    _ => {
                        return syn::Error::new_spanned(
                            path,
                            format!("unknown attribute `{ident}`, expected: llcs, cores, threads, numa_nodes, memory_mb, scheduler, payload, workloads, auto_repro, not_starved, isolation, max_gap_ms, max_spread_pct, max_throughput_cv, min_work_rate, max_p99_wake_latency_ns, max_wake_latency_cv, min_iteration_rate, max_migration_ratio, max_imbalance_ratio, max_local_dsq_depth, fail_on_stall, sustained_samples, max_fallback_rate, max_keep_last_rate, min_page_locality, max_cross_node_migration_ratio, max_slow_tier_ratio, extra_sched_args, required_flags, excluded_flags, min_numa_nodes, min_llcs, requires_smt, min_cpus, max_llcs, max_numa_nodes, max_cpus, watchdog_timeout_s, performance_mode, duration_s, workers_per_cgroup, bpf_map_write, expect_err, host_only, cleanup_budget_ms, post_vm"),
                        )
                        .to_compile_error()
                        .into();
                    }
                }
            }
            other => {
                return syn::Error::new_spanned(other, "expected `key = value`")
                    .to_compile_error()
                    .into();
            }
        }
    }

    // Mutual exclusion: the primary payload must not also appear
    // in the workloads array. A Rust `syn::Path` is compared via
    // `ToTokens` so `FIO` and `crate::FIO` remain distinct strings —
    // this catches the common in-file alias case (same ident both
    // places) but not resolved-path-identity, which is impossible
    // to verify at macro expansion time. Runtime validation can
    // add path-deduplication after `payload`/`workloads` are read
    // back from the registered KtstrTestEntry.
    if let Some(primary) = payload.as_ref() {
        let primary_repr = primary.to_token_stream().to_string();
        for w in &workloads {
            if w.to_token_stream().to_string() == primary_repr {
                return syn::Error::new_spanned(
                    w,
                    format!(
                        "`{primary_repr}` appears in both `payload = ...` and \
                         `workloads = [..]` — pick one. The primary payload \
                         runs as the test's main workload; entries in \
                         `workloads` are composed alongside it."
                    ),
                )
                .to_compile_error()
                .into();
            }
        }
    }

    // Pairwise dedup inside the workloads array itself. `[FIO, FIO]`
    // is almost always a typo — a test author meant to list two
    // distinct payloads and accidentally repeated one. Same caveat
    // as the payload/workloads cross-check: token-string equality
    // catches in-file aliases (`FIO` == `FIO`) but not resolved-path
    // identity (`FIO` vs `crate::FIO`).
    for (i, wi_path) in workloads.iter().enumerate() {
        let wi = wi_path.to_token_stream().to_string();
        for wj_path in workloads.iter().skip(i + 1) {
            let wj = wj_path.to_token_stream().to_string();
            if wi == wj {
                return syn::Error::new_spanned(
                    wj_path,
                    format!(
                        "`{wi}` appears twice in `workloads = [..]` — each \
                         workload entry must be distinct. Remove the \
                         duplicate or compose the payload once and rely \
                         on runtime scheduling to spread it across cgroups."
                    ),
                )
                .to_compile_error()
                .into();
            }
        }
    }

    // Reject zero values at compile time (only for explicitly set values).
    if llcs_set && llcs == 0 {
        return syn::Error::new(
            proc_macro2::Span::call_site(),
            "llcs must be > 0 (a topology with zero LLCs has zero CPUs — \
             `total_cpus = llcs * cores * threads` — so the VM would boot \
             with no addressable processors)",
        )
        .to_compile_error()
        .into();
    }
    if cores_set && cores == 0 {
        return syn::Error::new(
            proc_macro2::Span::call_site(),
            "cores must be > 0 (a topology with zero cores per LLC has \
             zero CPUs — `total_cpus = llcs * cores * threads` — so the \
             VM would boot with no addressable processors)",
        )
        .to_compile_error()
        .into();
    }
    if threads_set && threads == 0 {
        return syn::Error::new(
            proc_macro2::Span::call_site(),
            "threads must be > 0 (a topology with zero threads per core \
             has zero CPUs — `total_cpus = llcs * cores * threads` — so \
             the VM would boot with no addressable processors)",
        )
        .to_compile_error()
        .into();
    }
    if numa_nodes_set && numa_nodes == 0 {
        return syn::Error::new(
            proc_macro2::Span::call_site(),
            "numa_nodes must be > 0 (a topology with zero NUMA nodes has \
             nothing to attach LLCs or memory to; every downstream \
             accessor would observe an empty node set)",
        )
        .to_compile_error()
        .into();
    }
    if llcs_set && numa_nodes_set && !llcs.is_multiple_of(numa_nodes) {
        return syn::Error::new(
            proc_macro2::Span::call_site(),
            format!("llcs ({llcs}) must be divisible by numa_nodes ({numa_nodes})"),
        )
        .to_compile_error()
        .into();
    }
    if memory_mb == 0 {
        return syn::Error::new(
            proc_macro2::Span::call_site(),
            "memory_mb must be > 0 (a VM with zero memory cannot boot)",
        )
        .to_compile_error()
        .into();
    }
    if duration_s == 0 {
        return syn::Error::new(
            proc_macro2::Span::call_site(),
            "duration_s must be > 0 (a zero-duration run never exercises the \
             scheduler and produces no data for assertions)",
        )
        .to_compile_error()
        .into();
    }
    if workers_per_cgroup == 0 {
        return syn::Error::new(
            proc_macro2::Span::call_site(),
            "workers_per_cgroup must be > 0 (a zero-worker cgroup emits no \
             WorkerReports and assertions vacuously pass)",
        )
        .to_compile_error()
        .into();
    }
    if cleanup_budget_ms == Some(0) {
        return syn::Error::new(
            proc_macro2::Span::call_site(),
            "cleanup_budget_ms must be > 0 — a zero budget would \
             reject every successful run (any measurable cleanup \
             duration overshoots zero). Omit the attribute to \
             disable the check.",
        )
        .to_compile_error()
        .into();
    }
    // Validate explicitly set constraint values. When a field is
    // inherited from the scheduler, the proc macro doesn't know the
    // value so cross-field validation is deferred to runtime.
    if max_llcs_set && max_llcs == Some(0) {
        return syn::Error::new(
            proc_macro2::Span::call_site(),
            "max_llcs must be > 0 (a zero cap excludes every host from \
             the gauntlet — use a non-zero cap, or omit the field to \
             use the default)",
        )
        .to_compile_error()
        .into();
    }
    if max_numa_nodes_set && max_numa_nodes == Some(0) {
        return syn::Error::new(
            proc_macro2::Span::call_site(),
            "max_numa_nodes must be > 0 (a zero cap excludes every host \
             from the gauntlet — use a non-zero cap, or omit the field \
             to inherit the scheduler-level default)",
        )
        .to_compile_error()
        .into();
    }
    if max_cpus_set && max_cpus == Some(0) {
        return syn::Error::new(
            proc_macro2::Span::call_site(),
            "max_cpus must be > 0 (a zero cap excludes every host from \
             the gauntlet — use a non-zero cap, or omit the field to \
             use the default)",
        )
        .to_compile_error()
        .into();
    }
    if min_llcs_set && max_llcs_set && matches!(max_llcs, Some(m) if m < min_llcs) {
        let m = max_llcs.unwrap();
        return syn::Error::new(
            proc_macro2::Span::call_site(),
            format!("min_llcs ({min_llcs}) exceeds max_llcs ({m}). Set max_llcs explicitly."),
        )
        .to_compile_error()
        .into();
    }
    if min_numa_nodes_set
        && max_numa_nodes_set
        && matches!(max_numa_nodes, Some(m) if m < min_numa_nodes)
    {
        let m = max_numa_nodes.unwrap();
        return syn::Error::new(
            proc_macro2::Span::call_site(),
            format!(
                "min_numa_nodes ({min_numa_nodes}) exceeds max_numa_nodes ({m}). Set max_numa_nodes explicitly."
            ),
        )
        .to_compile_error()
        .into();
    }
    if min_cpus_set && max_cpus_set && matches!(max_cpus, Some(m) if m < min_cpus) {
        let m = max_cpus.unwrap();
        return syn::Error::new(
            proc_macro2::Span::call_site(),
            format!("min_cpus ({min_cpus}) exceeds max_cpus ({m}). Set max_cpus explicitly."),
        )
        .to_compile_error()
        .into();
    }

    // Build the scheduler reference token. The `scheduler` slot on
    // `KtstrTestEntry` is `&'static Payload`; callers pass either a
    // `{NAME}_PAYLOAD` wrapper emitted by `#[derive(Scheduler)]` or
    // `Payload::KERNEL_DEFAULT` directly. The default is the
    // kernel-default placeholder.
    let scheduler_tokens = match &scheduler {
        Some(p) => {
            quote! { &#p }
        }
        None => {
            quote! { &::ktstr::test_support::Payload::KERNEL_DEFAULT }
        }
    };

    // Build topology tokens. Each dimension independently inherits from
    // the scheduler payload's topology when not explicitly set.
    // `Payload::topology()` is a `const fn` that returns the inner
    // scheduler's `Topology` for scheduler-kind payloads and
    // `Topology::DEFAULT_FOR_PAYLOAD` for binary-kind, so the field
    // access below remains valid inside a `const` initializer.
    let llcs_tokens = if llcs_set {
        let l = llcs;
        quote! { #l }
    } else if let Some(ref p) = scheduler {
        quote! { #p.topology().llcs }
    } else {
        let l = llcs;
        quote! { #l }
    };
    let cores_tokens = if cores_set {
        let c = cores;
        quote! { #c }
    } else if let Some(ref p) = scheduler {
        quote! { #p.topology().cores_per_llc }
    } else {
        let c = cores;
        quote! { #c }
    };
    let threads_tokens = if threads_set {
        let t = threads;
        quote! { #t }
    } else if let Some(ref p) = scheduler {
        quote! { #p.topology().threads_per_core }
    } else {
        let t = threads;
        quote! { #t }
    };
    let numa_nodes_tokens = if numa_nodes_set {
        let n = numa_nodes;
        quote! { #n }
    } else if let Some(ref p) = scheduler {
        quote! { #p.topology().numa_nodes }
    } else {
        let n = numa_nodes;
        quote! { #n }
    };
    let topology_tokens = quote! {
        ::ktstr::test_support::Topology {
            llcs: #llcs_tokens,
            cores_per_llc: #cores_tokens,
            threads_per_core: #threads_tokens,
            numa_nodes: #numa_nodes_tokens,
            nodes: None,
            distances: None,
        }
    };

    // Build the renamed inner function
    let vis = &input.vis;
    let sig = &input.sig;
    let block = &input.block;
    let attrs = &input.attrs;
    let inner_sig = syn::Signature {
        ident: inner_name.clone(),
        ..sig.clone()
    };

    // Build Assert field tokens.
    let not_starved_tokens = option_tokens(&not_starved);
    let isolation_tokens = option_tokens(&isolation);
    let gap_tokens = option_tokens(&max_gap_ms);
    let spread_tokens = option_tokens(&max_spread_pct);
    let imbalance_tokens = option_tokens(&max_imbalance_ratio);
    let dsq_tokens = option_tokens(&max_local_dsq_depth);
    let stall_tokens = option_tokens(&fail_on_stall);
    let sustained_tokens = option_tokens(&sustained_samples);
    let throughput_cv_tokens = option_tokens(&max_throughput_cv);
    let work_rate_tokens = option_tokens(&min_work_rate);
    let fallback_rate_tokens = option_tokens(&max_fallback_rate);
    let keep_last_rate_tokens = option_tokens(&max_keep_last_rate);
    let p99_wake_tokens = option_tokens(&max_p99_wake_latency_ns);
    let wake_cv_tokens = option_tokens(&max_wake_latency_cv);
    let iter_rate_tokens = option_tokens(&min_iteration_rate);
    let mig_ratio_tokens = option_tokens(&max_migration_ratio);
    let page_locality_tokens = option_tokens(&min_page_locality);
    let cross_node_mig_tokens = option_tokens(&max_cross_node_migration_ratio);
    let slow_tier_tokens = option_tokens(&max_slow_tier_ratio);

    // `cleanup_budget_ms` lives on the macro side as `Option<u64>` of
    // milliseconds; the entry field is `Option<Duration>`, so wrap
    // the literal in `Duration::from_millis(...)` at emission time.
    let cleanup_budget_tokens = match cleanup_budget_ms {
        Some(ms) => {
            quote! { ::core::option::Option::Some(::std::time::Duration::from_millis(#ms)) }
        }
        None => quote! { ::core::option::Option::None },
    };

    let bpf_map_write_tokens = match &bpf_map_write {
        Some(p) => quote! { &[&#p] },
        None => quote! { &[] },
    };

    // Emit `Option<&'static Payload>` for the primary payload. The
    // user supplies a path (`&FIO` equivalent in source), so we
    // wrap it in `Some(&#p)` at emission time to preserve the
    // `entry.payload: Option<&'static Payload>` field type.
    let payload_tokens = match &payload {
        Some(p) => quote! { Some(&#p) },
        None => quote! { None },
    };
    // Emit `&'static [&'static Payload]` for workloads. Each path
    // the user supplied is a `const Payload`; we take `&` on each
    // to match the stored type.
    let workload_refs: Vec<proc_macro2::TokenStream> =
        workloads.iter().map(|p| quote! { &#p }).collect();
    let workloads_tokens = quote! { &[#(#workload_refs),*] };

    // Conditionally-emitted KtstrTestEntry fields. Each block is
    // either an empty TokenStream (so the field is left to
    // `..KtstrTestEntry::DEFAULT` in the spread) or a `field: VAL,`
    // pair when the macro must override the default. This pattern
    // means new struct fields with sane defaults need no macro
    // change — adding to KtstrTestEntry::DEFAULT alone is enough.
    let memory_mb_field = if memory_mb_set {
        quote! { memory_mb: #memory_mb, }
    } else {
        quote! {}
    };
    let payload_field = if payload_set {
        quote! { payload: #payload_tokens, }
    } else {
        quote! {}
    };
    let workloads_field = if workloads_set {
        quote! { workloads: #workloads_tokens, }
    } else {
        quote! {}
    };
    let auto_repro_field = if auto_repro_set {
        quote! { auto_repro: #auto_repro, }
    } else {
        quote! {}
    };
    // Any of the per-check assert fields supplied by the attribute
    // forces emission of the full `assert: Assert { .. }` block. When
    // none are set the spread inherits `Assert::NO_OVERRIDES` from
    // `KtstrTestEntry::DEFAULT`, which is bit-for-bit identical to
    // the all-`None` Assert the prior unconditional emission produced.
    let any_assert_set = not_starved.is_some()
        || isolation.is_some()
        || max_gap_ms.is_some()
        || max_spread_pct.is_some()
        || max_throughput_cv.is_some()
        || min_work_rate.is_some()
        || max_p99_wake_latency_ns.is_some()
        || max_wake_latency_cv.is_some()
        || min_iteration_rate.is_some()
        || max_migration_ratio.is_some()
        || max_imbalance_ratio.is_some()
        || max_local_dsq_depth.is_some()
        || fail_on_stall.is_some()
        || sustained_samples.is_some()
        || max_fallback_rate.is_some()
        || max_keep_last_rate.is_some()
        || min_page_locality.is_some()
        || max_cross_node_migration_ratio.is_some()
        || max_slow_tier_ratio.is_some();
    let assert_field = if any_assert_set {
        quote! {
            assert: ::ktstr::assert::Assert {
                not_starved: #not_starved_tokens,
                isolation: #isolation_tokens,
                max_gap_ms: #gap_tokens,
                max_spread_pct: #spread_tokens,
                max_throughput_cv: #throughput_cv_tokens,
                min_work_rate: #work_rate_tokens,
                max_p99_wake_latency_ns: #p99_wake_tokens,
                max_wake_latency_cv: #wake_cv_tokens,
                min_iteration_rate: #iter_rate_tokens,
                max_migration_ratio: #mig_ratio_tokens,
                max_imbalance_ratio: #imbalance_tokens,
                max_local_dsq_depth: #dsq_tokens,
                fail_on_stall: #stall_tokens,
                sustained_samples: #sustained_tokens,
                max_fallback_rate: #fallback_rate_tokens,
                max_keep_last_rate: #keep_last_rate_tokens,
                min_page_locality: #page_locality_tokens,
                max_cross_node_migration_ratio: #cross_node_mig_tokens,
                max_slow_tier_ratio: #slow_tier_tokens,
            },
        }
    } else {
        quote! {}
    };
    let extra_sched_args_field = if extra_sched_args.is_empty() {
        quote! {}
    } else {
        quote! { extra_sched_args: &[#(#extra_sched_args),*], }
    };
    let required_flags_field = if required_flags.is_empty() {
        quote! {}
    } else {
        quote! { required_flags: &[#(#required_flags),*], }
    };
    let excluded_flags_field = if excluded_flags.is_empty() {
        quote! {}
    } else {
        quote! { excluded_flags: &[#(#excluded_flags),*], }
    };
    let watchdog_timeout_field = if watchdog_timeout_s_set {
        quote! { watchdog_timeout: ::std::time::Duration::from_secs(#watchdog_timeout_s), }
    } else {
        quote! {}
    };
    let bpf_map_write_field = if bpf_map_write.is_some() {
        quote! { bpf_map_write: #bpf_map_write_tokens, }
    } else {
        quote! {}
    };
    let performance_mode_field = if performance_mode_set {
        quote! { performance_mode: #performance_mode, }
    } else {
        quote! {}
    };
    let duration_field = if duration_s_set {
        quote! { duration: ::std::time::Duration::from_secs(#duration_s), }
    } else {
        quote! {}
    };
    let workers_per_cgroup_field = if workers_per_cgroup_set {
        quote! { workers_per_cgroup: #workers_per_cgroup, }
    } else {
        quote! {}
    };
    let expect_err_field = if expect_err_set {
        quote! { expect_err: #expect_err, }
    } else {
        quote! {}
    };
    let host_only_field = if host_only_set {
        quote! { host_only: #host_only, }
    } else {
        quote! {}
    };
    let extra_include_files_field = if extra_include_files.is_empty() {
        quote! {}
    } else {
        quote! { extra_include_files: &[#(#extra_include_files),*], }
    };
    let cleanup_budget_field = if cleanup_budget_ms.is_some() {
        quote! { cleanup_budget: #cleanup_budget_tokens, }
    } else {
        quote! {}
    };
    // The user-supplied path resolves to a `fn(&VmResult) ->
    // Result<()>`. Wrap in `Some(...)` so the entry's
    // `Option<fn(&VmResult) -> Result<()>>` field accepts it.
    let post_vm_field = if let Some(ref p) = post_vm {
        quote! { post_vm: Some(#p), }
    } else {
        quote! {}
    };

    let test_body = if expect_err {
        quote! {
            match ::ktstr::test_support::run_ktstr_test(&#entry_name) {
                Ok(_) => panic!("expected test to fail but it passed"),
                Err(e) if ::ktstr::test_support::is_kernel_unavailable(&e) => {
                    // Harness not configured (no kernel resolved):
                    // running outside `cargo ktstr test` produces no
                    // expected failure either, because the test
                    // never ran. Skip cleanly so a developer running
                    // `cargo nextest run` directly sees a SKIP
                    // banner rather than a confusing "no kernel
                    // found" panic.
                    eprintln!("ktstr: SKIP: harness not configured: {e:#}");
                    return;
                }
                Err(e) if ::ktstr::test_support::is_resource_contention(&e) => {
                    // Resource contention is host-infra, not a test
                    // outcome: emit the canonical SKIP banner and
                    // early-return so libtest sees pass. Otherwise an
                    // expect_err test would mask host contention as a
                    // satisfied "expected failure", hiding the
                    // contention from stats tooling and producing a
                    // false-positive pass for the wrong reason.
                    eprintln!("ktstr: SKIP: resource contention: {e:#}");
                    return;
                }
                Err(_) => {}
            }
        }
    } else {
        quote! {
            match ::ktstr::test_support::run_ktstr_test(&#entry_name) {
                Ok(_) => {}
                Err(e) if ::ktstr::test_support::is_kernel_unavailable(&e) => {
                    // Harness not configured (no kernel resolved):
                    // the binary was likely invoked outside
                    // `cargo ktstr test`, which builds and injects a
                    // kernel automatically. Skip cleanly so a
                    // developer running `cargo nextest run` directly
                    // sees a SKIP banner rather than a confusing
                    // "no kernel found" panic.
                    eprintln!("ktstr: SKIP: harness not configured: {e:#}");
                    return;
                }
                Err(e) if ::ktstr::test_support::is_resource_contention(&e) => {
                    // Resource contention is host-infra, not a test
                    // failure: emit the canonical SKIP banner and
                    // early-return so libtest sees pass. The skip
                    // sidecar is recorded inside `run_ktstr_test_inner`
                    // at every contention site, so stats tooling still
                    // sees the skip without a panic-driven retry.
                    eprintln!("ktstr: SKIP: resource contention: {e:#}");
                    return;
                }
                Err(e) => panic!("{e:#}"),
            }
        }
    };

    // Build constraint tokens. Each field independently inherits from
    // the scheduler's constraints when not explicitly set, following
    // the same pattern as topology inheritance.
    let min_numa_nodes_tokens = if min_numa_nodes_set {
        let v = min_numa_nodes;
        quote! { #v }
    } else if let Some(ref p) = scheduler {
        quote! { #p.constraints().min_numa_nodes }
    } else {
        let v = min_numa_nodes;
        quote! { #v }
    };
    let max_numa_nodes_tokens = if max_numa_nodes_set {
        let t = option_tokens(&max_numa_nodes);
        quote! { #t }
    } else if let Some(ref p) = scheduler {
        quote! { #p.constraints().max_numa_nodes }
    } else {
        let t = option_tokens(&max_numa_nodes);
        quote! { #t }
    };
    let min_llcs_tokens = if min_llcs_set {
        let v = min_llcs;
        quote! { #v }
    } else if let Some(ref p) = scheduler {
        quote! { #p.constraints().min_llcs }
    } else {
        let v = min_llcs;
        quote! { #v }
    };
    let max_llcs_tokens = if max_llcs_set {
        let t = option_tokens(&max_llcs);
        quote! { #t }
    } else if let Some(ref p) = scheduler {
        quote! { #p.constraints().max_llcs }
    } else {
        let t = option_tokens(&max_llcs);
        quote! { #t }
    };
    let requires_smt_tokens = if requires_smt_set {
        quote! { #requires_smt }
    } else if let Some(ref p) = scheduler {
        quote! { #p.constraints().requires_smt }
    } else {
        quote! { #requires_smt }
    };
    let min_cpus_tokens = if min_cpus_set {
        let v = min_cpus;
        quote! { #v }
    } else if let Some(ref p) = scheduler {
        quote! { #p.constraints().min_cpus }
    } else {
        let v = min_cpus;
        quote! { #v }
    };
    let max_cpus_tokens = if max_cpus_set {
        let t = option_tokens(&max_cpus);
        quote! { #t }
    } else if let Some(ref p) = scheduler {
        quote! { #p.constraints().max_cpus }
    } else {
        let t = option_tokens(&max_cpus);
        quote! { #t }
    };

    let expanded = quote! {
        #(#attrs)*
        #vis #inner_sig #block

        #[::ktstr::__private::linkme::distributed_slice(::ktstr::test_support::KTSTR_TESTS)]
        #[linkme(crate = ::ktstr::__private::linkme)]
        static #entry_name: ::ktstr::test_support::KtstrTestEntry = ::ktstr::test_support::KtstrTestEntry {
            // Always-emit fields. `name`/`func` are macro-generated;
            // `topology`/`constraints` inherit from the scheduler
            // payload via const accessors that the spread cannot
            // recover; `scheduler` substitutes
            // `Payload::KERNEL_DEFAULT` when no `scheduler = ...`
            // attribute was supplied. Every remaining field below
            // (memory_mb, payload, workloads, auto_repro, assert,
            // extra_sched_args, ..., disk, and any future addition)
            // falls through to `..KtstrTestEntry::DEFAULT` when the
            // attribute did not specify a value, so future fields
            // with sane defaults need no macro change at all.
            name: #name_str,
            func: #inner_name,
            topology: #topology_tokens,
            constraints: ::ktstr::test_support::TopologyConstraints {
                min_numa_nodes: #min_numa_nodes_tokens,
                max_numa_nodes: #max_numa_nodes_tokens,
                min_llcs: #min_llcs_tokens,
                max_llcs: #max_llcs_tokens,
                requires_smt: #requires_smt_tokens,
                min_cpus: #min_cpus_tokens,
                max_cpus: #max_cpus_tokens,
            },
            scheduler: #scheduler_tokens,
            #memory_mb_field
            #payload_field
            #workloads_field
            #auto_repro_field
            #assert_field
            #extra_sched_args_field
            #required_flags_field
            #excluded_flags_field
            #watchdog_timeout_field
            #bpf_map_write_field
            #performance_mode_field
            #duration_field
            #workers_per_cgroup_field
            #expect_err_field
            #host_only_field
            #extra_include_files_field
            #cleanup_budget_field
            #post_vm_field
            ..::ktstr::test_support::KtstrTestEntry::DEFAULT
        };

        #[test]
        fn #orig_name() {
            #test_body
        }
    };

    expanded.into()
}

/// Convert a CamelCase identifier to kebab-case.
///
/// Handles acronyms (consecutive uppercase): a separator is inserted
/// before the last letter of a run when followed by lowercase.
///
/// `Llc` -> `"llc"`, `RejectPin` -> `"reject-pin"`, `NoCtrl` -> `"no-ctrl"`,
/// `LLC` -> `"llc"`, `HTTPServer` -> `"http-server"`.
fn camel_to_kebab(s: &str) -> String {
    let chars: Vec<char> = s.chars().collect();
    let mut out = String::new();
    for (i, &ch) in chars.iter().enumerate() {
        if ch.is_uppercase() && i > 0 {
            let prev_upper = chars[i - 1].is_uppercase();
            let next_lower = chars.get(i + 1).is_some_and(|c| c.is_lowercase());
            // Insert separator when:
            // - previous char is lowercase (standard CamelCase boundary), OR
            // - previous char is uppercase AND next char is lowercase
            //   (end of acronym run: "HTTPServer" -> "http-server")
            if !prev_upper || next_lower {
                out.push('-');
            }
        }
        out.push(ch.to_ascii_lowercase());
    }
    out
}

/// Convert a CamelCase identifier to SCREAMING_SNAKE_CASE.
///
/// Handles acronyms (consecutive uppercase): a separator is inserted
/// before the last letter of a run when followed by lowercase.
///
/// `Llc` -> `"LLC"`, `RejectPin` -> `"REJECT_PIN"`, `NoCtrl` -> `"NO_CTRL"`,
/// `LLC` -> `"LLC"`, `HTTPServer` -> `"HTTP_SERVER"`.
fn camel_to_screaming_snake(s: &str) -> String {
    let chars: Vec<char> = s.chars().collect();
    let mut out = String::new();
    for (i, &ch) in chars.iter().enumerate() {
        if ch.is_uppercase() && i > 0 {
            let prev_upper = chars[i - 1].is_uppercase();
            let next_lower = chars.get(i + 1).is_some_and(|c| c.is_lowercase());
            if !prev_upper || next_lower {
                out.push('_');
            }
        }
        out.push(ch.to_ascii_uppercase());
    }
    out
}

/// Derive macro that generates a `Scheduler` const, `FlagDecl` statics,
/// and associated name constants from an annotated enum.
///
/// # Scheduler attributes (`#[scheduler(...)]`)
///
/// | Attribute | Required | Description |
/// |---|---|---|
/// | `name = "..."` | yes | Scheduler name passed to `Scheduler::new()` |
/// | `binary = "..."` | no | Binary name for `SchedulerSpec::Discover(...)`. Omit for EEVDF. |
/// | `topology(N, L, C, T)` | no | Default VM topology `(numa_nodes, llcs, cores, threads)`. Defaults to `(1, 1, 2, 1)`. |
/// | `cgroup_parent = "..."` | no | Cgroup parent path. Must begin with `/` (e.g. `"/ktstr"`). |
/// | `sched_args = [...]` | no | Default scheduler CLI args. |
/// | `sysctls = [Sysctl::new("key", "value"), ...]` | no | Guest sysctls applied before the scheduler starts. Each element is a fully-qualified Rust expression that evaluates to a [`Sysctl`](crate::test_support::Sysctl); the macro inserts the token stream verbatim, so `Sysctl::new(...)`, a path to a `const Sysctl`, or any user-written constructor call all work. Unlike the bare-form `default_check(min(...))` grammar, `sysctls` has no implicit path prefix — write the type name explicitly (or bring it into scope with `use`). |
/// | `kargs = ["arg1", "arg2"]` | no | Extra kernel command-line args appended when booting the VM. |
/// | `min_numa_nodes = N` | no | Minimum NUMA nodes for gauntlet filtering. |
/// | `max_numa_nodes = N` | no | Maximum NUMA nodes for gauntlet filtering. |
/// | `min_llcs = N` | no | Minimum LLCs for gauntlet filtering. |
/// | `max_llcs = N` | no | Maximum LLCs for gauntlet filtering. |
/// | `min_cpus = N` | no | Minimum total CPUs for gauntlet filtering. |
/// | `max_cpus = N` | no | Maximum total CPUs for gauntlet filtering. |
/// | `requires_smt = bool` | no | Require SMT (threads_per_core > 1). |
/// | `config_file = "..."` | no | Host-side path to an opaque config file passed to the scheduler via `--config`. |
///
/// # Flag attributes (`#[flag(...)]`)
///
/// | Attribute | Description |
/// |---|---|
/// | `args = ["--flag-a", "--flag-b"]` | CLI args passed when this flag is active |
/// | `requires = [OtherVariant]` | Variants that must also be active |
///
/// # Generated items
///
/// Given `enum MitosisFlag { Llc, Steal }`:
///
/// - Per-variant `static FlagDecl` entries
/// - A `static &[&FlagDecl]` flags array
/// - `const MITOSIS: Scheduler` (see naming below)
/// - `impl MitosisFlag { pub const LLC: &str = "llc"; pub const STEAL: &str = "steal"; }`
///
/// The associated constants enable typed flag references:
/// `required_flags = [MitosisFlag::LLC]` in `#[ktstr_test]`.
///
/// # Const name derivation
///
/// The generated `Scheduler` const name is derived from the enum name:
/// 1. Strip trailing `"Flag"` or `"Flags"` suffix (if present)
/// 2. Convert to `SCREAMING_SNAKE_CASE`
///
/// Examples: `MitosisFlag` -> `MITOSIS`, `EevdfFlags` -> `EEVDF`,
/// `MySchedFlag` -> `MY_SCHED`, `Mitosis` -> `MITOSIS`.
///
/// # Variant naming
///
/// Variant identifiers are converted to kebab-case for the flag name.
/// Consecutive uppercase letters are treated as acronyms:
/// `Llc` -> `"llc"`, `RejectPin` -> `"reject-pin"`, `LLC` -> `"llc"`.
///
/// # Example
///
/// ```rust,ignore
/// use ktstr::prelude::*;
///
/// #[derive(Scheduler)]
/// #[scheduler(name = "mitosis", binary = "scx_mitosis", topology(1, 2, 4, 1),
///             cgroup_parent = "/ktstr", sched_args = ["--exit-dump-len", "1048576"])]
/// #[allow(dead_code)]
/// enum MitosisFlag {
///     #[flag(args = ["--enable-llc-awareness"])]
///     Llc,
///     #[flag(args = ["--enable-work-stealing"], requires = [Llc])]
///     Steal,
/// }
/// ```
#[proc_macro_derive(Scheduler, attributes(scheduler, flag))]
pub fn derive_scheduler(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    match derive_scheduler_inner(input) {
        Ok(ts) => ts.into(),
        Err(e) => e.to_compile_error().into(),
    }
}

fn derive_scheduler_inner(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    let enum_name = &input.ident;

    // Parse #[scheduler(...)] attributes
    let mut sched_name: Option<String> = None;
    let mut sched_binary: Option<String> = None;
    let mut sched_topology: Option<(u32, u32, u32, u32)> = None;
    let mut sched_cgroup_parent: Option<String> = None;
    let mut sched_args: Vec<String> = Vec::new();
    let mut sched_sysctls: Vec<proc_macro2::TokenStream> = Vec::new();
    let mut sched_sysctls_set = false;
    let mut sched_kargs: Vec<String> = Vec::new();
    let mut sched_kargs_set = false;
    let mut sched_min_numa_nodes: Option<u32> = None;
    let mut sched_max_numa_nodes: Option<u32> = None;
    let mut sched_min_llcs: Option<u32> = None;
    let mut sched_max_llcs: Option<u32> = None;
    let mut sched_min_cpus: Option<u32> = None;
    let mut sched_max_cpus: Option<u32> = None;
    let mut sched_requires_smt: Option<bool> = None;
    let mut sched_config_file: Option<String> = None;

    for attr in &input.attrs {
        if !attr.path().is_ident("scheduler") {
            continue;
        }
        attr.parse_nested_meta(|meta| {
            if meta.path.is_ident("name") {
                let value = meta.value()?;
                let lit: syn::LitStr = value.parse()?;
                sched_name = Some(lit.value());
                Ok(())
            } else if meta.path.is_ident("binary") {
                let value = meta.value()?;
                let lit: syn::LitStr = value.parse()?;
                sched_binary = Some(lit.value());
                Ok(())
            } else if meta.path.is_ident("topology") {
                let content;
                syn::parenthesized!(content in meta.input);
                let numa_nodes_lit: syn::LitInt = content.parse()?;
                let _: syn::Token![,] = content.parse()?;
                let llcs_lit: syn::LitInt = content.parse()?;
                let _: syn::Token![,] = content.parse()?;
                let cores: syn::LitInt = content.parse()?;
                let _: syn::Token![,] = content.parse()?;
                let threads: syn::LitInt = content.parse()?;
                sched_topology = Some((
                    numa_nodes_lit.base10_parse()?,
                    llcs_lit.base10_parse()?,
                    cores.base10_parse()?,
                    threads.base10_parse()?,
                ));
                Ok(())
            } else if meta.path.is_ident("cgroup_parent") {
                let value = meta.value()?;
                let lit: syn::LitStr = value.parse()?;
                sched_cgroup_parent = Some(lit.value());
                Ok(())
            } else if meta.path.is_ident("sched_args") {
                let value = meta.value()?;
                let arr: syn::ExprArray = value.parse()?;
                for elem in &arr.elems {
                    match elem {
                        syn::Expr::Lit(syn::ExprLit {
                            lit: syn::Lit::Str(ls),
                            ..
                        }) => sched_args.push(ls.value()),
                        _ => {
                            return Err(syn::Error::new_spanned(
                                elem,
                                "expected string literal in sched_args",
                            ));
                        }
                    }
                }
                Ok(())
            } else if meta.path.is_ident("sysctls") {
                let value = meta.value()?;
                let arr: syn::ExprArray = value.parse()?;
                sched_sysctls_set = true;
                for elem in &arr.elems {
                    sched_sysctls.push(elem.to_token_stream());
                }
                Ok(())
            } else if meta.path.is_ident("kargs") {
                let value = meta.value()?;
                let arr: syn::ExprArray = value.parse()?;
                sched_kargs_set = true;
                for elem in &arr.elems {
                    match elem {
                        syn::Expr::Lit(syn::ExprLit {
                            lit: syn::Lit::Str(ls),
                            ..
                        }) => sched_kargs.push(ls.value()),
                        _ => {
                            return Err(syn::Error::new_spanned(
                                elem,
                                "expected string literal in kargs",
                            ));
                        }
                    }
                }
                Ok(())
            } else if meta.path.is_ident("min_numa_nodes") {
                let value = meta.value()?;
                let lit: syn::LitInt = value.parse()?;
                sched_min_numa_nodes = Some(lit.base10_parse()?);
                Ok(())
            } else if meta.path.is_ident("max_numa_nodes") {
                let value = meta.value()?;
                let lit: syn::LitInt = value.parse()?;
                sched_max_numa_nodes = Some(lit.base10_parse()?);
                Ok(())
            } else if meta.path.is_ident("min_llcs") {
                let value = meta.value()?;
                let lit: syn::LitInt = value.parse()?;
                sched_min_llcs = Some(lit.base10_parse()?);
                Ok(())
            } else if meta.path.is_ident("max_llcs") {
                let value = meta.value()?;
                let lit: syn::LitInt = value.parse()?;
                sched_max_llcs = Some(lit.base10_parse()?);
                Ok(())
            } else if meta.path.is_ident("min_cpus") {
                let value = meta.value()?;
                let lit: syn::LitInt = value.parse()?;
                sched_min_cpus = Some(lit.base10_parse()?);
                Ok(())
            } else if meta.path.is_ident("max_cpus") {
                let value = meta.value()?;
                let lit: syn::LitInt = value.parse()?;
                sched_max_cpus = Some(lit.base10_parse()?);
                Ok(())
            } else if meta.path.is_ident("requires_smt") {
                let value = meta.value()?;
                let lit: syn::LitBool = value.parse()?;
                sched_requires_smt = Some(lit.value());
                Ok(())
            } else if meta.path.is_ident("config_file") {
                let value = meta.value()?;
                let lit: syn::LitStr = value.parse()?;
                sched_config_file = Some(lit.value());
                Ok(())
            } else {
                Err(meta.error(format!(
                    "unknown scheduler attribute `{}`",
                    meta.path
                        .get_ident()
                        .map(|i| i.to_string())
                        .unwrap_or_default()
                )))
            }
        })?;
    }

    let sched_name = sched_name
        .ok_or_else(|| syn::Error::new_spanned(enum_name, "missing `name` in #[scheduler(...)]"))?;

    // Validate topology values at compile time.
    if let Some((n, l, c, t)) = sched_topology {
        if n == 0 || l == 0 || c == 0 || t == 0 {
            return Err(syn::Error::new_spanned(
                enum_name,
                "topology values must all be > 0",
            ));
        }
        if l % n != 0 {
            return Err(syn::Error::new_spanned(
                enum_name,
                format!("topology: llcs ({l}) must be divisible by numa_nodes ({n})"),
            ));
        }
    }

    // Extract enum variants
    let variants = match &input.data {
        Data::Enum(data) => &data.variants,
        _ => {
            return Err(syn::Error::new_spanned(
                enum_name,
                "Scheduler can only be derived for enums",
            ));
        }
    };

    // Validate all variants are unit variants
    for v in variants {
        if !matches!(v.fields, Fields::Unit) {
            return Err(syn::Error::new_spanned(
                v,
                "Scheduler derive requires unit variants",
            ));
        }
    }

    // Collect variant info
    struct FlagInfo {
        ident: syn::Ident,
        kebab_name: String,
        screaming_snake: String,
        args: Vec<String>,
        requires: Vec<syn::Ident>,
    }

    let mut flag_infos: Vec<FlagInfo> = Vec::new();

    for v in variants {
        let ident = v.ident.clone();
        let kebab_name = camel_to_kebab(&ident.to_string());
        let screaming_snake = camel_to_screaming_snake(&ident.to_string());
        let mut args: Vec<String> = Vec::new();
        let mut requires: Vec<syn::Ident> = Vec::new();

        for attr in &v.attrs {
            if !attr.path().is_ident("flag") {
                continue;
            }
            attr.parse_nested_meta(|meta| {
                if meta.path.is_ident("args") {
                    let value = meta.value()?;
                    let arr: syn::ExprArray = value.parse()?;
                    for elem in &arr.elems {
                        match elem {
                            syn::Expr::Lit(syn::ExprLit {
                                lit: syn::Lit::Str(ls),
                                ..
                            }) => args.push(ls.value()),
                            _ => {
                                return Err(syn::Error::new_spanned(
                                    elem,
                                    "expected string literal in flag args",
                                ));
                            }
                        }
                    }
                    Ok(())
                } else if meta.path.is_ident("requires") {
                    let value = meta.value()?;
                    let arr: syn::ExprArray = value.parse()?;
                    for elem in &arr.elems {
                        match elem {
                            syn::Expr::Path(ep) => {
                                let req_ident = ep
                                    .path
                                    .get_ident()
                                    .ok_or_else(|| {
                                        syn::Error::new_spanned(
                                            ep,
                                            "expected variant identifier in requires",
                                        )
                                    })?
                                    .clone();
                                requires.push(req_ident);
                            }
                            _ => {
                                return Err(syn::Error::new_spanned(
                                    elem,
                                    "expected variant identifier in requires",
                                ));
                            }
                        }
                    }
                    Ok(())
                } else {
                    Err(meta.error(format!(
                        "unknown flag attribute `{}`",
                        meta.path
                            .get_ident()
                            .map(|i| i.to_string())
                            .unwrap_or_default()
                    )))
                }
            })?;
        }

        flag_infos.push(FlagInfo {
            ident,
            kebab_name,
            screaming_snake,
            args,
            requires,
        });
    }

    // Validate requires references exist
    let variant_names: Vec<String> = flag_infos.iter().map(|f| f.ident.to_string()).collect();
    for fi in &flag_infos {
        for req in &fi.requires {
            if !variant_names.contains(&req.to_string()) {
                return Err(syn::Error::new_spanned(
                    req,
                    format!(
                        "unknown variant `{}` in requires (expected one of: {})",
                        req,
                        variant_names.join(", ")
                    ),
                ));
            }
        }
    }

    // Generate static FlagDecl names
    let enum_upper = camel_to_screaming_snake(&enum_name.to_string());
    let decl_idents: Vec<syn::Ident> = flag_infos
        .iter()
        .map(|fi| format_ident!("__{}_DECL_{}", enum_upper, fi.screaming_snake))
        .collect();

    // Generate static FlagDecl entries
    let mut decl_statics = Vec::new();
    for (i, fi) in flag_infos.iter().enumerate() {
        let decl_ident = &decl_idents[i];
        let name_str = &fi.kebab_name;
        let args = &fi.args;

        let requires_tokens: Vec<proc_macro2::TokenStream> = fi
            .requires
            .iter()
            .map(|req_ident| {
                let req_idx = flag_infos
                    .iter()
                    .position(|f| f.ident == *req_ident)
                    .unwrap();
                let req_decl_ident = &decl_idents[req_idx];
                quote! { &#req_decl_ident }
            })
            .collect();

        decl_statics.push(quote! {
            static #decl_ident: ::ktstr::scenario::flags::FlagDecl = ::ktstr::scenario::flags::FlagDecl {
                name: #name_str,
                args: &[#(#args),*],
                requires: &[#(#requires_tokens),*],
            };
        });
    }

    // Generate the flags array
    let flags_array_ident = format_ident!("__{}_FLAGS", enum_upper);
    let decl_refs: Vec<proc_macro2::TokenStream> =
        decl_idents.iter().map(|di| quote! { &#di }).collect();

    // Generate associated name constants
    let name_consts: Vec<proc_macro2::TokenStream> = flag_infos
        .iter()
        .map(|fi| {
            let const_ident = format_ident!("{}", fi.screaming_snake);
            let name_str = &fi.kebab_name;
            quote! {
                pub const #const_ident: &'static str = #name_str;
            }
        })
        .collect();

    // Derive the const name from enum name: strip "Flag"/"Flags" suffix, uppercase
    let enum_str = enum_name.to_string();
    let base_name = enum_str
        .strip_suffix("Flags")
        .or_else(|| enum_str.strip_suffix("Flag"))
        .unwrap_or(&enum_str);
    if base_name.is_empty() {
        return Err(syn::Error::new(
            enum_name.span(),
            "enum name cannot be just \"Flag\" or \"Flags\"",
        ));
    }
    let const_name = format_ident!("{}", camel_to_screaming_snake(base_name));

    // Build the Scheduler const with builder chain
    let sched_name_str = &sched_name;
    let mut builder_chain = quote! {
        ::ktstr::test_support::Scheduler::new(#sched_name_str)
    };

    if let Some(ref binary) = sched_binary {
        builder_chain = quote! {
            #builder_chain.binary(::ktstr::test_support::SchedulerSpec::Discover(#binary))
        };
    }

    builder_chain = quote! {
        #builder_chain.flags(#flags_array_ident)
    };

    if let Some((n, s, c, t)) = sched_topology {
        builder_chain = quote! {
            #builder_chain.topology(#n, #s, #c, #t)
        };
    }

    if let Some(ref parent) = sched_cgroup_parent {
        builder_chain = quote! {
            #builder_chain.cgroup_parent(#parent)
        };
    }

    if !sched_args.is_empty() {
        builder_chain = quote! {
            #builder_chain.sched_args(&[#(#sched_args),*])
        };
    }

    if sched_sysctls_set {
        let entries = &sched_sysctls;
        builder_chain = quote! {
            #builder_chain.sysctls(&[#(#entries),*])
        };
    }

    if sched_kargs_set {
        builder_chain = quote! {
            #builder_chain.kargs(&[#(#sched_kargs),*])
        };
    }

    // Chain individual constraint builder calls for each explicitly set
    // attribute. Unset fields inherit from TopologyConstraints::DEFAULT.
    if let Some(v) = sched_min_numa_nodes {
        builder_chain = quote! { #builder_chain.min_numa_nodes(#v) };
    }
    if let Some(v) = sched_max_numa_nodes {
        builder_chain = quote! { #builder_chain.max_numa_nodes(#v) };
    }
    if let Some(v) = sched_min_llcs {
        builder_chain = quote! { #builder_chain.min_llcs(#v) };
    }
    if let Some(v) = sched_max_llcs {
        builder_chain = quote! { #builder_chain.max_llcs(#v) };
    }
    if let Some(v) = sched_requires_smt {
        builder_chain = quote! { #builder_chain.requires_smt(#v) };
    }
    if let Some(v) = sched_min_cpus {
        builder_chain = quote! { #builder_chain.min_cpus(#v) };
    }
    if let Some(v) = sched_max_cpus {
        builder_chain = quote! { #builder_chain.max_cpus(#v) };
    }
    if let Some(ref v) = sched_config_file {
        builder_chain = quote! { #builder_chain.config_file(#v) };
    }

    // Generate the ctor function name for --ktstr-list-flags interception.
    let list_flags_ctor = format_ident!("__ktstr_list_flags_{}", enum_upper.to_lowercase());

    // Additionally emit a `Payload` const wrapping the `Scheduler`
    // so the scheduler is usable in the entry's scheduler slot and
    // in compositions that need a `Payload` reference without
    // calling `.run()`. Scheduler-kind Payloads in `workloads = [..]`
    // would fail at `ctx.payload(p).run()` — those slots are
    // binary-only — so the example is deliberately not about
    // `workloads`.
    //
    // Naming: `#[derive(Payload)]` strips the `Payload` suffix from
    // the struct name; the scheduler side appends `_PAYLOAD` so the
    // two surfaces stay symmetric and there's no collision with the
    // Scheduler const.
    let payload_const_name = format_ident!("{}_PAYLOAD", const_name);
    let sched_name_for_payload = sched_name_str;

    let expanded = quote! {
        #(#decl_statics)*

        static #flags_array_ident: &[&::ktstr::scenario::flags::FlagDecl] = &[#(#decl_refs),*];

        const #const_name: ::ktstr::test_support::Scheduler = #builder_chain;

        /// Payload wrapper around the generated `Scheduler` const so
        /// the scheduler is usable wherever a `&'static Payload` is
        /// expected without calling `.run()` — chiefly the entry's
        /// scheduler slot, plus any composition site that takes a
        /// `&'static Payload` reference. Scheduler-kind Payloads are
        /// rejected at `ctx.payload(p).run()`, so this wrapper is
        /// intentionally not meant for `workloads = [..]` (which is
        /// binary-only).
        const #payload_const_name: ::ktstr::test_support::Payload =
            ::ktstr::test_support::Payload::new(
                #sched_name_for_payload,
                ::ktstr::test_support::PayloadKind::Scheduler(&#const_name),
                ::ktstr::test_support::OutputFormat::ExitCode,
                &[],
                &[],
                &[],
                &[],
                false,
                None,
                None,
            );

        impl #enum_name {
            #(#name_consts)*
        }

        /// Intercept `--ktstr-list-flags` before `main()` runs.
        /// Serializes this scheduler's flag declarations as JSON to
        /// stdout and exits, avoiding BPF program loading.
        #[::ktstr::__private::ctor::ctor(crate_path = ::ktstr::__private::ctor)]
        fn #list_flags_ctor() {
            if !::std::env::args().any(|a| a == "--ktstr-list-flags") {
                return;
            }
            let decls: ::std::vec::Vec<::ktstr::scenario::flags::FlagDeclJson> =
                #flags_array_ident
                    .iter()
                    .map(|d| ::ktstr::scenario::flags::FlagDeclJson::from_decl(d))
                    .collect();
            let json = ::ktstr::__private::serde_json::to_string(&decls).expect("serialize flags");
            println!("{json}");
            ::std::process::exit(0);
        }
    };

    Ok(expanded)
}

/// Derive macro that generates a `Payload` const from an annotated
/// struct for a userspace binary workload (stress-ng, fio, and
/// similar tools test authors compose under a scheduler).
///
/// # Required struct-level attributes (`#[payload(...)]`)
///
/// - `binary = "..."` — the binary name resolved by the guest's
///   include-files infrastructure (required). Becomes
///   [`PayloadKind::Binary(name)`](ktstr::test_support::PayloadKind::Binary),
///   and is also auto-prepended to the emitted `include_files` slice
///   so the binary is packaged into the initramfs without needing a
///   separate `#[include_files("...")]` entry. Extra auxiliary files
///   (helpers, configs, fixtures) still go on `#[include_files(...)]`.
///
/// # Optional struct-level attributes
///
/// - `name = "..."` — short name used in logs and sidecar records.
///   Defaults to the binary name.
/// - `output = Json | ExitCode | LlmExtract("hint")` — how the
///   framework extracts metrics from the payload's stdout. The
///   variant names match the `OutputFormat` enum and the `Polarity`
///   kwarg grammar. Defaults to `ExitCode`. The `LlmExtract` form
///   accepts an optional string literal focus hint appended to the
///   default LLM prompt; bare `LlmExtract` with no parenthesized
///   argument is a shorthand for `LlmExtract()` (no hint).
///
/// # Optional outer attributes
///
/// - `#[default_args("--a", "--b", ...)]` — variadic string
///   literals appended to the binary's argv when the payload runs.
///   May repeat across multiple `#[default_args(...)]` attrs; entries
///   accumulate in source order.
/// - `#[default_check(...)]` — one [`MetricCheck`](ktstr::test_support::MetricCheck)
///   construction expression (e.g. `min("iops", 1000.0)`,
///   `exit_code_eq(0)`). May repeat; entries accumulate in source
///   order. Both `min(...)` and `MetricCheck::min(...)` are accepted: the
///   macro prepends `::ktstr::test_support::MetricCheck::` when the
///   expression doesn't already spell `MetricCheck::` on its callee path,
///   so bare constructors work without an import and qualified
///   constructors read naturally in modules that already have
///   `MetricCheck` in scope.
/// - `#[metric(name = "...", polarity = ..., unit = "...")]` —
///   kwarg form. `polarity` is one of `HigherBetter`, `LowerBetter`,
///   `TargetValue(f64)`, `Unknown`. May repeat; entries accumulate.
/// - `#[include_files("helper", "config.json", ...)]` — variadic
///   string literals appended to the emitted `include_files` slice
///   after the auto-injected binary entry. Each entry passes through
///   the same resolver used by the CLI `-i` flag (bare names search
///   host `PATH`; explicit paths must exist; directories are walked).
///   The primary binary is already packaged automatically, so this
///   attribute is only needed for auxiliary files the payload
///   depends on.
///
/// # Const name derivation
///
/// Strip trailing `"Payload"` suffix (if present), then convert to
/// `SCREAMING_SNAKE_CASE`. `FioPayload` → `FIO`,
/// `StressNgPayload` → `STRESS_NG`, `Fio` (no suffix) → `FIO`.
///
/// # Example
///
/// ```rust,ignore
/// use ktstr::prelude::*;
///
/// #[derive(Payload)]
/// #[payload(binary = "fio", output = Json)]
/// #[default_args("--output-format=json", "--minimal")]
/// #[default_check(exit_code_eq(0))]
/// #[metric(name = "jobs.0.read.iops", polarity = HigherBetter, unit = "iops")]
/// struct FioPayload;
/// ```
#[proc_macro_derive(Payload, attributes(payload, default_args, default_check, metric))]
pub fn derive_payload(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    match derive_payload_inner(input) {
        Ok(ts) => ts.into(),
        Err(e) => e.to_compile_error().into(),
    }
}

fn derive_payload_inner(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    let struct_name = &input.ident;
    // Inherit the input struct's visibility so the emitted `const`
    // matches: `pub struct FooPayload` → `pub const FOO: Payload`.
    // Private structs produce private consts, preserving the
    // previous behavior for in-crate tests that rely on it.
    let struct_vis = &input.vis;

    // Reject non-struct inputs; the flag-variant grammar is specific
    // to `Scheduler` (enums) and a struct-only payload keeps the
    // attribute space unambiguous.
    if !matches!(&input.data, Data::Struct(_)) {
        return Err(syn::Error::new_spanned(
            struct_name,
            "Payload can only be derived for structs",
        ));
    }

    let mut binary: Option<String> = None;
    let mut name_override: Option<String> = None;
    // `None` means "not specified" → default ExitCode at emit time.
    // `Some(tokens)` holds the fully-qualified OutputFormat variant
    // the user selected (possibly with an LlmExtract hint expression).
    let mut output_tokens: Option<proc_macro2::TokenStream> = None;

    for attr in &input.attrs {
        if !attr.path().is_ident("payload") {
            continue;
        }
        attr.parse_nested_meta(|meta| {
            if meta.path.is_ident("binary") {
                let value = meta.value()?;
                let lit: syn::LitStr = value.parse()?;
                binary = Some(lit.value());
                Ok(())
            } else if meta.path.is_ident("name") {
                let value = meta.value()?;
                let lit: syn::LitStr = value.parse()?;
                name_override = Some(lit.value());
                Ok(())
            } else if meta.path.is_ident("output") {
                let value = meta.value()?;
                let expr: syn::Expr = value.parse()?;
                output_tokens = Some(output_from_expr(&expr)?);
                Ok(())
            } else {
                Err(meta.error(format!(
                    "unknown payload attribute `{}`",
                    meta.path
                        .get_ident()
                        .map(|i| i.to_string())
                        .unwrap_or_default()
                )))
            }
        })?;
    }

    let binary = binary.ok_or_else(|| {
        syn::Error::new_spanned(struct_name, "missing `binary = \"...\"` in #[payload(...)]")
    })?;

    // Default output = ExitCode. Resolve once here to a canonical
    // TokenStream so the emitter only has one path.
    let output_tokens = output_tokens.unwrap_or_else(|| {
        quote! { ::ktstr::test_support::OutputFormat::ExitCode }
    });

    // `name` falls back to `binary` when omitted.
    let payload_name = name_override.unwrap_or_else(|| binary.clone());

    // Walk outer `#[default_args(...)]` / `#[default_check(...)]` /
    // `#[metric(...)]` / `#[include_files(...)]` attrs in source
    // order so the emitted slices match the declaration.
    let mut default_args: Vec<String> = Vec::new();
    let mut default_checks: Vec<proc_macro2::TokenStream> = Vec::new();
    let mut metrics: Vec<proc_macro2::TokenStream> = Vec::new();
    let mut seen_metric_names: Vec<String> = Vec::new();
    let mut include_files: Vec<String> = Vec::new();

    for attr in &input.attrs {
        if attr.path().is_ident("default_args") {
            // Variadic string literals: `#[default_args("--a", "--b")]`.
            let parser =
                syn::punctuated::Punctuated::<syn::LitStr, syn::Token![,]>::parse_terminated;
            let parsed = attr.parse_args_with(parser).map_err(|e| {
                syn::Error::new(
                    e.span(),
                    "default_args must be one or more string literals separated by `,`",
                )
            })?;
            for lit in parsed {
                default_args.push(lit.value());
            }
        } else if attr.path().is_ident("default_check") {
            // Single MetricCheck-constructing expression. Two forms accepted:
            //   - bare: `min("iops", 1000.0)` — the macro prepends
            //     `::ktstr::test_support::MetricCheck::` so users don't have
            //     to import `MetricCheck` in every module that derives.
            //   - qualified: `MetricCheck::min("iops", 1000.0)` — the user
            //     wrote `MetricCheck::` themselves; emit the expression
            //     unchanged so the user's own path resolution wins
            //     (and a double `MetricCheck::MetricCheck::` prefix can't happen).
            let expr: syn::Expr = attr.parse_args().map_err(|e| {
                syn::Error::new(
                    e.span(),
                    "default_check must be a MetricCheck constructor expression (e.g. min(\"iops\", 1000.0))",
                )
            })?;
            if expr_has_check_prefix(&expr) {
                default_checks.push(quote! { #expr });
            } else {
                default_checks.push(quote! { ::ktstr::test_support::MetricCheck::#expr });
            }
        } else if attr.path().is_ident("metric") {
            // Kwarg form: name = "...", polarity = ..., unit = "...".
            let (metric_name, tokens) = parse_metric_attr(attr)?;
            // Reject duplicate metric names — two `#[metric(name = "x", ...)]`
            // lines with the same name are almost certainly a copy-paste
            // typo; the runtime pipeline's `resolve_polarities` uses
            // last-wins semantics, so a duplicate silently shadows the
            // first hint without any signal to the test author.
            if let Some(existing) = seen_metric_names.iter().find(|n| *n == &metric_name) {
                return Err(syn::Error::new_spanned(
                    attr,
                    format!(
                        "duplicate metric name `{existing}` — each \
                         `#[metric(name = \"...\")]` declaration must name a \
                         distinct metric. Remove the duplicate or rename one \
                         of them."
                    ),
                ));
            }
            seen_metric_names.push(metric_name);
            metrics.push(tokens);
        } else if attr.path().is_ident("include_files") {
            // Variadic string literals: `#[include_files("helper",
            // "config.json")]`. Each entry is passed through to
            // `Payload::include_files` verbatim; the runtime
            // resolver (`resolve_include_files`) interprets bare
            // names vs explicit paths vs directories the same way
            // the CLI `-i` flag does. Order is preserved so the
            // user's declaration order is visible in the emitted
            // slice — useful when the resolver's dedup policy
            // reports a conflict, as the first-declared entry
            // wins.
            let parser =
                syn::punctuated::Punctuated::<syn::LitStr, syn::Token![,]>::parse_terminated;
            let parsed = attr.parse_args_with(parser).map_err(|e| {
                syn::Error::new(
                    e.span(),
                    "include_files must be one or more string literals separated by `,`",
                )
            })?;
            for lit in parsed {
                include_files.push(lit.value());
            }
        }
    }

    // Derive the const name: strip "Payload" suffix and uppercase.
    // The suffix strip matches the derive(Scheduler) convention
    // (strip "Flag"/"Flags") so the two macros feel consistent to
    // test authors declaring both.
    let struct_str = struct_name.to_string();
    let base = struct_str.strip_suffix("Payload").unwrap_or(&struct_str);
    if base.is_empty() {
        return Err(syn::Error::new(
            struct_name.span(),
            "struct name cannot be just \"Payload\"",
        ));
    }
    let const_name = format_ident!("{}", camel_to_screaming_snake(base));

    // Auto-inject the `binary` spec as the first entry in the emitted
    // `include_files` slice so `#[payload(binary = "X")]` alone is
    // enough to package `X` into the initramfs — no separate
    // `#[include_files("X")]` required. The runtime's
    // `dedupe_include_files` canonicalizes host paths, so a user who
    // also writes `#[include_files("X")]` (or lists the same binary
    // on `#[ktstr_test(extra_include_files = [..])]`) still works:
    // the duplicate collapses silently. User-declared entries follow
    // in source order — preserving the existing first-declared-wins
    // behavior within the user's own list.
    include_files.insert(0, binary.clone());

    let expanded = quote! {
        #struct_vis const #const_name: ::ktstr::test_support::Payload =
            ::ktstr::test_support::Payload::new(
                #payload_name,
                ::ktstr::test_support::PayloadKind::Binary(#binary),
                #output_tokens,
                &[#(#default_args),*],
                &[#(#default_checks),*],
                &[#(#metrics),*],
                &[#(#include_files),*],
                false,
                None,
                None,
            );
    };

    Ok(expanded)
}

/// Translate the user-facing `output = ...` expression into a
/// fully-qualified `OutputFormat` variant token stream. Accepts
/// the variant names as they appear on the `OutputFormat` enum,
/// so the attribute reads identically to `Polarity` below:
///
/// - `Json` / `ExitCode` — bare idents.
/// - `LlmExtract` — bare ident (no hint).
/// - `LlmExtract("hint")` — call with a single string literal.
/// - `LlmExtract()` — call with no args (no hint).
fn output_from_expr(expr: &syn::Expr) -> syn::Result<proc_macro2::TokenStream> {
    match expr {
        syn::Expr::Path(ep) => {
            let ident = ep.path.get_ident().ok_or_else(|| {
                syn::Error::new_spanned(expr, "expected `Json`, `ExitCode`, or `LlmExtract`")
            })?;
            match ident.to_string().as_str() {
                "Json" => Ok(quote! { ::ktstr::test_support::OutputFormat::Json }),
                "ExitCode" => Ok(quote! { ::ktstr::test_support::OutputFormat::ExitCode }),
                "LlmExtract" => {
                    Ok(quote! { ::ktstr::test_support::OutputFormat::LlmExtract(None) })
                }
                other => Err(syn::Error::new_spanned(
                    expr,
                    format!(
                        "unknown output format `{other}` (expected `Json`, `ExitCode`, or `LlmExtract`)"
                    ),
                )),
            }
        }
        syn::Expr::Call(call) => {
            // Only `LlmExtract(...)` is callable.
            let ident = match &*call.func {
                syn::Expr::Path(ep) => ep.path.get_ident().ok_or_else(|| {
                    syn::Error::new_spanned(expr, "expected `LlmExtract(...)` call form")
                })?,
                _ => {
                    return Err(syn::Error::new_spanned(
                        expr,
                        "expected `LlmExtract(...)` call form",
                    ));
                }
            };
            if ident != "LlmExtract" {
                return Err(syn::Error::new_spanned(
                    expr,
                    format!(
                        "unknown output format `{ident}(...)` (only `LlmExtract(...)` takes arguments)"
                    ),
                ));
            }
            match call.args.len() {
                0 => Ok(quote! { ::ktstr::test_support::OutputFormat::LlmExtract(None) }),
                1 => {
                    let arg = &call.args[0];
                    match arg {
                        syn::Expr::Lit(syn::ExprLit {
                            lit: syn::Lit::Str(ls),
                            ..
                        }) => {
                            let hint = ls.value();
                            Ok(quote! {
                                ::ktstr::test_support::OutputFormat::LlmExtract(Some(#hint))
                            })
                        }
                        _ => Err(syn::Error::new_spanned(
                            arg,
                            "LlmExtract argument must be a string literal hint",
                        )),
                    }
                }
                _ => Err(syn::Error::new_spanned(
                    expr,
                    "LlmExtract takes at most one string literal argument",
                )),
            }
        }
        _ => Err(syn::Error::new_spanned(
            expr,
            "output must be `Json`, `ExitCode`, `LlmExtract`, or `LlmExtract(\"hint\")`",
        )),
    }
}

/// Parse one `#[metric(name = "...", polarity = ..., unit = "...")]`
/// attribute into a `(name, MetricHint { ... } token stream)` pair.
/// The name is returned separately so the caller can check for
/// duplicate `#[metric(name = ...)]` declarations across the struct.
///
/// `polarity` accepts bare idents `HigherBetter`, `LowerBetter`,
/// `Unknown`, and the call form `TargetValue(<float literal>)`. The
/// float literal is stamped into a `Polarity::TargetValue(lit)` so
/// the generated const is const-evaluable.
fn parse_metric_attr(attr: &syn::Attribute) -> syn::Result<(String, proc_macro2::TokenStream)> {
    let mut name: Option<String> = None;
    let mut polarity: Option<proc_macro2::TokenStream> = None;
    let mut unit: String = String::new();
    attr.parse_nested_meta(|meta| {
        if meta.path.is_ident("name") {
            let value = meta.value()?;
            let lit: syn::LitStr = value.parse()?;
            name = Some(lit.value());
            Ok(())
        } else if meta.path.is_ident("polarity") {
            let value = meta.value()?;
            let expr: syn::Expr = value.parse()?;
            polarity = Some(polarity_from_expr(&expr)?);
            Ok(())
        } else if meta.path.is_ident("unit") {
            let value = meta.value()?;
            let lit: syn::LitStr = value.parse()?;
            unit = lit.value();
            Ok(())
        } else {
            Err(meta.error(format!(
                "unknown metric attribute `{}` (expected name, polarity, unit)",
                meta.path
                    .get_ident()
                    .map(|i| i.to_string())
                    .unwrap_or_default()
            )))
        }
    })?;
    let name = name.ok_or_else(|| {
        syn::Error::new_spanned(attr, "metric attribute is missing `name = \"...\"`")
    })?;
    let polarity = polarity.unwrap_or_else(|| {
        quote! { ::ktstr::test_support::Polarity::Unknown }
    });
    let tokens = quote! {
        ::ktstr::test_support::MetricHint {
            name: #name,
            polarity: #polarity,
            unit: #unit,
        }
    };
    Ok((name, tokens))
}

/// Does this `#[default_check(...)]` expression already spell
/// `MetricCheck::` somewhere in its function path? Returns true for
/// `MetricCheck::min(...)` and `::ktstr::test_support::MetricCheck::min(...)`;
/// false for bare `min(...)`. Used to skip the macro's implicit
/// `::ktstr::test_support::MetricCheck::` prepend when the user has
/// already written the prefix, so `MetricCheck::MetricCheck::min(...)` can't
/// happen.
///
/// Only inspects the callee path of an `Expr::Call`; non-call
/// expressions (rare but legal: a free function returning `MetricCheck`,
/// or a `const` value) fall back to the prepend path, matching the
/// pre-bugfix behavior for anything that isn't a plain constructor
/// call. A future refactor could lift this to also handle
/// `MethodCall` / `Path`, but the MetricCheck API today is constructor
/// calls only — adding more shapes is a no-op until a new constructor
/// form lands.
fn expr_has_check_prefix(expr: &syn::Expr) -> bool {
    let syn::Expr::Call(call) = expr else {
        return false;
    };
    let syn::Expr::Path(expr_path) = &*call.func else {
        return false;
    };
    expr_path
        .path
        .segments
        .iter()
        .any(|seg| seg.ident == "MetricCheck")
}

/// Translate the user-facing `polarity = ...` expression to a
/// fully-qualified `Polarity` variant. Accepts the four enum
/// variants in bare-ident form (`HigherBetter`, `LowerBetter`,
/// `Unknown`) or as `TargetValue(<float>)`.
fn polarity_from_expr(expr: &syn::Expr) -> syn::Result<proc_macro2::TokenStream> {
    match expr {
        syn::Expr::Path(ep) => {
            let ident = ep.path.get_ident().ok_or_else(|| {
                syn::Error::new_spanned(
                    expr,
                    "expected `HigherBetter`, `LowerBetter`, `TargetValue(..)`, or `Unknown`",
                )
            })?;
            match ident.to_string().as_str() {
                "HigherBetter" => Ok(quote! { ::ktstr::test_support::Polarity::HigherBetter }),
                "LowerBetter" => Ok(quote! { ::ktstr::test_support::Polarity::LowerBetter }),
                "Unknown" => Ok(quote! { ::ktstr::test_support::Polarity::Unknown }),
                "TargetValue" => Err(syn::Error::new_spanned(
                    expr,
                    "TargetValue requires a float argument: `TargetValue(42.0)`",
                )),
                other => Err(syn::Error::new_spanned(
                    expr,
                    format!("unknown polarity `{other}`"),
                )),
            }
        }
        syn::Expr::Call(call) => {
            let ident = match &*call.func {
                syn::Expr::Path(ep) => ep.path.get_ident().ok_or_else(|| {
                    syn::Error::new_spanned(expr, "expected `TargetValue(<float>)`")
                })?,
                _ => {
                    return Err(syn::Error::new_spanned(
                        expr,
                        "expected `TargetValue(<float>)`",
                    ));
                }
            };
            if ident != "TargetValue" {
                return Err(syn::Error::new_spanned(
                    expr,
                    format!(
                        "unknown polarity `{ident}(...)` (only `TargetValue` takes an argument)"
                    ),
                ));
            }
            if call.args.len() != 1 {
                return Err(syn::Error::new_spanned(
                    expr,
                    "TargetValue takes exactly one float literal argument",
                ));
            }
            let arg = &call.args[0];
            let lit = match arg {
                syn::Expr::Lit(syn::ExprLit {
                    lit: syn::Lit::Float(lf),
                    ..
                }) => lf,
                _ => {
                    return Err(syn::Error::new_spanned(
                        arg,
                        "TargetValue argument must be a float literal (e.g. 42.0)",
                    ));
                }
            };
            Ok(quote! { ::ktstr::test_support::Polarity::TargetValue(#lit) })
        }
        _ => Err(syn::Error::new_spanned(
            expr,
            "polarity must be HigherBetter, LowerBetter, TargetValue(<float>), or Unknown",
        )),
    }
}

// ============================================================================
// #[derive(Claim)] — pointwise-claim accessor generator
// ----------------------------------------------------------------------------
//
// Emits an extension trait `<StructName>Claim` with one
// `claim_<field_name>` method per public scalar field (returning
// `ClaimBuilder<'_, FieldType>`), plus container-typed accessors:
//
//   * `BTreeSet<T>` fields → `claim_<field_name>(&mut self, s: &Struct)
//     -> SetClaim<'_, T>`, dispatched through `Verdict::claim_set`.
//   * `Vec<T>` fields → `claim_<field_name>(&mut self, s: &Struct)
//     -> SeqClaim<'_, T>`, dispatched through `Verdict::claim_seq`.
//   * `BTreeMap<K, V>` / `HashMap<K, V>` fields → SKIPPED (no claim
//     surface for maps in v1; users reach for `claim!(verdict,
//     map.len())` or `claim!(verdict, map.contains_key(&k))` against
//     the explicit expression). Maps that need first-class support
//     can be added in a follow-up — the derive emits no method, so
//     the user's call site fails to compile rather than silently
//     dispatching to a wrong type.
//
// Per-field opt-out via `#[claim(skip)]`. Non-pub fields are skipped
// automatically (the claim surface is for the test author's view of
// the struct; private fields aren't part of that view).
//
// The generated trait is named `<StructName>Claim` with the same
// visibility as the input struct. The single
// `impl <StructName>Claim for ::ktstr::assert::Verdict` lives in the
// same expansion so callers only need `use ::ktstr::prelude::*` (which
// re-exports the trait) to bring the methods into scope.
//
// Label source: every method body is `verdict.claim(stringify!(field),
// ...)` — the label is the field's source-text identifier. Renaming
// the field updates both the method name AND the rendered failure
// label in lock-step; a stale call site that referenced the old
// method name fails to compile. This is the compile-mechanical
// drift-free axis that the design rejected manual-string labels in
// favor of.

/// Detect whether a `syn::Type` is a path whose last segment matches
/// `name` (e.g. `BTreeSet<T>` → `is_path_named(ty, "BTreeSet") == true`).
/// Used to dispatch container fields onto `claim_set` / `claim_seq`
/// without forcing the caller to import `BTreeSet` / `Vec` from a
/// specific module path. Misses `std::collections::BTreeSet<T>` if the
/// caller uses an alias — acceptable in v1 because the project's
/// stats structs all use the canonical `BTreeSet` / `Vec` names.
fn is_path_named(ty: &syn::Type, name: &str) -> bool {
    if let syn::Type::Path(tp) = ty
        && let Some(seg) = tp.path.segments.last()
    {
        return seg.ident == name;
    }
    false
}

/// Inner element type from a `Container<T>` syn::Type. Returns `None`
/// when the path has no angle-bracketed argument or the argument is
/// not a type (e.g. lifetime-only). Used by [`derive_claim_inner`] to
/// thread the element type through the emitted accessor's return
/// type.
fn first_type_arg(ty: &syn::Type) -> Option<&syn::Type> {
    if let syn::Type::Path(tp) = ty
        && let Some(seg) = tp.path.segments.last()
        && let syn::PathArguments::AngleBracketed(args) = &seg.arguments
    {
        for arg in &args.args {
            if let syn::GenericArgument::Type(inner) = arg {
                return Some(inner);
            }
        }
    }
    None
}

/// Detect a `#[claim(skip)]` attribute on a field. Returns true when
/// any `#[claim(skip)]` is present — the field gets no claim accessor
/// in the emitted trait.
fn field_has_claim_skip(field: &syn::Field) -> bool {
    for attr in &field.attrs {
        if !attr.path().is_ident("claim") {
            continue;
        }
        let mut skip = false;
        let _ = attr.parse_nested_meta(|meta| {
            if meta.path.is_ident("skip") {
                skip = true;
            }
            Ok(())
        });
        if skip {
            return true;
        }
    }
    false
}

/// Generate per-field claim accessors on a stats struct.
///
/// See the section comment above this fn for the dispatch rules and
/// label invariant. Reject non-struct inputs and tuple-struct inputs
/// — the claim API is keyed on field names, which tuple structs do
/// not have.
#[proc_macro_derive(Claim, attributes(claim))]
pub fn derive_claim(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    match derive_claim_inner(input) {
        Ok(ts) => ts.into(),
        Err(e) => e.to_compile_error().into(),
    }
}

fn derive_claim_inner(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    let struct_name = &input.ident;
    let struct_vis = &input.vis;
    let trait_name = format_ident!("{}Claim", struct_name);

    let fields = match &input.data {
        Data::Struct(s) => match &s.fields {
            Fields::Named(named) => &named.named,
            Fields::Unnamed(_) => {
                return Err(syn::Error::new_spanned(
                    struct_name,
                    "Claim cannot be derived for tuple structs (claim labels need field names)",
                ));
            }
            Fields::Unit => {
                return Err(syn::Error::new_spanned(
                    struct_name,
                    "Claim cannot be derived for unit structs (no fields to claim against)",
                ));
            }
        },
        _ => {
            return Err(syn::Error::new_spanned(
                struct_name,
                "Claim can only be derived for structs",
            ));
        }
    };

    let mut trait_methods: Vec<proc_macro2::TokenStream> = Vec::new();
    let mut impl_methods: Vec<proc_macro2::TokenStream> = Vec::new();

    for field in fields {
        // Private fields are not part of the claim surface — the
        // generated accessors must be callable from outside the
        // defining crate. A private field would force the impl
        // body to dereference an inaccessible field path. Skip
        // silently rather than erroring; the field is invisible
        // to the API surface either way.
        if !matches!(field.vis, syn::Visibility::Public(_)) {
            continue;
        }
        if field_has_claim_skip(field) {
            continue;
        }
        let Some(field_name) = field.ident.as_ref() else {
            continue;
        };
        let method_name = format_ident!("claim_{}", field_name);
        let field_ty = &field.ty;

        // Container dispatch: BTreeSet<T> / Vec<T> route through
        // claim_set / claim_seq with element type T. Map types are
        // skipped (no method emitted).
        if is_path_named(field_ty, "BTreeSet") {
            let Some(elem) = first_type_arg(field_ty) else {
                continue;
            };
            trait_methods.push(quote! {
                fn #method_name<'a>(
                    &'a self,
                    verdict: &'a mut ::ktstr::assert::Verdict,
                ) -> ::ktstr::assert::SetClaim<'a, #elem>;
            });
            impl_methods.push(quote! {
                fn #method_name<'a>(
                    &'a self,
                    verdict: &'a mut ::ktstr::assert::Verdict,
                ) -> ::ktstr::assert::SetClaim<'a, #elem> {
                    verdict.claim_set(stringify!(#field_name), &self.#field_name)
                }
            });
            continue;
        }
        if is_path_named(field_ty, "Vec") {
            let Some(elem) = first_type_arg(field_ty) else {
                continue;
            };
            trait_methods.push(quote! {
                fn #method_name<'a>(
                    &'a self,
                    verdict: &'a mut ::ktstr::assert::Verdict,
                ) -> ::ktstr::assert::SeqClaim<'a, #elem>;
            });
            impl_methods.push(quote! {
                fn #method_name<'a>(
                    &'a self,
                    verdict: &'a mut ::ktstr::assert::Verdict,
                ) -> ::ktstr::assert::SeqClaim<'a, #elem> {
                    verdict.claim_seq(stringify!(#field_name), &self.#field_name)
                }
            });
            continue;
        }
        if is_path_named(field_ty, "BTreeMap") || is_path_named(field_ty, "HashMap") {
            // Skip map fields — no claim surface in v1.
            continue;
        }

        // Scalar field. Emit a method returning `ClaimBuilder<'_,
        // FieldType>` that copies/clones the value. Cloning compiles
        // for both `Copy` and `Clone` types; primitive fields lower
        // to a single move at -O.
        trait_methods.push(quote! {
            fn #method_name<'a>(
                &'a self,
                verdict: &'a mut ::ktstr::assert::Verdict,
            ) -> ::ktstr::assert::ClaimBuilder<'a, #field_ty>;
        });
        impl_methods.push(quote! {
            fn #method_name<'a>(
                &'a self,
                verdict: &'a mut ::ktstr::assert::Verdict,
            ) -> ::ktstr::assert::ClaimBuilder<'a, #field_ty> {
                verdict.claim(stringify!(#field_name), ::core::clone::Clone::clone(&self.#field_name))
            }
        });
    }

    let doc = format!(
        "Pointwise-claim accessors generated by `#[derive(Claim)]` on \
         [`{name}`]. One `claim_<field>` method per public field, taking \
         `&mut Verdict` as the accumulator; container fields (`BTreeSet`/`Vec`) \
         route through `SetClaim`/`SeqClaim`. Method dispatch keys on the \
         stats struct's type, so identical field names across distinct stats \
         structs do not collide. Brought into scope via `use ktstr::prelude::*`.",
        name = struct_name,
    );

    Ok(quote! {
        #[doc = #doc]
        #struct_vis trait #trait_name {
            #(#trait_methods)*
        }

        impl #trait_name for #struct_name {
            #(#impl_methods)*
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use quote::quote;

    #[test]
    fn camel_to_kebab_single_uppercase_word() {
        assert_eq!(camel_to_kebab("Llc"), "llc");
    }

    #[test]
    fn camel_to_kebab_acronym_then_word() {
        assert_eq!(camel_to_kebab("HTTPServer"), "http-server");
    }

    #[test]
    fn camel_to_kebab_single_char() {
        assert_eq!(camel_to_kebab("X"), "x");
    }

    #[test]
    fn camel_to_kebab_all_caps() {
        assert_eq!(camel_to_kebab("CPU"), "cpu");
    }

    #[test]
    fn camel_to_kebab_standard_camel() {
        assert_eq!(camel_to_kebab("RejectPin"), "reject-pin");
    }

    #[test]
    fn camel_to_kebab_lowercase_passthrough() {
        assert_eq!(camel_to_kebab("foo"), "foo");
    }

    #[test]
    fn camel_to_kebab_empty() {
        assert_eq!(camel_to_kebab(""), "");
    }

    #[test]
    fn camel_to_screaming_snake_strip_flag_suffix() {
        let s = "MitosisFlag".strip_suffix("Flag").unwrap();
        assert_eq!(camel_to_screaming_snake(s), "MITOSIS");
    }

    #[test]
    fn camel_to_screaming_snake_strip_flags_suffix() {
        let s = "EevdfFlags".strip_suffix("Flags").unwrap();
        assert_eq!(camel_to_screaming_snake(s), "EEVDF");
    }

    #[test]
    fn camel_to_screaming_snake_multi_word() {
        let s = "MySchedFlag".strip_suffix("Flag").unwrap();
        assert_eq!(camel_to_screaming_snake(s), "MY_SCHED");
    }

    #[test]
    fn camel_to_screaming_snake_acronym_run() {
        assert_eq!(camel_to_screaming_snake("HTTPServer"), "HTTP_SERVER");
    }

    #[test]
    fn camel_to_screaming_snake_single_word() {
        assert_eq!(camel_to_screaming_snake("Llc"), "LLC");
    }

    #[test]
    fn camel_to_screaming_snake_all_caps_passthrough() {
        assert_eq!(camel_to_screaming_snake("LLC"), "LLC");
    }

    #[test]
    fn option_tokens_some_int() {
        let opt: Option<u32> = Some(42);
        let ts = option_tokens(&opt);
        assert_eq!(ts.to_string(), quote! { Some(42u32) }.to_string());
    }

    #[test]
    fn option_tokens_none_int() {
        let opt: Option<u32> = None;
        let ts = option_tokens(&opt);
        assert_eq!(ts.to_string(), quote! { None }.to_string());
    }

    #[test]
    fn option_tokens_some_bool() {
        let opt: Option<bool> = Some(true);
        let ts = option_tokens(&opt);
        assert_eq!(ts.to_string(), quote! { Some(true) }.to_string());
    }
}
