Skip to main content

agent_plugins/
mem.rs

1//! An in-memory plugin package: files in a map, directories implied.
2//!
3//! Useful for tests, for loading plugins out of archives or version control
4//! without touching disk, and as the smallest possible demonstration that the
5//! loader truly does not care where bytes come from.
6
7use std::collections::BTreeMap;
8use std::convert::Infallible;
9
10use crate::load::{DirEntry, FileKind, LoadedPlugin, Rejection};
11use crate::path::PackagePath;
12use crate::source::{self, Source};
13
14/// A plugin package held entirely in memory.
15///
16/// Keys are `/`-separated paths relative to the plugin root; directories
17/// exist implicitly wherever a key has a parent. There are no symlinks, so
18/// every path is truthfully confined.
19///
20/// ```
21/// use agent_plugins::MemSource;
22///
23/// let plugin = MemSource::new()
24///     .file("plugin.json", br#"{
25///         "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
26///         "name": "in-memory"
27///     }"#)
28///     .load()
29///     .unwrap();
30/// assert_eq!(plugin.manifest.name.as_str(), "in-memory");
31/// ```
32#[derive(Debug, Clone, Default)]
33pub struct MemSource {
34    files: BTreeMap<String, Vec<u8>>,
35}
36
37impl MemSource {
38    /// An empty package (which, containing no manifest, would be rejected).
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Add a file at a `/`-separated path relative to the plugin root.
44    pub fn file(mut self, path: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
45        self.files.insert(path.into(), bytes.into());
46        self
47    }
48
49    /// Load the package.
50    pub fn load(mut self) -> Result<LoadedPlugin, Rejection> {
51        source::load(&mut self)
52    }
53}
54
55impl Source for MemSource {
56    type Error = Infallible;
57
58    fn kind(&mut self, path: &PackagePath) -> Result<Option<FileKind>, Infallible> {
59        let path = path.as_str();
60        if self.files.contains_key(path) {
61            return Ok(Some(FileKind::File));
62        }
63        let prefix = format!("{path}/");
64        Ok(self.files.keys().any(|k| k.starts_with(&prefix)).then_some(FileKind::Directory))
65    }
66
67    fn confined(&mut self, _path: &PackagePath) -> Result<bool, Infallible> {
68        Ok(true) // no symlinks in a map
69    }
70
71    fn read(&mut self, path: &PackagePath) -> Result<Vec<u8>, Infallible> {
72        Ok(self.files.get(path.as_str()).cloned().unwrap_or_default())
73    }
74
75    fn list(&mut self, path: &PackagePath) -> Result<Vec<DirEntry>, Infallible> {
76        let prefix = format!("{}/", path.as_str());
77        let mut names: Vec<DirEntry> = Vec::new();
78        for key in self.files.keys() {
79            let Some(rest) = key.strip_prefix(&prefix) else {
80                continue;
81            };
82            let (name, kind) = match rest.split_once('/') {
83                Some((dir, _)) => (dir, FileKind::Directory),
84                None => (rest, FileKind::File),
85            };
86            if names.last().map(|e: &DirEntry| e.name.as_str()) != Some(name) {
87                names.push(DirEntry::new(name, kind));
88            }
89        }
90        Ok(names) // BTreeMap iteration order keeps this sorted and deduped
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::load::McpOutcome;
98
99    const MANIFEST: &[u8] = br#"{
100        "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
101        "name": "mem-plugin"
102    }"#;
103
104    #[test]
105    fn loads_skills_and_ignores_deeper_nesting() {
106        let plugin = MemSource::new()
107            .file("plugin.json", MANIFEST)
108            .file(
109                "skills/greet/SKILL.md",
110                b"---\nname: greet\ndescription: Greets. Use when greeting.\n---\nSay hi.\n"
111                    .to_vec(),
112            )
113            // ยง7.1: deeper descendants are never searched for skills.
114            .file(
115                "skills/greet/nested/SKILL.md",
116                b"---\nname: nested\ndescription: d\n---\n".to_vec(),
117            )
118            .file("skills/loose-file.md", b"not a skill".to_vec())
119            .load()
120            .unwrap();
121        assert_eq!(plugin.skills.len(), 1);
122        assert_eq!(plugin.skills[0].meta.name, "greet");
123        assert_eq!(plugin.mcp, McpOutcome::Absent);
124        assert!(plugin.diagnostics.is_empty());
125    }
126
127    #[test]
128    fn missing_manifest_rejects() {
129        let outcome = MemSource::new().load();
130        assert!(matches!(outcome, Err(Rejection::ManifestMissing)));
131    }
132}