Skip to main content

znippy_common/
plugin.rs

1//! Plugin trait for archive-type-aware metadata extraction.
2//!
3//! A handler is one static [`HandlerSpec`] (name, type_id, aliases, extensions,
4//! columns — each stated once) plus [`ArchiveTypePlugin::extract_metadata`].
5//! Everything else (`name`, `type_id`, `meta`, `schema_fields`, `matches_path`,
6//! the shared `coords` subcommand) is derived from the spec.
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use arrow::datatypes::{DataType, Field};
12
13/// Metadata extracted from a single file by a plugin
14#[derive(Debug, Clone)]
15pub struct ExtensionRow {
16    /// Key-value pairs matching the extension struct fields
17    pub fields: HashMap<String, ExtensionValue>,
18}
19
20/// Typed values for extension fields
21#[derive(Debug, Clone, PartialEq)]
22pub enum ExtensionValue {
23    Str(String),
24    OptStr(Option<String>),
25    U32(u32),
26    StrList(Vec<String>),
27    Bytes(Vec<u8>),
28}
29
30/// The Arrow type of one handler column.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Col {
33    /// `Utf8` — written from `Str` / `OptStr`.
34    Utf8,
35    /// `UInt32` — written from `U32`.
36    U32,
37    /// `List<Utf8>` — written from `StrList`.
38    Utf8List,
39}
40
41impl Col {
42    pub fn data_type(self) -> DataType {
43        match self {
44            Col::Utf8 => DataType::Utf8,
45            Col::U32 => DataType::UInt32,
46            Col::Utf8List => DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))),
47        }
48    }
49}
50
51/// How a handler decides that a path is its own. It is part of the spec, so
52/// `matches_path` is derived from it and never a hidden second opinion.
53#[derive(Debug, Clone, Copy)]
54pub enum Claim {
55    /// The path ends with one of the spec's `extensions` (case-sensitive).
56    Extensions,
57    /// As [`Claim::Extensions`], ignoring ASCII case (`IMG_0001.JPG`).
58    ExtensionsAnyCase,
59    /// By layout: the function decides from the whole path. `extensions` then
60    /// only names the member kinds such a layout holds.
61    Layout(fn(&str) -> bool),
62    /// Every path; the bytes decide in `extract_metadata`.
63    Content,
64}
65
66/// The static description of one handler: every fact about it, stated once.
67#[derive(Debug)]
68pub struct HandlerSpec {
69    /// Canonical handler name, e.g. `"cargo"`.
70    pub name: &'static str,
71    /// Alternate names accepted on the CLI, e.g. `["rust"]`.
72    pub aliases: &'static [&'static str],
73    /// On-disk `pkg_type` discriminant this handler writes.
74    pub type_id: i8,
75    /// Human description of the packaging ecosystem.
76    pub ecosystem: &'static str,
77    /// File extensions the handler advertises.
78    pub extensions: &'static [&'static str],
79    /// How the handler claims a path (see [`Claim`]); `matches_path` is this.
80    pub claim: Claim,
81    /// One-line summary.
82    pub description: &'static str,
83    /// The index columns this handler contributes (keys of its `ExtensionRow`).
84    pub fields: &'static [(&'static str, Col)],
85    /// Subcommands beyond the shared `coords`, as `(verb, about)`.
86    pub commands: &'static [(&'static str, &'static str)],
87}
88
89impl HandlerSpec {
90    /// True if this handler claims `path`, as its [`Claim`] states.
91    pub fn claims(&self, path: &str) -> bool {
92        match self.claim {
93            Claim::Extensions => self.extensions.iter().any(|e| path.ends_with(e)),
94            Claim::ExtensionsAnyCase => {
95                let p = path.as_bytes();
96                self.extensions.iter().any(|e| {
97                    p.len() >= e.len() && p[p.len() - e.len()..].eq_ignore_ascii_case(e.as_bytes())
98                })
99            }
100            Claim::Layout(f) => f(path),
101            Claim::Content => true,
102        }
103    }
104}
105
106/// One handler-specific subcommand advertised through `HandlerMeta::commands`.
107#[derive(Debug, Clone)]
108pub struct HandlerCommand {
109    /// Subcommand verb, e.g. `"coords"`.
110    pub name: String,
111    /// One-line description shown by `znippy handlers`.
112    pub about: String,
113}
114
115impl HandlerCommand {
116    pub fn new(name: impl Into<String>, about: impl Into<String>) -> Self {
117        Self { name: name.into(), about: about.into() }
118    }
119}
120
121/// Owned discovery record of a handler (what `znippy handlers` lists and
122/// `--format` resolves against). Derived from the [`HandlerSpec`].
123#[derive(Debug, Clone)]
124pub struct HandlerMeta {
125    pub name: String,
126    pub aliases: Vec<String>,
127    pub type_id: i8,
128    pub ecosystem: String,
129    pub extensions: Vec<String>,
130    pub description: String,
131    pub commands: Vec<HandlerCommand>,
132}
133
134/// Trait implemented by each archive type plugin.
135pub trait ArchiveTypePlugin: Send + Sync {
136    /// The handler's static description.
137    fn spec(&self) -> &HandlerSpec;
138
139    /// Per-file extraction. Return None to skip.
140    fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow>;
141
142    /// Dispatch a subcommand. Default: the shared `coords` command.
143    fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
144        default_command(self, cmd, args)
145    }
146
147    /// Return true if this plugin wants to handle this file: the spec's [`Claim`].
148    /// Override it only to make the same decision faster; the register's tests
149    /// hold every handler to `spec().claims`.
150    fn matches_path(&self, path: &str) -> bool {
151        self.spec().claims(path)
152    }
153
154    /// The Arrow columns this handler contributes. Default: the spec's fields.
155    fn schema_fields(&self) -> Vec<Field> {
156        self.spec().fields.iter().map(|(n, c)| Field::new(*n, c.data_type(), true)).collect()
157    }
158
159    fn name(&self) -> &str {
160        self.spec().name
161    }
162
163    fn type_id(&self) -> i8 {
164        self.spec().type_id
165    }
166
167    /// Owned discovery record, derived from the spec.
168    fn meta(&self) -> HandlerMeta {
169        let s = self.spec();
170        let owned = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
171        let mut commands = Vec::new();
172        if !self.schema_fields().is_empty() {
173            commands.push(HandlerCommand::new("coords", format!("Print {} coords read from a file", s.name)));
174        }
175        commands.extend(s.commands.iter().map(|(n, a)| HandlerCommand::new(*n, *a)));
176        HandlerMeta {
177            name: s.name.into(),
178            aliases: owned(s.aliases),
179            type_id: s.type_id,
180            ecosystem: s.ecosystem.into(),
181            extensions: owned(s.extensions),
182            description: s.description.into(),
183            commands,
184        }
185    }
186}
187
188/// The shared subcommand set: `coords <file>` runs the handler's own
189/// `extract_metadata` over the file (its bytes when readable, else just the
190/// path) and prints the coordinate columns — every column up to and including
191/// `version`, or the first column when there is no `version`.
192pub fn default_command<P: ArchiveTypePlugin + ?Sized>(p: &P, cmd: &str, args: &[String]) -> anyhow::Result<()> {
193    if cmd != "coords" {
194        anyhow::bail!("handler '{}' has no subcommand '{}'", p.name(), cmd);
195    }
196    let path = args.first().ok_or_else(|| anyhow::anyhow!("usage: {} coords <file>", p.name()))?;
197    let data = std::fs::read(path).unwrap_or_default();
198    let row = p
199        .extract_metadata(path, &data)
200        .ok_or_else(|| anyhow::anyhow!("{}: no coords in '{}'", p.name(), path))?;
201    let fields = p.schema_fields();
202    let end = fields.iter().position(|f| f.name() == "version").map_or(1, |i| i + 1);
203    let out: Vec<&str> = fields[..end.min(fields.len())]
204        .iter()
205        .filter_map(|f| match row.fields.get(f.name()) {
206            Some(ExtensionValue::Str(s)) | Some(ExtensionValue::OptStr(Some(s))) => Some(s.as_str()),
207            _ => None,
208        })
209        .collect();
210    println!("{}", out.join(" "));
211    Ok(())
212}
213
214/// The last path segment (`a/b/c.crate` → `c.crate`).
215pub fn file_name(path: &str) -> &str {
216    path.rsplit('/').next().unwrap_or(path)
217}
218
219/// Registry holding the single active package handler.
220///
221/// One znippy archive carries one package type, so the registry holds exactly
222/// one handler (selected by `--format`).
223pub struct PluginRegistry {
224    plugin: Option<Box<dyn ArchiveTypePlugin>>,
225}
226
227impl PluginRegistry {
228    pub fn new() -> Self {
229        Self { plugin: None }
230    }
231
232    pub fn with_plugin(plugin: Box<dyn ArchiveTypePlugin>) -> Self {
233        Self { plugin: Some(plugin) }
234    }
235
236    /// Per-file extract (`None` when the handler does not claim the path).
237    pub fn extract(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
238        self.extract_typed(path, data).map(|(_, row)| row)
239    }
240
241    /// Per-file extract returning the handler's `type_id` alongside the row.
242    pub fn extract_typed(&self, path: &str, data: &[u8]) -> Option<(i8, ExtensionRow)> {
243        let p = self.plugin.as_ref()?;
244        if !p.matches_path(path) {
245            return None;
246        }
247        Some((p.type_id(), p.extract_metadata(path, data)?))
248    }
249
250    pub fn type_id(&self) -> Option<i8> {
251        self.plugin.as_ref().map(|p| p.type_id())
252    }
253
254    /// Arrow columns contributed by the active handler (empty if none).
255    pub fn schema_fields(&self) -> Vec<Field> {
256        self.plugin.as_ref().map(|p| p.schema_fields()).unwrap_or_default()
257    }
258
259    pub fn matches(&self, path: &str) -> bool {
260        self.plugin.as_ref().map(|p| p.matches_path(path)).unwrap_or(false)
261    }
262}
263
264impl Default for PluginRegistry {
265    fn default() -> Self {
266        Self::new()
267    }
268}