Skip to main content

znippy_common/
plugin.rs

1//! Plugin trait for archive-type-aware metadata extraction.
2//!
3//! Two modes:
4//! - **Per-file**: plugin.extract_metadata() called inline (e.g. CargoPlugin — filename only)
5//! - **Batch**: files staged in IngestBatch, plugin called once before compression starts.
6//!   Data is still in memory (zero-copy borrows) — no extra allocation.
7//!
8//! The batch runs BETWEEN read and compress:
9//!   read N files → batch extract (zero-copy &[u8]) → chunk → compress → write
10
11use std::collections::HashMap;
12
13use arrow::datatypes::Field;
14
15/// Metadata extracted from a single file by a plugin
16#[derive(Debug, Clone)]
17pub struct ExtensionRow {
18    /// Key-value pairs matching the extension struct fields
19    pub fields: HashMap<String, ExtensionValue>,
20}
21
22/// Typed values for extension fields
23#[derive(Debug, Clone, PartialEq)]
24pub enum ExtensionValue {
25    Str(String),
26    OptStr(Option<String>),
27    U32(u32),
28    StrList(Vec<String>),
29    Bytes(Vec<u8>),
30}
31
32/// Item in a batch — borrows data already in memory
33pub struct BatchItem<'a> {
34    pub path: &'a str,
35    pub data: &'a [u8],
36}
37
38/// One handler-specific subcommand advertised through `HandlerMeta::commands`.
39/// This is the unit of a package handler's "API" that the CLI can list and dispatch.
40#[derive(Debug, Clone)]
41pub struct HandlerCommand {
42    /// Subcommand verb, e.g. `"coords"`.
43    pub name: String,
44    /// One-line description shown by `znippy handlers`.
45    pub about: String,
46}
47
48impl HandlerCommand {
49    pub fn new(name: impl Into<String>, about: impl Into<String>) -> Self {
50        Self { name: name.into(), about: about.into() }
51    }
52}
53
54/// Discovery metadata for a package handler — the single source of truth the
55/// registry uses to enumerate handlers at runtime (`znippy handlers`) and to
56/// resolve a `--format <name>` selection (canonical name or any alias).
57#[derive(Debug, Clone)]
58pub struct HandlerMeta {
59    /// Canonical handler name, e.g. `"cargo"`.
60    pub name: String,
61    /// Alternate names accepted on the CLI, e.g. `["rust"]`.
62    pub aliases: Vec<String>,
63    /// On-disk DenseUnion / pkg_type discriminant this handler writes.
64    pub type_id: i8,
65    /// Human description of the packaging ecosystem, e.g. `"Rust / crates.io"`.
66    pub ecosystem: String,
67    /// File extensions the handler claims, e.g. `[".crate"]`.
68    pub extensions: Vec<String>,
69    /// One-line summary.
70    pub description: String,
71    /// Handler-specific subcommands callable from the CLI (the handler's "API").
72    pub commands: Vec<HandlerCommand>,
73}
74
75/// Trait implemented by each archive type plugin.
76///
77/// A handler targets exactly one packaging ecosystem (crates.io, Maven Central,
78/// PyPI, …). Every handler shares the same *base* commands defined here; the
79/// per-ecosystem specialisation lives in the implementation plus the optional
80/// subcommands advertised through [`HandlerMeta::commands`].
81pub trait ArchiveTypePlugin: Send + Sync {
82    /// Human-readable name
83    fn name(&self) -> &str;
84
85    /// DenseUnion type_id this plugin writes to
86    fn type_id(&self) -> i8;
87
88    /// Discovery metadata — name, aliases, type_id, claimed extensions and the
89    /// per-handler subcommand "API". Used by the registry to list and select
90    /// handlers at runtime. The default derives a minimal record from
91    /// [`Self::name`] / [`Self::type_id`]; native handlers override it with a
92    /// richer record (aliases, ecosystem, extensions, commands).
93    fn meta(&self) -> HandlerMeta {
94        HandlerMeta {
95            name: self.name().to_string(),
96            aliases: Vec::new(),
97            type_id: self.type_id(),
98            ecosystem: String::new(),
99            extensions: Vec::new(),
100            description: String::new(),
101            commands: Vec::new(),
102        }
103    }
104
105    /// Dispatch a handler-specific subcommand (one of [`HandlerMeta::commands`]).
106    /// Default: the handler exposes no extra subcommands.
107    fn run_command(&self, cmd: &str, _args: &[String]) -> anyhow::Result<()> {
108        anyhow::bail!("handler '{}' has no subcommand '{}'", self.name(), cmd)
109    }
110
111    /// Per-file extraction. Return None to skip.
112    fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow>;
113
114    /// The Arrow columns this module contributes to the index. The writer composes the
115    /// on-disk schema as `base columns + pkg_type + these fields`. Each `Field`'s name must
116    /// match the key the plugin uses in `ExtensionRow::fields`. Default: no columns.
117    fn schema_fields(&self) -> Vec<Field> { Vec::new() }
118
119    /// Return true if this plugin wants to handle this file (checked before reading data).
120    /// Default false means the plugin is never called for that path.
121    fn matches_path(&self, _path: &str) -> bool { false }
122
123    /// Whether this plugin benefits from batched processing.
124    fn supports_batch(&self) -> bool { false }
125
126    /// Byte threshold to trigger batch flush (default 200MB).
127    fn batch_threshold(&self) -> usize { 200 * 1024 * 1024 }
128
129    /// Batch extraction — process many files at once (zero-copy borrows).
130    /// Returns one Option<ExtensionRow> per input item, in same order.
131    fn extract_batch(&self, items: &[BatchItem<'_>]) -> Vec<Option<ExtensionRow>> {
132        items.iter().map(|item| self.extract_metadata(item.path, item.data)).collect()
133    }
134}
135
136// ─── IngestBatch: zero-copy staging buffer ───────────────────────────
137
138/// A package handler targets one packaging ecosystem (crates.io, Maven Central,
139/// PyPI, …). Conceptual alias of [`ArchiveTypePlugin`] — use whichever name reads
140/// better at the call site.
141pub use self::ArchiveTypePlugin as PackageHandler;
142
143/// A batch of files staged for ingest. Holds file data in memory;
144/// plugin borrows it (zero-copy) before compression begins.
145pub struct IngestBatch {
146    files: Vec<StagedFile>,
147    total_bytes: usize,
148}
149
150/// A file staged in the batch — owns the data until compression consumes it
151pub struct StagedFile {
152    pub path: String,
153    pub data: Vec<u8>,
154    /// Metadata extracted by plugin (populated after extract phase)
155    pub metadata: Option<ExtensionRow>,
156}
157
158impl IngestBatch {
159    pub fn new() -> Self {
160        Self { files: Vec::new(), total_bytes: 0 }
161    }
162
163    pub fn with_capacity(cap: usize) -> Self {
164        Self { files: Vec::with_capacity(cap), total_bytes: 0 }
165    }
166
167    /// Stage a file. Data is moved in (caller gives up ownership).
168    /// No copy — the Vec<u8> from the file read is moved directly here.
169    pub fn push(&mut self, path: String, data: Vec<u8>) {
170        self.total_bytes += data.len();
171        self.files.push(StagedFile { path, data, metadata: None });
172    }
173
174    pub fn total_bytes(&self) -> usize {
175        self.total_bytes
176    }
177
178    pub fn len(&self) -> usize {
179        self.files.len()
180    }
181
182    pub fn is_empty(&self) -> bool {
183        self.files.is_empty()
184    }
185
186    /// Run the plugin's batch extraction on all staged files (zero-copy).
187    /// After this call, each StagedFile.metadata is populated.
188    pub fn extract_metadata(&mut self, plugin: &dyn ArchiveTypePlugin) {
189        if self.files.is_empty() {
190            return;
191        }
192
193        if plugin.supports_batch() {
194            // Batch mode: one call, plugin sees all data via borrows
195            let items: Vec<BatchItem<'_>> = self.files.iter()
196                .map(|f| BatchItem { path: &f.path, data: &f.data })
197                .collect();
198
199            let results = plugin.extract_batch(&items);
200
201            for (file, meta) in self.files.iter_mut().zip(results) {
202                file.metadata = meta;
203            }
204        } else {
205            // Per-file mode: call once per file (still zero-copy — borrows &data)
206            for file in &mut self.files {
207                file.metadata = plugin.extract_metadata(&file.path, &file.data);
208            }
209        }
210    }
211
212    /// Drain files for compression. Caller gets ownership of (path, data, metadata).
213    /// After this, the batch is empty and ready for reuse.
214    pub fn drain(&mut self) -> impl Iterator<Item = StagedFile> + '_ {
215        self.total_bytes = 0;
216        self.files.drain(..)    }
217
218    /// Iterate staged files (for inspection before drain)
219    pub fn iter(&self) -> impl Iterator<Item = &StagedFile> {
220        self.files.iter()
221    }
222}
223
224impl Default for IngestBatch {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230// ─── PluginRegistry (simplified — batch logic moves to IngestBatch) ──
231
232/// Registry holding the single active package handler.
233///
234/// One znippy archive carries one package type, so the registry holds exactly
235/// one handler (selected by `--format`). Combining multiple package types, or
236/// signing/merging archives, is a higher-layer concern (the sibling `holger`
237/// repo) — not something znippy does internally.
238pub struct PluginRegistry {
239    plugin: Option<Box<dyn ArchiveTypePlugin>>,
240}
241
242impl PluginRegistry {
243    pub fn new() -> Self {
244        Self { plugin: None }
245    }
246
247    pub fn with_plugin(plugin: Box<dyn ArchiveTypePlugin>) -> Self {
248        Self { plugin: Some(plugin) }
249    }
250
251    /// Get batch threshold from the handler (or default 200MB if none).
252    pub fn batch_threshold(&self) -> usize {
253        self.plugin.as_ref().map(|p| p.batch_threshold()).unwrap_or(200 * 1024 * 1024)
254    }
255
256    /// Run extraction on a batch (delegates to the handler).
257    pub fn extract_batch(&self, batch: &mut IngestBatch) {
258        if let Some(plugin) = &self.plugin {
259            batch.extract_metadata(plugin.as_ref());
260        }
261    }
262
263    /// Per-file extract (convenience for non-batched paths).
264    pub fn extract(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
265        let p = self.plugin.as_ref()?;
266        if !p.matches_path(path) {
267            return None;
268        }
269        p.extract_metadata(path, data)
270    }
271
272    /// Per-file extract returning the handler's `type_id` alongside the row.
273    pub fn extract_typed(&self, path: &str, data: &[u8]) -> Option<(i8, ExtensionRow)> {
274        let p = self.plugin.as_ref()?;
275        if !p.matches_path(path) {
276            return None;
277        }
278        let row = p.extract_metadata(path, data)?;
279        Some((p.type_id(), row))
280    }
281
282    pub fn type_id(&self) -> Option<i8> {
283        self.plugin.as_ref().map(|p| p.type_id())
284    }
285
286    /// Arrow columns contributed by the active handler (empty if none).
287    pub fn schema_fields(&self) -> Vec<Field> {
288        self.plugin.as_ref().map(|p| p.schema_fields()).unwrap_or_default()
289    }
290
291    pub fn matches(&self, path: &str) -> bool {
292        self.plugin.as_ref().map(|p| p.matches_path(path)).unwrap_or(false)
293    }
294}
295
296impl Default for PluginRegistry {
297    fn default() -> Self {
298        Self::new()
299    }
300}