agent-plugins 0.1.1

Sans-IO implementation of the Agent Plugins specification (agent-plugins.org): load, validate, and prepare agent plugins from any IO
Documentation
//! An in-memory plugin package: files in a map, directories implied.
//!
//! Useful for tests, for loading plugins out of archives or version control
//! without touching disk, and as the smallest possible demonstration that the
//! loader truly does not care where bytes come from.

use std::collections::BTreeMap;
use std::convert::Infallible;

use crate::load::{DirEntry, FileKind, LoadedPlugin, Rejection};
use crate::path::PackagePath;
use crate::source::{self, Source};

/// A plugin package held entirely in memory.
///
/// Keys are `/`-separated paths relative to the plugin root; directories
/// exist implicitly wherever a key has a parent. There are no symlinks, so
/// every path is truthfully confined.
///
/// ```
/// use agent_plugins::MemSource;
///
/// let plugin = MemSource::new()
///     .file("plugin.json", br#"{
///         "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
///         "name": "in-memory"
///     }"#)
///     .load()
///     .unwrap();
/// assert_eq!(plugin.manifest.name.as_str(), "in-memory");
/// ```
#[derive(Debug, Clone, Default)]
pub struct MemSource {
    files: BTreeMap<String, Vec<u8>>,
}

impl MemSource {
    /// An empty package (which, containing no manifest, would be rejected).
    pub fn new() -> Self {
        Self::default()
    }

    /// Add a file at a `/`-separated path relative to the plugin root.
    pub fn file(mut self, path: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
        self.files.insert(path.into(), bytes.into());
        self
    }

    /// Load the package.
    pub fn load(mut self) -> Result<LoadedPlugin, Rejection> {
        source::load(&mut self)
    }
}

impl Source for MemSource {
    type Error = Infallible;

    fn kind(&mut self, path: &PackagePath) -> Result<Option<FileKind>, Infallible> {
        let path = path.as_str();
        if self.files.contains_key(path) {
            return Ok(Some(FileKind::File));
        }
        let prefix = format!("{path}/");
        Ok(self.files.keys().any(|k| k.starts_with(&prefix)).then_some(FileKind::Directory))
    }

    fn confined(&mut self, _path: &PackagePath) -> Result<bool, Infallible> {
        Ok(true) // no symlinks in a map
    }

    fn read(&mut self, path: &PackagePath) -> Result<Vec<u8>, Infallible> {
        Ok(self.files.get(path.as_str()).cloned().unwrap_or_default())
    }

    fn list(&mut self, path: &PackagePath) -> Result<Vec<DirEntry>, Infallible> {
        let prefix = format!("{}/", path.as_str());
        let mut names: Vec<DirEntry> = Vec::new();
        for key in self.files.keys() {
            let Some(rest) = key.strip_prefix(&prefix) else {
                continue;
            };
            let (name, kind) = match rest.split_once('/') {
                Some((dir, _)) => (dir, FileKind::Directory),
                None => (rest, FileKind::File),
            };
            if names.last().map(|e: &DirEntry| e.name.as_str()) != Some(name) {
                names.push(DirEntry::new(name, kind));
            }
        }
        Ok(names) // BTreeMap iteration order keeps this sorted and deduped
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::load::McpOutcome;

    const MANIFEST: &[u8] = br#"{
        "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
        "name": "mem-plugin"
    }"#;

    #[test]
    fn loads_skills_and_ignores_deeper_nesting() {
        let plugin = MemSource::new()
            .file("plugin.json", MANIFEST)
            .file(
                "skills/greet/SKILL.md",
                b"---\nname: greet\ndescription: Greets. Use when greeting.\n---\nSay hi.\n"
                    .to_vec(),
            )
            // ยง7.1: deeper descendants are never searched for skills.
            .file(
                "skills/greet/nested/SKILL.md",
                b"---\nname: nested\ndescription: d\n---\n".to_vec(),
            )
            .file("skills/loose-file.md", b"not a skill".to_vec())
            .load()
            .unwrap();
        assert_eq!(plugin.skills.len(), 1);
        assert_eq!(plugin.skills[0].meta.name, "greet");
        assert_eq!(plugin.mcp, McpOutcome::Absent);
        assert!(plugin.diagnostics.is_empty());
    }

    #[test]
    fn missing_manifest_rejects() {
        let outcome = MemSource::new().load();
        assert!(matches!(outcome, Err(Rejection::ManifestMissing)));
    }
}