1use 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
21fn 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
47fn 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
73pub(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
86pub(crate) fn fetch_active_prefixes() -> anyhow::Result<Vec<String>> {
98 let releases = crate::fetch::cached_releases()?;
106 Ok(active_prefixes_from_releases(&releases))
107}
108
109fn 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
134pub 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 let status = entry.kconfig_status(kconfig_hash);
150 if !matches!(status, KconfigStatus::Matches) {
151 tags.push_str(&format!(" ({status} kconfig)"));
152 }
153 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
169pub fn kernel_list(json: bool) -> Result<()> {
294 kernel_list_inner(json, None, false)
298}
299
300pub 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 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 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 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 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
496fn 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 for v in &versions {
557 println!("{v}");
558 }
559 Ok(())
560}
561
562fn 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 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
642pub 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}