znippy-plugin-rust-toolchain 0.1.1

Airgapped Rust-toolchain bundle metadata plugin for znippy (native builtin — no WASM)
Documentation
//! Bundle-member classification for an airgapped Rust-developer bundle.
//!
//! A `rust-dev-rhel8-<ver>.znippy` is a fixed directory layout — the canonical
//! copy is [`crate::BUNDLE_LAYOUT`], which the `layout` subcommand prints and
//! which this classifier mirrors. Every entry belongs to
//! exactly one [`MemberKind`], decided **purely from its relative path** — no file
//! read, no parsing. This mirrors the sibling `znippy-plugin-skidbladnir`
//! classifier: one pure, exhaustively-tested fn is the whole ownership rule the
//! plugin's `matches_path` + `extract_metadata` lean on.
//!
//! Canonical layout:
//! ```text
//! rust-dev-rhel8-1.97.1.znippy
//! ├── manifest/
//! │   ├── bundle.json            # rustc/cargo version, host triple, provenance
//! │   ├── toolchain.pins.json    # per-payload sha256 + blake3 (pinned like the kernel)
//! │   └── vendor.lock            # a copy of the source Cargo.lock (the crate set)
//! ├── toolchain/                 # the standalone (rustup-dist) toolchain, UNPACKED
//! │   ├── bin/                   # rustc, cargo, rustdoc, rust-lld, …
//! │   ├── lib/                   # librustc driver .so's, std .rlibs
//! │   └── lib/rustlib/<triple>/  # the rust-std for the target
//! ├── vendor/<crate>-<ver>/…     # `cargo vendor` output — the "basic crates" set
//! └── cargo-config/config.toml   # source-replacement → vendor/, offline hard-on
//! ```

/// One payload member's role inside the bundle. The discriminant strings are the
/// values written into the `member_kind` Arrow column (queryable via DuckDB).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemberKind {
    /// `manifest/bundle.json` — rustc/cargo version, host triple, source-URL provenance.
    Manifest,
    /// `manifest/toolchain.pins.json` — per-payload sha256 + blake3 (the "pinned like
    /// the kernel" table). Kept distinct from a plain manifest so a `znippy meta` query
    /// can find the integrity pins directly.
    ToolchainPins,
    /// `manifest/vendor.lock` — the exact crate set (a copy of the source `Cargo.lock`).
    VendorLock,
    /// `toolchain/bin/*` — the executables (`rustc`, `cargo`, `rustdoc`, `rust-lld`, …).
    ToolchainBin,
    /// `toolchain/**/rustlib/<triple>/*` — the `rust-std` for the build/run target.
    RustStd,
    /// `toolchain/lib/*` and any other toolchain support file (driver `.so`s, `.rlib`s).
    ToolchainLib,
    /// `vendor/<crate>-<ver>/*` — a vendored crate's expanded source.
    VendorCrate,
    /// `cargo-config/*` — the `config.toml` dropped at install (`[source]` replacement).
    CargoConfig,
}

impl MemberKind {
    /// The stable string written to the `member_kind` column.
    pub fn as_str(self) -> &'static str {
        match self {
            MemberKind::Manifest => "manifest",
            MemberKind::ToolchainPins => "toolchain-pins",
            MemberKind::VendorLock => "vendor-lock",
            MemberKind::ToolchainBin => "toolchain-bin",
            MemberKind::RustStd => "rust-std",
            MemberKind::ToolchainLib => "toolchain-lib",
            MemberKind::VendorCrate => "vendor-crate",
            MemberKind::CargoConfig => "cargo-config",
        }
    }
}

/// Normalise a relative path to `/`-separated components, dropping a leading
/// `./` and any empty segments. Windows `\` is tolerated.
fn segments(path: &str) -> Vec<&str> {
    path.split(['/', '\\']).filter(|s| !s.is_empty() && *s != ".").collect()
}

/// Classify a bundle entry by its relative path. `None` = not a recognised
/// rust-dev bundle member (the plugin then does not claim it). Pure and total.
pub fn classify(path: &str) -> Option<MemberKind> {
    let segs = segments(path);
    let top = *segs.first()?;

    match top {
        "manifest" => {
            let name = *segs.last().unwrap_or(&top);
            if name == "toolchain.pins.json" {
                Some(MemberKind::ToolchainPins)
            } else if name == "vendor.lock" {
                Some(MemberKind::VendorLock)
            } else {
                Some(MemberKind::Manifest)
            }
        }
        "toolchain" => {
            if segs.get(1).copied() == Some("bin") {
                Some(MemberKind::ToolchainBin)
            } else if let Some(i) = segs.iter().position(|s| *s == "rustlib") {
                // `.../rustlib/<triple>/<content>` is the per-target std — there must
                // be a segment AFTER the triple dir. The bare `rustlib/` root
                // (`components`, version manifests) stays a generic toolchain lib.
                if segs.len() > i + 2 {
                    Some(MemberKind::RustStd)
                } else {
                    Some(MemberKind::ToolchainLib)
                }
            } else {
                Some(MemberKind::ToolchainLib)
            }
        }
        "vendor" => Some(MemberKind::VendorCrate),
        "cargo-config" => Some(MemberKind::CargoConfig),
        _ => None,
    }
}

/// The logical component a member belongs to (the `component` column). Dynamic —
/// the crate name for a vendored crate, the target triple for a rust-std member —
/// so this returns an owned `String` (unlike the skidbladnir plugin's fixed set).
/// `None` for bundle-level members (manifest/config) that span the whole toolchain.
pub fn component_of(kind: MemberKind, path: &str) -> Option<String> {
    let segs = segments(path);
    match kind {
        // The `<crate>-<ver>` directory directly under `vendor/`.
        MemberKind::VendorCrate => segs.get(1).map(|s| s.to_string()),
        // The `<triple>` directory directly after `rustlib/`.
        MemberKind::RustStd => {
            let idx = segs.iter().position(|s| *s == "rustlib")?;
            segs.get(idx + 1).map(|s| s.to_string())
        }
        // The executable name under `toolchain/bin/`.
        MemberKind::ToolchainBin => segs.last().map(|s| s.to_string()),
        _ => None,
    }
}

/// Does `name` look like a rust-dev bundle archive — `rust-dev-<...>.znippy`?
/// (e.g. `rust-dev-rhel8-1.97.1.znippy`). Used by the `bundle-name` subcommand and
/// by a caller that wants to route a `.znippy` to this classifier.
pub fn is_rust_dev_bundle(name: &str) -> bool {
    let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
    base.starts_with("rust-dev-") && base.ends_with(".znippy")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classifies_every_member_role() {
        let cases = [
            ("manifest/bundle.json", MemberKind::Manifest),
            ("manifest/toolchain.pins.json", MemberKind::ToolchainPins),
            ("manifest/vendor.lock", MemberKind::VendorLock),
            ("toolchain/bin/rustc", MemberKind::ToolchainBin),
            ("toolchain/bin/cargo", MemberKind::ToolchainBin),
            ("toolchain/lib/librustc_driver.so", MemberKind::ToolchainLib),
            (
                "toolchain/lib/rustlib/x86_64-unknown-linux-gnu/lib/libstd.rlib",
                MemberKind::RustStd,
            ),
            ("vendor/serde-1.0.203/src/lib.rs", MemberKind::VendorCrate),
            ("vendor/anyhow-1.0.86/Cargo.toml", MemberKind::VendorCrate),
            ("cargo-config/config.toml", MemberKind::CargoConfig),
        ];
        for (p, want) in cases {
            assert_eq!(classify(p), Some(want), "classify({p})");
        }
    }

    #[test]
    fn rustlib_root_is_generic_lib_not_std() {
        // The bare rustlib root (components list, not a per-target std) is generic.
        assert_eq!(
            classify("toolchain/lib/rustlib/components"),
            Some(MemberKind::ToolchainLib)
        );
    }

    #[test]
    fn unknown_paths_are_not_claimed() {
        assert_eq!(classify("README.md"), None);
        assert_eq!(classify("s3/doc.pdf"), None);
        assert_eq!(classify(""), None);
        // Leading ./ is tolerated.
        assert_eq!(classify("./vendor/serde-1.0/src/lib.rs"), Some(MemberKind::VendorCrate));
    }

    #[test]
    fn component_derivation() {
        assert_eq!(
            component_of(MemberKind::VendorCrate, "vendor/serde-1.0.203/src/lib.rs").as_deref(),
            Some("serde-1.0.203")
        );
        assert_eq!(
            component_of(
                MemberKind::RustStd,
                "toolchain/lib/rustlib/x86_64-unknown-linux-gnu/lib/libstd.rlib"
            )
            .as_deref(),
            Some("x86_64-unknown-linux-gnu")
        );
        assert_eq!(
            component_of(MemberKind::ToolchainBin, "toolchain/bin/cargo").as_deref(),
            Some("cargo")
        );
        assert_eq!(component_of(MemberKind::Manifest, "manifest/bundle.json"), None);
    }

    #[test]
    fn recognises_bundle_archive_name() {
        assert!(is_rust_dev_bundle("rust-dev-rhel8-1.97.1.znippy"));
        assert!(is_rust_dev_bundle("/tmp/rust-dev-rhel8-1.97.1.znippy"));
        assert!(!is_rust_dev_bundle("tillsynia-20260706.znippy"));
        assert!(!is_rust_dev_bundle("rust-dev-rhel8-1.97.1.tar.zst"));
    }
}