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}