znippy-common 0.10.1

Core logic and data structures for Znippy, a parallel chunked compression system.
Documentation
//! 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()
    }
}