ktstr/cli/
kernel_list.rs

1//! `kernel list` and `kernel clean` implementations.
2//!
3//! Holds the table renderer ([`format_entry_row`]), the EOL gate
4//! ([`is_eol`] / [`entry_is_eol`]), the cache enumeration entry
5//! points ([`kernel_list`], [`kernel_list_range_preview`]) and the
6//! per-bucket retention partitioner ([`partition_clean_candidates`])
7//! plus the [`kernel_clean`] driver.
8
9use std::io::{BufRead, Write};
10
11use anyhow::{Result, bail};
12
13use crate::cache::{CacheDir, CacheEntry, KconfigStatus};
14
15use super::kernel_cmd::{
16    corrupt_footer_if_any, embedded_kconfig_hash, eol_legend_if_any, stale_legend_if_any,
17    untracked_legend_if_any,
18};
19use super::resolve::expand_kernel_range;
20
21/// Extract the `major.minor` series prefix from a version string.
22///
23/// The minor component is normalized to its leading ASCII-digit run
24/// so RC, linux-next, and any other `-suffix` strings collapse to
25/// the same prefix as a released kernel in the same series:
26/// - `"6.12.81"` → `"6.12"`
27/// - `"7.0"` → `"7.0"`
28/// - `"6.15-rc3"` → `"6.15"` (RC folds into series)
29/// - `"6.16-rc2-next-20260420"` → `"6.16"` (linux-next folds too)
30/// - `"7.0-rc1"` → `"7.0"` (brand-new RC matches non-RC same-series)
31/// - `"abc"` → `None` (no `.`)
32/// - `"6.abc"` → `None` (no digits in minor)
33///
34/// Returning the same prefix for both sides of the
35/// [`is_eol`] comparison is what makes the predicate immune to
36/// releases.json and local-cache versions using different
37/// RC / pre-release suffixes within the same series.
38fn version_prefix(version: &str) -> Option<String> {
39    let (major, rest) = version.split_once('.')?;
40    let minor_digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
41    if minor_digits.is_empty() {
42        return None;
43    }
44    Some(format!("{major}.{minor_digits}"))
45}
46
47/// Return `true` when `version`'s major.minor series is absent
48/// from a non-empty `active_prefixes` list — i.e. the version is
49/// end-of-life relative to the kernel.org releases snapshot the
50/// caller supplied.
51///
52/// Returns `false` in three cases:
53/// - `active_prefixes` is empty. Callers pass an empty slice to
54///   signal "active list unknown" (fetch failure, or skipped
55///   lookup), per the `kernel list --json` doc contract that
56///   fetch failure must not flag any entry EOL. Without the
57///   explicit empty-slice guard, `!any(..)` on an empty iterator
58///   is `true` and every entry would be tagged EOL — the exact
59///   opposite of the contract.
60/// - `version` has no parseable major.minor prefix (e.g. a cache
61///   key or freeform string).
62/// - `version`'s major.minor prefix appears in `active_prefixes`.
63fn is_eol(version: &str, active_prefixes: &[String]) -> bool {
64    if active_prefixes.is_empty() {
65        return false;
66    }
67    let Some(prefix) = version_prefix(version) else {
68        return false;
69    };
70    !active_prefixes.iter().any(|p| p == &prefix)
71}
72
73/// Whether a cache entry is end-of-life relative to the supplied
74/// active-prefix list. Handles the `version == None` / `"-"`
75/// short-circuit once for both the text-path `(EOL)` tag render in
76/// [`format_entry_row`] and the JSON-path `eol` field emission in
77/// [`kernel_list`], so the two surfaces cannot drift: any change to
78/// the predicate or the missing-version gate lands in both by
79/// construction. `kernel_list_eol_json_human_parity` pins this
80/// invariant.
81pub(crate) fn entry_is_eol(entry: &CacheEntry, active_prefixes: &[String]) -> bool {
82    let v = entry.metadata.version.as_deref().unwrap_or("-");
83    v != "-" && is_eol(v, active_prefixes)
84}
85
86/// Fetch active kernel series prefixes from releases.json.
87///
88/// Returns major.minor prefixes for every stable/longterm/mainline
89/// entry on success. Propagates the underlying
90/// [`crate::fetch::cached_releases`] error on failure (network error,
91/// HTTP status, JSON parse failure, missing releases array) so
92/// callers can distinguish "fetched and empty" (kernel.org shipped
93/// no active series — a violated assumption) from "fetch failed"
94/// (transient outage where EOL annotation must degrade, not flip).
95///
96/// See [`is_eol`]'s empty-slice guard for the recommended fallback pattern.
97pub(crate) fn fetch_active_prefixes() -> anyhow::Result<Vec<String>> {
98    // Route through the process-wide releases.json cache so the
99    // EOL-annotation pass shares its fetch with the rayon-driven
100    // resolve pipeline that calls [`expand_kernel_range`] under
101    // `cargo ktstr`'s `resolve_kernel_set`. First caller across
102    // the whole process pays the network cost; every subsequent
103    // caller (within this command or peer Range/active-prefix
104    // consumers) clones the cached vector.
105    let releases = crate::fetch::cached_releases()?;
106    Ok(active_prefixes_from_releases(&releases))
107}
108
109/// Reduce [`Release`](crate::fetch::Release) rows to the deduplicated
110/// list of major.minor prefixes the `(EOL)` annotation compares
111/// against.
112///
113/// Separated from [`fetch_active_prefixes`] so the normalization path
114/// — `linux-next` skip, RC-suffix collapse via [`version_prefix`], and
115/// first-seen dedup preserving input order — is testable without
116/// hitting the network. The on-network wrapper is a one-line adapter
117/// over this helper, so any future change to the normalization lands
118/// here once and both call sites consume it.
119fn active_prefixes_from_releases(releases: &[crate::fetch::Release]) -> Vec<String> {
120    let mut prefixes = Vec::new();
121    for r in releases {
122        if crate::fetch::is_skippable_release_moniker(&r.moniker) {
123            continue;
124        }
125        if let Some(prefix) = version_prefix(&r.version)
126            && !prefixes.contains(&prefix)
127        {
128            prefixes.push(prefix);
129        }
130    }
131    prefixes
132}
133
134/// Format a human-readable table row for a cache entry.
135pub fn format_entry_row(
136    entry: &CacheEntry,
137    kconfig_hash: &str,
138    active_prefixes: &[String],
139) -> String {
140    let meta = &entry.metadata;
141    let version = meta.version.as_deref().unwrap_or("-");
142    let source = meta.source.to_string();
143    let mut tags = String::new();
144    // Compose the kconfig tag from `KconfigStatus`'s `Display` impl
145    // so the tag word ("stale" / "untracked") and the JSON
146    // `kconfig_status` field both flow through one source of truth.
147    // `Matches` emits no tag — `kernel list` only annotates entries
148    // that deviate from the current kconfig.
149    let status = entry.kconfig_status(kconfig_hash);
150    if !matches!(status, KconfigStatus::Matches) {
151        tags.push_str(&format!(" ({status} kconfig)"));
152    }
153    // `(extra kconfig)` is orthogonal to baked-in status: an entry
154    // can be Matches/Stale/Untracked AND carry user extras. Emit
155    // independently so an operator reading the table sees both
156    // signals without one masking the other.
157    if entry.has_extra_kconfig() {
158        tags.push_str(" (extra kconfig)");
159    }
160    if entry_is_eol(entry, active_prefixes) {
161        tags.push_str(" (EOL)");
162    }
163    format!(
164        "  {:<48} {:<12} {:<8} {:<7} {}{}",
165        entry.key, version, source, meta.arch, meta.built_at, tags,
166    )
167}
168
169/// List cached kernel images.
170///
171/// # JSON output schema (`--json`)
172///
173/// ```json
174/// {
175///   "current_ktstr_kconfig_hash": "abc123...",
176///   "active_prefixes_fetch_error": null,
177///   "entries": [
178///     {
179///       "key": "7.1.0-rc2",
180///       "path": "/path/to/cache/entry",
181///       "version": "7.1.0-rc2",
182///       "source": { "type": "tarball" },
183///       "arch": "x86_64",
184///       "built_at": "2026-04-15T12:34:56Z",
185///       "ktstr_kconfig_hash": "abc123...",
186///       "extra_kconfig_hash": null,
187///       "kconfig_status": "matches",
188///       "eol": false,
189///       "config_hash": "def456...",
190///       "image_name": "bzImage",
191///       "image_path": "/path/to/cache/entry/bzImage",
192///       "has_vmlinux": true,
193///       "vmlinux_stripped": true
194///     },
195///     {
196///       "key": "6.12.0-broken",
197///       "path": "/path/to/cache/broken-entry",
198///       "error": "metadata.json schema drift: missing field `source` at line 1 column 21",
199///       "error_kind": "schema_drift"
200///     }
201///   ]
202/// }
203/// ```
204///
205/// **Wrapper fields:**
206/// - `current_ktstr_kconfig_hash`: hex digest of the kconfig fragment
207///   the running binary was built against, so consumers can detect
208///   entries that were built with a different fragment.
209/// - `active_prefixes_fetch_error`: `null` on success, human-readable
210///   error string on failure to fetch the active kernel-series list
211///   from kernel.org. When non-null, `eol` annotation is disabled for
212///   the run (no series data to compare against) and every entry's
213///   `eol` is `false` regardless of actual support status — so
214///   consumers must check this field before trusting `eol`.
215/// - `entries`: heterogeneous array; each element is either a valid
216///   entry (object with the full field set) or a corrupt entry
217///   (object with `key`, `path`, `error`, and `error_kind`). Corrupt entries
218///   have a structurally different shape — consumers should detect the
219///   `"error"` key and branch.
220///
221/// **Entry fields (valid entries):**
222/// - `kconfig_status`: one of `"matches"`, `"stale"`, or `"untracked"`
223///   (the Display forms of `cache::KconfigStatus`). `matches` means
224///   the entry's `ktstr_kconfig_hash` equals
225///   `current_ktstr_kconfig_hash`; `stale` means they differ;
226///   `untracked` means the entry has no recorded kconfig hash (pre-dates
227///   kconfig hash tracking).
228/// - `extra_kconfig_hash`: CRC32 (8 hex chars, lowercase) of the user
229///   `--extra-kconfig` fragment as raw bytes (no canonicalization), or
230///   `null` when the entry was built without `--extra-kconfig`. Cache
231///   keys grow from `kc{baked}` to `kc{baked}-xkc{extra}` when extras
232///   are present; this field stores the `xkc` segment so `kernel list`
233///   is self-describing for entries that carry user modifications.
234///   Independent of `kconfig_status` — an entry can match the baked-in
235///   hash AND carry a non-null extras hash.
236/// - `eol`: `true` iff the entry's version series does not appear in
237///   the active-prefix list. Only meaningful when
238///   `active_prefixes_fetch_error` is `null`.
239/// - `has_vmlinux`: whether the cache entry includes the uncompressed
240///   `vmlinux` (needed for DWARF-driven probes); when `false`, only
241///   the compressed `image_path` is available.
242/// - `vmlinux_stripped`: whether the cached vmlinux came from a
243///   successful strip pass (`true`) or the raw-fallback path
244///   (`false`). A `false` here indicates the strip pipeline errored
245///   on this kernel and the unstripped bytes were copied instead —
246///   the entry still works but carries a large on-disk payload that
247///   signals a parseability regression worth investigating. Always
248///   `false` when `has_vmlinux` is `false`.
249/// - `source`: tagged object (serde internally tagged on `"type"`).
250///   Variants: `{"type": "tarball"}`, `{"type": "git", "git_hash": ?,
251///   "ref": ?}`, `{"type": "local", "source_tree_path": ?, "git_hash":
252///   ?}`. Variant-specific fields are nullable — consumers must
253///   dispatch on `"type"` before reading them. See `cache::KernelSource`.
254///
255/// **Entry fields (corrupt entries):**
256/// - `error`: human-readable reason from `cache::read_metadata`,
257///   prefixed by failure class so programmatic consumers can branch
258///   on `starts_with` without parsing the free-form tail. Prefixes:
259///   - `"metadata.json missing"` — file absent (not a cache entry).
260///   - `"metadata.json unreadable: ..."` — I/O error on
261///     `fs::read_to_string` other than ENOENT (e.g. EISDIR,
262///     permission).
263///   - `"metadata.json schema drift: ..."` — JSON parsed but does
264///     not match the `KernelMetadata` shape (serde_json
265///     `Category::Data`). Typical cause: older cache from a ktstr
266///     whose schema has since changed.
267///   - `"metadata.json malformed: ..."` — not valid JSON at all
268///     (serde_json `Category::Syntax`).
269///   - `"metadata.json truncated: ..."` — JSON ends mid-value
270///     (serde_json `Category::Eof`), e.g. a partially-written
271///     metadata from a crashed `store()`.
272///   - `"metadata.json parse error: ..."` — fallback for an
273///     unexpected `Category::Io` from `from_str`; does not fire on
274///     the current serde_json version but kept as a defense-in-depth
275///     fallback so the field is never absent.
276///   - `"image file <name> missing from entry directory"` —
277///     metadata parsed cleanly but the declared image file is gone
278///     (partial download, manual deletion, failed strip+rename).
279///
280///   The example above shows the schema-drift case; consumers that
281///   treat corrupt entries as a single category can key on the
282///   `"error"` key alone.
283/// - `error_kind`: machine-readable classification of the failure
284///   mode — a stable snake_case identifier CI scripts can dispatch
285///   on without parsing the free-form `error`. Values:
286///   `"missing"`, `"unreadable"`, `"schema_drift"`, `"malformed"`,
287///   `"truncated"`, `"parse_error"`, `"image_missing"`, and
288///   `"unknown"` as a defensive fallback for a future producer
289///   prefix that has not yet been taught to the classifier. Always
290///   present on corrupt entries; always absent on valid entries.
291///   See [`crate::cache::ListedEntry::error_kind`] for the
292///   classifier contract.
293pub fn kernel_list(json: bool) -> Result<()> {
294    // `include_eol` is meaningless in cache-listing mode (no range to
295    // expand); pass `false` — `kernel_list_inner` only consults it on
296    // the `range = Some(_)` branch.
297    kernel_list_inner(json, None, false)
298}
299
300/// Range-preview variant of [`kernel_list`].
301///
302/// Routes through `kernel_list_inner` with `range = Some(spec)`,
303/// switching the subcommand from "walk the cache and list local
304/// entries" to "fetch releases.json once and print the versions
305/// `spec` expands to." See the `kernel` arg's doc on
306/// [`super::KernelCommand::List`] for operator-facing semantics.
307///
308/// Surfaced as a thin wrapper because the binary dispatch sites
309/// (`ktstr kernel list --kernel R` /
310/// `cargo ktstr kernel list --kernel R`) read more naturally as
311/// `cli::kernel_list_range_preview(json, R)` than as
312/// `cli::kernel_list_inner(json, Some(R))`. The shared inner
313/// function keeps a single `--json` formatter and a single test
314/// surface.
315pub fn kernel_list_range_preview(json: bool, range: &str, include_eol: bool) -> Result<()> {
316    kernel_list_inner(json, Some(range), include_eol)
317}
318
319fn kernel_list_inner(json: bool, range: Option<&str>, include_eol: bool) -> Result<()> {
320    if let Some(spec) = range {
321        return run_kernel_list_range(json, spec, include_eol);
322    }
323    let cache = CacheDir::new()?;
324    let entries = cache.list()?;
325    let kconfig_hash = embedded_kconfig_hash();
326
327    // Track the fetch result so the `--json` path can surface the
328    // error string to scripted consumers. Before this, a failure
329    // was eprintln'd but never appeared in the JSON wrapper, so
330    // downstream tooling could only observe "all entries are
331    // non-EOL" without any signal that the prefix list was
332    // actually empty because the network fetch failed.
333    let (active_prefixes, active_prefixes_fetch_error): (Vec<String>, Option<String>) =
334        match fetch_active_prefixes() {
335            Ok(p) => (p, None),
336            Err(e) => {
337                let msg = format!("{e:#}");
338                eprintln!(
339                    "kernel list: failed to fetch active kernel series ({msg}); \
340                     EOL annotation disabled for this run. \
341                     Check that kernel.org is reachable from this host.",
342                );
343                (Vec::new(), Some(msg))
344            }
345        };
346
347    if json {
348        let json_entries: Vec<serde_json::Value> = entries
349            .iter()
350            .map(|e| match e {
351                crate::cache::ListedEntry::Valid(entry) => {
352                    let meta = &entry.metadata;
353                    let eol = entry_is_eol(entry, &active_prefixes);
354                    let kconfig_status = entry.kconfig_status(&kconfig_hash).to_string();
355                    serde_json::json!({
356                        "key": entry.key,
357                        "path": entry.path.display().to_string(),
358                        "version": meta.version,
359                        "source": meta.source,
360                        "arch": meta.arch,
361                        "built_at": meta.built_at,
362                        "ktstr_kconfig_hash": meta.ktstr_kconfig_hash,
363                        "extra_kconfig_hash": meta.extra_kconfig_hash,
364                        "kconfig_status": kconfig_status,
365                        "eol": eol,
366                        "config_hash": meta.config_hash,
367                        "image_name": meta.image_name,
368                        "image_path": entry.image_path().display().to_string(),
369                        "has_vmlinux": meta.has_vmlinux(),
370                        "vmlinux_stripped": meta.vmlinux_stripped(),
371                    })
372                }
373                crate::cache::ListedEntry::Corrupt { key, path, reason } => {
374                    // `error_kind` is the machine-readable classification
375                    // of the failure mode (snake_case identifier); `error`
376                    // keeps the human-readable reason. Both fields emit
377                    // on every corrupt entry so consumers that dispatch
378                    // on `error_kind` AND consumers that display `error`
379                    // work without a version gate. See
380                    // `ListedEntry::error_kind` for the classifier.
381                    let error_kind = e.error_kind().unwrap_or("unknown");
382                    serde_json::json!({
383                        "key": key,
384                        "path": path.display().to_string(),
385                        "error": reason,
386                        "error_kind": error_kind,
387                    })
388                }
389            })
390            .collect();
391        // `active_prefixes_fetch_error` is `null` on success and a
392        // human-readable string on fetch failure, so JSON consumers
393        // can distinguish "no active prefixes learned" (fetch
394        // failed, EOL annotation was disabled for this run) from
395        // "all kernels are current" (fetch succeeded, list is
396        // simply not gating any entry).
397        let wrapper = serde_json::json!({
398            "current_ktstr_kconfig_hash": kconfig_hash,
399            "active_prefixes_fetch_error": active_prefixes_fetch_error,
400            "entries": json_entries,
401        });
402        println!("{}", serde_json::to_string_pretty(&wrapper)?);
403        return Ok(());
404    }
405
406    eprintln!("cache: {}", cache.root().display());
407
408    if entries.is_empty() {
409        println!("no cached kernels. Run `kernel build` to download and build a kernel.");
410        return Ok(());
411    }
412
413    println!(
414        "  {:<48} {:<12} {:<8} {:<7} BUILT",
415        "KEY", "VERSION", "SOURCE", "ARCH"
416    );
417    let mut any_stale = false;
418    let mut any_untracked = false;
419    let mut any_eol = false;
420    let mut corrupt_count: usize = 0;
421    for listed in &entries {
422        match listed {
423            crate::cache::ListedEntry::Valid(entry) => {
424                let status = entry.kconfig_status(&kconfig_hash);
425                if status.is_stale() {
426                    any_stale = true;
427                }
428                if status.is_untracked() {
429                    any_untracked = true;
430                }
431                if entry_is_eol(entry, &active_prefixes) {
432                    any_eol = true;
433                }
434                println!(
435                    "{}",
436                    format_entry_row(entry, &kconfig_hash, &active_prefixes)
437                );
438            }
439            crate::cache::ListedEntry::Corrupt { key, reason, .. } => {
440                corrupt_count += 1;
441                println!("  {key:<48} (corrupt: {reason})");
442            }
443        }
444    }
445    // Annotation footers. The emission order is fixed and load-bearing
446    // — the integration test
447    // `kernel_list_legend_ordering_pins_untracked_stale_corrupt` in
448    // `tests/ktstr_cli.rs` pins the sequence against regressions by
449    // running the real binary against a fixture cache:
450    //
451    //   1. EOL        (informational, inherent-to-upstream-release)
452    //   2. untracked  (informational, actionable with a rebuild)
453    //   3. stale      (informational, actionable with a rebuild)
454    //   4. corrupt    (operational, requires manual inspection + clean)
455    //
456    // Rationale: informational legends come first because they do
457    // not demand operator action to resolve — an EOL tag is a state
458    // of the world, not a cache pathology. The `untracked` and
459    // `stale` legends share a remediation shape (`kernel build
460    // --force VERSION`) and are grouped adjacent so an operator who
461    // needs to batch-rebuild sees the two one-line recipes together.
462    // The corrupt footer comes last because its remediation is the
463    // most disruptive (`kernel clean`), runs against a separate
464    // command, and interpolates a runtime cache-root path that is
465    // irrelevant to the preceding tags; surfacing it last keeps the
466    // informational/operational distinction visually obvious in the
467    // output stream.
468    //
469    // Each legend surfaces only when a tag was actually rendered, so
470    // the normal no-tag case stays noise-free. Decisions are routed
471    // through the `*_legend_if_any` / `*_footer_if_any` helpers so
472    // both branches per legend are unit-testable.
473    //
474    // Channel: stderr (diagnostic). The rendered entry rows above
475    // flow to stdout so `kernel list | awk` / `kernel list >
476    // kernels.txt` downstream scripts receive table data without
477    // legend text mixed in; the legends only become visible on an
478    // interactive terminal where both channels are typically
479    // displayed. Pinned by `kernel_list_legends_emit_on_stderr` in
480    // `tests/ktstr_cli.rs`.
481    if let Some(legend) = eol_legend_if_any(any_eol) {
482        eprintln!("{legend}");
483    }
484    if let Some(legend) = untracked_legend_if_any(any_untracked) {
485        eprintln!("{legend}");
486    }
487    if let Some(legend) = stale_legend_if_any(any_stale) {
488        eprintln!("{legend}");
489    }
490    if let Some(footer) = corrupt_footer_if_any(corrupt_count, cache.root()) {
491        eprintln!("{footer}");
492    }
493    Ok(())
494}
495
496/// Render a `kernel list --kernel START..END` preview by parsing
497/// `spec` as a [`crate::kernel_path::KernelId::Range`], expanding
498/// it via [`expand_kernel_range`], and printing the resulting
499/// version list.
500///
501/// Performs no cache reads or builds — only the single
502/// `releases.json` fetch [`expand_kernel_range`] already runs for
503/// real range resolves. Bails when:
504/// - `spec` does not parse as a `Range` (passes through
505///   `KernelId::parse` and rejects non-Range variants with an
506///   actionable diagnostic naming the expected shape);
507/// - `KernelId::Range::validate` rejects the endpoints (inverted
508///   range, malformed version components — same diagnostics the
509///   real resolver emits);
510/// - the network fetch fails or the range expands to zero
511///   versions (the same hard-error contract documented on
512///   [`expand_kernel_range`]).
513///
514/// Output shape mirrors `kernel list`:
515/// - text: one version per line on stdout, prefixed with the
516///   parsed range and version count on stderr so shell pipelines
517///   (`| awk`, `| grep`) see clean stdout.
518/// - JSON: a single object with the literal range, the parsed
519///   start / end strings, and the expanded version array.
520fn run_kernel_list_range(json: bool, spec: &str, include_eol: bool) -> Result<()> {
521    use crate::kernel_path::KernelId;
522
523    let id = KernelId::parse(spec);
524    let (start, end) = match &id {
525        KernelId::Range { start, end, .. } => (start.clone(), end.clone()),
526        _ => {
527            bail!(
528                "kernel list --kernel: `{spec}` does not parse as a \
529                 `START..END` range. Expected `MAJOR.MINOR[.PATCH][-rcN]..\
530                 MAJOR.MINOR[.PATCH][-rcN]` (e.g. `6.12..6.14`)."
531            );
532        }
533    };
534    id.validate()
535        .map_err(|e| anyhow::anyhow!("kernel list --kernel {spec}: {e}"))?;
536
537    let versions = expand_kernel_range(&start, &end, "kernel list", include_eol)?;
538
539    if json {
540        let payload = serde_json::json!({
541            "range": spec,
542            "start": start,
543            "end": end,
544            "versions": versions,
545        });
546        println!("{}", serde_json::to_string_pretty(&payload)?);
547        return Ok(());
548    }
549
550    // Text output: versions on stdout (one per line) so
551    // `kernel list --kernel R | xargs -I{} kernel build --kernel {}`
552    // works without tearing on legend lines. The header on
553    // stderr matches `expand_kernel_range`'s own status output
554    // shape so the operator gets the same "expanded to N
555    // kernel(s)" context they would see during a real resolve.
556    for v in &versions {
557        println!("{v}");
558    }
559    Ok(())
560}
561
562/// Pure partitioner for [`kernel_clean`]: given an ordered
563/// (newest-first per `cache::list()`) slice of entries, return the
564/// subset that should be removed.
565///
566/// Split from [`kernel_clean`] so the policy is covered by
567/// fixture tests without touching the filesystem: selection
568/// semantics are a four-axis matrix (`Valid` vs `Corrupt`, `keep`
569/// vs no keep, `corrupt_only` true vs false) and the previous
570/// inline loop made every edge regress-only-at-runtime.
571///
572/// Rules:
573/// - `Corrupt` entries are always removal candidates (they occupy
574///   disk without being usable, and never consume a `keep` slot).
575/// - `Valid` entries are removal candidates only when
576///   `corrupt_only = false`; the first `keep.unwrap_or(0)` valid
577///   entries PER
578///   `(version, ktstr_kconfig_hash, extra_kconfig_hash)` BUCKET in
579///   input order are retained, every subsequent valid entry in
580///   that bucket is a candidate.
581/// - Input order is preserved in the output — `cache.list()` sorts
582///   `built_at`-descending, so the retained `keep` prefix per
583///   bucket is the most recent entries.
584///
585/// **Bucketing rationale**: a single `--keep N` pool would let a
586/// flurry of builds at one configuration evict useful entries at a
587/// different configuration. Bucketing by the
588/// `(version, baked-in-kconfig, extras)` tuple preserves the N
589/// newest entries in each configuration variant independently, so
590/// users iterating on extras for one kernel don't lose unrelated
591/// cache slots, and a `ktstr.kconfig` bump (changes
592/// `ktstr_kconfig_hash`) doesn't push out the prior baked-in
593/// build's slots before the new one is fully exercised.
594///
595/// `None` participates as its own bucket key value:
596/// - `version: None` (no version could be established — an unparsable
597///   source `Makefile`, or a non-version-recording acquisition) is
598///   distinct from any `Some(version)`.
599/// - `ktstr_kconfig_hash: None` (entries that predate the field)
600///   is distinct from any `Some(hash)`.
601/// - `extra_kconfig_hash: None` (no user extras) is distinct from
602///   any `Some(hash)`.
603fn partition_clean_candidates<'a>(
604    entries: &'a [crate::cache::ListedEntry],
605    keep: Option<usize>,
606    corrupt_only: bool,
607) -> Vec<&'a crate::cache::ListedEntry> {
608    let skip = keep.unwrap_or(0);
609    // Bucket key groups Valid entries by `(version,
610    // ktstr_kconfig_hash, extra_kconfig_hash)` — three optional
611    // strings, distinct shapes need distinct retention counters.
612    type BucketKey = (Option<String>, Option<String>, Option<String>);
613    let mut bucket_kept: std::collections::HashMap<BucketKey, usize> =
614        std::collections::HashMap::new();
615    let mut to_remove: Vec<&'a crate::cache::ListedEntry> = Vec::new();
616    for listed in entries {
617        match listed {
618            crate::cache::ListedEntry::Valid(entry) => {
619                if corrupt_only {
620                    continue;
621                }
622                let bucket_key = (
623                    entry.metadata.version.clone(),
624                    entry.metadata.ktstr_kconfig_hash.clone(),
625                    entry.metadata.extra_kconfig_hash.clone(),
626                );
627                let kept = bucket_kept.entry(bucket_key).or_insert(0);
628                if *kept < skip {
629                    *kept += 1;
630                    continue;
631                }
632                to_remove.push(listed);
633            }
634            crate::cache::ListedEntry::Corrupt { .. } => {
635                to_remove.push(listed);
636            }
637        }
638    }
639    to_remove
640}
641
642/// Remove cached kernels with optional keep-N and confirmation prompt.
643///
644/// `corrupt_only = true` narrows removal to `ListedEntry::Corrupt`
645/// (metadata missing or unparseable, image file absent); valid
646/// entries are left untouched regardless of `keep` / `force`.
647///
648/// `keep = Some(N)` retains the N newest **valid** entries.
649pub fn kernel_clean(keep: Option<usize>, force: bool, corrupt_only: bool) -> Result<()> {
650    let cache = CacheDir::new()?;
651    let entries = cache.list()?;
652
653    if entries.is_empty() {
654        println!("nothing to clean");
655        return Ok(());
656    }
657
658    let kconfig_hash = embedded_kconfig_hash();
659
660    let to_remove = partition_clean_candidates(&entries, keep, corrupt_only);
661
662    if to_remove.is_empty() {
663        println!("nothing to clean");
664        return Ok(());
665    }
666
667    if !force {
668        use std::io::IsTerminal;
669        if !std::io::stdin().is_terminal() {
670            bail!("confirmation requires a terminal. Use --force to skip.");
671        }
672        // Fetch active-series prefixes for the (EOL) annotation on
673        // the confirmation prompt. Scoped to the `!force` branch —
674        // force mode skips the prompt, so there's no point burning
675        // a network roundtrip to kernel.org. A fetch failure is
676        // surfaced via `eprintln!` (mirroring `kernel_list`'s
677        // diagnostic) so the operator knows why the `(EOL)`
678        // annotations are missing instead of silently degrading.
679        let active_prefixes = match fetch_active_prefixes() {
680            Ok(p) => p,
681            Err(e) => {
682                eprintln!(
683                    "kernel clean: failed to fetch active kernel series ({e:#}); \
684                     EOL annotation disabled for this run. \
685                     Check that kernel.org is reachable from this host."
686                );
687                Vec::new()
688            }
689        };
690        println!("the following entries will be removed:");
691        for listed in &to_remove {
692            match listed {
693                crate::cache::ListedEntry::Valid(entry) => {
694                    println!(
695                        "{}",
696                        format_entry_row(entry, &kconfig_hash, &active_prefixes)
697                    );
698                }
699                crate::cache::ListedEntry::Corrupt { key, reason, .. } => {
700                    println!("  {key:<48} (corrupt: {reason})");
701                }
702            }
703        }
704        eprint!("remove {} entries? [y/N] ", to_remove.len());
705        std::io::stderr().flush()?;
706        let mut answer = String::new();
707        std::io::stdin().lock().read_line(&mut answer)?;
708        if !matches!(answer.trim(), "y" | "Y") {
709            println!("aborted");
710            return Ok(());
711        }
712    }
713
714    let total = to_remove.len();
715    let mut removed = 0usize;
716    let mut last_err: Option<String> = None;
717    for listed in &to_remove {
718        match std::fs::remove_dir_all(listed.path()) {
719            Ok(()) => removed += 1,
720            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
721                removed += 1;
722            }
723            Err(e) => {
724                last_err = Some(format!("remove {}: {e}", listed.key()));
725            }
726        }
727    }
728
729    println!("removed {removed} cached kernel(s).");
730    if let Some(err) = last_err {
731        bail!("removed {removed} of {total} entries; {err}");
732    }
733    Ok(())
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739
740    /// Stable release path.
741    #[test]
742    fn version_prefix_stable_release() {
743        assert_eq!(version_prefix("6.14.2").as_deref(), Some("6.14"));
744        assert_eq!(version_prefix("6.12.81").as_deref(), Some("6.12"));
745        assert_eq!(version_prefix("7.0").as_deref(), Some("7.0"));
746    }
747
748    /// RC suffix collapses into series.
749    #[test]
750    fn version_prefix_strips_rc_suffix() {
751        assert_eq!(version_prefix("6.15-rc1").as_deref(), Some("6.15"));
752        assert_eq!(version_prefix("6.15-rc3").as_deref(), Some("6.15"));
753        assert_eq!(version_prefix("7.0-rc1").as_deref(), Some("7.0"));
754    }
755
756    /// linux-next collapses to merge target.
757    #[test]
758    fn version_prefix_strips_linux_next_suffix() {
759        assert_eq!(
760            version_prefix("6.16-rc2-next-20260420").as_deref(),
761            Some("6.16"),
762        );
763        assert_eq!(
764            version_prefix("7.1-rc1-next-20260501").as_deref(),
765            Some("7.1"),
766        );
767    }
768
769    /// No dot → None.
770    #[test]
771    fn version_prefix_rejects_no_dot() {
772        assert!(version_prefix("abc").is_none());
773        assert!(version_prefix("6").is_none());
774        assert!(version_prefix("").is_none());
775    }
776
777    /// Non-numeric minor → None.
778    #[test]
779    fn version_prefix_rejects_non_numeric_minor() {
780        assert!(version_prefix("6.x").is_none());
781        assert!(version_prefix("6.-rc1").is_none());
782        assert!(version_prefix("6.").is_none());
783    }
784
785    /// Empty active_prefixes is the "active list unknown" signal.
786    #[test]
787    fn is_eol_empty_active_prefixes_returns_false() {
788        assert!(!is_eol("6.14.2", &[]));
789    }
790
791    #[test]
792    fn is_eol_prefix_in_active_list_returns_false() {
793        assert!(!is_eol("6.14.2", &["6.14".to_string()]));
794    }
795
796    #[test]
797    fn is_eol_prefix_absent_from_active_list_returns_true() {
798        assert!(is_eol(
799            "5.10.200",
800            &["6.14".to_string(), "6.12".to_string()],
801        ));
802    }
803
804    #[test]
805    fn is_eol_unparseable_version_returns_false() {
806        assert!(!is_eol("abc", &["6.14".to_string()]));
807    }
808
809    #[test]
810    fn is_eol_rc_suffix_mismatch_does_not_flag() {
811        let active = ["6.15".to_string()];
812        assert!(!is_eol("6.15-rc1", &active));
813        assert!(!is_eol("6.15-rc4", &active));
814    }
815
816    #[test]
817    fn is_eol_linux_next_matches_mainline_prefix() {
818        let active = ["6.16".to_string()];
819        assert!(!is_eol("6.16-rc2-next-20260420", &active));
820    }
821
822    #[test]
823    fn is_eol_brand_new_major_matches_rc_variant() {
824        assert!(!is_eol("7.0", &["7.0".to_string()]));
825        assert!(!is_eol("7.0-rc1", &["7.0".to_string()]));
826    }
827
828    #[test]
829    fn is_eol_brand_new_zero_release_in_active_list() {
830        let active = ["7.0".to_string()];
831        assert!(!is_eol("7.0", &active));
832        assert!(!is_eol("7.0.0", &active));
833    }
834
835    #[test]
836    fn is_eol_linux_next_version_not_falsely_tagged() {
837        assert!(is_eol(
838            "6.16-rc1",
839            &["6.14".to_string(), "6.13".to_string()]
840        ));
841    }
842
843    fn owned(pairs: &[(&str, &str)]) -> Vec<crate::fetch::Release> {
844        pairs
845            .iter()
846            .map(|(m, v)| crate::fetch::Release {
847                moniker: (*m).to_string(),
848                version: (*v).to_string(),
849            })
850            .collect()
851    }
852
853    /// RC-suffixed mainline normalizes to series.
854    #[test]
855    fn active_prefixes_from_releases_normalizes_rc_versions() {
856        let releases = owned(&[
857            ("mainline", "6.16-rc3"),
858            ("stable", "6.15.2"),
859            ("longterm", "6.12.81"),
860        ]);
861        let prefixes = active_prefixes_from_releases(&releases);
862        assert_eq!(
863            prefixes,
864            vec!["6.16".to_string(), "6.15".to_string(), "6.12".to_string()],
865        );
866    }
867
868    #[test]
869    fn active_prefixes_from_releases_skips_linux_next_moniker() {
870        let releases = owned(&[
871            ("linux-next", "6.17-rc2-next-20260421"),
872            ("mainline", "6.16-rc3"),
873            ("stable", "6.15.2"),
874        ]);
875        let prefixes = active_prefixes_from_releases(&releases);
876        assert!(!prefixes.contains(&"6.17".to_string()));
877        assert_eq!(prefixes, vec!["6.16".to_string(), "6.15".to_string()]);
878    }
879
880    #[test]
881    fn active_prefixes_from_releases_dedups_in_input_order() {
882        let releases = owned(&[
883            ("stable", "6.14.2"),
884            ("longterm", "6.14.1"),
885            ("longterm", "6.12.81"),
886        ]);
887        let prefixes = active_prefixes_from_releases(&releases);
888        assert_eq!(prefixes, vec!["6.14".to_string(), "6.12".to_string()]);
889    }
890
891    /// kernel_list_range_preview rejects non-Range spec.
892    #[test]
893    fn kernel_list_range_preview_rejects_non_range_spec() {
894        let err = run_kernel_list_range(false, "6.14.2", false)
895            .expect_err("bare version must not parse as a Range");
896        let msg = format!("{err:#}");
897        assert!(msg.contains("does not parse as a `START..END` range"));
898        assert!(msg.contains("`6.14.2`"));
899    }
900
901    /// kernel_list_range_preview rejects inverted range.
902    #[test]
903    fn kernel_list_range_preview_rejects_inverted_range() {
904        let err = run_kernel_list_range(false, "6.16..6.12", false)
905            .expect_err("inverted range must not be accepted");
906        let msg = format!("{err:#}");
907        assert!(msg.contains("kernel list --kernel 6.16..6.12"));
908    }
909
910    fn mk_valid(key: &str) -> crate::cache::ListedEntry {
911        use crate::cache::{CacheEntry, KernelMetadata, KernelSource};
912        let path = std::path::PathBuf::from(format!("/tmp/fixture/{key}"));
913        let metadata = KernelMetadata::new(
914            KernelSource::Tarball,
915            "x86_64",
916            "bzImage",
917            "2026-04-22T00:00:00Z",
918        );
919        crate::cache::ListedEntry::Valid(Box::new(CacheEntry {
920            key: key.to_string(),
921            path,
922            metadata,
923        }))
924    }
925
926    fn mk_corrupt(key: &str) -> crate::cache::ListedEntry {
927        crate::cache::ListedEntry::Corrupt {
928            key: key.to_string(),
929            path: std::path::PathBuf::from(format!("/tmp/fixture/{key}")),
930            reason: "test fixture corrupt".to_string(),
931        }
932    }
933
934    #[test]
935    fn partition_clean_candidates_empty_input_yields_empty_output() {
936        let out = partition_clean_candidates(&[], None, false);
937        assert!(out.is_empty());
938        let out = partition_clean_candidates(&[], Some(5), true);
939        assert!(out.is_empty());
940    }
941
942    #[test]
943    fn partition_clean_candidates_corrupt_only_skips_valid_entries() {
944        let entries = vec![mk_valid("v1"), mk_corrupt("c1"), mk_valid("v2")];
945        let out = partition_clean_candidates(&entries, None, true);
946        assert_eq!(out.len(), 1);
947        assert_eq!(out[0].key(), "c1");
948    }
949
950    #[test]
951    fn partition_clean_candidates_no_keep_removes_every_entry() {
952        let entries = vec![mk_valid("v1"), mk_corrupt("c1"), mk_valid("v2")];
953        let out = partition_clean_candidates(&entries, None, false);
954        let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
955        assert_eq!(keys, vec!["v1", "c1", "v2"]);
956    }
957
958    #[test]
959    fn partition_clean_candidates_keep_retains_n_newest_valid_preserves_corrupt() {
960        let entries = vec![
961            mk_valid("v_new1"),
962            mk_corrupt("c_mid"),
963            mk_valid("v_new2"),
964            mk_valid("v_old"),
965        ];
966        let out = partition_clean_candidates(&entries, Some(2), false);
967        let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
968        assert_eq!(keys, vec!["c_mid", "v_old"]);
969    }
970
971    #[test]
972    fn partition_clean_candidates_keep_never_preserves_corrupt() {
973        let entries = vec![mk_corrupt("c1"), mk_valid("v1"), mk_valid("v2")];
974        let out = partition_clean_candidates(&entries, Some(3), false);
975        let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
976        assert_eq!(keys, vec!["c1"]);
977    }
978
979    /// Defensive cell: corrupt_only=true makes keep inert.
980    #[test]
981    fn partition_clean_candidates_corrupt_only_ignores_keep() {
982        let entries = vec![
983            mk_valid("v_new1"),
984            mk_corrupt("c_mid"),
985            mk_valid("v_new2"),
986            mk_valid("v_old"),
987        ];
988        let out = partition_clean_candidates(&entries, Some(2), true);
989        let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
990        assert_eq!(keys, vec!["c_mid"]);
991    }
992
993    /// Constructs a single `ListedEntry::Valid` with the given key,
994    /// version, and extra-kconfig-hash (no ktstr-kconfig-hash), by
995    /// delegating to `mk_valid_bucketed_full`.
996    fn mk_valid_bucketed(
997        key: &str,
998        version: Option<&str>,
999        extra_kconfig_hash: Option<&str>,
1000    ) -> crate::cache::ListedEntry {
1001        mk_valid_bucketed_full(key, version, None, extra_kconfig_hash)
1002    }
1003
1004    fn mk_valid_bucketed_full(
1005        key: &str,
1006        version: Option<&str>,
1007        ktstr_kconfig_hash: Option<&str>,
1008        extra_kconfig_hash: Option<&str>,
1009    ) -> crate::cache::ListedEntry {
1010        use crate::cache::{CacheEntry, KernelMetadata, KernelSource};
1011        let path = std::path::PathBuf::from(format!("/tmp/fixture/{key}"));
1012        let mut metadata = KernelMetadata::new(
1013            KernelSource::Tarball,
1014            "x86_64",
1015            "bzImage",
1016            "2026-04-22T00:00:00Z",
1017        );
1018        if let Some(v) = version {
1019            metadata = metadata.with_version(v);
1020        }
1021        if let Some(h) = ktstr_kconfig_hash {
1022            metadata = metadata.with_ktstr_kconfig_hash(h);
1023        }
1024        if let Some(h) = extra_kconfig_hash {
1025            metadata = metadata.with_extra_kconfig_hash(h);
1026        }
1027        crate::cache::ListedEntry::Valid(Box::new(CacheEntry {
1028            key: key.to_string(),
1029            path,
1030            metadata,
1031        }))
1032    }
1033
1034    #[test]
1035    fn partition_clean_candidates_keep_buckets_by_version() {
1036        let entries = vec![
1037            mk_valid_bucketed("v6_14_new", Some("6.14.2"), None),
1038            mk_valid_bucketed("v6_15_new", Some("6.15.0"), None),
1039            mk_valid_bucketed("v6_14_old", Some("6.14.2"), None),
1040            mk_valid_bucketed("v6_15_old", Some("6.15.0"), None),
1041        ];
1042        let out = partition_clean_candidates(&entries, Some(1), false);
1043        let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
1044        assert_eq!(keys, vec!["v6_14_old", "v6_15_old"]);
1045    }
1046
1047    #[test]
1048    fn partition_clean_candidates_keep_buckets_by_extra_kconfig_hash() {
1049        let entries = vec![
1050            mk_valid_bucketed("v6_14_xkc_aaaa_new", Some("6.14.2"), Some("aaaa")),
1051            mk_valid_bucketed("v6_14_xkc_bbbb_new", Some("6.14.2"), Some("bbbb")),
1052            mk_valid_bucketed("v6_14_xkc_aaaa_old", Some("6.14.2"), Some("aaaa")),
1053            mk_valid_bucketed("v6_14_xkc_bbbb_old", Some("6.14.2"), Some("bbbb")),
1054        ];
1055        let out = partition_clean_candidates(&entries, Some(1), false);
1056        let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
1057        assert_eq!(keys, vec!["v6_14_xkc_aaaa_old", "v6_14_xkc_bbbb_old"]);
1058    }
1059
1060    #[test]
1061    fn partition_clean_candidates_keep_distinguishes_none_from_some_extras() {
1062        let entries = vec![
1063            mk_valid_bucketed("bare_new", Some("6.14.2"), None),
1064            mk_valid_bucketed("xkc_new", Some("6.14.2"), Some("aaaa")),
1065            mk_valid_bucketed("bare_old", Some("6.14.2"), None),
1066            mk_valid_bucketed("xkc_old", Some("6.14.2"), Some("aaaa")),
1067        ];
1068        let out = partition_clean_candidates(&entries, Some(1), false);
1069        let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
1070        assert_eq!(keys, vec!["bare_old", "xkc_old"]);
1071    }
1072
1073    #[test]
1074    fn partition_clean_candidates_keep_per_bucket_with_corrupt_interleaved() {
1075        let entries = vec![
1076            mk_valid_bucketed("v6_14_new", Some("6.14.2"), None),
1077            mk_corrupt("c_mid"),
1078            mk_valid_bucketed("v6_15_new", Some("6.15.0"), None),
1079            mk_valid_bucketed("v6_14_old", Some("6.14.2"), None),
1080        ];
1081        let out = partition_clean_candidates(&entries, Some(1), false);
1082        let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
1083        assert_eq!(keys, vec!["c_mid", "v6_14_old"]);
1084    }
1085
1086    #[test]
1087    fn partition_clean_candidates_keep_buckets_by_ktstr_kconfig_hash() {
1088        let entries = vec![
1089            mk_valid_bucketed_full("baked_v2_new", Some("6.14.2"), Some("v2hash"), None),
1090            mk_valid_bucketed_full("baked_v1_new", Some("6.14.2"), Some("v1hash"), None),
1091            mk_valid_bucketed_full("baked_v2_old", Some("6.14.2"), Some("v2hash"), None),
1092            mk_valid_bucketed_full("baked_v1_old", Some("6.14.2"), Some("v1hash"), None),
1093        ];
1094        let out = partition_clean_candidates(&entries, Some(1), false);
1095        let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
1096        assert_eq!(keys, vec!["baked_v2_old", "baked_v1_old"]);
1097    }
1098
1099    #[test]
1100    fn partition_clean_candidates_keep_local_untagged_builds_form_own_bucket() {
1101        let entries = vec![
1102            mk_valid_bucketed("local_new", None, None),
1103            mk_valid_bucketed("v6_14_new", Some("6.14.2"), None),
1104            mk_valid_bucketed("local_old", None, None),
1105            mk_valid_bucketed("v6_14_old", Some("6.14.2"), None),
1106        ];
1107        let out = partition_clean_candidates(&entries, Some(1), false);
1108        let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
1109        assert_eq!(keys, vec!["local_old", "v6_14_old"]);
1110    }
1111
1112    /// `format_entry_row` with `extra_kconfig_hash = Some(_)` must
1113    /// emit `(extra kconfig)`. Tag is orthogonal to the kconfig-status
1114    /// tag — entry can be Matches AND carry extras.
1115    #[test]
1116    fn format_entry_row_emits_extra_kconfig_tag() {
1117        use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
1118        let tmp = tempfile::TempDir::new().unwrap();
1119        let cache = CacheDir::with_root(tmp.path().join("cache"));
1120        let src = tempfile::TempDir::new().unwrap();
1121        let image = src.path().join("bzImage");
1122        std::fs::write(&image, b"fake kernel").unwrap();
1123        let current_hash = "abc1234";
1124        let meta_with = KernelMetadata::new(
1125            KernelSource::Tarball,
1126            "x86_64",
1127            "bzImage",
1128            "2026-04-12T10:00:00Z",
1129        )
1130        .with_version("6.14.2")
1131        .with_ktstr_kconfig_hash(current_hash)
1132        .with_extra_kconfig_hash("deadbeef");
1133        let entry_with = cache
1134            .store("with-extras", &CacheArtifacts::new(&image), &meta_with)
1135            .unwrap();
1136        let row_with = format_entry_row(&entry_with, current_hash, &[]);
1137        assert!(row_with.contains("(extra kconfig)"));
1138        assert!(!row_with.contains("(stale kconfig)"));
1139        assert!(!row_with.contains("(untracked kconfig)"));
1140
1141        let meta_without = KernelMetadata::new(
1142            KernelSource::Tarball,
1143            "x86_64",
1144            "bzImage",
1145            "2026-04-12T10:00:00Z",
1146        )
1147        .with_version("6.14.2")
1148        .with_ktstr_kconfig_hash(current_hash);
1149        let entry_without = cache
1150            .store(
1151                "without-extras",
1152                &CacheArtifacts::new(&image),
1153                &meta_without,
1154            )
1155            .unwrap();
1156        let row_without = format_entry_row(&entry_without, current_hash, &[]);
1157        assert!(!row_without.contains("(extra kconfig)"));
1158    }
1159
1160    /// Empty `active_prefixes` must NOT tag any entry EOL — that's
1161    /// the fetch-failed fallback signal.
1162    #[test]
1163    fn format_entry_row_empty_active_prefixes_does_not_tag_eol() {
1164        use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
1165        let tmp = tempfile::TempDir::new().unwrap();
1166        let cache = CacheDir::with_root(tmp.path().join("cache"));
1167        let src = tempfile::TempDir::new().unwrap();
1168        let image = src.path().join("bzImage");
1169        std::fs::write(&image, b"fake kernel").unwrap();
1170        let meta = KernelMetadata::new(
1171            KernelSource::Tarball,
1172            "x86_64",
1173            "bzImage",
1174            "2026-04-12T10:00:00Z",
1175        )
1176        .with_version("2.6.32");
1177        let entry = cache
1178            .store("fetch-failed-fallback", &CacheArtifacts::new(&image), &meta)
1179            .unwrap();
1180        let row_fallback = format_entry_row(&entry, "kconfig_hash", &[]);
1181        assert!(!row_fallback.contains("(EOL)"));
1182        let row_with_active = format_entry_row(&entry, "kconfig_hash", &["6.14".to_string()]);
1183        assert!(row_with_active.contains("(EOL)"));
1184    }
1185
1186    /// Tag-ordering invariant: kconfig-state tag must precede `(EOL)`.
1187    #[test]
1188    fn format_entry_row_tags_appear_in_stable_order() {
1189        use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
1190        let tmp = tempfile::TempDir::new().unwrap();
1191        let cache = CacheDir::with_root(tmp.path().join("cache"));
1192        let src = tempfile::TempDir::new().unwrap();
1193        let image = src.path().join("bzImage");
1194        std::fs::write(&image, b"fake kernel").unwrap();
1195        let current_hash = "a1b2c3d4";
1196        let active_prefixes = ["6.14".to_string()];
1197
1198        let stale_meta = KernelMetadata::new(
1199            KernelSource::Tarball,
1200            "x86_64",
1201            "bzImage",
1202            "2026-04-12T10:00:00Z",
1203        )
1204        .with_version("2.6.32")
1205        .with_ktstr_kconfig_hash("deadbeef");
1206        let stale_entry = cache
1207            .store("stale-eol", &CacheArtifacts::new(&image), &stale_meta)
1208            .unwrap();
1209        let stale_row = format_entry_row(&stale_entry, current_hash, &active_prefixes);
1210        let stale_idx = stale_row
1211            .find("(stale kconfig)")
1212            .expect("stale-kconfig tag must appear on dual-tag row");
1213        let eol_idx = stale_row
1214            .find("(EOL)")
1215            .expect("EOL tag must appear on dual-tag row");
1216        assert!(stale_idx < eol_idx);
1217
1218        let untracked_meta = KernelMetadata::new(
1219            KernelSource::Tarball,
1220            "x86_64",
1221            "bzImage",
1222            "2026-04-12T10:00:00Z",
1223        )
1224        .with_version("2.6.32")
1225        .clear_ktstr_kconfig_hash();
1226        let untracked_entry = cache
1227            .store(
1228                "untracked-eol",
1229                &CacheArtifacts::new(&image),
1230                &untracked_meta,
1231            )
1232            .unwrap();
1233        let untracked_row = format_entry_row(&untracked_entry, current_hash, &active_prefixes);
1234        let untracked_idx = untracked_row
1235            .find("(untracked kconfig)")
1236            .expect("untracked-kconfig tag must appear on dual-tag row");
1237        let eol_idx = untracked_row
1238            .find("(EOL)")
1239            .expect("EOL tag must appear on dual-tag row");
1240        assert!(untracked_idx < eol_idx);
1241    }
1242
1243    /// JSON/human parity: rows where `(EOL)` appears in text must
1244    /// also produce `eol: true` via entry_is_eol.
1245    #[test]
1246    fn kernel_list_eol_json_human_parity() {
1247        use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
1248        let tmp = tempfile::TempDir::new().unwrap();
1249        let cache = CacheDir::with_root(tmp.path().join("cache"));
1250        let src_dir = tmp.path().join("src");
1251        std::fs::create_dir_all(&src_dir).unwrap();
1252        let image = src_dir.join("bzImage");
1253        std::fs::write(&image, b"fake kernel").unwrap();
1254
1255        let make_entry = |key: &str, version: &str| {
1256            let meta = KernelMetadata::new(
1257                KernelSource::Tarball,
1258                "x86_64",
1259                "bzImage",
1260                "2026-04-12T10:00:00Z",
1261            )
1262            .with_version(version);
1263            cache
1264                .store(key, &CacheArtifacts::new(&image), &meta)
1265                .unwrap()
1266        };
1267
1268        let cases: &[(&str, &str, &[&str])] = &[
1269            ("active", "6.14.2", &["6.14"]),
1270            ("eol", "2.6.32", &["6.14"]),
1271            ("fetch-fail", "2.6.32", &[]),
1272        ];
1273        for (label, version, active) in cases {
1274            let entry = make_entry(&format!("parity-{label}"), version);
1275            let active_vec: Vec<String> = active.iter().map(|s| s.to_string()).collect();
1276            let row = format_entry_row(&entry, "kconfig_hash", &active_vec);
1277            let json_eol = entry_is_eol(&entry, &active_vec);
1278            let human_eol = row.contains("(EOL)");
1279            assert_eq!(
1280                json_eol, human_eol,
1281                "JSON/human parity broken for case {label}: \
1282                 json_eol={json_eol}, human_eol={human_eol}, row={row:?}",
1283            );
1284        }
1285    }
1286
1287    /// `format_corrupt_footer` is the helper kernel_list emits at
1288    /// the bottom of the table when at least one cache entry was
1289    /// surfaced as `ListedEntry::Corrupt`. Pin both the gating
1290    /// predicate (`any_corrupt` over the entries the caller built)
1291    /// and the wording invariants the operator depends on:
1292    /// `(corrupt)` tag, `kernel clean --force` recommendation, the
1293    /// `ALL` clarifier, the partial-cleanup `--keep N` alternative,
1294    /// and the cache root's path so the operator knows where to
1295    /// inspect.
1296    #[test]
1297    fn kernel_list_corrupt_footer_fires_iff_any_corrupt() {
1298        use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
1299        use crate::cli::kernel_cmd::format_corrupt_footer;
1300
1301        let tmp = tempfile::TempDir::new().unwrap();
1302        let cache = CacheDir::with_root(tmp.path().join("cache"));
1303        let src_dir = tmp.path().join("src");
1304        std::fs::create_dir_all(&src_dir).unwrap();
1305        let image = src_dir.join("bzImage");
1306        std::fs::write(&image, b"fake kernel").unwrap();
1307
1308        let meta = KernelMetadata::new(
1309            KernelSource::Tarball,
1310            "x86_64",
1311            "bzImage",
1312            "2026-04-22T00:00:00Z",
1313        )
1314        .with_version("6.14.2");
1315        let valid_1 = cache
1316            .store("valid-entry-a", &CacheArtifacts::new(&image), &meta)
1317            .unwrap();
1318        let valid_2 = cache
1319            .store("valid-entry-b", &CacheArtifacts::new(&image), &meta)
1320            .unwrap();
1321        let corrupt_entry = crate::cache::ListedEntry::Corrupt {
1322            key: "corrupt-entry".to_string(),
1323            path: cache.root().join("corrupt-entry"),
1324            reason: "metadata.json missing".to_string(),
1325        };
1326
1327        let entries_with_corrupt = [
1328            crate::cache::ListedEntry::Valid(Box::new(valid_1)),
1329            corrupt_entry,
1330        ];
1331        let entries_clean_only = [crate::cache::ListedEntry::Valid(Box::new(valid_2))];
1332
1333        fn any_corrupt(entries: &[crate::cache::ListedEntry]) -> bool {
1334            entries
1335                .iter()
1336                .any(|e| matches!(e, crate::cache::ListedEntry::Corrupt { .. }))
1337        }
1338
1339        assert!(
1340            any_corrupt(&entries_with_corrupt),
1341            "mixed list must trip the footer",
1342        );
1343        assert!(
1344            !any_corrupt(&entries_clean_only),
1345            "clean-only list must not trip the footer",
1346        );
1347
1348        let footer = format_corrupt_footer(cache.root());
1349        assert!(
1350            footer.contains("(corrupt)"),
1351            "footer must reference the tag users see",
1352        );
1353        assert!(
1354            footer.contains("kernel clean --force"),
1355            "footer must offer a remediation command",
1356        );
1357        assert!(
1358            footer.contains("ALL cached entries"),
1359            "footer must spell out that `kernel clean --force` is not surgical",
1360        );
1361        assert!(
1362            footer.contains("kernel clean --keep N --force"),
1363            "footer must offer a partial-cleanup alternative",
1364        );
1365        assert!(
1366            footer.contains(&cache.root().display().to_string()),
1367            "footer must name the cache root so operators know where to inspect",
1368        );
1369    }
1370
1371    /// JSON/human parity for stale kconfig.
1372    #[test]
1373    fn kernel_list_stale_kconfig_json_human_parity() {
1374        use crate::cache::{CacheArtifacts, CacheDir, KernelSource};
1375        fn metadata_with_hash(hash: Option<&str>) -> crate::cache::KernelMetadata {
1376            let m = crate::cache::KernelMetadata::new(
1377                KernelSource::Tarball,
1378                "x86_64",
1379                "bzImage",
1380                "2026-04-12T10:00:00Z",
1381            )
1382            .with_version("6.14.2");
1383            if let Some(h) = hash {
1384                m.with_ktstr_kconfig_hash(h)
1385            } else {
1386                m
1387            }
1388        }
1389        let cases: &[(&str, Option<&str>, &str)] = &[
1390            ("matches", Some("same"), "same"),
1391            ("stale", Some("old"), "new"),
1392            ("untracked", None, "anything"),
1393        ];
1394        for &(label, entry_hash, current_hash) in cases {
1395            let tmp = tempfile::TempDir::new().unwrap();
1396            let cache = CacheDir::with_root(tmp.path().join("cache"));
1397            let src = tempfile::TempDir::new().unwrap();
1398            let image = src.path().join("bzImage");
1399            std::fs::write(&image, b"fake kernel").unwrap();
1400            let meta = metadata_with_hash(entry_hash);
1401            let entry = cache
1402                .store(label, &CacheArtifacts::new(&image), &meta)
1403                .unwrap();
1404            let json_stale = entry.kconfig_status(current_hash).is_stale();
1405            let human_row = format_entry_row(&entry, current_hash, &[]);
1406            let human_stale = human_row.contains("stale kconfig");
1407            assert_eq!(
1408                json_stale, human_stale,
1409                "kernel_list JSON/human stale-kconfig disagreement on `{label}` \
1410                 (entry_hash={entry_hash:?}, current_hash={current_hash:?})",
1411            );
1412        }
1413    }
1414
1415    /// Snapshot pin for `format_entry_row` across the 6-case outcome
1416    /// matrix over (EOL, not-EOL) × (Matches, Stale, Untracked); empty
1417    /// and unparseable `active_prefixes` branches are pinned by sibling
1418    /// `is_eol_` tests. A 7th case fixes the `version == "-"`
1419    /// short-circuit where a missing version skips the EOL tag even
1420    /// under a non-empty active list. c8 and c9 pin column-boundary
1421    /// behavior (exactly 48 chars vs overflow). c10 / c11 pin the
1422    /// RC-version → series-strip → active-list compare path: c10
1423    /// catches "suffix left attached to compare key", c11 catches "RC
1424    /// compare skipped entirely."
1425    ///
1426    /// Inline snapshot captures exact padding and tag ordering so any
1427    /// drift — column width change, tag reorder, `(EOL)` string rename,
1428    /// Display-impl tweak on `KconfigStatus` — fails this one test.
1429    #[test]
1430    fn format_entry_row_renders_eol_kconfig_matrix() {
1431        use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
1432
1433        let tmp = tempfile::TempDir::new().unwrap();
1434        let cache = CacheDir::with_root(tmp.path().join("cache"));
1435        let src_dir = tmp.path().join("src");
1436        std::fs::create_dir_all(&src_dir).unwrap();
1437        let image = src_dir.join("bzImage");
1438        std::fs::write(&image, b"fake kernel").unwrap();
1439
1440        let current_hash = "a1b2c3d4";
1441        let active_prefixes = ["6.14".to_string()];
1442
1443        let build_row = |key: &str, version: Option<&str>, entry_hash: Option<&str>| -> String {
1444            let mut meta = KernelMetadata::new(
1445                KernelSource::Tarball,
1446                "x86_64",
1447                "bzImage",
1448                "2026-04-12T10:00:00Z",
1449            );
1450            if let Some(v) = version {
1451                meta = meta.with_version(v);
1452            }
1453            if let Some(h) = entry_hash {
1454                meta = meta.with_ktstr_kconfig_hash(h);
1455            }
1456            let entry = cache
1457                .store(key, &CacheArtifacts::new(&image), &meta)
1458                .unwrap();
1459            format_entry_row(&entry, current_hash, &active_prefixes)
1460        };
1461
1462        let c8_key = "c8-long-key-exactly-forty-eight-chars-xxxxxxxxxx";
1463        let c9_key = "c9-key-longer-than-forty-eight-chars-by-twelve-xxxxxxxxxxxx";
1464        debug_assert_eq!(c8_key.len(), 48);
1465        debug_assert_eq!(c9_key.len(), 59);
1466        let rows = [
1467            build_row("c1-active-matches", Some("6.14.2"), Some(current_hash)),
1468            build_row("c2-active-stale", Some("6.14.2"), Some("deadbeef")),
1469            build_row("c3-active-untracked", Some("6.14.2"), None),
1470            build_row("c4-eol-matches", Some("2.6.32"), Some(current_hash)),
1471            build_row("c5-eol-stale", Some("2.6.32"), Some("deadbeef")),
1472            build_row("c6-eol-untracked", Some("2.6.32"), None),
1473            build_row("c7-active-no-version", None, Some(current_hash)),
1474            build_row(c8_key, Some("6.14.2"), Some(current_hash)),
1475            build_row(c9_key, Some("6.14.2"), Some(current_hash)),
1476            build_row("c10-active-rc", Some("6.14-rc2"), Some(current_hash)),
1477            build_row("c11-eol-rc", Some("7.0-rc1"), Some(current_hash)),
1478        ];
1479        let joined = rows.join("\n");
1480        insta::assert_snapshot!(joined, @r"
1481          c1-active-matches                                6.14.2       tarball  x86_64  2026-04-12T10:00:00Z
1482          c2-active-stale                                  6.14.2       tarball  x86_64  2026-04-12T10:00:00Z (stale kconfig)
1483          c3-active-untracked                              6.14.2       tarball  x86_64  2026-04-12T10:00:00Z (untracked kconfig)
1484          c4-eol-matches                                   2.6.32       tarball  x86_64  2026-04-12T10:00:00Z (EOL)
1485          c5-eol-stale                                     2.6.32       tarball  x86_64  2026-04-12T10:00:00Z (stale kconfig) (EOL)
1486          c6-eol-untracked                                 2.6.32       tarball  x86_64  2026-04-12T10:00:00Z (untracked kconfig) (EOL)
1487          c7-active-no-version                             -            tarball  x86_64  2026-04-12T10:00:00Z
1488          c8-long-key-exactly-forty-eight-chars-xxxxxxxxxx 6.14.2       tarball  x86_64  2026-04-12T10:00:00Z
1489          c9-key-longer-than-forty-eight-chars-by-twelve-xxxxxxxxxxxx 6.14.2       tarball  x86_64  2026-04-12T10:00:00Z
1490          c10-active-rc                                    6.14-rc2     tarball  x86_64  2026-04-12T10:00:00Z
1491          c11-eol-rc                                       7.0-rc1      tarball  x86_64  2026-04-12T10:00:00Z (EOL)
1492        ");
1493    }
1494}