use std::io::{BufRead, Write};
use anyhow::{Result, bail};
use crate::cache::{CacheDir, CacheEntry, KconfigStatus};
use super::kernel_cmd::{
corrupt_footer_if_any, embedded_kconfig_hash, eol_legend_if_any, stale_legend_if_any,
untracked_legend_if_any,
};
use super::resolve::expand_kernel_range;
fn version_prefix(version: &str) -> Option<String> {
let (major, rest) = version.split_once('.')?;
let minor_digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if minor_digits.is_empty() {
return None;
}
Some(format!("{major}.{minor_digits}"))
}
fn is_eol(version: &str, active_prefixes: &[String]) -> bool {
if active_prefixes.is_empty() {
return false;
}
let Some(prefix) = version_prefix(version) else {
return false;
};
!active_prefixes.iter().any(|p| p == &prefix)
}
pub(crate) fn entry_is_eol(entry: &CacheEntry, active_prefixes: &[String]) -> bool {
let v = entry.metadata.version.as_deref().unwrap_or("-");
v != "-" && is_eol(v, active_prefixes)
}
pub(crate) fn fetch_active_prefixes() -> anyhow::Result<Vec<String>> {
let releases = crate::fetch::cached_releases()?;
Ok(active_prefixes_from_releases(&releases))
}
fn active_prefixes_from_releases(releases: &[crate::fetch::Release]) -> Vec<String> {
let mut prefixes = Vec::new();
for r in releases {
if crate::fetch::is_skippable_release_moniker(&r.moniker) {
continue;
}
if let Some(prefix) = version_prefix(&r.version)
&& !prefixes.contains(&prefix)
{
prefixes.push(prefix);
}
}
prefixes
}
pub fn format_entry_row(
entry: &CacheEntry,
kconfig_hash: &str,
active_prefixes: &[String],
) -> String {
let meta = &entry.metadata;
let version = meta.version.as_deref().unwrap_or("-");
let source = meta.source.to_string();
let mut tags = String::new();
let status = entry.kconfig_status(kconfig_hash);
if !matches!(status, KconfigStatus::Matches) {
tags.push_str(&format!(" ({status} kconfig)"));
}
if entry.has_extra_kconfig() {
tags.push_str(" (extra kconfig)");
}
if entry_is_eol(entry, active_prefixes) {
tags.push_str(" (EOL)");
}
format!(
" {:<48} {:<12} {:<8} {:<7} {}{}",
entry.key, version, source, meta.arch, meta.built_at, tags,
)
}
pub fn kernel_list(json: bool) -> Result<()> {
kernel_list_inner(json, None)
}
pub fn kernel_list_range_preview(json: bool, range: &str) -> Result<()> {
kernel_list_inner(json, Some(range))
}
fn kernel_list_inner(json: bool, range: Option<&str>) -> Result<()> {
if let Some(spec) = range {
return run_kernel_list_range(json, spec);
}
let cache = CacheDir::new()?;
let entries = cache.list()?;
let kconfig_hash = embedded_kconfig_hash();
let (active_prefixes, active_prefixes_fetch_error): (Vec<String>, Option<String>) =
match fetch_active_prefixes() {
Ok(p) => (p, None),
Err(e) => {
let msg = format!("{e:#}");
eprintln!(
"kernel list: failed to fetch active kernel series ({msg}); \
EOL annotation disabled for this run. \
Check that kernel.org is reachable from this host.",
);
(Vec::new(), Some(msg))
}
};
if json {
let json_entries: Vec<serde_json::Value> = entries
.iter()
.map(|e| match e {
crate::cache::ListedEntry::Valid(entry) => {
let meta = &entry.metadata;
let eol = entry_is_eol(entry, &active_prefixes);
let kconfig_status = entry.kconfig_status(&kconfig_hash).to_string();
serde_json::json!({
"key": entry.key,
"path": entry.path.display().to_string(),
"version": meta.version,
"source": meta.source,
"arch": meta.arch,
"built_at": meta.built_at,
"ktstr_kconfig_hash": meta.ktstr_kconfig_hash,
"extra_kconfig_hash": meta.extra_kconfig_hash,
"kconfig_status": kconfig_status,
"eol": eol,
"config_hash": meta.config_hash,
"image_name": meta.image_name,
"image_path": entry.image_path().display().to_string(),
"has_vmlinux": meta.has_vmlinux(),
"vmlinux_stripped": meta.vmlinux_stripped(),
})
}
crate::cache::ListedEntry::Corrupt { key, path, reason } => {
let error_kind = e.error_kind().unwrap_or("unknown");
serde_json::json!({
"key": key,
"path": path.display().to_string(),
"error": reason,
"error_kind": error_kind,
})
}
})
.collect();
let wrapper = serde_json::json!({
"current_ktstr_kconfig_hash": kconfig_hash,
"active_prefixes_fetch_error": active_prefixes_fetch_error,
"entries": json_entries,
});
println!("{}", serde_json::to_string_pretty(&wrapper)?);
return Ok(());
}
eprintln!("cache: {}", cache.root().display());
if entries.is_empty() {
println!("no cached kernels. Run `kernel build` to download and build a kernel.");
return Ok(());
}
println!(
" {:<48} {:<12} {:<8} {:<7} BUILT",
"KEY", "VERSION", "SOURCE", "ARCH"
);
let mut any_stale = false;
let mut any_untracked = false;
let mut any_eol = false;
let mut corrupt_count: usize = 0;
for listed in &entries {
match listed {
crate::cache::ListedEntry::Valid(entry) => {
let status = entry.kconfig_status(&kconfig_hash);
if status.is_stale() {
any_stale = true;
}
if status.is_untracked() {
any_untracked = true;
}
if entry_is_eol(entry, &active_prefixes) {
any_eol = true;
}
println!(
"{}",
format_entry_row(entry, &kconfig_hash, &active_prefixes)
);
}
crate::cache::ListedEntry::Corrupt { key, reason, .. } => {
corrupt_count += 1;
println!(" {key:<48} (corrupt: {reason})");
}
}
}
if let Some(legend) = eol_legend_if_any(any_eol) {
eprintln!("{legend}");
}
if let Some(legend) = untracked_legend_if_any(any_untracked) {
eprintln!("{legend}");
}
if let Some(legend) = stale_legend_if_any(any_stale) {
eprintln!("{legend}");
}
if let Some(footer) = corrupt_footer_if_any(corrupt_count, cache.root()) {
eprintln!("{footer}");
}
Ok(())
}
fn run_kernel_list_range(json: bool, spec: &str) -> Result<()> {
use crate::kernel_path::KernelId;
let id = KernelId::parse(spec);
let (start, end) = match &id {
KernelId::Range { start, end } => (start.clone(), end.clone()),
_ => {
bail!(
"kernel list --range: `{spec}` does not parse as a \
`START..END` range. Expected `MAJOR.MINOR[.PATCH][-rcN]..\
MAJOR.MINOR[.PATCH][-rcN]` (e.g. `6.12..6.14`)."
);
}
};
id.validate()
.map_err(|e| anyhow::anyhow!("kernel list --range {spec}: {e}"))?;
let versions = expand_kernel_range(&start, &end, "kernel list")?;
if json {
let payload = serde_json::json!({
"range": spec,
"start": start,
"end": end,
"versions": versions,
});
println!("{}", serde_json::to_string_pretty(&payload)?);
return Ok(());
}
for v in &versions {
println!("{v}");
}
Ok(())
}
fn partition_clean_candidates<'a>(
entries: &'a [crate::cache::ListedEntry],
keep: Option<usize>,
corrupt_only: bool,
) -> Vec<&'a crate::cache::ListedEntry> {
let skip = keep.unwrap_or(0);
type BucketKey = (Option<String>, Option<String>, Option<String>);
let mut bucket_kept: std::collections::HashMap<BucketKey, usize> =
std::collections::HashMap::new();
let mut to_remove: Vec<&'a crate::cache::ListedEntry> = Vec::new();
for listed in entries {
match listed {
crate::cache::ListedEntry::Valid(entry) => {
if corrupt_only {
continue;
}
let bucket_key = (
entry.metadata.version.clone(),
entry.metadata.ktstr_kconfig_hash.clone(),
entry.metadata.extra_kconfig_hash.clone(),
);
let kept = bucket_kept.entry(bucket_key).or_insert(0);
if *kept < skip {
*kept += 1;
continue;
}
to_remove.push(listed);
}
crate::cache::ListedEntry::Corrupt { .. } => {
to_remove.push(listed);
}
}
}
to_remove
}
pub fn kernel_clean(keep: Option<usize>, force: bool, corrupt_only: bool) -> Result<()> {
let cache = CacheDir::new()?;
let entries = cache.list()?;
if entries.is_empty() {
println!("nothing to clean");
return Ok(());
}
let kconfig_hash = embedded_kconfig_hash();
let to_remove = partition_clean_candidates(&entries, keep, corrupt_only);
if to_remove.is_empty() {
println!("nothing to clean");
return Ok(());
}
if !force {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
bail!("confirmation requires a terminal. Use --force to skip.");
}
let active_prefixes = match fetch_active_prefixes() {
Ok(p) => p,
Err(e) => {
eprintln!(
"kernel clean: failed to fetch active kernel series ({e:#}); \
EOL annotation disabled for this run. \
Check that kernel.org is reachable from this host."
);
Vec::new()
}
};
println!("the following entries will be removed:");
for listed in &to_remove {
match listed {
crate::cache::ListedEntry::Valid(entry) => {
println!(
"{}",
format_entry_row(entry, &kconfig_hash, &active_prefixes)
);
}
crate::cache::ListedEntry::Corrupt { key, reason, .. } => {
println!(" {key:<48} (corrupt: {reason})");
}
}
}
eprint!("remove {} entries? [y/N] ", to_remove.len());
std::io::stderr().flush()?;
let mut answer = String::new();
std::io::stdin().lock().read_line(&mut answer)?;
if !matches!(answer.trim(), "y" | "Y") {
println!("aborted");
return Ok(());
}
}
let total = to_remove.len();
let mut removed = 0usize;
let mut last_err: Option<String> = None;
for listed in &to_remove {
match std::fs::remove_dir_all(listed.path()) {
Ok(()) => removed += 1,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
removed += 1;
}
Err(e) => {
last_err = Some(format!("remove {}: {e}", listed.key()));
}
}
}
println!("removed {removed} cached kernel(s).");
if let Some(err) = last_err {
bail!("removed {removed} of {total} entries; {err}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_prefix_stable_release() {
assert_eq!(version_prefix("6.14.2").as_deref(), Some("6.14"));
assert_eq!(version_prefix("6.12.81").as_deref(), Some("6.12"));
assert_eq!(version_prefix("7.0").as_deref(), Some("7.0"));
}
#[test]
fn version_prefix_strips_rc_suffix() {
assert_eq!(version_prefix("6.15-rc1").as_deref(), Some("6.15"));
assert_eq!(version_prefix("6.15-rc3").as_deref(), Some("6.15"));
assert_eq!(version_prefix("7.0-rc1").as_deref(), Some("7.0"));
}
#[test]
fn version_prefix_strips_linux_next_suffix() {
assert_eq!(
version_prefix("6.16-rc2-next-20260420").as_deref(),
Some("6.16"),
);
assert_eq!(
version_prefix("7.1-rc1-next-20260501").as_deref(),
Some("7.1"),
);
}
#[test]
fn version_prefix_rejects_no_dot() {
assert!(version_prefix("abc").is_none());
assert!(version_prefix("6").is_none());
assert!(version_prefix("").is_none());
}
#[test]
fn version_prefix_rejects_non_numeric_minor() {
assert!(version_prefix("6.x").is_none());
assert!(version_prefix("6.-rc1").is_none());
assert!(version_prefix("6.").is_none());
}
#[test]
fn is_eol_empty_active_prefixes_returns_false() {
assert!(!is_eol("6.14.2", &[]));
}
#[test]
fn is_eol_prefix_in_active_list_returns_false() {
assert!(!is_eol("6.14.2", &["6.14".to_string()]));
}
#[test]
fn is_eol_prefix_absent_from_active_list_returns_true() {
assert!(is_eol(
"5.10.200",
&["6.14".to_string(), "6.12".to_string()],
));
}
#[test]
fn is_eol_unparseable_version_returns_false() {
assert!(!is_eol("abc", &["6.14".to_string()]));
}
#[test]
fn is_eol_rc_suffix_mismatch_does_not_flag() {
let active = ["6.15".to_string()];
assert!(!is_eol("6.15-rc1", &active));
assert!(!is_eol("6.15-rc4", &active));
}
#[test]
fn is_eol_linux_next_matches_mainline_prefix() {
let active = ["6.16".to_string()];
assert!(!is_eol("6.16-rc2-next-20260420", &active));
}
#[test]
fn is_eol_brand_new_major_matches_rc_variant() {
assert!(!is_eol("7.0", &["7.0".to_string()]));
assert!(!is_eol("7.0-rc1", &["7.0".to_string()]));
}
#[test]
fn is_eol_brand_new_zero_release_in_active_list() {
let active = ["7.0".to_string()];
assert!(!is_eol("7.0", &active));
assert!(!is_eol("7.0.0", &active));
}
#[test]
fn is_eol_linux_next_version_not_falsely_tagged() {
assert!(is_eol(
"6.16-rc1",
&["6.14".to_string(), "6.13".to_string()]
));
}
fn owned(pairs: &[(&str, &str)]) -> Vec<crate::fetch::Release> {
pairs
.iter()
.map(|(m, v)| crate::fetch::Release {
moniker: (*m).to_string(),
version: (*v).to_string(),
})
.collect()
}
#[test]
fn active_prefixes_from_releases_normalizes_rc_versions() {
let releases = owned(&[
("mainline", "6.16-rc3"),
("stable", "6.15.2"),
("longterm", "6.12.81"),
]);
let prefixes = active_prefixes_from_releases(&releases);
assert_eq!(
prefixes,
vec!["6.16".to_string(), "6.15".to_string(), "6.12".to_string()],
);
}
#[test]
fn active_prefixes_from_releases_skips_linux_next_moniker() {
let releases = owned(&[
("linux-next", "6.17-rc2-next-20260421"),
("mainline", "6.16-rc3"),
("stable", "6.15.2"),
]);
let prefixes = active_prefixes_from_releases(&releases);
assert!(!prefixes.contains(&"6.17".to_string()));
assert_eq!(prefixes, vec!["6.16".to_string(), "6.15".to_string()]);
}
#[test]
fn active_prefixes_from_releases_dedups_in_input_order() {
let releases = owned(&[
("stable", "6.14.2"),
("longterm", "6.14.1"),
("longterm", "6.12.81"),
]);
let prefixes = active_prefixes_from_releases(&releases);
assert_eq!(prefixes, vec!["6.14".to_string(), "6.12".to_string()]);
}
#[test]
fn kernel_list_range_preview_rejects_non_range_spec() {
let err = run_kernel_list_range(false, "6.14.2")
.expect_err("bare version must not parse as a Range");
let msg = format!("{err:#}");
assert!(msg.contains("does not parse as a `START..END` range"));
assert!(msg.contains("`6.14.2`"));
}
#[test]
fn kernel_list_range_preview_rejects_inverted_range() {
let err = run_kernel_list_range(false, "6.16..6.12")
.expect_err("inverted range must not be accepted");
let msg = format!("{err:#}");
assert!(msg.contains("kernel list --range 6.16..6.12"));
}
fn mk_valid(key: &str) -> crate::cache::ListedEntry {
use crate::cache::{CacheEntry, KernelMetadata, KernelSource};
let path = std::path::PathBuf::from(format!("/tmp/fixture/{key}"));
let metadata = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-22T00:00:00Z".to_string(),
);
crate::cache::ListedEntry::Valid(Box::new(CacheEntry {
key: key.to_string(),
path,
metadata,
}))
}
fn mk_corrupt(key: &str) -> crate::cache::ListedEntry {
crate::cache::ListedEntry::Corrupt {
key: key.to_string(),
path: std::path::PathBuf::from(format!("/tmp/fixture/{key}")),
reason: "test fixture corrupt".to_string(),
}
}
#[test]
fn partition_clean_candidates_empty_input_yields_empty_output() {
let out = partition_clean_candidates(&[], None, false);
assert!(out.is_empty());
let out = partition_clean_candidates(&[], Some(5), true);
assert!(out.is_empty());
}
#[test]
fn partition_clean_candidates_corrupt_only_skips_valid_entries() {
let entries = vec![mk_valid("v1"), mk_corrupt("c1"), mk_valid("v2")];
let out = partition_clean_candidates(&entries, None, true);
assert_eq!(out.len(), 1);
assert_eq!(out[0].key(), "c1");
}
#[test]
fn partition_clean_candidates_no_keep_removes_every_entry() {
let entries = vec![mk_valid("v1"), mk_corrupt("c1"), mk_valid("v2")];
let out = partition_clean_candidates(&entries, None, false);
let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
assert_eq!(keys, vec!["v1", "c1", "v2"]);
}
#[test]
fn partition_clean_candidates_keep_retains_n_newest_valid_preserves_corrupt() {
let entries = vec![
mk_valid("v_new1"),
mk_corrupt("c_mid"),
mk_valid("v_new2"),
mk_valid("v_old"),
];
let out = partition_clean_candidates(&entries, Some(2), false);
let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
assert_eq!(keys, vec!["c_mid", "v_old"]);
}
#[test]
fn partition_clean_candidates_keep_never_preserves_corrupt() {
let entries = vec![mk_corrupt("c1"), mk_valid("v1"), mk_valid("v2")];
let out = partition_clean_candidates(&entries, Some(3), false);
let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
assert_eq!(keys, vec!["c1"]);
}
#[test]
fn partition_clean_candidates_corrupt_only_ignores_keep() {
let entries = vec![
mk_valid("v_new1"),
mk_corrupt("c_mid"),
mk_valid("v_new2"),
mk_valid("v_old"),
];
let out = partition_clean_candidates(&entries, Some(2), true);
let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
assert_eq!(keys, vec!["c_mid"]);
}
fn mk_valid_bucketed(
key: &str,
version: Option<&str>,
extra_kconfig_hash: Option<&str>,
) -> crate::cache::ListedEntry {
mk_valid_bucketed_full(key, version, None, extra_kconfig_hash)
}
fn mk_valid_bucketed_full(
key: &str,
version: Option<&str>,
ktstr_kconfig_hash: Option<&str>,
extra_kconfig_hash: Option<&str>,
) -> crate::cache::ListedEntry {
use crate::cache::{CacheEntry, KernelMetadata, KernelSource};
let path = std::path::PathBuf::from(format!("/tmp/fixture/{key}"));
let metadata = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-22T00:00:00Z".to_string(),
)
.with_version(version.map(String::from))
.with_ktstr_kconfig_hash(ktstr_kconfig_hash.map(String::from))
.with_extra_kconfig_hash(extra_kconfig_hash.map(String::from));
crate::cache::ListedEntry::Valid(Box::new(CacheEntry {
key: key.to_string(),
path,
metadata,
}))
}
#[test]
fn partition_clean_candidates_keep_buckets_by_version() {
let entries = vec![
mk_valid_bucketed("v6_14_new", Some("6.14.2"), None),
mk_valid_bucketed("v6_15_new", Some("6.15.0"), None),
mk_valid_bucketed("v6_14_old", Some("6.14.2"), None),
mk_valid_bucketed("v6_15_old", Some("6.15.0"), None),
];
let out = partition_clean_candidates(&entries, Some(1), false);
let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
assert_eq!(keys, vec!["v6_14_old", "v6_15_old"]);
}
#[test]
fn partition_clean_candidates_keep_buckets_by_extra_kconfig_hash() {
let entries = vec![
mk_valid_bucketed("v6_14_xkc_aaaa_new", Some("6.14.2"), Some("aaaa")),
mk_valid_bucketed("v6_14_xkc_bbbb_new", Some("6.14.2"), Some("bbbb")),
mk_valid_bucketed("v6_14_xkc_aaaa_old", Some("6.14.2"), Some("aaaa")),
mk_valid_bucketed("v6_14_xkc_bbbb_old", Some("6.14.2"), Some("bbbb")),
];
let out = partition_clean_candidates(&entries, Some(1), false);
let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
assert_eq!(keys, vec!["v6_14_xkc_aaaa_old", "v6_14_xkc_bbbb_old"]);
}
#[test]
fn partition_clean_candidates_keep_distinguishes_none_from_some_extras() {
let entries = vec![
mk_valid_bucketed("bare_new", Some("6.14.2"), None),
mk_valid_bucketed("xkc_new", Some("6.14.2"), Some("aaaa")),
mk_valid_bucketed("bare_old", Some("6.14.2"), None),
mk_valid_bucketed("xkc_old", Some("6.14.2"), Some("aaaa")),
];
let out = partition_clean_candidates(&entries, Some(1), false);
let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
assert_eq!(keys, vec!["bare_old", "xkc_old"]);
}
#[test]
fn partition_clean_candidates_keep_per_bucket_with_corrupt_interleaved() {
let entries = vec![
mk_valid_bucketed("v6_14_new", Some("6.14.2"), None),
mk_corrupt("c_mid"),
mk_valid_bucketed("v6_15_new", Some("6.15.0"), None),
mk_valid_bucketed("v6_14_old", Some("6.14.2"), None),
];
let out = partition_clean_candidates(&entries, Some(1), false);
let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
assert_eq!(keys, vec!["c_mid", "v6_14_old"]);
}
#[test]
fn partition_clean_candidates_keep_buckets_by_ktstr_kconfig_hash() {
let entries = vec![
mk_valid_bucketed_full("baked_v2_new", Some("6.14.2"), Some("v2hash"), None),
mk_valid_bucketed_full("baked_v1_new", Some("6.14.2"), Some("v1hash"), None),
mk_valid_bucketed_full("baked_v2_old", Some("6.14.2"), Some("v2hash"), None),
mk_valid_bucketed_full("baked_v1_old", Some("6.14.2"), Some("v1hash"), None),
];
let out = partition_clean_candidates(&entries, Some(1), false);
let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
assert_eq!(keys, vec!["baked_v2_old", "baked_v1_old"]);
}
#[test]
fn partition_clean_candidates_keep_local_untagged_builds_form_own_bucket() {
let entries = vec![
mk_valid_bucketed("local_new", None, None),
mk_valid_bucketed("v6_14_new", Some("6.14.2"), None),
mk_valid_bucketed("local_old", None, None),
mk_valid_bucketed("v6_14_old", Some("6.14.2"), None),
];
let out = partition_clean_candidates(&entries, Some(1), false);
let keys: Vec<&str> = out.iter().map(|e| e.key()).collect();
assert_eq!(keys, vec!["local_old", "v6_14_old"]);
}
#[test]
fn format_entry_row_emits_extra_kconfig_tag() {
use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src = tempfile::TempDir::new().unwrap();
let image = src.path().join("bzImage");
std::fs::write(&image, b"fake kernel").unwrap();
let current_hash = "abc1234";
let meta_with = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
)
.with_version(Some("6.14.2".to_string()))
.with_ktstr_kconfig_hash(Some(current_hash.to_string()))
.with_extra_kconfig_hash(Some("deadbeef".to_string()));
let entry_with = cache
.store("with-extras", &CacheArtifacts::new(&image), &meta_with)
.unwrap();
let row_with = format_entry_row(&entry_with, current_hash, &[]);
assert!(row_with.contains("(extra kconfig)"));
assert!(!row_with.contains("(stale kconfig)"));
assert!(!row_with.contains("(untracked kconfig)"));
let meta_without = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
)
.with_version(Some("6.14.2".to_string()))
.with_ktstr_kconfig_hash(Some(current_hash.to_string()));
let entry_without = cache
.store(
"without-extras",
&CacheArtifacts::new(&image),
&meta_without,
)
.unwrap();
let row_without = format_entry_row(&entry_without, current_hash, &[]);
assert!(!row_without.contains("(extra kconfig)"));
}
#[test]
fn format_entry_row_empty_active_prefixes_does_not_tag_eol() {
use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src = tempfile::TempDir::new().unwrap();
let image = src.path().join("bzImage");
std::fs::write(&image, b"fake kernel").unwrap();
let meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
)
.with_version(Some("2.6.32".to_string()));
let entry = cache
.store("fetch-failed-fallback", &CacheArtifacts::new(&image), &meta)
.unwrap();
let row_fallback = format_entry_row(&entry, "kconfig_hash", &[]);
assert!(!row_fallback.contains("(EOL)"));
let row_with_active = format_entry_row(&entry, "kconfig_hash", &["6.14".to_string()]);
assert!(row_with_active.contains("(EOL)"));
}
#[test]
fn format_entry_row_tags_appear_in_stable_order() {
use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src = tempfile::TempDir::new().unwrap();
let image = src.path().join("bzImage");
std::fs::write(&image, b"fake kernel").unwrap();
let current_hash = "a1b2c3d4";
let active_prefixes = ["6.14".to_string()];
let stale_meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
)
.with_version(Some("2.6.32".to_string()))
.with_ktstr_kconfig_hash(Some("deadbeef".to_string()));
let stale_entry = cache
.store("stale-eol", &CacheArtifacts::new(&image), &stale_meta)
.unwrap();
let stale_row = format_entry_row(&stale_entry, current_hash, &active_prefixes);
let stale_idx = stale_row
.find("(stale kconfig)")
.expect("stale-kconfig tag must appear on dual-tag row");
let eol_idx = stale_row
.find("(EOL)")
.expect("EOL tag must appear on dual-tag row");
assert!(stale_idx < eol_idx);
let untracked_meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
)
.with_version(Some("2.6.32".to_string()))
.with_ktstr_kconfig_hash(None);
let untracked_entry = cache
.store(
"untracked-eol",
&CacheArtifacts::new(&image),
&untracked_meta,
)
.unwrap();
let untracked_row = format_entry_row(&untracked_entry, current_hash, &active_prefixes);
let untracked_idx = untracked_row
.find("(untracked kconfig)")
.expect("untracked-kconfig tag must appear on dual-tag row");
let eol_idx = untracked_row
.find("(EOL)")
.expect("EOL tag must appear on dual-tag row");
assert!(untracked_idx < eol_idx);
}
#[test]
fn kernel_list_eol_json_human_parity() {
use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src_dir = tmp.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let image = src_dir.join("bzImage");
std::fs::write(&image, b"fake kernel").unwrap();
let make_entry = |key: &str, version: &str| {
let meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
)
.with_version(Some(version.to_string()));
cache
.store(key, &CacheArtifacts::new(&image), &meta)
.unwrap()
};
let cases: &[(&str, &str, &[&str])] = &[
("active", "6.14.2", &["6.14"]),
("eol", "2.6.32", &["6.14"]),
("fetch-fail", "2.6.32", &[]),
];
for (label, version, active) in cases {
let entry = make_entry(&format!("parity-{label}"), version);
let active_vec: Vec<String> = active.iter().map(|s| s.to_string()).collect();
let row = format_entry_row(&entry, "kconfig_hash", &active_vec);
let json_eol = entry_is_eol(&entry, &active_vec);
let human_eol = row.contains("(EOL)");
assert_eq!(
json_eol, human_eol,
"JSON/human parity broken for case {label}: \
json_eol={json_eol}, human_eol={human_eol}, row={row:?}",
);
}
}
#[test]
fn kernel_list_corrupt_footer_fires_iff_any_corrupt() {
use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
use crate::cli::kernel_cmd::format_corrupt_footer;
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src_dir = tmp.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let image = src_dir.join("bzImage");
std::fs::write(&image, b"fake kernel").unwrap();
let meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-22T00:00:00Z".to_string(),
)
.with_version(Some("6.14.2".to_string()));
let valid_1 = cache
.store("valid-entry-a", &CacheArtifacts::new(&image), &meta)
.unwrap();
let valid_2 = cache
.store("valid-entry-b", &CacheArtifacts::new(&image), &meta)
.unwrap();
let corrupt_entry = crate::cache::ListedEntry::Corrupt {
key: "corrupt-entry".to_string(),
path: cache.root().join("corrupt-entry"),
reason: "metadata.json missing".to_string(),
};
let entries_with_corrupt = [
crate::cache::ListedEntry::Valid(Box::new(valid_1)),
corrupt_entry,
];
let entries_clean_only = [crate::cache::ListedEntry::Valid(Box::new(valid_2))];
fn any_corrupt(entries: &[crate::cache::ListedEntry]) -> bool {
entries
.iter()
.any(|e| matches!(e, crate::cache::ListedEntry::Corrupt { .. }))
}
assert!(
any_corrupt(&entries_with_corrupt),
"mixed list must trip the footer",
);
assert!(
!any_corrupt(&entries_clean_only),
"clean-only list must not trip the footer",
);
let footer = format_corrupt_footer(cache.root());
assert!(
footer.contains("(corrupt)"),
"footer must reference the tag users see",
);
assert!(
footer.contains("kernel clean --force"),
"footer must offer a remediation command",
);
assert!(
footer.contains("ALL cached entries"),
"footer must spell out that `kernel clean --force` is not surgical",
);
assert!(
footer.contains("kernel clean --keep N --force"),
"footer must offer a partial-cleanup alternative",
);
assert!(
footer.contains(&cache.root().display().to_string()),
"footer must name the cache root so operators know where to inspect",
);
}
#[test]
fn kernel_list_stale_kconfig_json_human_parity() {
use crate::cache::{CacheArtifacts, CacheDir, KernelSource};
fn metadata_with_hash(hash: Option<&str>) -> crate::cache::KernelMetadata {
crate::cache::KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
)
.with_version(Some("6.14.2".to_string()))
.with_ktstr_kconfig_hash(hash.map(str::to_string))
}
let cases: &[(&str, Option<&str>, &str)] = &[
("matches", Some("same"), "same"),
("stale", Some("old"), "new"),
("untracked", None, "anything"),
];
for &(label, entry_hash, current_hash) in cases {
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src = tempfile::TempDir::new().unwrap();
let image = src.path().join("bzImage");
std::fs::write(&image, b"fake kernel").unwrap();
let meta = metadata_with_hash(entry_hash);
let entry = cache
.store(label, &CacheArtifacts::new(&image), &meta)
.unwrap();
let json_stale = entry.kconfig_status(current_hash).is_stale();
let human_row = format_entry_row(&entry, current_hash, &[]);
let human_stale = human_row.contains("stale kconfig");
assert_eq!(
json_stale, human_stale,
"kernel_list JSON/human stale-kconfig disagreement on `{label}` \
(entry_hash={entry_hash:?}, current_hash={current_hash:?})",
);
}
}
#[test]
fn format_entry_row_renders_eol_kconfig_matrix() {
use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src_dir = tmp.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let image = src_dir.join("bzImage");
std::fs::write(&image, b"fake kernel").unwrap();
let current_hash = "a1b2c3d4";
let active_prefixes = ["6.14".to_string()];
let build_row = |key: &str, version: Option<&str>, entry_hash: Option<&str>| -> String {
let meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
)
.with_version(version.map(str::to_string))
.with_ktstr_kconfig_hash(entry_hash.map(str::to_string));
let entry = cache
.store(key, &CacheArtifacts::new(&image), &meta)
.unwrap();
format_entry_row(&entry, current_hash, &active_prefixes)
};
let c8_key = "c8-long-key-exactly-forty-eight-chars-xxxxxxxxxx";
let c9_key = "c9-key-longer-than-forty-eight-chars-by-twelve-xxxxxxxxxxxx";
debug_assert_eq!(c8_key.len(), 48);
debug_assert_eq!(c9_key.len(), 59);
let rows = [
build_row("c1-active-matches", Some("6.14.2"), Some(current_hash)),
build_row("c2-active-stale", Some("6.14.2"), Some("deadbeef")),
build_row("c3-active-untracked", Some("6.14.2"), None),
build_row("c4-eol-matches", Some("2.6.32"), Some(current_hash)),
build_row("c5-eol-stale", Some("2.6.32"), Some("deadbeef")),
build_row("c6-eol-untracked", Some("2.6.32"), None),
build_row("c7-active-no-version", None, Some(current_hash)),
build_row(c8_key, Some("6.14.2"), Some(current_hash)),
build_row(c9_key, Some("6.14.2"), Some(current_hash)),
build_row("c10-active-rc", Some("6.14-rc2"), Some(current_hash)),
build_row("c11-eol-rc", Some("7.0-rc1"), Some(current_hash)),
];
let joined = rows.join("\n");
insta::assert_snapshot!(joined, @r"
c1-active-matches 6.14.2 tarball x86_64 2026-04-12T10:00:00Z
c2-active-stale 6.14.2 tarball x86_64 2026-04-12T10:00:00Z (stale kconfig)
c3-active-untracked 6.14.2 tarball x86_64 2026-04-12T10:00:00Z (untracked kconfig)
c4-eol-matches 2.6.32 tarball x86_64 2026-04-12T10:00:00Z (EOL)
c5-eol-stale 2.6.32 tarball x86_64 2026-04-12T10:00:00Z (stale kconfig) (EOL)
c6-eol-untracked 2.6.32 tarball x86_64 2026-04-12T10:00:00Z (untracked kconfig) (EOL)
c7-active-no-version - tarball x86_64 2026-04-12T10:00:00Z
c8-long-key-exactly-forty-eight-chars-xxxxxxxxxx 6.14.2 tarball x86_64 2026-04-12T10:00:00Z
c9-key-longer-than-forty-eight-chars-by-twelve-xxxxxxxxxxxx 6.14.2 tarball x86_64 2026-04-12T10:00:00Z
c10-active-rc 6.14-rc2 tarball x86_64 2026-04-12T10:00:00Z
c11-eol-rc 7.0-rc1 tarball x86_64 2026-04-12T10:00:00Z (EOL)
");
}
}