//! Pure data types for the kernel image cache.
//!
//! Public shape of cache entries — [`KernelSource`] /
//! [`KernelMetadata`] / [`CacheArtifacts`] / [`KconfigStatus`] /
//! [`CacheEntry`] / [`ListedEntry`] — plus the internal
//! [`classify_corrupt_reason`] dispatcher that routes
//! `read_metadata`-emitted reason strings into stable `error_kind`
//! snake_case identifiers surfaced by `kernel list --json`. No I/O,
//! no syscalls — every entry point is a pure transformation over
//! already-loaded data.

use std::fmt;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

/// How a cached kernel's source was acquired, with per-variant
/// payload (git details for `Git`, source-tree path and git hash for
/// `Local`).
///
/// Serialized as `{"type": "tarball"}`, `{"type": "git", "git_hash": ..., "ref": ...}`,
/// or `{"type": "local", "source_tree_path": ..., "git_hash": ...}`.
/// Every per-variant payload field is emitted explicitly — `Option`
/// fields serialize as `null` when `None` rather than being skipped,
/// so JSON consumers see stable keys across every variant regardless
/// of which optional payload values are set.
///
/// On deserialize, serde_json treats absent `Option` keys as `None`,
/// so an old `metadata.json` that drops `git_hash`, `ref`, or
/// `source_tree_path` still round-trips. Cache-integrity enforcement
/// (truncated `metadata.json` surfacing as [`ListedEntry::Corrupt`]
/// via [`crate::cache::CacheDir::list`]) rides on the required
/// non-`Option` fields of [`KernelMetadata`], not on the optional
/// payloads here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase", tag = "type")]
#[non_exhaustive]
pub enum KernelSource {
    /// Downloaded tarball from kernel.org (version / prefix / EOL
    /// probe paths).
    Tarball,
    /// Shallow clone of a git URL at a caller-specified ref.
    Git {
        /// Git commit hash of the kernel source (short form).
        git_hash: Option<String>,
        /// Git ref used for checkout (branch, tag, or ref spec).
        #[serde(rename = "ref")]
        git_ref: Option<String>,
    },
    /// Build of a local on-disk kernel source tree.
    Local {
        /// Path to the source tree on disk. `None` when the tree has
        /// been sanitized for remote cache transport or is otherwise
        /// unavailable.
        source_tree_path: Option<PathBuf>,
        /// Git commit hash of the source tree at build time (short
        /// form). `None` when the tree is not a git repository, the
        /// hash could not be read, or the worktree is dirty — a
        /// HEAD hash does not describe a tree with uncommitted
        /// changes, so identifying it by that hash would mislead a
        /// reproducer.
        git_hash: Option<String>,
    },
}

impl fmt::Display for KernelSource {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            KernelSource::Tarball => f.write_str("tarball"),
            KernelSource::Git { .. } => f.write_str("git"),
            KernelSource::Local { .. } => f.write_str("local"),
        }
    }
}

impl KernelSource {
    /// Borrow the `git_hash` field on a [`KernelSource::Local`]
    /// variant. Returns `None` for any other variant or when the
    /// Local variant carries `git_hash: None` (dirty / non-git
    /// tree at acquire time).
    ///
    /// Mainly used by [`crate::cli::kernel_build_pipeline`]'s
    /// post-build dirty re-check, which compares the post-build
    /// HEAD hash against the acquire-time hash to detect mid-build
    /// commits or branch flips. Borrows rather than clones so the
    /// caller does not pay an allocation when only comparing.
    pub fn as_local_git_hash(&self) -> Option<&str> {
        match self {
            KernelSource::Local { git_hash, .. } => git_hash.as_deref(),
            _ => None,
        }
    }

    /// Construct [`KernelSource::Git`] with both `git_hash` and
    /// `git_ref` populated. Use struct-literal syntax directly if
    /// either field should be `None`.
    pub fn git(git_hash: impl Into<String>, git_ref: impl Into<String>) -> Self {
        KernelSource::Git {
            git_hash: Some(git_hash.into()),
            git_ref: Some(git_ref.into()),
        }
    }
}

/// Metadata stored alongside a cached kernel image.
///
/// Required fields (`source`, `arch`, `image_name`, `built_at`,
/// `has_vmlinux`, `vmlinux_stripped`) must be present in
/// `metadata.json` during deserialization; a truncated file that
/// drops any of them surfaces the entry as [`ListedEntry::Corrupt`]
/// via [`crate::cache::CacheDir::list`] rather than silently
/// defaulting. Optional fields tolerate absent keys as `None`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct KernelMetadata {
    /// Kernel version string (e.g. "6.14.2", "6.15-rc3").
    /// `None` when no version could be established — a local or git
    /// source whose `Makefile` is unreadable/unparsable, or an
    /// acquisition path that records none.
    pub version: Option<String>,
    /// How the kernel source was acquired, with per-source payload.
    pub source: KernelSource,
    /// Target architecture (e.g. "x86_64", "aarch64").
    pub arch: String,
    /// Boot image filename (e.g. "bzImage", "Image").
    pub image_name: String,
    /// CRC32 of the final .config used for the build.
    pub config_hash: Option<String>,
    /// ISO 8601 timestamp of when the image was built.
    pub built_at: String,
    /// CRC32 of ktstr.kconfig at build time.
    pub ktstr_kconfig_hash: Option<String>,
    /// CRC32 of the user-supplied `--extra-kconfig` fragment (raw
    /// bytes) at build time. `None` for builds without
    /// `--extra-kconfig`.
    pub extra_kconfig_hash: Option<String>,
    /// Whether a vmlinux ELF was cached alongside the image.
    /// Required in metadata.json.
    pub(crate) has_vmlinux: bool,
    /// Whether the cached vmlinux ELF came from a successful strip
    /// pass (`true`) or the raw-fallback path (`false`).
    pub(crate) vmlinux_stripped: bool,
    /// Size in bytes of the SOURCE-TREE vmlinux at cache-store time.
    pub source_vmlinux_size: Option<u64>,
    /// Modification time (seconds since UNIX epoch) of the
    /// SOURCE-TREE vmlinux at cache-store time.
    pub source_vmlinux_mtime_secs: Option<i64>,
}

impl KernelMetadata {
    /// Create a new KernelMetadata with required fields.
    pub fn new(
        source: KernelSource,
        arch: impl Into<String>,
        image_name: impl Into<String>,
        built_at: impl Into<String>,
    ) -> Self {
        KernelMetadata {
            version: None,
            source,
            arch: arch.into(),
            image_name: image_name.into(),
            config_hash: None,
            built_at: built_at.into(),
            ktstr_kconfig_hash: None,
            extra_kconfig_hash: None,
            has_vmlinux: false,
            vmlinux_stripped: false,
            source_vmlinux_size: None,
            source_vmlinux_mtime_secs: None,
        }
    }

    /// Set the source-tree vmlinux size and mtime captured at cache
    /// store time.
    pub fn with_source_vmlinux_stat(mut self, size: u64, mtime_secs: i64) -> Self {
        self.source_vmlinux_size = Some(size);
        self.source_vmlinux_mtime_secs = Some(mtime_secs);
        self
    }

    /// Unset the source-tree vmlinux stat (both size and mtime back
    /// to the `None` default).
    pub fn clear_source_vmlinux_stat(mut self) -> Self {
        self.source_vmlinux_size = None;
        self.source_vmlinux_mtime_secs = None;
        self
    }

    /// Set the kernel version.
    pub fn with_version(mut self, version: impl Into<String>) -> Self {
        self.version = Some(version.into());
        self
    }

    /// Unset the kernel version (back to the `None` default).
    pub fn clear_version(mut self) -> Self {
        self.version = None;
        self
    }

    /// Set the .config CRC32 hash.
    pub fn with_config_hash(mut self, hash: impl Into<String>) -> Self {
        self.config_hash = Some(hash.into());
        self
    }

    /// Unset the .config CRC32 hash (back to the `None` default).
    pub fn clear_config_hash(mut self) -> Self {
        self.config_hash = None;
        self
    }

    /// Set the ktstr.kconfig CRC32 hash.
    pub fn with_ktstr_kconfig_hash(mut self, hash: impl Into<String>) -> Self {
        self.ktstr_kconfig_hash = Some(hash.into());
        self
    }

    /// Unset the ktstr.kconfig CRC32 hash (back to the `None` default).
    pub fn clear_ktstr_kconfig_hash(mut self) -> Self {
        self.ktstr_kconfig_hash = None;
        self
    }

    /// Set the `--extra-kconfig` fragment CRC32 hash.
    pub fn with_extra_kconfig_hash(mut self, hash: impl Into<String>) -> Self {
        self.extra_kconfig_hash = Some(hash.into());
        self
    }

    /// Unset the `--extra-kconfig` fragment CRC32 hash (back to the `None` default).
    pub fn clear_extra_kconfig_hash(mut self) -> Self {
        self.extra_kconfig_hash = None;
        self
    }

    /// Whether a vmlinux ELF was cached alongside the image.
    pub fn has_vmlinux(&self) -> bool {
        self.has_vmlinux
    }

    /// Crate-only mutator for `has_vmlinux`.
    pub(crate) fn set_has_vmlinux(&mut self, value: bool) {
        self.has_vmlinux = value;
    }

    /// Whether the cached vmlinux came from a successful strip pass.
    pub fn vmlinux_stripped(&self) -> bool {
        self.vmlinux_stripped
    }

    /// Crate-only mutator for `vmlinux_stripped`.
    pub(crate) fn set_vmlinux_stripped(&mut self, value: bool) {
        self.vmlinux_stripped = value;
    }
}

/// Bundle of cache artifacts for [`crate::cache::CacheDir::store`].
///
/// The vmlinux path points at the raw (unstripped) ELF. `store()`
/// strips it internally via `crate::cache::strip_vmlinux_debug`
/// and writes the result.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CacheArtifacts<'a> {
    /// Path to the kernel boot image (bzImage or Image).
    pub image: &'a Path,
    /// Optional path to the raw (unstripped) vmlinux ELF. `store()`
    /// strips it internally before caching.
    pub vmlinux: Option<&'a Path>,
}

impl<'a> CacheArtifacts<'a> {
    /// Create an artifact bundle with only the required image.
    pub fn new(image: &'a Path) -> Self {
        CacheArtifacts {
            image,
            vmlinux: None,
        }
    }

    /// Attach the raw (unstripped) vmlinux ELF.
    pub fn with_vmlinux(mut self, vmlinux: &'a Path) -> Self {
        self.vmlinux = Some(vmlinux);
        self
    }
}

/// Comparison between a cache entry's kconfig hash and a current
/// reference hash. Returned by [`CacheEntry::kconfig_status`].
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum KconfigStatus {
    /// Entry was built with the current kconfig — nothing to do.
    Matches,
    /// Entry was built with a different kconfig.
    Stale {
        /// Hash recorded in the cache entry.
        cached: String,
        /// Hash the caller compared against.
        current: String,
    },
    /// Entry has no kconfig hash recorded (pre-tracking cache
    /// format). Treat as unknown; do not assume stale.
    Untracked,
}

impl KconfigStatus {
    /// `true` iff the entry is stale against the current kconfig.
    pub fn is_stale(&self) -> bool {
        matches!(self, Self::Stale { .. })
    }

    /// `true` iff the entry has no recorded kconfig hash.
    pub fn is_untracked(&self) -> bool {
        matches!(self, Self::Untracked)
    }
}

impl fmt::Display for KconfigStatus {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            KconfigStatus::Matches => f.write_str("matches"),
            KconfigStatus::Stale { .. } => f.write_str("stale"),
            KconfigStatus::Untracked => f.write_str("untracked"),
        }
    }
}

/// A cached kernel entry returned by
/// [`crate::cache::CacheDir::lookup`] and
/// [`crate::cache::CacheDir::store`].
#[derive(Debug)]
#[non_exhaustive]
pub struct CacheEntry {
    /// Cache key (directory name).
    pub key: String,
    /// Path to the cache entry directory.
    pub path: PathBuf,
    /// Deserialized metadata. Always present on a [`CacheEntry`].
    pub metadata: KernelMetadata,
}

impl CacheEntry {
    /// Absolute path to the cached boot image.
    pub fn image_path(&self) -> PathBuf {
        self.path.join(&self.metadata.image_name)
    }

    /// Absolute path to the cached stripped vmlinux ELF, when one
    /// was stored alongside the image.
    pub fn vmlinux_path(&self) -> Option<PathBuf> {
        self.metadata.has_vmlinux.then(|| self.path.join("vmlinux"))
    }

    /// Absolute path to the cached btrfs disk template
    /// (`<entry>/disk-template.img`).
    pub fn disk_template_path(&self) -> PathBuf {
        self.path.join("disk-template.img")
    }

    /// Compare this entry's kconfig hash against `current_hash`.
    pub fn kconfig_status(&self, current_hash: &str) -> KconfigStatus {
        match self.metadata.ktstr_kconfig_hash.as_deref() {
            None => KconfigStatus::Untracked,
            Some(h) if h == current_hash => KconfigStatus::Matches,
            Some(h) => KconfigStatus::Stale {
                cached: h.to_string(),
                current: current_hash.to_string(),
            },
        }
    }

    /// Whether this cache entry was built with a user
    /// `--extra-kconfig` fragment.
    pub fn has_extra_kconfig(&self) -> bool {
        self.metadata.extra_kconfig_hash.is_some()
    }
}

/// Entry yielded by [`crate::cache::CacheDir::list`]. Distinguishes
/// valid entries from corrupt ones.
#[derive(Debug)]
#[non_exhaustive]
pub enum ListedEntry {
    /// Valid cache entry with parsed metadata and an image file
    /// present on disk at the metadata-declared path.
    Valid(Box<CacheEntry>),
    /// Entry directory exists but is unusable.
    Corrupt {
        /// Cache key (directory name).
        key: String,
        /// Path to the (corrupt) entry directory.
        path: PathBuf,
        /// Human-readable explanation of why the entry is classified
        /// as corrupt.
        reason: String,
    },
}

impl ListedEntry {
    /// Cache key (directory name) for either variant.
    pub fn key(&self) -> &str {
        match self {
            ListedEntry::Valid(e) => &e.key,
            ListedEntry::Corrupt { key, .. } => key,
        }
    }

    /// Path to the entry directory for either variant.
    pub fn path(&self) -> &Path {
        match self {
            ListedEntry::Valid(e) => &e.path,
            ListedEntry::Corrupt { path, .. } => path,
        }
    }

    /// Borrow the valid [`CacheEntry`] payload, or `None` for
    /// [`ListedEntry::Corrupt`].
    pub fn as_valid(&self) -> Option<&CacheEntry> {
        match self {
            ListedEntry::Valid(e) => Some(e.as_ref()),
            ListedEntry::Corrupt { .. } => None,
        }
    }

    /// Machine-readable classification of a corrupt entry's failure
    /// mode. Returns `None` on a `Valid` entry.
    pub fn error_kind(&self) -> Option<&'static str> {
        match self {
            ListedEntry::Valid(_) => None,
            ListedEntry::Corrupt { reason, .. } => Some(classify_corrupt_reason(reason)),
        }
    }
}

/// Trailing literal of every "image_missing" reason string. Pinned
/// here so [`format_image_missing_reason`] (the producer) and
/// [`classify_corrupt_reason`] (the consumer) reference the same
/// constant — the exact-suffix match in the classifier's
/// `image_missing` arm cannot drift if a future edit changes the
/// trailing wording without updating both sites.
pub(crate) const IMAGE_MISSING_SUFFIX: &str = " missing from entry directory";

/// Leading literal of every "image_missing" reason string. Pinned
/// alongside [`IMAGE_MISSING_SUFFIX`] so both the producer and the
/// classifier key on the same constants.
pub(crate) const IMAGE_MISSING_PREFIX: &str = "image file ";

/// Format the canonical "image_missing" reason string emitted by
/// [`crate::cache::CacheDir::list`] when an entry's
/// `metadata.json` is parseable but the boot image it names is
/// absent from the entry directory.
///
/// Centralised here so the producer site (`cache_dir.rs`'s
/// `list` corrupt-entry arm) and the classifier
/// [`classify_corrupt_reason`] cannot drift out of lockstep —
/// the result begins with [`IMAGE_MISSING_PREFIX`] and ends with
/// [`IMAGE_MISSING_SUFFIX`], so the classifier's exact prefix +
/// exact suffix predicate matches by construction without
/// admitting unrelated reasons that merely happen to contain
/// either literal.
pub(crate) fn format_image_missing_reason(image_name: &str) -> String {
    format!("{IMAGE_MISSING_PREFIX}{image_name}{IMAGE_MISSING_SUFFIX}")
}

/// Shared prefix → `error_kind` classifier.
///
/// Each `ListedEntry::Corrupt` carries a free-form `reason` string
/// produced either by [`super::housekeeping::read_metadata`] (the
/// six `metadata.json …` reasons) or by
/// [`format_image_missing_reason`] at list-time (the `image_missing`
/// reason, emitted from `cache_dir.rs`'s `list` corrupt arm when the
/// image file is absent). This helper
/// flattens those strings into a small, stable enum-of-strings the
/// CLI surfaces in `cargo ktstr kernel list --json` as the
/// `error_kind` field.
///
/// Reason-prefix → kind mapping (matched in this order):
///
/// | Reason (prefix or exact)                     | `error_kind`     |
/// |----------------------------------------------|------------------|
/// | `"metadata.json missing"` (exact)            | `"missing"`      |
/// | `"metadata.json unreadable: …"`              | `"unreadable"`   |
/// | `"metadata.json schema drift: …"`            | `"schema_drift"` |
/// | `"metadata.json malformed: …"`               | `"malformed"`    |
/// | `"metadata.json truncated: …"`               | `"truncated"`    |
/// | `"metadata.json parse error: …"`             | `"parse_error"`  |
/// | `"image file <name> missing from entry directory"` | `"image_missing"`|
/// | (anything else)                              | `"unknown"`      |
///
/// The `image_missing` arm matches on the exact prefix
/// [`IMAGE_MISSING_PREFIX`] AND the exact suffix
/// [`IMAGE_MISSING_SUFFIX`] — both literals are produced verbatim
/// by [`format_image_missing_reason`]. A loose `contains("missing")`
/// would also match unrelated future reasons that happen to mention
/// "missing" inside an `image file …` prefix (e.g. an "image file X
/// missing checksum field" reason), so the dispatcher pins both ends
/// of the canonical form.
///
/// [`super::housekeeping::read_metadata`] is the authoritative
/// source of the six `metadata.json …` prefixes;
/// [`format_image_missing_reason`] is the authoritative source of
/// the `image_missing` prefix. If a new failure mode is
/// added there, both this dispatcher and the
/// `classify_corrupt_reason_covers_every_documented_prefix` test
/// must be updated in lockstep so the JSON contract stays stable.
pub(crate) fn classify_corrupt_reason(reason: &str) -> &'static str {
    if reason == "metadata.json missing" {
        "missing"
    } else if reason.starts_with("metadata.json unreadable: ") {
        "unreadable"
    } else if reason.starts_with("metadata.json schema drift: ") {
        "schema_drift"
    } else if reason.starts_with("metadata.json malformed: ") {
        "malformed"
    } else if reason.starts_with("metadata.json truncated: ") {
        "truncated"
    } else if reason.starts_with("metadata.json parse error: ") {
        // Forward-compat slot: no live producer in the current
        // codebase. `read_metadata` uses `serde_json::from_str`
        // which cannot return `Category::Io` (the arm is pinned
        // `unreachable!` — see housekeeping.rs). The classifier
        // arm is retained so a future producer that switches to
        // `from_reader` (or any path that genuinely surfaces an
        // I/O error during JSON decode) can emit the canonical
        // prefix and have it route to the same stable
        // `error_kind` value scripted consumers already dispatch
        // on. Removing the arm now would force such a producer
        // to either invent a new prefix (breaking the contract)
        // or land in `unknown` (losing dispatch fidelity).
        "parse_error"
    } else if reason.starts_with(IMAGE_MISSING_PREFIX)
        && reason.ends_with(IMAGE_MISSING_SUFFIX)
        && reason.len() > IMAGE_MISSING_PREFIX.len() + IMAGE_MISSING_SUFFIX.len()
    {
        "image_missing"
    } else {
        "unknown"
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cache::shared_test_helpers::{create_fake_image, test_metadata};
    use crate::cache::{CacheArtifacts, CacheDir};
    use std::fs;
    use std::path::PathBuf;
    use tempfile::TempDir;

    // -- KernelMetadata serde --

    #[test]
    fn cache_metadata_serde_roundtrip() {
        let meta = test_metadata("6.14.2");
        let json = serde_json::to_string_pretty(&meta).unwrap();
        let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.version.as_deref(), Some("6.14.2"));
        assert_eq!(parsed.source, KernelSource::Tarball);
        assert_eq!(parsed.arch, "x86_64");
        assert_eq!(parsed.image_name, "bzImage");
        assert_eq!(parsed.config_hash.as_deref(), Some("abc123"));
        assert_eq!(parsed.built_at, "2026-04-12T10:00:00Z");
        assert_eq!(parsed.ktstr_kconfig_hash.as_deref(), Some("def456"));
        assert!(!parsed.has_vmlinux);
        assert!(!parsed.vmlinux_stripped);
    }

    #[test]
    fn cache_metadata_serde_git_with_payload() {
        let meta = KernelMetadata {
            version: Some("6.15-rc3".to_string()),
            source: KernelSource::git("a1b2c3d", "v6.15-rc3"),
            arch: "aarch64".to_string(),
            image_name: "Image".to_string(),
            config_hash: None,
            built_at: "2026-04-12T12:00:00Z".to_string(),
            ktstr_kconfig_hash: None,
            extra_kconfig_hash: None,
            has_vmlinux: false,
            vmlinux_stripped: false,
            source_vmlinux_size: None,
            source_vmlinux_mtime_secs: None,
        };
        let json = serde_json::to_string(&meta).unwrap();
        let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
        assert!(matches!(
            parsed.source,
            KernelSource::Git {
                git_hash: Some(ref h),
                git_ref: Some(ref r),
            }
            if h == "a1b2c3d" && r == "v6.15-rc3"
        ));
    }

    /// Every `Option<…>` field on the `KernelMetadata` wrapper struct
    /// (not on its `KernelSource` payload — that is covered by
    /// `kernel_source_*` tests) serializes as an explicit `null` when
    /// `None` and deserializes back as `None`. Pins the post-shim
    /// wire shape: with `serde(default)` and `skip_serializing_if`
    /// removed, `None` round-trips through a literal `null` token in
    /// the JSON, never an absent key.
    ///
    /// Built via `KernelMetadata::new` with `KernelSource::Tarball` —
    /// the constructor sets every `Option` field to `None`, so the
    /// `is_none()` assertions confirm both that the constructor is
    /// the canonical path to an all-`None` value and that the wire
    /// shape preserves it across a round-trip.
    #[test]
    fn kernel_metadata_option_fields_serialize_as_explicit_null() {
        let meta = KernelMetadata::new(
            KernelSource::Tarball,
            "x86_64",
            "bzImage",
            "2026-04-12T10:00:00Z",
        );
        let json = serde_json::to_string(&meta).unwrap();
        for null_key in [
            r#""version":null"#,
            r#""config_hash":null"#,
            r#""ktstr_kconfig_hash":null"#,
            r#""extra_kconfig_hash":null"#,
            r#""source_vmlinux_size":null"#,
            r#""source_vmlinux_mtime_secs":null"#,
        ] {
            assert!(
                json.contains(null_key),
                "serialized JSON must contain explicit `{null_key}` \
                 — None must round-trip as null, not as an absent \
                 key. Got: {json}",
            );
        }
        // Round-trip back: every Option must still be None.
        let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
        assert!(parsed.version.is_none(), "version must round-trip None");
        assert!(
            parsed.config_hash.is_none(),
            "config_hash must round-trip None"
        );
        assert!(
            parsed.ktstr_kconfig_hash.is_none(),
            "ktstr_kconfig_hash must round-trip None"
        );
        assert!(
            parsed.extra_kconfig_hash.is_none(),
            "extra_kconfig_hash must round-trip None"
        );
        assert!(
            parsed.source_vmlinux_size.is_none(),
            "source_vmlinux_size must round-trip None"
        );
        assert!(
            parsed.source_vmlinux_mtime_secs.is_none(),
            "source_vmlinux_mtime_secs must round-trip None"
        );
    }

    /// Every `Option<…>` wrapper field with a Some(...) value
    /// round-trips byte-equal through serialize → deserialize.
    /// Pins the populated branch of every Option, complementing the
    /// all-None test.
    ///
    /// Built via `KernelMetadata::new` followed by every chainable
    /// setter for the six `Option` fields (`with_version`,
    /// `with_config_hash`, `with_ktstr_kconfig_hash`,
    /// `with_extra_kconfig_hash`, `with_source_vmlinux_stat` covers
    /// both `source_vmlinux_size` and `source_vmlinux_mtime_secs`),
    /// so this test also pins that the builder API can produce a
    /// fully-populated value without touching the struct directly.
    #[test]
    fn kernel_metadata_all_option_fields_populated_roundtrip() {
        let meta = KernelMetadata::new(
            KernelSource::Tarball,
            "x86_64",
            "bzImage",
            "2026-04-12T10:00:00Z",
        )
        .with_version("6.14.2")
        .with_config_hash("cfg-hash")
        .with_ktstr_kconfig_hash("ktstr-hash")
        .with_extra_kconfig_hash("extra-hash")
        .with_source_vmlinux_stat(123_456_789, 1_700_000_000);
        let json = serde_json::to_string(&meta).unwrap();
        let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.version.as_deref(), Some("6.14.2"));
        assert_eq!(parsed.config_hash.as_deref(), Some("cfg-hash"));
        assert_eq!(parsed.ktstr_kconfig_hash.as_deref(), Some("ktstr-hash"));
        assert_eq!(parsed.extra_kconfig_hash.as_deref(), Some("extra-hash"));
        assert_eq!(parsed.source_vmlinux_size, Some(123_456_789));
        assert_eq!(parsed.source_vmlinux_mtime_secs, Some(1_700_000_000));
    }

    /// Pins the with_X → clear_X round-trip for every Option-bearing
    /// setter group. Each pair must be symmetric: with_X populates,
    /// clear_X resets to None. The pair-stat method
    /// (`with_source_vmlinux_stat` / `clear_source_vmlinux_stat`)
    /// covers two fields together — both must be Some after with_X
    /// and both must be None after clear_X.
    #[test]
    fn kernel_metadata_clear_setters_round_trip() {
        let populated = KernelMetadata::new(
            KernelSource::Tarball,
            "x86_64",
            "bzImage",
            "2026-04-12T10:00:00Z",
        )
        .with_version("6.14.2")
        .with_config_hash("cfg-hash")
        .with_ktstr_kconfig_hash("ktstr-hash")
        .with_extra_kconfig_hash("extra-hash")
        .with_source_vmlinux_stat(123_456_789, 1_700_000_000);
        assert!(populated.version.is_some());
        assert!(populated.config_hash.is_some());
        assert!(populated.ktstr_kconfig_hash.is_some());
        assert!(populated.extra_kconfig_hash.is_some());
        assert!(populated.source_vmlinux_size.is_some());
        assert!(populated.source_vmlinux_mtime_secs.is_some());

        let cleared = populated
            .clear_version()
            .clear_config_hash()
            .clear_ktstr_kconfig_hash()
            .clear_extra_kconfig_hash()
            .clear_source_vmlinux_stat();
        assert!(cleared.version.is_none());
        assert!(cleared.config_hash.is_none());
        assert!(cleared.ktstr_kconfig_hash.is_none());
        assert!(cleared.extra_kconfig_hash.is_none());
        assert!(cleared.source_vmlinux_size.is_none());
        assert!(cleared.source_vmlinux_mtime_secs.is_none());
    }

    #[test]
    fn cache_metadata_serde_local_with_source_tree() {
        let meta = KernelMetadata {
            version: Some("6.14.0".to_string()),
            source: KernelSource::Local {
                source_tree_path: Some(PathBuf::from("/tmp/linux")),
                git_hash: Some("deadbee".to_string()),
            },
            arch: "x86_64".to_string(),
            image_name: "bzImage".to_string(),
            config_hash: Some("fff000".to_string()),
            built_at: "2026-04-12T14:00:00Z".to_string(),
            ktstr_kconfig_hash: Some("aaa111".to_string()),
            extra_kconfig_hash: None,
            has_vmlinux: true,
            vmlinux_stripped: true,
            source_vmlinux_size: None,
            source_vmlinux_mtime_secs: None,
        };
        let json = serde_json::to_string(&meta).unwrap();
        let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
        assert!(matches!(
            parsed.source,
            KernelSource::Local {
                source_tree_path: Some(ref p),
                git_hash: Some(ref h),
            }
            if p == &PathBuf::from("/tmp/linux") && h == "deadbee"
        ));
        assert!(parsed.has_vmlinux);
        assert!(parsed.vmlinux_stripped);
    }

    /// git_hash on KernelSource::Local is a plain Option<String> with
    /// no serde attributes — the compat shims (serde(default) +
    /// skip_serializing_if) were removed for pre-1.0, so `None`
    /// serializes as an explicit `null` key and deserialization
    /// accepts `null` back as `None`. This test pins only the
    /// None → null → None round trip; the absent-key branch is
    /// exercised separately by
    /// [`kernel_source_absent_option_keys_deserialize_as_none`].
    #[test]
    fn kernel_source_local_git_hash_serde_round_trip_none() {
        let src = KernelSource::Local {
            source_tree_path: Some(PathBuf::from("/tmp/linux")),
            git_hash: None,
        };
        let json = serde_json::to_string(&src).unwrap();
        assert!(
            json.contains(r#""git_hash":null"#),
            "git_hash=None must round-trip as explicit null, got {json}"
        );
        let parsed: KernelSource = serde_json::from_str(&json).unwrap();
        assert!(matches!(parsed, KernelSource::Local { git_hash: None, .. }));
    }

    /// Pins the post-shim wire format: `Option` payload fields inside
    /// every [`KernelSource`] variant serialize as explicit `null`
    /// rather than being omitted.
    #[test]
    fn kernel_source_option_fields_serialize_as_explicit_null() {
        let local = KernelSource::Local {
            source_tree_path: None,
            git_hash: None,
        };
        let local_json = serde_json::to_string(&local).unwrap();
        assert!(
            local_json.contains(r#""source_tree_path":null"#),
            "Local.source_tree_path=None must serialize as explicit null, got {local_json}"
        );
        assert!(
            local_json.contains(r#""git_hash":null"#),
            "Local.git_hash=None must serialize as explicit null, got {local_json}"
        );

        let git = KernelSource::Git {
            git_hash: None,
            git_ref: None,
        };
        let git_json = serde_json::to_string(&git).unwrap();
        assert!(
            git_json.contains(r#""git_hash":null"#),
            "Git.git_hash=None must serialize as explicit null, got {git_json}"
        );
        assert!(
            git_json.contains(r#""ref":null"#),
            "Git.git_ref=None must serialize as explicit null under the `ref` key, got {git_json}"
        );
    }

    /// Older `metadata.json` files written before `Option` fields
    /// were emitted as explicit `null` simply omit the keys.
    #[test]
    fn kernel_source_absent_option_keys_deserialize_as_none() {
        let git_bare: KernelSource = serde_json::from_str(r#"{"type":"git"}"#)
            .expect("Git with absent Option keys must deserialize");
        assert!(matches!(
            git_bare,
            KernelSource::Git {
                git_hash: None,
                git_ref: None,
            }
        ));

        let git_hash_only: KernelSource =
            serde_json::from_str(r#"{"type":"git","git_hash":"abc"}"#)
                .expect("Git with only git_hash must deserialize");
        assert!(matches!(
            git_hash_only,
            KernelSource::Git {
                git_hash: Some(ref h),
                git_ref: None,
            } if h == "abc"
        ));

        let git_ref_only: KernelSource = serde_json::from_str(r#"{"type":"git","ref":"main"}"#)
            .expect("Git with only ref must deserialize");
        assert!(matches!(
            git_ref_only,
            KernelSource::Git {
                git_hash: None,
                git_ref: Some(ref r),
            } if r == "main"
        ));

        let local_bare: KernelSource = serde_json::from_str(r#"{"type":"local"}"#)
            .expect("Local with absent Option keys must deserialize");
        assert!(matches!(
            local_bare,
            KernelSource::Local {
                source_tree_path: None,
                git_hash: None,
            }
        ));

        let local_path_only: KernelSource =
            serde_json::from_str(r#"{"type":"local","source_tree_path":"/tmp/linux"}"#)
                .expect("Local with only source_tree_path must deserialize");
        assert!(matches!(
            local_path_only,
            KernelSource::Local {
                source_tree_path: Some(ref p),
                git_hash: None,
            } if p.to_str() == Some("/tmp/linux")
        ));

        let local_hash_only: KernelSource =
            serde_json::from_str(r#"{"type":"local","git_hash":"deadbeef"}"#)
                .expect("Local with only git_hash must deserialize");
        assert!(matches!(
            local_hash_only,
            KernelSource::Local {
                source_tree_path: None,
                git_hash: Some(ref h),
            } if h == "deadbeef"
        ));
    }

    #[test]
    fn kernel_source_serde_tagged_representation() {
        let t = serde_json::to_string(&KernelSource::Tarball).unwrap();
        assert_eq!(t, r#"{"type":"tarball"}"#);
        let g = serde_json::to_string(&KernelSource::git("abc", "main")).unwrap();
        assert!(g.contains(r#""type":"git""#));
        assert!(g.contains(r#""git_hash":"abc""#));
        assert!(g.contains(r#""ref":"main""#));
        let l = serde_json::to_string(&KernelSource::Local {
            source_tree_path: Some(PathBuf::from("/tmp/linux")),
            git_hash: Some("a1b2c3d".to_string()),
        })
        .unwrap();
        assert!(l.contains(r#""type":"local""#));
        assert!(l.contains(r#""source_tree_path":"/tmp/linux""#));
        assert!(l.contains(r#""git_hash":"a1b2c3d""#));
    }

    /// Table-drive every prefix → `error_kind` classifier mapping.
    #[test]
    fn classify_corrupt_reason_covers_every_documented_prefix() {
        let cases: &[(&str, &str)] = &[
            ("metadata.json missing", "missing"),
            (
                "metadata.json unreadable: Is a directory (os error 21)",
                "unreadable",
            ),
            (
                "metadata.json schema drift: missing field `source` at line 1 column 21",
                "schema_drift",
            ),
            (
                "metadata.json malformed: expected value at line 1 column 1",
                "malformed",
            ),
            (
                "metadata.json truncated: EOF while parsing a value at line 1 column 10",
                "truncated",
            ),
            (
                "metadata.json parse error: something unexpected",
                "parse_error",
            ),
            (
                "image file bzImage missing from entry directory",
                "image_missing",
            ),
            ("some future prefix nobody wrote yet", "unknown"),
        ];
        for (reason, expected) in cases {
            assert_eq!(
                classify_corrupt_reason(reason),
                *expected,
                "reason `{reason}` should classify as `{expected}`",
            );
        }
    }

    /// The `image_missing` arm requires BOTH the canonical prefix
    /// AND the canonical trailing literal — strings that share only
    /// one half (or that would have matched the legacy loose
    /// `starts_with("image file ") && contains("missing")` predicate)
    /// must drop into `unknown`. Locks the tightening described on
    /// [`classify_corrupt_reason`].
    #[test]
    fn classify_corrupt_reason_image_missing_requires_exact_suffix() {
        // Loose predicate would match (prefix + the substring
        // "missing" in the wrong position). The tightened predicate
        // rejects it because the suffix isn't the canonical
        // ` missing from entry directory`.
        assert_eq!(
            classify_corrupt_reason("image file bzImage missing checksum field"),
            "unknown",
            "non-canonical 'image file … missing X' reason must NOT \
             classify as `image_missing` — only the exact \
             format_image_missing_reason() form is accepted",
        );
        // Suffix matches but prefix doesn't — must not classify.
        assert_eq!(
            classify_corrupt_reason("foo bzImage missing from entry directory"),
            "unknown",
            "suffix-only match without the canonical prefix must classify as unknown",
        );
        // Empty image name produces a degenerate prefix+suffix abut —
        // the length guard rejects it so a future bug that emits
        // `format_image_missing_reason("")` cannot silently classify.
        assert_eq!(
            classify_corrupt_reason("image file  missing from entry directory"),
            "unknown",
            "prefix+suffix with no image_name in between must classify as unknown",
        );
    }

    /// Producer→consumer round trip: every reason produced by
    /// [`format_image_missing_reason`] must classify as
    /// `image_missing`, regardless of the image_name value
    /// (alphanumerics, dots, dashes, multi-word names).
    #[test]
    fn classify_corrupt_reason_accepts_every_format_image_missing_output() {
        for image_name in [
            "bzImage",
            "Image",
            "vmlinuz-6.14.2",
            "kernel.bin",
            "image_with_underscores",
            "name-with-many-dashes",
        ] {
            let reason = format_image_missing_reason(image_name);
            assert_eq!(
                classify_corrupt_reason(&reason),
                "image_missing",
                "every produced reason (image_name={image_name:?}) must \
                 classify as image_missing — got reason {reason:?}",
            );
        }
    }

    /// `ListedEntry::error_kind()` returns `None` on a Valid entry
    /// and the classifier result on a Corrupt entry.
    #[test]
    fn listed_entry_error_kind_dispatches_on_variant() {
        let tmp = TempDir::new().unwrap();
        let cache = CacheDir::with_root(tmp.path().join("cache"));
        let src_dir = TempDir::new().unwrap();
        let image = create_fake_image(src_dir.path());
        let meta = test_metadata("6.14.2");
        cache
            .store("valid-ek", &CacheArtifacts::new(&image), &meta)
            .unwrap();

        let bad_dir = tmp.path().join("cache").join("corrupt-ek");
        fs::create_dir_all(&bad_dir).unwrap();

        let entries = cache.list().unwrap();
        assert_eq!(entries.len(), 2);
        let valid = entries
            .iter()
            .find(|e| e.key() == "valid-ek")
            .expect("valid entry must be listed");
        let corrupt = entries
            .iter()
            .find(|e| e.key() == "corrupt-ek")
            .expect("corrupt entry must be listed");
        assert_eq!(
            valid.error_kind(),
            None,
            "Valid entries must report no error_kind",
        );
        assert_eq!(
            corrupt.error_kind(),
            Some("missing"),
            "missing-metadata Corrupt entry must classify as `missing`",
        );
    }

    // -- KconfigStatus Display impl --
    //
    // Pins the three Display strings that flow through `kernel list
    // --json` as the `kconfig_status` field. CI scripts consume these
    // exact strings, so any rewording is a downstream-visible
    // contract change.

    #[test]
    fn kconfig_status_display_matches_renders_lowercase_word() {
        assert_eq!(KconfigStatus::Matches.to_string(), "matches");
    }

    #[test]
    fn kconfig_status_display_stale_renders_lowercase_word_without_hashes() {
        let s = KconfigStatus::Stale {
            cached: "deadbeef".to_string(),
            current: "cafebabe".to_string(),
        }
        .to_string();
        assert_eq!(
            s, "stale",
            "Display elides the cached/current hashes; callers that need them must match on the variant directly"
        );
    }

    #[test]
    fn kconfig_status_display_untracked_renders_lowercase_word() {
        assert_eq!(KconfigStatus::Untracked.to_string(), "untracked");
    }

    // -- KconfigStatus predicates --
    //
    // `is_stale()` and `is_untracked()` collapse the variant pattern
    // match into a bool, which the kernel-build pipeline keys on for
    // its rebuild decision. The predicates are independent of the
    // Display impl tested above — the pipeline never round-trips
    // through Display, it dispatches on the bool. A regression that
    // flipped either predicate's polarity (e.g. `Stale` returning
    // false from `is_stale`) would invisibly change the rebuild
    // policy.

    /// `Matches` is neither stale nor untracked.
    #[test]
    fn kconfig_status_matches_is_neither_stale_nor_untracked() {
        let s = KconfigStatus::Matches;
        assert!(!s.is_stale(), "Matches must not be stale");
        assert!(!s.is_untracked(), "Matches must not be untracked");
    }

    /// `Stale` is stale and not untracked — the two predicates are
    /// mutually exclusive on this variant.
    #[test]
    fn kconfig_status_stale_is_stale_only() {
        let s = KconfigStatus::Stale {
            cached: "old".to_string(),
            current: "new".to_string(),
        };
        assert!(s.is_stale(), "Stale variant must report is_stale=true");
        assert!(
            !s.is_untracked(),
            "Stale must NOT report is_untracked — the two predicates \
             discriminate distinct variants",
        );
    }

    /// `Untracked` is untracked and not stale — pre-tracking-format
    /// entries must NOT trigger a rebuild via the stale-check.
    #[test]
    fn kconfig_status_untracked_is_untracked_only() {
        let s = KconfigStatus::Untracked;
        assert!(
            s.is_untracked(),
            "Untracked variant must report is_untracked=true"
        );
        assert!(
            !s.is_stale(),
            "Untracked must NOT report is_stale — pre-tracking-format \
             entries are unknown, not stale; treating them as stale \
             would force a rebuild on every old cache hit",
        );
    }

    // -- KernelSource::as_local_git_hash --
    //
    // The accessor exists for kernel_build_pipeline's post-build
    // dirty re-check (see cli/kernel_build/build.rs
    // compute_mid_wait_state / post_build_dirty_skip, where
    // as_local_git_hash is called): it
    // compares the post-build HEAD hash against the acquire-time
    // hash to detect mid-build commits. The accessor MUST return
    // the inner `git_hash` for `Local` variant only and `None` for
    // every other variant — a regression that returned `git_hash`
    // for `Git` (which has its own `git_hash` field at a different
    // role) would silently corrupt the dirty-check.

    /// `Local` with `git_hash: Some(...)` returns the borrowed hash.
    /// Borrows rather than clones — a regression that owned the
    /// returned string would force the caller to allocate.
    #[test]
    fn as_local_git_hash_returns_local_hash() {
        let src = KernelSource::Local {
            source_tree_path: Some(PathBuf::from("/tmp/linux")),
            git_hash: Some("deadbee".to_string()),
        };
        assert_eq!(
            src.as_local_git_hash(),
            Some("deadbee"),
            "Local with git_hash=Some must surface the inner str",
        );
    }

    /// `Local` with `git_hash: None` (dirty / non-git tree at acquire
    /// time) returns `None`. The caller distinguishes "we have a
    /// hash to compare" from "we have no anchor" — collapsing both
    /// into `Some("")` would mislead the dirty-check.
    #[test]
    fn as_local_git_hash_returns_none_when_local_has_no_hash() {
        let src = KernelSource::Local {
            source_tree_path: Some(PathBuf::from("/tmp/linux")),
            git_hash: None,
        };
        assert_eq!(
            src.as_local_git_hash(),
            None,
            "Local with git_hash=None must surface None — the dirty \
             check has no anchor on a non-git or dirty tree",
        );
    }

    /// `Tarball` returns `None` — tarball builds have no git anchor
    /// and the accessor must NOT borrow from the wrong variant's
    /// payload (Tarball has no payload at all, so a regression
    /// reaching for one would produce a compile error rather than a
    /// silent bug — but pin the contract anyway).
    #[test]
    fn as_local_git_hash_returns_none_for_tarball() {
        let src = KernelSource::Tarball;
        assert_eq!(
            src.as_local_git_hash(),
            None,
            "Tarball variant has no git_hash and must surface None",
        );
    }

    /// `Git` variant returns `None` even though it has its own
    /// `git_hash` field — the accessor is `as_local_git_hash`, not
    /// `as_git_hash`. Pins that the accessor does NOT cross-wire
    /// the Git variant's `git_hash` (which describes the cloned
    /// commit, a different role from the Local variant's
    /// `git_hash` which describes the acquire-time HEAD).
    #[test]
    fn as_local_git_hash_returns_none_for_git_even_with_hash_field() {
        let src = KernelSource::git("a1b2c3d", "main");
        assert_eq!(
            src.as_local_git_hash(),
            None,
            "Git variant has its own git_hash field but the \
             accessor is named as_LOCAL_git_hash — it MUST NOT \
             surface the Git variant's hash, since the Git hash \
             describes the cloned commit (acquire-time) and the \
             Local hash describes the operator's working tree HEAD \
             (a different role with different semantics)",
        );
    }

    // -- format_image_missing_reason --
    //
    // The producer pairs with `classify_corrupt_reason`'s
    // `image_missing` arm via the [`IMAGE_MISSING_PREFIX`] /
    // [`IMAGE_MISSING_SUFFIX`] constants. Direct producer coverage
    // pins the canonical literal so a future edit to the format!
    // string can't drift the producer side without breaking the
    // round-trip test in `classify_corrupt_reason_accepts_every_format_image_missing_output`.

    /// Producer composes prefix + image_name + suffix verbatim.
    #[test]
    fn format_image_missing_reason_uses_canonical_prefix_and_suffix() {
        let reason = format_image_missing_reason("bzImage");
        assert_eq!(
            reason, "image file bzImage missing from entry directory",
            "the produced reason MUST be the exact prefix + image_name + \
             suffix concatenation — any drift breaks the classifier's \
             exact-match predicate",
        );
        assert!(
            reason.starts_with(IMAGE_MISSING_PREFIX),
            "produced reason must start with IMAGE_MISSING_PREFIX",
        );
        assert!(
            reason.ends_with(IMAGE_MISSING_SUFFIX),
            "produced reason must end with IMAGE_MISSING_SUFFIX",
        );
    }

    /// Empty image_name produces a degenerate prefix+suffix abut
    /// (the prefix's trailing space and the suffix's leading space
    /// become adjacent, giving `"file  missing"` with two consecutive
    /// spaces). The classifier's length-guard rejects the result.
    /// The producer itself does NOT validate (image_name is supposed
    /// to come from already-validated metadata.image_name), so the
    /// empty case still produces the verbatim concatenation — but the
    /// classifier MUST reject the result. Pins the contract that
    /// degenerate producer output is consumer-rejected, not silently
    /// classified as image_missing.
    #[test]
    fn format_image_missing_reason_with_empty_image_name() {
        let reason = format_image_missing_reason("");
        assert_eq!(
            reason, "image file  missing from entry directory",
            "empty image_name must produce a verbatim concatenation \
             (prefix trailing-space + suffix leading-space → two \
             consecutive spaces between `file` and `missing`); the \
             producer does not validate (validation is at \
             validate_filename time), so the degenerate concatenation \
             is the documented behaviour",
        );
        // The classifier MUST reject this degenerate form via its
        // length-guard arm so the empty case classifies as `unknown`
        // rather than `image_missing`.
        assert_eq!(
            classify_corrupt_reason(&reason),
            "unknown",
            "the classifier's length-guard MUST reject the \
             prefix+suffix-with-empty-image-name form — a regression \
             that loosened the guard would let a future bug emit \
             format_image_missing_reason(\"\") and silently classify \
             it as image_missing, hiding the real defect (empty \
             image_name in metadata)",
        );
    }

    // -- CacheEntry::disk_template_path --
    //
    // The accessor names the canonical filename for the per-entry
    // btrfs disk template (`<entry>/disk-template.img`) that
    // `vmm/disk_template.rs` writes and reads. The contract is
    // path-only: no I/O, no validation. Pin the literal so a
    // future rename (`disk-template.img` → `disk.img`) is caught
    // by the test rather than only at runtime when the VMM tries
    // to read a path that no longer exists.
    #[test]
    fn cache_entry_disk_template_path_joins_canonical_filename() {
        let tmp = TempDir::new().unwrap();
        let cache = CacheDir::with_root(tmp.path().join("cache"));
        let src_dir = TempDir::new().unwrap();
        let image = create_fake_image(src_dir.path());
        let entry = cache
            .store(
                "disk-tmpl-key",
                &CacheArtifacts::new(&image),
                &test_metadata("6.14.2"),
            )
            .unwrap();
        assert_eq!(
            entry.disk_template_path(),
            entry.path.join("disk-template.img"),
            "disk_template_path() MUST resolve to <entry>/disk-template.img — \
             the literal `disk-template.img` is the canonical filename the \
             VMM disk_template module writes/reads",
        );
        // The accessor is path-only; the file does NOT have to
        // exist for the path to be returned. Pin that the file
        // is absent immediately after store() (since store() does
        // not create the disk template — that's a separate
        // pipeline step in vmm/disk_template.rs).
        assert!(
            !entry.disk_template_path().exists(),
            "disk_template_path() must be path-only — store() does not \
             create the file; absence is the expected post-store state",
        );
    }

    // -- CacheEntry::has_extra_kconfig --
    //
    // Predicate that wraps `metadata.extra_kconfig_hash.is_some()`.
    // Pin both branches so a regression that flipped the polarity
    // (returning `is_none()`) would surface here rather than only
    // in downstream consumers that select rebuild policies on the
    // bool.

    /// `extra_kconfig_hash: Some(...)` → predicate returns true.
    #[test]
    fn cache_entry_has_extra_kconfig_true_when_hash_present() {
        let tmp = TempDir::new().unwrap();
        let cache = CacheDir::with_root(tmp.path().join("cache"));
        let src_dir = TempDir::new().unwrap();
        let image = create_fake_image(src_dir.path());
        let meta = test_metadata("6.14.2").with_extra_kconfig_hash("user-fragment-hash");
        let entry = cache
            .store("with-extra", &CacheArtifacts::new(&image), &meta)
            .unwrap();
        assert!(
            entry.has_extra_kconfig(),
            "extra_kconfig_hash=Some MUST report has_extra_kconfig()=true — \
             a regression that flipped polarity would invert the \
             rebuild-on-fragment-change policy",
        );
    }

    /// `extra_kconfig_hash: None` → predicate returns false.
    #[test]
    fn cache_entry_has_extra_kconfig_false_when_hash_absent() {
        let tmp = TempDir::new().unwrap();
        let cache = CacheDir::with_root(tmp.path().join("cache"));
        let src_dir = TempDir::new().unwrap();
        let image = create_fake_image(src_dir.path());
        // test_metadata sets extra_kconfig_hash=None by default.
        let meta = test_metadata("6.14.2");
        assert!(
            meta.extra_kconfig_hash.is_none(),
            "test_metadata default must keep extra_kconfig_hash=None \
             so this test exercises the false branch",
        );
        let entry = cache
            .store("no-extra", &CacheArtifacts::new(&image), &meta)
            .unwrap();
        assert!(
            !entry.has_extra_kconfig(),
            "extra_kconfig_hash=None MUST report has_extra_kconfig()=false",
        );
    }

    // -- ListedEntry::path accessor --
    //
    // The accessor abstracts over the variant — Valid carries the
    // path inside its boxed CacheEntry, Corrupt carries it as a
    // direct field. Existing list() tests reach into the variant
    // and read the field, so the accessor itself is uncovered.
    // Pin both branches.

    /// `Valid` variant: path() returns the inner CacheEntry's path.
    #[test]
    fn listed_entry_path_accessor_returns_valid_entry_path() {
        let tmp = TempDir::new().unwrap();
        let cache = CacheDir::with_root(tmp.path().join("cache"));
        let src_dir = TempDir::new().unwrap();
        let image = create_fake_image(src_dir.path());
        let entry = cache
            .store(
                "valid-path-test",
                &CacheArtifacts::new(&image),
                &test_metadata("6.14.2"),
            )
            .unwrap();
        let expected_path = entry.path.clone();
        let entries = cache.list().unwrap();
        let listed = entries
            .iter()
            .find(|e| e.key() == "valid-path-test")
            .expect("the just-stored entry must be in the list");
        assert!(
            matches!(listed, ListedEntry::Valid(_)),
            "precondition: stored entry must surface as Valid",
        );
        assert_eq!(
            listed.path(),
            expected_path,
            "ListedEntry::path() on Valid must return the inner \
             CacheEntry's path — accessor MUST forward, not synthesize",
        );
    }

    /// `Corrupt` variant: path() returns the per-variant path field.
    #[test]
    fn listed_entry_path_accessor_returns_corrupt_entry_path() {
        let tmp = TempDir::new().unwrap();
        let cache = CacheDir::with_root(tmp.path().join("cache"));
        // Create a corrupt-shaped entry (directory exists but no
        // metadata.json), so list() classifies it as Corrupt.
        let entry_dir = tmp.path().join("cache").join("corrupt-path-test");
        std::fs::create_dir_all(&entry_dir).unwrap();
        let entries = cache.list().unwrap();
        let listed = entries
            .iter()
            .find(|e| e.key() == "corrupt-path-test")
            .expect("corrupt-shaped entry must be in the list");
        assert!(
            matches!(listed, ListedEntry::Corrupt { .. }),
            "precondition: missing-metadata entry must surface as Corrupt",
        );
        assert_eq!(
            listed.path(),
            entry_dir,
            "ListedEntry::path() on Corrupt must return the variant's \
             `path` field — accessor MUST forward, not synthesize",
        );
    }
}
