ktstr/cli/
kernel_cmd.rs

1//! `kernel` subcommand definition and help-text constants.
2//!
3//! Holds the `KernelCommand` enum (shared by `ktstr` and
4//! `cargo-ktstr`), the `--help` text constants for kernel-related
5//! flags (`--kernel`, `--cpu-cap`, `--extra-kconfig`, `--disk`),
6//! and the legend / footer helpers that flow through `kernel list`'s
7//! tag-emission gates.
8
9use std::path::{Path, PathBuf};
10
11use clap::Subcommand;
12
13/// Shared `kernel` subcommand tree used by both `ktstr` and
14/// `cargo ktstr`. The two binaries embed this as
15/// `ktstr kernel <subcmd>` / `cargo ktstr kernel <subcmd>` and
16/// dispatch identically; defining the variants once means a new
17/// `kernel` subcommand (or a flag change) lands in both surfaces by
18/// construction.
19#[derive(Subcommand, Debug)]
20pub enum KernelCommand {
21    /// List cached kernel images, or preview a range expansion
22    /// without downloading or building.
23    ///
24    /// Default mode (no `--kernel`): walks the local cache and
25    /// reports every cached kernel image. `--kernel START..END`
26    /// switches to PREVIEW mode: fetches kernel.org's
27    /// `releases.json`, expands the inclusive range against the
28    /// `stable` / `longterm` releases, and prints the resulting
29    /// version list. Preview mode performs no downloads or builds
30    /// and ignores the local cache — operators can use it to
31    /// answer "what does `--kernel 6.12..6.16` actually cover?"
32    /// before paying the network or cache-store cost of a real
33    /// resolve.
34    #[command(long_about = KERNEL_LIST_LONG_ABOUT)]
35    List {
36        /// Output in JSON format for CI scripting.
37        #[arg(long)]
38        json: bool,
39        /// Range to PREVIEW. When supplied, switches the subcommand
40        /// from "list cached kernels" to "fetch releases.json and
41        /// print the versions a `START..END` range expands to."
42        /// Format: `MAJOR.MINOR[.PATCH][-rcN]..MAJOR.MINOR[.PATCH][-rcN]`,
43        /// matching [`crate::kernel_path::KernelId::Range`].
44        /// Example: `--kernel 6.12..6.14` → every stable/longterm
45        /// release in `[6.12, 6.14]` inclusive. A non-range
46        /// `--kernel` (a single version, path, or git source) is
47        /// rejected — preview mode expands ranges only; to inspect a
48        /// cached single kernel run `kernel list` without `--kernel`.
49        ///
50        /// In preview mode the subcommand performs no cache
51        /// reads or kernel.org tarball downloads — only the
52        /// single `releases.json` fetch that
53        /// [`crate::cli::expand_kernel_range`] already runs for
54        /// real range resolves. `--json` (when also supplied)
55        /// emits a JSON object with the literal range string and
56        /// the expanded version array; without `--json` the
57        /// versions are written one per line to stdout for shell
58        /// pipelines.
59        #[arg(long)]
60        kernel: Option<String>,
61        /// See [`INCLUDE_EOL_HELP`]. Only affects a `--kernel`
62        /// range preview; ignored in the default cache-listing mode
63        /// (which reads no releases.json and enumerates no series).
64        #[arg(long, help = INCLUDE_EOL_HELP)]
65        include_eol: bool,
66    },
67    /// Download, build, and cache a kernel image.
68    Build {
69        /// Kernel to build (the unified `--kernel` grammar). Omitted,
70        /// builds the latest stable release. See [`KERNEL_HELP_BUILD`]
71        /// for the accepted shapes.
72        #[arg(long, help = KERNEL_HELP_BUILD)]
73        kernel: Option<String>,
74        /// Rebuild even if a cached image exists.
75        #[arg(long)]
76        force: bool,
77        /// Run `make mrproper` before configuring. Only meaningful for
78        /// a `--kernel <path>` source tree: version / range / git
79        /// sources build from a fresh tarball or clone, so this flag
80        /// prints a notice and is ignored for them.
81        #[arg(long)]
82        clean: bool,
83        #[arg(long, help = CPU_CAP_HELP)]
84        cpu_cap: Option<usize>,
85        /// Path to an additional kconfig fragment merged on top of
86        /// the baked-in `ktstr.kconfig`.
87        ///
88        /// # Format
89        ///
90        /// One declaration per line, same shapes the kernel itself
91        /// uses:
92        ///
93        /// ```text
94        /// # comment lines start with `#` and a space
95        /// CONFIG_FOO=y                  # boolean enable
96        /// CONFIG_FOO=m                  # build as module
97        /// CONFIG_FOO=n                  # disable (equivalent to is-not-set)
98        /// CONFIG_BAR="some value"       # string
99        /// CONFIG_BAR=42                 # integer / hex
100        /// # CONFIG_FOO is not set       # explicit disable directive
101        /// ```
102        ///
103        /// The baked-in fragment lives at `ktstr.kconfig` in the
104        /// ktstr repository root. See [`EMBEDDED_KCONFIG`] for the
105        /// const that loads it at compile time.
106        ///
107        /// # Conflict resolution
108        ///
109        /// User values win on conflict — kbuild's `.config` parser
110        /// (`scripts/kconfig/confdata.c::conf_read_simple`) emits
111        /// "override: reassigning to symbol X" and keeps the
112        /// last-occurring assignment, so appending the user fragment
113        /// AFTER the baked-in fragment makes user values take
114        /// precedence. Non-conflicting user lines combine with the
115        /// baked-in set verbatim.
116        ///
117        /// Override warnings: `kernel build` emits one
118        /// `tracing::warn!` per user line that overrides a baked-in
119        /// symbol (format: "--extra-kconfig overrides baked-in
120        /// CONFIG_FOO (was =y, now =n)"). The build proceeds; the
121        /// warning lets the operator see they are shadowing a
122        /// baked-in setting before make olddefconfig runs.
123        ///
124        /// # Dependency resolution
125        ///
126        /// `make olddefconfig` runs after the merge to resolve any
127        /// added symbols' dependencies. Options whose deps are not
128        /// met land as `# CONFIG_X is not set` in the final
129        /// `.config`; those silent drops surface as `tracing::warn!`
130        /// lines (not errors) so the operator sees the diagnostic
131        /// without the build failing.
132        ///
133        /// # Critical-symbol protection
134        ///
135        /// After build, [`super::validate_kernel_config`] rejects entries
136        /// that disabled symbols required by ktstr (CONFIG_BPF_SYSCALL,
137        /// CONFIG_DEBUG_INFO_BTF, CONFIG_FTRACE,
138        /// CONFIG_SCHED_CLASS_EXT, etc.). The error names
139        /// `--extra-kconfig` as the likely cause when extras were
140        /// supplied. So a fragment with
141        /// `# CONFIG_BPF is not set` will fail
142        /// `validate_kernel_config` post-build with an actionable
143        /// message — the override warning fires pre-build and the
144        /// validation error fires post-build, giving the operator
145        /// two chances to catch a fatal override.
146        ///
147        /// # Caching
148        ///
149        /// The cache key suffix grows from `kc{baked}` to
150        /// `kc{baked}-xkc{extra}` when extras are present (see
151        /// [`crate::cache_key_suffix_with_extra`]). Two builds
152        /// with distinct extra-kconfig content land at distinct
153        /// cache entries (different content = cache miss; same
154        /// content = cache hit on re-run). Builds with NO
155        /// `--extra-kconfig` keep using the bare `kc{baked}` suffix,
156        /// so existing cached kernels are not orphaned. An
157        /// `--extra-kconfig`-built kernel is only addressable by a
158        /// matching `--extra-kconfig` invocation or by an explicit
159        /// `--kernel <path>` / `KTSTR_KERNEL` path — `cargo ktstr test
160        /// --kernel 6.14.2` (which doesn't take `--extra-kconfig`)
161        /// will not surface the extra-built artifact.
162        ///
163        /// `kernel list` tags entries built with extras as
164        /// `(extra kconfig)` so an operator can spot which cached
165        /// kernels carry user modifications.
166        #[arg(long = "extra-kconfig", value_name = "PATH", help = EXTRA_KCONFIG_HELP)]
167        extra_kconfig: Option<PathBuf>,
168        /// Skip SHA-256 verification of downloaded stable tarballs.
169        /// Useful when cdn.kernel.org updates a tarball in-place (new
170        /// point release reusing the same URL) and the
171        /// sha256sums.asc manifest is stale or mismatched. Only affects
172        /// a version / range `--kernel` (the tarball download): no
173        /// effect on a `--kernel <path>` source tree (no download), a
174        /// `--kernel git+…` source (no manifest), or RC tarballs
175        /// (git.kernel.org dynamically generates RC archives and
176        /// publishes no upstream manifest, so RC downloads always run
177        /// unverified regardless of this flag). Bypassing verification
178        /// is security-sensitive: a single `--skip-sha256: bypassing
179        /// checksum verification` warning fires per affected download
180        /// so the lost guarantee is visible alongside the
181        /// verification-success line that would otherwise appear.
182        #[arg(long)]
183        skip_sha256: bool,
184        /// See [`INCLUDE_EOL_HELP`]. Only affects a `--kernel START..END`
185        /// range (e.g. `kernel build --kernel 6.11..6.14`); ignored for
186        /// a single version, path, or git source, none of which expand
187        /// a range.
188        #[arg(long, help = INCLUDE_EOL_HELP)]
189        include_eol: bool,
190    },
191    /// Remove cached kernel images.
192    Clean {
193        /// Keep the N most recent VALID cached kernels. When absent,
194        /// removes every valid entry. Corrupt entries are always
195        /// candidates for removal regardless of this value — they
196        /// waste disk space and serve no build — so a corrupt entry
197        /// never consumes a keep slot.
198        #[arg(long)]
199        keep: Option<usize>,
200        /// Skip the y/N confirmation prompt before deleting. Always
201        /// required in non-interactive contexts: without `--force`
202        /// the command bails on a non-tty stdin rather than hang
203        /// waiting for input. In an interactive shell, omit
204        /// `--force` to be prompted.
205        #[arg(long)]
206        force: bool,
207        /// Remove only corrupt cache entries (metadata missing or
208        /// unparseable, image file absent). Valid entries are left
209        /// untouched regardless of `--force`. Useful for clearing
210        /// broken entries after an interrupted build without
211        /// risking the curated set of good kernels. Mutually
212        /// exclusive with `--keep`: `--corrupt-only` never touches
213        /// valid entries, so a keep budget would silently be
214        /// ignored; rejecting at parse time surfaces the
215        /// misunderstanding instead.
216        #[arg(long, conflicts_with = "keep")]
217        corrupt_only: bool,
218    },
219}
220
221/// Help text for `--kernel` in contexts that reject raw image files:
222/// `cargo ktstr test`, `cargo ktstr coverage`, `cargo ktstr llvm-cov`,
223/// and `ktstr shell`. Matches
224/// `KernelResolvePolicy { accept_raw_image: false, .. }`.
225///
226/// Raw images are rejected here because these commands depend on a
227/// matching `vmlinux` and the cached kconfig fragment alongside the
228/// image (test/coverage need BTF, `ktstr shell` reuses the cache
229/// entry for kconfig discovery). A bare `bzImage`/`Image` passed
230/// directly carries neither, so silently accepting it would produce
231/// hard-to-diagnose mid-run failures.
232/// `cargo ktstr shell` accepts raw images because its flow does not
233/// need that companion metadata; see [`KERNEL_HELP_RAW_OK`].
234pub const KERNEL_HELP_NO_RAW: &str = "Kernel identifier: a source directory \
235     path (e.g. `../linux`), a version (`6.14.2`, or major.minor prefix \
236     `6.14` for latest patch), a cache key (see `kernel list`), a \
237     version range (`6.12..6.14`), or a git source \
238     (`git+URL#tag=NAME`, `git+URL#branch=NAME`, or `git+URL#sha=<40-hex>`). Raw \
239     image files are rejected. Source directories auto-build (can be slow \
240     on a fresh tree); versions auto-download from kernel.org on cache \
241     miss. The flag is REPEATABLE on `test`, `coverage`, and `llvm-cov` \
242     — passing multiple `--kernel` flags fans the gauntlet across every \
243     resolved kernel; each (test × scenario × topology × kernel) \
244     tuple becomes a distinct nextest test case so nextest's parallelism, \
245     retries, and `-E` filtering work natively. Ranges expand to every \
246     `stable` and `longterm` release inside `[START, END]` inclusive \
247     (mainline / linux-next dropped). Git sources are fetched at the \
248     given ref (GitHub via a codeload snapshot, other hosts via a \
249     shallow clone) and built once. In contrast, `ktstr shell` accepts a single \
250     kernel only — pass exactly one `--kernel`.";
251
252/// Help text for `--kernel` in contexts that accept raw image files:
253/// `cargo ktstr shell`. Matches
254/// `KernelResolvePolicy { accept_raw_image: true, .. }`. See
255/// [`KERNEL_HELP_NO_RAW`] for the converse and the rationale for
256/// the asymmetry.
257pub const KERNEL_HELP_RAW_OK: &str = "Kernel identifier: a source directory \
258     path (e.g. `../linux`), a raw image file (`bzImage` / `Image`), a \
259     version (`6.14.2`, or major.minor prefix `6.14` for latest patch), \
260     or a cache key (see `kernel list`). Source directories auto-build \
261     (can be slow on a fresh tree); versions auto-download from kernel.org \
262     on cache miss. When absent, resolves via cache then filesystem, \
263     falling back to downloading the latest stable kernel. Ranges \
264     (`START..END`) and git sources (`git+URL#tag=NAME`) are not supported \
265     in this context; pass a single kernel.";
266
267/// Help text for `--kernel` on `kernel build` (`ktstr` and
268/// `cargo ktstr`). Unlike [`KERNEL_HELP_NO_RAW`], this surface builds a
269/// kernel rather than resolving one for a test run: it is NOT repeatable
270/// (one kernel or one range per invocation), and a cache key is rejected
271/// (a cache key names an already-built entry, so there is nothing to
272/// build). A source-directory path auto-builds; a version / range
273/// auto-downloads; a git source is fetched and built. Omitting `--kernel`
274/// builds the latest stable release.
275pub const KERNEL_HELP_BUILD: &str = "Kernel to build: a version (`6.14.2`, \
276     a `MAJOR.MINOR` prefix `6.14` for the latest patch in that series, or a \
277     bare `MAJOR` prefix `6` for the latest patch across all `6.x`), a version range \
278     (`6.11..6.14` — builds every stable/longterm release in the range), a \
279     source directory path (`./linux`, `~/linux`, or an absolute path; a \
280     bare relative name like `linux` is read as a cache key, so prefix a \
281     relative source dir with `./`), or a git source (`git+URL#tag=NAME`, \
282     `git+URL#branch=NAME`, or `git+URL#sha=<40-hex>`). Omitted, the latest \
283     stable release is built. A cache key (an already-built entry from \
284     `kernel list`) is rejected — it names a built kernel, so there is \
285     nothing to build. Build one kernel (or one range) per invocation.";
286
287/// Help text for the `--include-eol` flag, shared by every command
288/// that expands a `START..END` kernel range (`cargo ktstr test`,
289/// `coverage`, `llvm-cov`, `verifier`, `kernel list`, and
290/// `kernel build` when the `--kernel` value is a range). One const so
291/// the wording stays identical across every surface the flag appears on.
292///
293/// Describes what the code DOES: [`crate::cli::expand_kernel_range`]
294/// draws only from kernel.org's `releases.json` by default (active
295/// `stable`/`longterm` series); under this flag it unions in the
296/// `vX.Y.Z` tags of the gregkh linux-stable mirror
297/// (`crate::fetch::cached_stable_tags`) and takes the latest patch
298/// per `(major, minor)` series across both sources, so a series that
299/// has aged out of `releases.json` still contributes its highest
300/// point release. If the mirror tag list cannot be fetched, expansion
301/// proceeds with a warning against the active-release set alone.
302pub const INCLUDE_EOL_HELP: &str = "Include end-of-life stable series when \
303     expanding a `START..END` kernel range. Default range resolution draws \
304     only from kernel.org's active releases (releases.json), which omits \
305     series no longer maintained; with this flag the range additionally \
306     covers every `vX.Y.Z` tag in the gregkh linux-stable mirror, so an EOL \
307     series inside [START, END] still contributes its highest point release. \
308     Has no effect on a single version, path, cache key, or git source — \
309     only range expansion consults the EOL set. When the mirror tag list \
310     cannot be fetched, expansion proceeds with a warning against the \
311     active-release set alone.";
312
313/// Help text for the `--cpu-cap N` flag. Shared across `ktstr kernel build`,
314/// `cargo ktstr kernel build`, and `ktstr shell` so the operator-facing
315/// wording is identical regardless of entry point.
316///
317/// This flag is the resource-budget contract: the operator promises
318/// (and the framework enforces) that the build or no-perf-mode shell
319/// VM will stay within N CPUs' worth of reservation and the NUMA
320/// nodes hosting them. Setting `--cpu-cap N` flips several internal
321/// defaults on this run: the LLC discovery walks whole LLCs in
322/// consolidation- and NUMA-aware order until the CPU budget is met;
323/// make's `-jN` parallelism matches the plan's CPU count so gcc
324/// can't fan out beyond the budget; a cgroup v2 sandbox binds make +
325/// gcc's cpuset to the plan's CPUs and `cpuset.mems` to the plan's
326/// NUMA nodes, so any degradation is fatal under the flag rather
327/// than a silent warning.
328pub const CPU_CAP_HELP: &str = "Reserve exactly N host CPUs for the build or \
329     no-perf-mode shell. Integer ≥ 1; must be ≤ the calling process's \
330     sched_getaffinity cpuset size (the allowed CPU count, NOT the \
331     host's total online CPUs — under a cgroup-restricted runner the \
332     allowed set is typically smaller). When absent, 30% of the \
333     allowed CPUs are reserved (minimum 1). The planner walks whole \
334     LLCs in consolidation- and NUMA-aware order, filtered to the \
335     allowed cpuset, partial-taking the last LLC so `plan.cpus.len() \
336     == N` exactly. The flock set may cover more LLCs than strictly \
337     required (flock coordination is per-LLC even when the last LLC \
338     is only partially used for the CPU budget). Run `ktstr locks \
339     --watch 1s` to observe NUMA placement live. Under --cpu-cap, \
340     make's `-jN` parallelism matches the reserved CPU count and the \
341     kernel build runs inside a cgroup v2 sandbox that pins gcc/ld \
342     to the reserved CPUs + NUMA nodes; if the sandbox cannot be \
343     installed (missing cgroup v2, missing cpuset controller, \
344     permission denied), the build aborts rather than running \
345     without enforcement. Mutually exclusive with \
346     KTSTR_BYPASS_LLC_LOCKS=1. On `ktstr shell`, requires \
347     --no-perf-mode (perf-mode already holds every LLC exclusively). \
348     Also settable via KTSTR_CPU_CAP env var (CLI flag wins when both \
349     are present).";
350
351/// Short clap-help for `--extra-kconfig`. Mirrors the [`CPU_CAP_HELP`]
352/// pattern: terse first sentence on the clap surface, full rustdoc
353/// on the [`KernelCommand::Build`] variant for `--help` long-about.
354///
355/// The full rustdoc covers: accepted line shapes, kbuild last-wins
356/// rule, `make olddefconfig` dependency resolution, post-build
357/// `validate_kernel_config` interaction, two-segment cache key,
358/// override warnings, and the unaddressable-from-other-flags
359/// rationale.
360pub const EXTRA_KCONFIG_HELP: &str = "Additional kconfig fragment merged on top of \
361     the baked-in `ktstr.kconfig`. Same line shapes the kernel uses: \
362     `CONFIG_FOO=y`, `CONFIG_FOO=m`, `CONFIG_FOO=\"value\"`, and \
363     `# CONFIG_FOO is not set`. User values win on conflict; \
364     `make olddefconfig` resolves dependencies. Each unique fragment \
365     produces a distinct cache slot via the `kc{baked}-xkc{extra}` \
366     key suffix. After build, `validate_kernel_config` rejects \
367     entries that disabled critical baked-in symbols \
368     (CONFIG_SCHED_CLASS_EXT, CONFIG_DEBUG_INFO_BTF, CONFIG_BPF_SYSCALL, \
369     CONFIG_FTRACE, CONFIG_KPROBE_EVENTS, CONFIG_BPF_EVENTS). \
370     The baked-in fragment lives at `ktstr.kconfig` in the ktstr \
371     repository root.";
372
373/// Literal text of the `(EOL)` tag explanation. Lives inside a macro
374/// (instead of a `pub const`) so that downstream `concat!` callers
375/// — specifically [`KERNEL_LIST_LONG_ABOUT`] — can embed the bytes at
376/// compile time without duplicating the string. `concat!` requires
377/// each argument to be a string literal at expansion, and a macro
378/// call that expands to a literal satisfies that requirement while
379/// a `&'static str` reference does not. Expansion order: the inner
380/// macro is expanded first, `concat!` then sees a literal.
381macro_rules! eol_explanation_literal {
382    () => {
383        "(EOL) marks entries whose major.minor series is absent from \
384         kernel.org's current active releases. Suppressed when the \
385         active-release list cannot be fetched."
386    };
387}
388
389/// Explanation of the `(EOL)` tag, shared between the text-output
390/// legend printed after `kernel list` and the `kernel list --help`
391/// long description (via [`KERNEL_LIST_LONG_ABOUT`], which embeds this
392/// exact byte sequence at its head through the shared
393/// `eol_explanation_literal!` macro). One literal → one source of
394/// truth, so a wording drift cannot put the two surfaces out of
395/// sync. `pub` matches the visibility of the sibling
396/// `KERNEL_HELP_*` constants so downstream consumers (e.g.
397/// documentation generators) can reference the exact text the CLI
398/// prints.
399pub const EOL_EXPLANATION: &str = eol_explanation_literal!();
400
401/// `long_about` for `kernel list --help`. Embeds [`EOL_EXPLANATION`]
402/// verbatim (via `eol_explanation_literal!`) so the tag legend
403/// cannot drift between the post-table output and the help copy,
404/// then appends a plain-text rendering of the `--json` output
405/// schema so scripted consumers can discover the contract from the
406/// terminal without running `cargo doc`. The schema wording
407/// mirrors the Rust-doc schema on [fn@super::kernel_list]; keeping both
408/// surfaces terse makes a drift obvious on review. A plain-text
409/// (not JSON/markdown) rendering is used because clap applies no
410/// JSON/markdown formatting pass, so the schema reads as plain
411/// text. Clap does apply terminal-width wrapping, so the embedded
412/// EOL sentence re-flows to the width of the host terminal; the
413/// schema block's explicit `\n` line breaks survive wrapping and
414/// preserve the column-aligned field table.
415pub const KERNEL_LIST_LONG_ABOUT: &str = concat!(
416    eol_explanation_literal!(),
417    "\n\n",
418    "--json emits one JSON object with three top-level fields:\n",
419    "\n",
420    "  current_ktstr_kconfig_hash   hex digest of the kconfig fragment the\n",
421    "                               running binary was built with, for\n",
422    "                               stale-entry detection.\n",
423    "  active_prefixes_fetch_error  null on success; error string on\n",
424    "                               active-series fetch failure. When\n",
425    "                               non-null, every entry's `eol` is false\n",
426    "                               regardless of actual support status —\n",
427    "                               check this field before trusting `eol`.\n",
428    "  entries                      array of per-entry objects. Each\n",
429    "                               element is either a VALID entry (full\n",
430    "                               field set) or a CORRUPT entry\n",
431    "                               (`key`, `path`, `error`,\n",
432    "                               `error_kind`). `error` is the\n",
433    "                               human-readable reason; `error_kind`\n",
434    "                               the machine-readable classifier.\n",
435    "                               Detect\n",
436    "                               corruption by the presence of `error`.\n",
437    "\n",
438    "Valid entry fields: key, path, version (nullable), source, arch,\n",
439    "built_at, ktstr_kconfig_hash (nullable), extra_kconfig_hash\n",
440    "(nullable), kconfig_status, eol, config_hash (nullable),\n",
441    "image_name, image_path, has_vmlinux, vmlinux_stripped.\n",
442    "\n",
443    "  path             absolute path to the cache entry DIRECTORY.\n",
444    "  image_path       absolute path to the boot image file INSIDE\n",
445    "                   that directory. `path` points at the dir, not\n",
446    "                   the image — scripts that want the kernel\n",
447    "                   artifact to pass to qemu/vm-loaders should\n",
448    "                   read `image_path`, not join `path` with a\n",
449    "                   hardcoded filename.\n",
450    "  kconfig_status   one of \"matches\", \"stale\", \"untracked\"\n",
451    "                   (Display form of cache::KconfigStatus).\n",
452    "  source           internally-tagged on \"type\":\n",
453    "                     {\"type\": \"tarball\"}\n",
454    "                     {\"type\": \"git\",   \"git_hash\": ?, \"ref\": ?}\n",
455    "                     {\"type\": \"local\", \"source_tree_path\": ?,\n",
456    "                                       \"git_hash\": ?}\n",
457    "                   Dispatch on \"type\" before reading variant\n",
458    "                   fields.\n",
459    "  eol              true iff the entry's major.minor series is absent\n",
460    "                   from the active-prefix list. Meaningful only when\n",
461    "                   active_prefixes_fetch_error is null. Also false\n",
462    "                   whenever version is null (the missing-version\n",
463    "                   short-circuit in `entry_is_eol`).\n",
464    "  has_vmlinux      true iff the uncompressed vmlinux is cached\n",
465    "                   alongside the compressed image (required for\n",
466    "                   DWARF-driven probes).\n",
467    "  vmlinux_stripped true iff the cached vmlinux came from a\n",
468    "                   successful strip pass. false marks the\n",
469    "                   raw-fallback path — a larger on-disk payload\n",
470    "                   indicating the strip pipeline errored on this\n",
471    "                   kernel; the entry is still usable but the\n",
472    "                   fallback is a signal to investigate. Meaningful\n",
473    "                   only when has_vmlinux is true (false otherwise).\n",
474    "  config_hash      CRC32 of the final merged .config; distinct\n",
475    "                   from ktstr_kconfig_hash which covers only the\n",
476    "                   ktstr fragment.\n",
477    "  extra_kconfig_hash\n",
478    "                   CRC32 of the user `--extra-kconfig` fragment\n",
479    "                   (raw bytes, no canonicalization), or null when\n",
480    "                   the entry was built without --extra-kconfig.\n",
481    "                   The cache key suffix grows from `kc{baked}` to\n",
482    "                   `kc{baked}-xkc{extra}` when extras are present,\n",
483    "                   and this field stores the `xkc` segment so\n",
484    "                   `kernel list` is self-describing for entries\n",
485    "                   that carry user modifications.\n",
486    "\n",
487    "When --kernel is a range, the subcommand SWITCHES to range-preview\n",
488    "mode and emits a structurally different JSON shape — the cache\n",
489    "is not walked at all, only kernel.org's releases.json is fetched\n",
490    "to expand the inclusive range. The --json output is one object\n",
491    "with four top-level fields:\n",
492    "\n",
493    "  range     literal range string supplied to --kernel\n",
494    "            (e.g. \"6.12..6.14\").\n",
495    "  start     parsed start endpoint\n",
496    "            (MAJOR.MINOR[.PATCH][-rcN]).\n",
497    "  end       parsed end endpoint, same shape as start.\n",
498    "  versions  array of resolved version strings inside\n",
499    "            [start, end] inclusive, ascending by\n",
500    "            (major, minor, patch, rc) tuple. Stable and\n",
501    "            longterm releases only — mainline / linux-next\n",
502    "            are excluded by the moniker filter.\n",
503    "\n",
504    "Range-mode output never carries cache metadata\n",
505    "(no current_ktstr_kconfig_hash, no entries) — to inspect cached\n",
506    "kernels for one of the resolved versions, run `kernel list`\n",
507    "without --kernel. Consumers should dispatch on the presence of\n",
508    "the `range` key (range mode) versus `entries` key (list mode)\n",
509    "to branch the parse."
510);
511
512/// Emitted by `kernel build` when a local source tree has
513/// uncommitted index/worktree changes. Caching would key the built
514/// artifact on a git hash that does not describe the actual tree,
515/// so the build completes but the result is not archived. The
516/// hint names the two remediation paths (commit or stash) so an
517/// operator re-running the build after cleaning the tree benefits
518/// from the cache. Extracted from the call site so a wording drift
519/// between what's printed and what's documented elsewhere is
520/// impossible by construction; pinned by
521/// `dirty_tree_cache_skip_hint_shape` below.
522pub const DIRTY_TREE_CACHE_SKIP_HINT: &str = "skipping cache — working tree has uncommitted changes; \
523     commit or stash to enable caching";
524
525/// Hint shown in place of [`DIRTY_TREE_CACHE_SKIP_HINT`] when the
526/// source tree is not a git repository at all. `commit` / `stash`
527/// are not actionable remediations in that case — the operator's
528/// only path to caching is to put the source under git (or use a
529/// kernel-source fetch mode that produces a git-tracked tree).
530/// Pinned by `non_git_tree_cache_skip_hint_shape` below so a
531/// wording drift is caught in unit tests.
532pub const NON_GIT_TREE_CACHE_SKIP_HINT: &str = "skipping cache — source tree is not a git repository so dirty \
533     state cannot be detected; put the source under git, or replace the \
534     `--kernel <path>` source with one of the content-keyed fetch modes \
535     that does not need dirty-state detection — `kernel build --kernel \
536     <version>` (downloads the tarball from kernel.org) or `kernel build \
537     --kernel git+URL#branch=NAME` (shallow-clones the given ref) — to \
538     enable caching";
539
540/// Decide whether to emit the `(EOL)` legend under the `kernel list`
541/// table. Returns `Some(EOL_EXPLANATION)` iff at least one rendered
542/// row carried the tag, else `None`. Splitting the conditional out
543/// of `kernel_list` lets both branches be pinned in unit tests
544/// without capturing stderr.
545pub(crate) fn eol_legend_if_any(any_eol: bool) -> Option<&'static str> {
546    if any_eol { Some(EOL_EXPLANATION) } else { None }
547}
548
549/// Explanation of the `(untracked kconfig)` tag. Consumer-facing
550/// wording mirrors `EOL_EXPLANATION`'s "one-const, one-surface"
551/// pattern so a doc-drift between the tag word and the legend
552/// cannot silently slip. Mirrors [`STALE_KCONFIG_EXPLANATION`] so
553/// the kconfig tag pair shares one shape.
554///
555/// The `(corrupt)` tag is deliberately not in this legend family —
556/// its remediation is operational, not informational. See
557/// `format_corrupt_footer` for the full rationale.
558pub const UNTRACKED_KCONFIG_EXPLANATION: &str = "(untracked kconfig) marks entries with no recorded ktstr.kconfig hash \
559     (pre-dates kconfig hash tracking). Rebuild with: kernel build --force --kernel VERSION \
560     (add --extra-kconfig PATH if the original entry was built with a user fragment).";
561
562/// Decide whether to emit the `(untracked kconfig)` legend under the
563/// `kernel list` table. Parallels [`eol_legend_if_any`] so both
564/// branches are unit-testable without stderr capture.
565pub(crate) fn untracked_legend_if_any(any_untracked: bool) -> Option<&'static str> {
566    if any_untracked {
567        Some(UNTRACKED_KCONFIG_EXPLANATION)
568    } else {
569        None
570    }
571}
572
573/// Explanation of the `(stale kconfig)` tag. Mirrors
574/// [`UNTRACKED_KCONFIG_EXPLANATION`] so the kconfig tag pair
575/// shares one shape — every kconfig-status legend in the
576/// informational trio (EOL / UNTRACKED / STALE) is now a const
577/// surfaced via a `*_legend_if_any` helper. Verbatim wording
578/// preserved from the prior inline `eprintln!` in `kernel_list`
579/// so existing operators see no behavioural change.
580pub const STALE_KCONFIG_EXPLANATION: &str = "warning: entries marked (stale kconfig) were built against a different ktstr.kconfig. \
581     Rebuild with: kernel build --force --kernel <entry version> \
582     (add --extra-kconfig PATH if the entry also carries the (extra kconfig) tag).";
583
584/// Decide whether to emit the `(stale kconfig)` legend under the
585/// `kernel list` table. Mirrors [`eol_legend_if_any`] and
586/// [`untracked_legend_if_any`] so all three informational legends
587/// share one shape (boolean in, `Option<&'static str>` out) and
588/// every branch is unit-testable without stderr capture.
589pub(crate) fn stale_legend_if_any(any_stale: bool) -> Option<&'static str> {
590    if any_stale {
591        Some(STALE_KCONFIG_EXPLANATION)
592    } else {
593        None
594    }
595}
596
597/// Footer emitted by `kernel_list` when at least one entry is
598/// corrupt. Pure function of the cache-root path so tests pin the
599/// exact same string the production path prints — not a hand-copied
600/// duplicate. Extracted alongside [`eol_legend_if_any`] so the
601/// three actionable elements (the `(corrupt)` tag label, the
602/// `kernel clean` variants, and the cache-root path) are enforced
603/// by one source of truth.
604///
605/// Scope-safe wording: callers inspecting the footer in isolation
606/// must not be able to misread `kernel clean --force` as surgical.
607/// The text explicitly spells out "ALL cached entries" and
608/// surfaces `--corrupt-only --force` (the surgical form that leaves
609/// valid entries intact) ahead of the broader `--force` and
610/// `--keep N --force` escalation paths, so an operator with valid
611/// alongside corrupt entries reaches for the safe option first
612/// rather than blowing them all away in a single command.
613///
614/// Design decision: `(corrupt)` is deliberately NOT promoted to a
615/// one-line tag-explanation const in the [`EOL_EXPLANATION`] /
616/// [`UNTRACKED_KCONFIG_EXPLANATION`] / [`STALE_KCONFIG_EXPLANATION`]
617/// legend family. Two constraints drive the decision:
618///
619/// 1. **Runtime cache-root path.** The remediation must surface
620///    the actual cache-root directory so operators know where to
621///    inspect, and a `&'static str` cannot interpolate a runtime
622///    value. [`UNTRACKED_KCONFIG_EXPLANATION`] fits on one line
623///    precisely because its remediation (`kernel build --force
624///    VERSION`) is a literal string with no runtime context;
625///    corrupt's is not, and splitting definition from remediation
626///    is only a fallback — not a solution — since the runtime
627///    path still has to land somewhere adjacent to the tag.
628///
629/// 2. **Duplication avoidance.** The footer's first sentence
630///    already IS the legend — it names the tag, states the
631///    unusable meaning, and enumerates the three corruption modes
632///    (missing metadata, malformed metadata, missing image). A
633///    separate `CORRUPT_EXPLANATION` const would duplicate that
634///    content at two surfaces (const + footer), create drift risk
635///    as either wording is edited, and pay for nothing: a reader
636///    who sees `(corrupt)` in a row and scrolls to the footer
637///    already hits the definition in the first line. Test
638///    `corrupt_footer_is_self_documenting` pins that invariant.
639///
640/// Consistency note: the informational trio (EOL / UNTRACKED /
641/// STALE) all share the const + `*_legend_if_any` shape;
642/// `(corrupt)` is the sole tag whose remediation requires runtime
643/// state (the cache-root path), which is why it stays in the
644/// footer family rather than joining the informational trio.
645///
646/// Command ordering inside the footer: `--corrupt-only --force`
647/// is listed FIRST because it is the zero-risk surgical option
648/// for the common case (a cache with both valid and corrupt
649/// entries — leaves valid alone). The broader `--force` (removes
650/// ALL) and `--keep N --force` (preserves N newest) variants
651/// follow as escalation paths for operators who want to expand
652/// scope beyond corrupt entries alone.
653pub(crate) fn format_corrupt_footer(cache_root: &Path) -> String {
654    format!(
655        "warning: entries marked (corrupt) cannot be used — cached metadata is \
656         missing, malformed, or references a missing image. Inspect the entry \
657         directory under {} to remove it manually, or run \
658         `kernel clean --corrupt-only --force` which removes ONLY corrupt \
659         entries and leaves valid ones intact. For broader cleanup, \
660         `kernel clean --force` removes ALL cached entries (valid and corrupt \
661         alike); `kernel clean --keep N --force` preserves the N newest \
662         cached entries while removing the rest.",
663        cache_root.display(),
664    )
665}
666
667/// Decide whether to emit the corrupt-entry footer under the
668/// `kernel list` table. Mirrors [`eol_legend_if_any`] and
669/// [`untracked_legend_if_any`] so the three "tag → footer" gates
670/// share one shape (count in, `Option<String>` out) and every
671/// branch is unit-testable without stderr capture. The
672/// unconditional emission-only-when-tag-rendered invariant is the
673/// signal that keeps the normal no-corrupt case noise-free; a
674/// regression that unconditionally emitted the footer would show
675/// up as a red test on the `corrupt_count == 0` branch here.
676///
677/// Prepends a `"N corrupt entr{y|ies}. Run `cargo ktstr kernel
678/// clean --corrupt-only` to remove.\n"` summary line before the
679/// full [`format_corrupt_footer`] body so an operator sees the
680/// count and the short remediation FIRST, with the multi-option
681/// escalation detail following. The pluralized form ("entry" vs
682/// "entries") matches the count, making the line read naturally
683/// at both the 1-entry and N>1-entry boundaries.
684pub(crate) fn corrupt_footer_if_any(corrupt_count: usize, cache_root: &Path) -> Option<String> {
685    if corrupt_count == 0 {
686        return None;
687    }
688    let noun = if corrupt_count == 1 {
689        "entry"
690    } else {
691        "entries"
692    };
693    let summary = format!(
694        "{corrupt_count} corrupt {noun}. \
695         Run `cargo ktstr kernel clean --corrupt-only` to remove.",
696    );
697    let detail = format_corrupt_footer(cache_root);
698    Some(format!("{summary}\n{detail}"))
699}
700
701/// ktstr.kconfig embedded at compile time.
702pub const EMBEDDED_KCONFIG: &str = crate::EMBEDDED_KCONFIG;
703
704/// Compute CRC32 of the embedded ktstr.kconfig fragment.
705pub fn embedded_kconfig_hash() -> String {
706    crate::kconfig_hash()
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    /// `eol_legend_if_any` is the sole gate on whether the text
714    /// output under `kernel list` emits the `(EOL)` legend.
715    #[test]
716    fn eol_legend_if_any_branches() {
717        assert_eq!(eol_legend_if_any(true), Some(EOL_EXPLANATION));
718        assert_eq!(eol_legend_if_any(false), None);
719    }
720
721    /// `untracked_legend_if_any` mirrors `eol_legend_if_any`.
722    #[test]
723    fn untracked_legend_if_any_branches() {
724        assert_eq!(
725            untracked_legend_if_any(true),
726            Some(UNTRACKED_KCONFIG_EXPLANATION),
727        );
728        assert_eq!(untracked_legend_if_any(false), None);
729    }
730
731    /// `stale_legend_if_any` completes the kconfig legend pair.
732    #[test]
733    fn stale_legend_if_any_branches() {
734        assert_eq!(stale_legend_if_any(true), Some(STALE_KCONFIG_EXPLANATION));
735        assert_eq!(stale_legend_if_any(false), None);
736    }
737
738    /// `STALE_KCONFIG_EXPLANATION` shape pin.
739    #[test]
740    fn stale_kconfig_explanation_shape() {
741        assert!(STALE_KCONFIG_EXPLANATION.starts_with("warning"));
742        assert!(STALE_KCONFIG_EXPLANATION.contains("(stale kconfig)"));
743        assert!(STALE_KCONFIG_EXPLANATION.contains("different ktstr.kconfig"));
744        assert!(
745            STALE_KCONFIG_EXPLANATION.contains("kernel build --force --kernel <entry version>")
746        );
747    }
748
749    /// `corrupt_footer_if_any` branches.
750    #[test]
751    fn corrupt_footer_if_any_branches() {
752        let root = std::path::Path::new("/tmp/ktstr-cache-test-root");
753        assert_eq!(corrupt_footer_if_any(0, root), None);
754        let one = corrupt_footer_if_any(1, root).expect("positive count must yield Some(footer)");
755        assert!(one.contains("1 corrupt entry."));
756        assert!(one.contains("cargo ktstr kernel clean --corrupt-only"));
757        assert!(one.contains(&format_corrupt_footer(root)));
758        let many = corrupt_footer_if_any(3, root).expect("positive count must yield Some(footer)");
759        assert!(many.contains("3 corrupt entries."));
760    }
761
762    /// Pin design decision: `(corrupt)` first sentence IS the
763    /// legend; the footer carries it AND the operational
764    /// remediation block.
765    #[test]
766    fn corrupt_footer_is_self_documenting() {
767        let root = std::path::Path::new("/tmp/ktstr-cache-test-root");
768        let footer = format_corrupt_footer(root);
769        let first_sentence = footer
770            .split_once(". ")
771            .map(|(head, _)| head)
772            .expect("footer must terminate legend sentence with period-space");
773        assert!(first_sentence.contains("(corrupt)"));
774        assert!(first_sentence.contains("cannot be used"));
775        for reason_token in ["metadata is missing", "malformed", "missing image"] {
776            assert!(
777                first_sentence.contains(reason_token),
778                "legend sentence must enumerate corruption modes; \
779                 expected `{reason_token}`, got: {first_sentence:?}",
780            );
781        }
782        assert!(footer.contains(&root.display().to_string()));
783        assert!(footer.contains("kernel clean --corrupt-only --force"));
784        assert!(footer.contains("kernel clean --force"));
785        assert!(footer.contains("kernel clean --keep N --force"));
786        assert!(footer.contains("ALL cached entries"));
787        let pos_corrupt_only = footer
788            .find("kernel clean --corrupt-only --force")
789            .expect("--corrupt-only must appear");
790        let pos_force = footer
791            .find("kernel clean --force")
792            .expect("--force must appear");
793        let pos_keep = footer
794            .find("kernel clean --keep N --force")
795            .expect("--keep must appear");
796        assert!(pos_corrupt_only < pos_force);
797        assert!(pos_force < pos_keep);
798    }
799
800    /// `DIRTY_TREE_CACHE_SKIP_HINT` shape pin.
801    #[test]
802    fn dirty_tree_cache_skip_hint_shape() {
803        assert!(DIRTY_TREE_CACHE_SKIP_HINT.contains("skipping cache"));
804        assert!(DIRTY_TREE_CACHE_SKIP_HINT.contains("uncommitted changes"));
805        assert!(
806            DIRTY_TREE_CACHE_SKIP_HINT.contains("commit")
807                && DIRTY_TREE_CACHE_SKIP_HINT.contains("stash")
808        );
809    }
810
811    /// `NON_GIT_TREE_CACHE_SKIP_HINT` shape pin.
812    #[test]
813    fn non_git_tree_cache_skip_hint_shape() {
814        assert!(NON_GIT_TREE_CACHE_SKIP_HINT.starts_with("skipping cache"));
815        assert!(NON_GIT_TREE_CACHE_SKIP_HINT.contains("not a git repository"));
816        assert!(NON_GIT_TREE_CACHE_SKIP_HINT.contains("put the source under git"));
817        assert!(NON_GIT_TREE_CACHE_SKIP_HINT.contains("kernel build --kernel <version>"));
818        assert!(NON_GIT_TREE_CACHE_SKIP_HINT.contains("kernel build --kernel git+URL#branch=NAME"));
819        assert!(!NON_GIT_TREE_CACHE_SKIP_HINT.contains("stash"));
820        assert!(!NON_GIT_TREE_CACHE_SKIP_HINT.contains("commit"));
821    }
822
823    /// `untracked_legend_names_the_tag_word` — legend mentions tag.
824    #[test]
825    fn untracked_legend_names_the_tag_word() {
826        assert!(UNTRACKED_KCONFIG_EXPLANATION.contains("(untracked kconfig)"));
827    }
828
829    /// kernel_clean rejects `--keep` together with `--corrupt-only`.
830    #[test]
831    fn kernel_clean_rejects_corrupt_only_with_keep() {
832        use clap::Parser as _;
833        #[derive(clap::Parser, Debug)]
834        struct TestCli {
835            #[command(subcommand)]
836            cmd: KernelCommand,
837        }
838        let err = TestCli::try_parse_from(["prog", "clean", "--keep", "2", "--corrupt-only"])
839            .expect_err("--keep together with --corrupt-only must fail parsing");
840        let msg = err.to_string();
841        assert!(
842            msg.to_ascii_lowercase().contains("cannot be used with")
843                || msg.to_ascii_lowercase().contains("conflict"),
844            "clap error must surface the conflict between --keep and --corrupt-only, got: {msg}",
845        );
846    }
847
848    #[test]
849    fn kernel_clean_accepts_corrupt_only_alone() {
850        use clap::Parser as _;
851        #[derive(clap::Parser, Debug)]
852        struct TestCli {
853            #[command(subcommand)]
854            cmd: KernelCommand,
855        }
856        let parsed = TestCli::try_parse_from(["prog", "clean", "--corrupt-only"])
857            .expect("--corrupt-only without --keep must parse cleanly");
858        match parsed.cmd {
859            KernelCommand::Clean {
860                keep,
861                force,
862                corrupt_only,
863            } => {
864                assert_eq!(keep, None);
865                assert!(!force);
866                assert!(corrupt_only);
867            }
868            other => panic!("expected KernelCommand::Clean, got {other:?}"),
869        }
870    }
871
872    /// `kernel build --cpu-cap N` parses to `Build { cpu_cap: Some(N) }`.
873    #[test]
874    fn kernel_build_parses_cpu_cap_without_extra_flags() {
875        use clap::Parser as _;
876        #[derive(clap::Parser, Debug)]
877        struct TestCli {
878            #[command(subcommand)]
879            cmd: KernelCommand,
880        }
881        let parsed =
882            TestCli::try_parse_from(["prog", "build", "--kernel", "6.14.2", "--cpu-cap", "4"])
883                .expect("kernel build --cpu-cap N must parse");
884        match parsed.cmd {
885            KernelCommand::Build {
886                cpu_cap, kernel, ..
887            } => {
888                assert_eq!(cpu_cap, Some(4));
889                assert_eq!(kernel.as_deref(), Some("6.14.2"));
890            }
891            other => panic!("expected KernelCommand::Build, got {other:?}"),
892        }
893    }
894
895    /// `kernel build` without `--cpu-cap` parses with cpu_cap: None.
896    #[test]
897    fn kernel_build_without_cpu_cap_defaults_to_none() {
898        use clap::Parser as _;
899        #[derive(clap::Parser, Debug)]
900        struct TestCli {
901            #[command(subcommand)]
902            cmd: KernelCommand,
903        }
904        let parsed = TestCli::try_parse_from(["prog", "build", "--kernel", "6.14.2"])
905            .expect("kernel build without --cpu-cap must parse");
906        match parsed.cmd {
907            KernelCommand::Build { cpu_cap, .. } => {
908                assert_eq!(cpu_cap, None, "no --cpu-cap must produce None, not Some(0)");
909            }
910            other => panic!("expected KernelCommand::Build, got {other:?}"),
911        }
912    }
913
914    /// Bare `kernel build` (no `--kernel`) parses to `Build { kernel:
915    /// None }` — the latest-stable default. Pins the None default at the
916    /// parse layer so the dispatch's `None => latest stable` arm stays
917    /// reachable.
918    #[test]
919    fn kernel_build_without_kernel_defaults_to_none() {
920        use clap::Parser as _;
921        #[derive(clap::Parser, Debug)]
922        struct TestCli {
923            #[command(subcommand)]
924            cmd: KernelCommand,
925        }
926        let parsed =
927            TestCli::try_parse_from(["prog", "build"]).expect("bare kernel build must parse");
928        match parsed.cmd {
929            KernelCommand::Build { kernel, .. } => {
930                assert_eq!(
931                    kernel, None,
932                    "no --kernel must produce None (the latest-stable default)"
933                );
934            }
935            other => panic!("expected KernelCommand::Build, got {other:?}"),
936        }
937    }
938
939    /// `kernel build --cpu-cap 0` passes clap (validation runs at runtime).
940    #[test]
941    fn kernel_build_cpu_cap_zero_passes_clap() {
942        use clap::Parser as _;
943        #[derive(clap::Parser, Debug)]
944        struct TestCli {
945            #[command(subcommand)]
946            cmd: KernelCommand,
947        }
948        let parsed =
949            TestCli::try_parse_from(["prog", "build", "--kernel", "6.14.2", "--cpu-cap", "0"])
950                .expect("clap-level parse must accept 0; runtime validation rejects");
951        match parsed.cmd {
952            KernelCommand::Build { cpu_cap, .. } => {
953                assert_eq!(cpu_cap, Some(0));
954            }
955            other => panic!("expected KernelCommand::Build, got {other:?}"),
956        }
957    }
958
959    /// `kernel build --skip-sha256` parses to
960    /// `Build { skip_sha256: true, .. }`. Pins the bypass flag's
961    /// wire path: a regression that dropped the field would surface
962    /// as a parse rejection.
963    #[test]
964    fn kernel_build_parses_skip_sha256() {
965        use clap::Parser as _;
966        #[derive(clap::Parser, Debug)]
967        struct TestCli {
968            #[command(subcommand)]
969            cmd: KernelCommand,
970        }
971        let parsed =
972            TestCli::try_parse_from(["prog", "build", "--kernel", "6.14.2", "--skip-sha256"])
973                .expect("kernel build --skip-sha256 must parse");
974        match parsed.cmd {
975            KernelCommand::Build { skip_sha256, .. } => {
976                assert!(
977                    skip_sha256,
978                    "--skip-sha256 must round-trip as true so the \
979                     downstream download path bypasses sha256sums.asc"
980                );
981            }
982            other => panic!("expected KernelCommand::Build, got {other:?}"),
983        }
984    }
985
986    /// `kernel build` without `--skip-sha256` parses with the safe
987    /// default `skip_sha256: false`. Pins the no-flag path so a
988    /// regression that flipped the default to true (silently
989    /// disabling checksum verification on every download) surfaces
990    /// as a test failure.
991    #[test]
992    fn kernel_build_without_skip_sha256_defaults_to_false() {
993        use clap::Parser as _;
994        #[derive(clap::Parser, Debug)]
995        struct TestCli {
996            #[command(subcommand)]
997            cmd: KernelCommand,
998        }
999        let parsed = TestCli::try_parse_from(["prog", "build", "--kernel", "6.14.2"])
1000            .expect("kernel build without --skip-sha256 must parse");
1001        match parsed.cmd {
1002            KernelCommand::Build { skip_sha256, .. } => {
1003                assert!(
1004                    !skip_sha256,
1005                    "no --skip-sha256 must produce false (verification \
1006                     enabled by default); got skip_sha256={skip_sha256}"
1007                );
1008            }
1009            other => panic!("expected KernelCommand::Build, got {other:?}"),
1010        }
1011    }
1012
1013    /// `KERNEL_LIST_LONG_ABOUT` drives `kernel list --help` and must
1014    /// expose the `--json` output contract so scripted consumers can
1015    /// discover the schema from the terminal alone. Pins:
1016    /// 1. the `(EOL)` legend text appears verbatim at the head;
1017    /// 2. every top-level wrapper field appears;
1018    /// 3. every valid-entry field appears;
1019    /// 4. each `Option<T>` field carries a `(nullable)` tag;
1020    /// 5. each `KernelSource` variant tag and `kconfig_status`
1021    ///    enum value is documented.
1022    #[test]
1023    fn kernel_list_long_about_exposes_json_schema() {
1024        assert!(
1025            KERNEL_LIST_LONG_ABOUT.starts_with(EOL_EXPLANATION),
1026            "KERNEL_LIST_LONG_ABOUT must embed EOL_EXPLANATION verbatim at its \
1027             head so the --help and post-table legend share one source of \
1028             truth; got: {KERNEL_LIST_LONG_ABOUT:?}",
1029        );
1030
1031        for wrapper_field in [
1032            "current_ktstr_kconfig_hash",
1033            "active_prefixes_fetch_error",
1034            "entries",
1035        ] {
1036            assert!(
1037                KERNEL_LIST_LONG_ABOUT.contains(wrapper_field),
1038                "KERNEL_LIST_LONG_ABOUT must mention top-level wrapper field \
1039                 `{wrapper_field}` so scripted consumers discover the \
1040                 schema without `cargo doc`",
1041            );
1042        }
1043
1044        for valid_entry_field in [
1045            "key",
1046            "path",
1047            "version",
1048            "source",
1049            "arch",
1050            "built_at",
1051            "ktstr_kconfig_hash",
1052            "kconfig_status",
1053            "eol",
1054            "config_hash",
1055            "image_name",
1056            "image_path",
1057            "has_vmlinux",
1058            "vmlinux_stripped",
1059            "git_hash",
1060            "\"ref\"",
1061            "source_tree_path",
1062        ] {
1063            assert!(
1064                KERNEL_LIST_LONG_ABOUT.contains(valid_entry_field),
1065                "KERNEL_LIST_LONG_ABOUT must mention valid-entry JSON \
1066                 field `{valid_entry_field}`",
1067            );
1068        }
1069
1070        assert!(
1071            KERNEL_LIST_LONG_ABOUT.contains("error"),
1072            "KERNEL_LIST_LONG_ABOUT must mention corrupt-entry JSON \
1073             field `error` so consumers know the corrupt-entry shape",
1074        );
1075
1076        for nullable_field in ["version", "ktstr_kconfig_hash", "config_hash"] {
1077            let marker = format!("{nullable_field} (nullable)");
1078            assert!(
1079                KERNEL_LIST_LONG_ABOUT.contains(&marker),
1080                "KERNEL_LIST_LONG_ABOUT must mark `{nullable_field}` \
1081                 as `(nullable)` (expected substring `{marker}`)",
1082            );
1083        }
1084
1085        for source_variant_tag in ["\"tarball\"", "\"git\"", "\"local\""] {
1086            assert!(
1087                KERNEL_LIST_LONG_ABOUT.contains(source_variant_tag),
1088                "KERNEL_LIST_LONG_ABOUT must list source variant tag \
1089                 `{source_variant_tag}`",
1090            );
1091        }
1092
1093        for status_variant in ["\"matches\"", "\"stale\"", "\"untracked\""] {
1094            assert!(
1095                KERNEL_LIST_LONG_ABOUT.contains(status_variant),
1096                "KERNEL_LIST_LONG_ABOUT must list kconfig_status variant \
1097                 `{status_variant}`",
1098            );
1099        }
1100    }
1101
1102    /// Pin that the `#[command(long_about = KERNEL_LIST_LONG_ABOUT)]`
1103    /// attribute on `KernelCommand::List` is wired through clap.
1104    #[test]
1105    fn kernel_list_long_about_wired_via_clap() {
1106        use clap::CommandFactory as _;
1107        #[derive(clap::Parser, Debug)]
1108        struct TestCli {
1109            #[command(subcommand)]
1110            cmd: KernelCommand,
1111        }
1112        let cmd = TestCli::command();
1113        let list = cmd
1114            .find_subcommand("list")
1115            .expect("clap must register a `list` subcommand on KernelCommand");
1116        let long_about = list
1117            .get_long_about()
1118            .expect("`list` subcommand must have a long_about set")
1119            .to_string();
1120        assert_eq!(long_about, KERNEL_LIST_LONG_ABOUT);
1121    }
1122
1123    /// `kernel build --help` must surface the `--skip-sha256` flag
1124    /// with documentation covering the no-op semantics on
1125    /// `--kernel <path>` / git+ / RC tarballs and the
1126    /// security-sensitive bypass-warning contract. Without these
1127    /// hints an operator setting the flag would have no way to
1128    /// know whether their fetch actually bypassed verification.
1129    #[test]
1130    fn kernel_build_help_documents_skip_sha256_no_op_semantics() {
1131        use clap::CommandFactory as _;
1132        #[derive(clap::Parser, Debug)]
1133        struct TestCli {
1134            #[command(subcommand)]
1135            cmd: KernelCommand,
1136        }
1137        let cmd = TestCli::command();
1138        let build = cmd
1139            .find_subcommand("build")
1140            .expect("clap must register a `build` subcommand on KernelCommand");
1141        // Locate the --skip-sha256 arg and read its long help.
1142        let arg = build
1143            .get_arguments()
1144            .find(|a| a.get_long() == Some("skip-sha256"))
1145            .expect("kernel build must register --skip-sha256 with clap");
1146        let help = arg
1147            .get_long_help()
1148            .or_else(|| arg.get_help())
1149            .map(|s| s.to_string())
1150            .expect("--skip-sha256 must carry help text");
1151        // Pins the user-facing semantics so a future doc rewrite
1152        // cannot drop the no-op clauses or the warning contract.
1153        assert!(
1154            help.contains("--kernel <path>"),
1155            "--skip-sha256 help must call out the --kernel <path> no-op so \
1156             operators don't expect bypass on local-source builds: {help}"
1157        );
1158        assert!(
1159            help.contains("git+"),
1160            "--skip-sha256 help must call out the git-source no-op so \
1161             operators don't expect bypass on git-source builds: {help}"
1162        );
1163        assert!(
1164            help.contains("RC"),
1165            "--skip-sha256 help must call out the RC-tarball no-op \
1166             (RC archives have no upstream manifest, so the flag is \
1167             a no-op there): {help}"
1168        );
1169        assert!(
1170            help.contains("warning"),
1171            "--skip-sha256 help must mention the bypass warning so \
1172             ops know the lost guarantee surfaces in the same channel \
1173             as verification-success lines: {help}"
1174        );
1175    }
1176}