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/// The static description of one handler: every fact about it, stated once.
52#[derive(Debug)]
53pub struct HandlerSpec {
54    /// Canonical handler name, e.g. `"cargo"`.
55    pub name: &'static str,
56    /// Alternate names accepted on the CLI, e.g. `["rust"]`.
57    pub aliases: &'static [&'static str],
58    /// On-disk `pkg_type` discriminant this handler writes.
59    pub type_id: i8,
60    /// Human description of the packaging ecosystem.
61    pub ecosystem: &'static str,
62    /// File extensions the handler claims; the default `matches_path` is "ends with one".
63    pub extensions: &'static [&'static str],
64    /// One-line summary.
65    pub description: &'static str,
66    /// The index columns this handler contributes (keys of its `ExtensionRow`).
67    pub fields: &'static [(&'static str, Col)],
68    /// Subcommands beyond the shared `coords`, as `(verb, about)`.
69    pub commands: &'static [(&'static str, &'static str)],
70}
71
72impl HandlerSpec {
73    /// True if `path` ends with one of the claimed extensions.
74    pub fn claims(&self, path: &str) -> bool {
75        self.extensions.iter().any(|e| path.ends_with(e))
76    }
77}
78
79/// One handler-specific subcommand advertised through `HandlerMeta::commands`.
80#[derive(Debug, Clone)]
81pub struct HandlerCommand {
82    /// Subcommand verb, e.g. `"coords"`.
83    pub name: String,
84    /// One-line description shown by `znippy handlers`.
85    pub about: String,
86}
87
88impl HandlerCommand {
89    pub fn new(name: impl Into<String>, about: impl Into<String>) -> Self {
90        Self { name: name.into(), about: about.into() }
91    }
92}
93
94/// Owned discovery record of a handler (what `znippy handlers` lists and
95/// `--format` resolves against). Derived from the [`HandlerSpec`].
96#[derive(Debug, Clone)]
97pub struct HandlerMeta {
98    pub name: String,
99    pub aliases: Vec<String>,
100    pub type_id: i8,
101    pub ecosystem: String,
102    pub extensions: Vec<String>,
103    pub description: String,
104    pub commands: Vec<HandlerCommand>,
105}
106
107/// Trait implemented by each archive type plugin.
108pub trait ArchiveTypePlugin: Send + Sync {
109    /// The handler's static description.
110    fn spec(&self) -> &HandlerSpec;
111
112    /// Per-file extraction. Return None to skip.
113    fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow>;
114
115    /// Dispatch a subcommand. Default: the shared `coords` command.
116    fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
117        default_command(self, cmd, args)
118    }
119
120    /// Return true if this plugin wants to handle this file. Default: the spec's extensions.
121    fn matches_path(&self, path: &str) -> bool {
122        self.spec().claims(path)
123    }
124
125    /// The Arrow columns this handler contributes. Default: the spec's fields.
126    fn schema_fields(&self) -> Vec<Field> {
127        self.spec().fields.iter().map(|(n, c)| Field::new(*n, c.data_type(), true)).collect()
128    }
129
130    fn name(&self) -> &str {
131        self.spec().name
132    }
133
134    fn type_id(&self) -> i8 {
135        self.spec().type_id
136    }
137
138    /// Owned discovery record, derived from the spec.
139    fn meta(&self) -> HandlerMeta {
140        let s = self.spec();
141        let owned = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
142        let mut commands = Vec::new();
143        if !self.schema_fields().is_empty() {
144            commands.push(HandlerCommand::new("coords", format!("Print {} coords read from a file", s.name)));
145        }
146        commands.extend(s.commands.iter().map(|(n, a)| HandlerCommand::new(*n, *a)));
147        HandlerMeta {
148            name: s.name.into(),
149            aliases: owned(s.aliases),
150            type_id: s.type_id,
151            ecosystem: s.ecosystem.into(),
152            extensions: owned(s.extensions),
153            description: s.description.into(),
154            commands,
155        }
156    }
157}
158
159/// The shared subcommand set: `coords <file>` runs the handler's own
160/// `extract_metadata` over the file (its bytes when readable, else just the
161/// path) and prints the coordinate columns — every column up to and including
162/// `version`, or the first column when there is no `version`.
163pub fn default_command<P: ArchiveTypePlugin + ?Sized>(p: &P, cmd: &str, args: &[String]) -> anyhow::Result<()> {
164    if cmd != "coords" {
165        anyhow::bail!("handler '{}' has no subcommand '{}'", p.name(), cmd);
166    }
167    let path = args.first().ok_or_else(|| anyhow::anyhow!("usage: {} coords <file>", p.name()))?;
168    let data = std::fs::read(path).unwrap_or_default();
169    let row = p
170        .extract_metadata(path, &data)
171        .ok_or_else(|| anyhow::anyhow!("{}: no coords in '{}'", p.name(), path))?;
172    let fields = p.schema_fields();
173    let end = fields.iter().position(|f| f.name() == "version").map_or(1, |i| i + 1);
174    let out: Vec<&str> = fields[..end.min(fields.len())]
175        .iter()
176        .filter_map(|f| match row.fields.get(f.name()) {
177            Some(ExtensionValue::Str(s)) | Some(ExtensionValue::OptStr(Some(s))) => Some(s.as_str()),
178            _ => None,
179        })
180        .collect();
181    println!("{}", out.join(" "));
182    Ok(())
183}
184
185/// The last path segment (`a/b/c.crate` → `c.crate`).
186pub fn file_name(path: &str) -> &str {
187    path.rsplit('/').next().unwrap_or(path)
188}
189
190/// Registry holding the single active package handler.
191///
192/// One znippy archive carries one package type, so the registry holds exactly
193/// one handler (selected by `--format`).
194pub struct PluginRegistry {
195    plugin: Option<Box<dyn ArchiveTypePlugin>>,
196}
197
198impl PluginRegistry {
199    pub fn new() -> Self {
200        Self { plugin: None }
201    }
202
203    pub fn with_plugin(plugin: Box<dyn ArchiveTypePlugin>) -> Self {
204        Self { plugin: Some(plugin) }
205    }
206
207    /// Per-file extract (`None` when the handler does not claim the path).
208    pub fn extract(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
209        self.extract_typed(path, data).map(|(_, row)| row)
210    }
211
212    /// Per-file extract returning the handler's `type_id` alongside the row.
213    pub fn extract_typed(&self, path: &str, data: &[u8]) -> Option<(i8, ExtensionRow)> {
214        let p = self.plugin.as_ref()?;
215        if !p.matches_path(path) {
216            return None;
217        }
218        Some((p.type_id(), p.extract_metadata(path, data)?))
219    }
220
221    pub fn type_id(&self) -> Option<i8> {
222        self.plugin.as_ref().map(|p| p.type_id())
223    }
224
225    /// Arrow columns contributed by the active handler (empty if none).
226    pub fn schema_fields(&self) -> Vec<Field> {
227        self.plugin.as_ref().map(|p| p.schema_fields()).unwrap_or_default()
228    }
229
230    pub fn matches(&self, path: &str) -> bool {
231        self.plugin.as_ref().map(|p| p.matches_path(path)).unwrap_or(false)
232    }
233}
234
235impl Default for PluginRegistry {
236    fn default() -> Self {
237        Self::new()
238    }
239}