znippy-common/src/plugin.rs
//! Plugin trait for archive-type-aware metadata extraction.
//!
//! A handler is one static [`HandlerSpec`] (name, type_id, aliases, extensions,
//! columns — each stated once) plus [`ArchiveTypePlugin::extract_metadata`].
//! Everything else (`name`, `type_id`, `meta`, `schema_fields`, `matches_path`,
//! the shared `coords` subcommand) is derived from the spec.
use std::collections::HashMap;
use std::sync::Arc;
use arrow::datatypes::{DataType, Field};
/// Metadata extracted from a single file by a plugin
#[derive(Debug, Clone)]
pub struct ExtensionRow {
/// Key-value pairs matching the extension struct fields
pub fields: HashMap<String, ExtensionValue>,
}
/// Typed values for extension fields
#[derive(Debug, Clone, PartialEq)]
pub enum ExtensionValue {
Str(String),
OptStr(Option<String>),
U32(u32),
StrList(Vec<String>),
Bytes(Vec<u8>),
}
/// The Arrow type of one handler column.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Col {
/// `Utf8` — written from `Str` / `OptStr`.
Utf8,
/// `UInt32` — written from `U32`.
U32,
/// `List<Utf8>` — written from `StrList`.
Utf8List,
}
impl Col {
pub fn data_type(self) -> DataType {
match self {
Col::Utf8 => DataType::Utf8,
Col::U32 => DataType::UInt32,
Col::Utf8List => DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))),
}
}
}
/// How a handler decides that a path is its own. It is part of the spec, so
/// `matches_path` is derived from it and never a hidden second opinion.
#[derive(Debug, Clone, Copy)]
pub enum Claim {
/// The path ends with one of the spec's `extensions` (case-sensitive).
Extensions,
/// As [`Claim::Extensions`], ignoring ASCII case (`IMG_0001.JPG`).
ExtensionsAnyCase,
/// By layout: the function decides from the whole path. `extensions` then
/// only names the member kinds such a layout holds.
Layout(fn(&str) -> bool),
/// Every path; the bytes decide in `extract_metadata`.
Content,
}
/// The static description of one handler: every fact about it, stated once.
#[derive(Debug)]
pub struct HandlerSpec {
/// Canonical handler name, e.g. `"cargo"`.
pub name: &'static str,
/// Alternate names accepted on the CLI, e.g. `["rust"]`.
pub aliases: &'static [&'static str],
/// On-disk `pkg_type` discriminant this handler writes.
pub type_id: i8,
/// Human description of the packaging ecosystem.
pub ecosystem: &'static str,
/// File extensions the handler advertises.
pub extensions: &'static [&'static str],
/// How the handler claims a path (see [`Claim`]); `matches_path` is this.
pub claim: Claim,
/// One-line summary.
pub description: &'static str,
/// The index columns this handler contributes (keys of its `ExtensionRow`).
pub fields: &'static [(&'static str, Col)],
/// Subcommands beyond the shared `coords`, as `(verb, about)`.
pub commands: &'static [(&'static str, &'static str)],
}
impl HandlerSpec {
/// True if this handler claims `path`, as its [`Claim`] states.
pub fn claims(&self, path: &str) -> bool {
match self.claim {
Claim::Extensions => self.extensions.iter().any(|e| path.ends_with(e)),
Claim::ExtensionsAnyCase => {
let p = path.as_bytes();
self.extensions.iter().any(|e| {
p.len() >= e.len() && p[p.len() - e.len()..].eq_ignore_ascii_case(e.as_bytes())
})
}
Claim::Layout(f) => f(path),
Claim::Content => true,
}
}
}
/// One handler-specific subcommand advertised through `HandlerMeta::commands`.
#[derive(Debug, Clone)]
pub struct HandlerCommand {
/// Subcommand verb, e.g. `"coords"`.
pub name: String,
/// One-line description shown by `znippy handlers`.
pub about: String,
}
impl HandlerCommand {
pub fn new(name: impl Into<String>, about: impl Into<String>) -> Self {
Self { name: name.into(), about: about.into() }
}
}
/// Owned discovery record of a handler (what `znippy handlers` lists and
/// `--format` resolves against). Derived from the [`HandlerSpec`].
#[derive(Debug, Clone)]
pub struct HandlerMeta {
pub name: String,
pub aliases: Vec<String>,
pub type_id: i8,
pub ecosystem: String,
pub extensions: Vec<String>,
pub description: String,
pub commands: Vec<HandlerCommand>,
}
/// Trait implemented by each archive type plugin.
pub trait ArchiveTypePlugin: Send + Sync {
/// The handler's static description.
fn spec(&self) -> &HandlerSpec;
/// Per-file extraction. Return None to skip.
fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow>;
/// Dispatch a subcommand. Default: the shared `coords` command.
fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
default_command(self, cmd, args)
}
/// Return true if this plugin wants to handle this file: the spec's [`Claim`].
/// Override it only to make the same decision faster; the register's tests
/// hold every handler to `spec().claims`.
fn matches_path(&self, path: &str) -> bool {
self.spec().claims(path)
}
/// The Arrow columns this handler contributes. Default: the spec's fields.
fn schema_fields(&self) -> Vec<Field> {
self.spec().fields.iter().map(|(n, c)| Field::new(*n, c.data_type(), true)).collect()
}
fn name(&self) -> &str {
self.spec().name
}
fn type_id(&self) -> i8 {
self.spec().type_id
}
/// Owned discovery record, derived from the spec.
fn meta(&self) -> HandlerMeta {
let s = self.spec();
let owned = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
let mut commands = Vec::new();
if !self.schema_fields().is_empty() {
commands.push(HandlerCommand::new("coords", format!("Print {} coords read from a file", s.name)));
}
commands.extend(s.commands.iter().map(|(n, a)| HandlerCommand::new(*n, *a)));
HandlerMeta {
name: s.name.into(),
aliases: owned(s.aliases),
type_id: s.type_id,
ecosystem: s.ecosystem.into(),
extensions: owned(s.extensions),
description: s.description.into(),
commands,
}
}
}
/// The shared subcommand set: `coords <file>` runs the handler's own
/// `extract_metadata` over the file (its bytes when readable, else just the
/// path) and prints the coordinate columns — every column up to and including
/// `version`, or the first column when there is no `version`.
pub fn default_command<P: ArchiveTypePlugin + ?Sized>(p: &P, cmd: &str, args: &[String]) -> anyhow::Result<()> {
if cmd != "coords" {
anyhow::bail!("handler '{}' has no subcommand '{}'", p.name(), cmd);
}
let path = args.first().ok_or_else(|| anyhow::anyhow!("usage: {} coords <file>", p.name()))?;
let data = std::fs::read(path).unwrap_or_default();
let row = p
.extract_metadata(path, &data)
.ok_or_else(|| anyhow::anyhow!("{}: no coords in '{}'", p.name(), path))?;
let fields = p.schema_fields();
let end = fields.iter().position(|f| f.name() == "version").map_or(1, |i| i + 1);
let out: Vec<&str> = fields[..end.min(fields.len())]
.iter()
.filter_map(|f| match row.fields.get(f.name()) {
Some(ExtensionValue::Str(s)) | Some(ExtensionValue::OptStr(Some(s))) => Some(s.as_str()),
_ => None,
})
.collect();
println!("{}", out.join(" "));
Ok(())
}
/// The last path segment (`a/b/c.crate` → `c.crate`).
pub fn file_name(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
/// Registry holding the single active package handler.
///
/// One znippy archive carries one package type, so the registry holds exactly
/// one handler (selected by `--format`).
pub struct PluginRegistry {
plugin: Option<Box<dyn ArchiveTypePlugin>>,
}
impl PluginRegistry {
pub fn new() -> Self {
Self { plugin: None }
}
pub fn with_plugin(plugin: Box<dyn ArchiveTypePlugin>) -> Self {
Self { plugin: Some(plugin) }
}
/// Per-file extract (`None` when the handler does not claim the path).
pub fn extract(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
self.extract_typed(path, data).map(|(_, row)| row)
}
/// Per-file extract returning the handler's `type_id` alongside the row.
pub fn extract_typed(&self, path: &str, data: &[u8]) -> Option<(i8, ExtensionRow)> {
let p = self.plugin.as_ref()?;
if !p.matches_path(path) {
return None;
}
Some((p.type_id(), p.extract_metadata(path, data)?))
}
pub fn type_id(&self) -> Option<i8> {
self.plugin.as_ref().map(|p| p.type_id())
}
/// Arrow columns contributed by the active handler (empty if none).
pub fn schema_fields(&self) -> Vec<Field> {
self.plugin.as_ref().map(|p| p.schema_fields()).unwrap_or_default()
}
pub fn matches(&self, path: &str) -> bool {
self.plugin.as_ref().map(|p| p.matches_path(path)).unwrap_or(false)
}
}
impl Default for PluginRegistry {
fn default() -> Self {
Self::new()
}
}