Skip to main content

znippy_common/plugins/native/
cargo_native.rs

1//! Native Cargo/crate registry plugin.
2//! Extracts crate name + version from .crate filenames (zero decompression cost).
3//! Optionally parses Cargo.toml inside the tarball for deps (only if needed).
4
5use crate::plugin::{ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta};
6use arrow::datatypes::{DataType, Field};
7use std::collections::HashMap;
8
9/// Native plugin that extracts crate metadata from .crate file paths.
10/// Name and version are parsed from the filename (no I/O needed).
11pub struct CargoPlugin {
12    /// If true, also decompress and parse Cargo.toml for dependency list
13    pub parse_deps: bool,
14}
15
16impl CargoPlugin {
17    pub fn new() -> Self {
18        Self { parse_deps: false }
19    }
20
21    pub fn with_deps() -> Self {
22        Self { parse_deps: true }
23    }
24
25    /// Parse name and version from filename like "serde-1.0.200.crate"
26    fn parse_filename(path: &str) -> Option<(String, String)> {
27        let filename = path.rsplit('/').next()?;
28        let stem = filename.strip_suffix(".crate")?;
29        // Split at last hyphen followed by a digit (version start)
30        let mut split_pos = None;
31        for (i, c) in stem.char_indices() {
32            if c == '-' {
33                // Check if next char is a digit
34                if let Some(next) = stem[i+1..].chars().next() {
35                    if next.is_ascii_digit() {
36                        split_pos = Some(i);
37                    }
38                }
39            }
40        }
41        let pos = split_pos?;
42        let name = &stem[..pos];
43        let version = &stem[pos+1..];
44        Some((name.to_string(), version.to_string()))
45    }
46
47    /// Parse deps from .crate tarball (only when parse_deps = true).
48    ///
49    /// Uses the workspace's own `lgz` crate, which does gzip-decompress +
50    /// tar-extract + filter-by-name in one parallel zero-copy call. We filter
51    /// for `Cargo.toml`, pick the entry whose path ends in `Cargo.toml`
52    /// (the top-level `<name-version>/Cargo.toml`), and feed it to
53    /// `extract_dep_names`. Any error → empty dep list (never panic).
54    #[cfg(feature = "host-decompressors")]
55    fn parse_deps_from_tarball(data: &[u8]) -> Vec<String> {
56        let entries = match lgz::decompress_tar_gz_filter(data, "Cargo.toml") {
57            Ok(entries) => entries,
58            Err(_) => return Vec::new(),
59        };
60
61        for (path, bytes) in &entries {
62            if path.ends_with("/Cargo.toml") || path == "Cargo.toml" {
63                let contents = String::from_utf8_lossy(bytes);
64                return Self::extract_dep_names(&contents);
65            }
66        }
67        Vec::new()
68    }
69
70    #[cfg(feature = "host-decompressors")]
71    fn extract_dep_names(cargo_toml: &str) -> Vec<String> {
72        let mut deps = Vec::new();
73        let mut in_deps = false;
74        for line in cargo_toml.lines() {
75            let trimmed = line.trim();
76            if trimmed == "[dependencies]" {
77                in_deps = true;
78            } else if trimmed.starts_with('[') {
79                in_deps = false;
80            } else if in_deps {
81                if let Some(dep_name) = trimmed.split('=').next() {
82                    let dep_name = dep_name.trim();
83                    if !dep_name.is_empty() && !dep_name.starts_with('#') {
84                        deps.push(dep_name.to_string());
85                    }
86                }
87            }
88        }
89        deps
90    }
91}
92
93impl ArchiveTypePlugin for CargoPlugin {
94    fn name(&self) -> &str {
95        "cargo"
96    }
97
98    fn type_id(&self) -> i8 {
99        1
100    }
101
102    fn meta(&self) -> HandlerMeta {
103        HandlerMeta {
104            name: "cargo".into(),
105            aliases: vec!["rust".into()],
106            type_id: 1,
107            ecosystem: "Rust / crates.io".into(),
108            extensions: vec![".crate".into()],
109            description: "Rust crate registry tarballs — name + version from filename, deps from Cargo.toml".into(),
110            commands: vec![
111                HandlerCommand::new("coords", "Print crate name + version parsed from a .crate path"),
112            ],
113        }
114    }
115
116    fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
117        match cmd {
118            "coords" => {
119                let path = args.first()
120                    .ok_or_else(|| anyhow::anyhow!("usage: cargo coords <file.crate>"))?;
121                let (name, version) = Self::parse_filename(path)
122                    .ok_or_else(|| anyhow::anyhow!("not a .crate path: {}", path))?;
123                println!("{} {}", name, version);
124                Ok(())
125            }
126            other => anyhow::bail!("cargo: unknown subcommand '{}'", other),
127        }
128    }
129
130    fn matches_path(&self, path: &str) -> bool {
131        path.ends_with(".crate")
132    }
133
134    /// Columns this handler contributes to the index. These are the READ-side
135    /// coords: the typed [`RustView`](crate::views::RustView) maps
136    /// `crate_name`/`version` back into `(name, version)`. Without these declared,
137    /// the writer never persists the columns and the view cannot resolve coords.
138    fn schema_fields(&self) -> Vec<Field> {
139        vec![
140            Field::new("crate_name", DataType::Utf8, true),
141            Field::new("version", DataType::Utf8, true),
142        ]
143    }
144
145    fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
146        let (crate_name, version) = Self::parse_filename(path)?;
147
148        let mut fields = HashMap::new();
149        fields.insert("crate_name".into(), ExtensionValue::Str(crate_name));
150        fields.insert("version".into(), ExtensionValue::Str(version));
151
152        #[cfg(feature = "host-decompressors")]
153        if self.parse_deps {
154            let deps = Self::parse_deps_from_tarball(data);
155            fields.insert("deps".into(), ExtensionValue::StrList(deps));
156        }
157
158        #[cfg(not(feature = "host-decompressors"))]
159        let _ = data; // suppress unused warning
160
161        Some(ExtensionRow { fields })
162    }
163}