Skip to main content

metarepo_core/
module_manifest.rs

1//! Parsing for `meta.module.toml` — the manifest that turns a single repository
2//! into a **meta module**: a self-contained bundle of the plugin(s) it provides
3//! and the Claude Code skill(s) that drive them.
4//!
5//! A module manifest is a thin index. It does not redefine the plugin or skill
6//! formats — its `plugins` entries point at existing `plugin.manifest.*` files
7//! (or protocol-plugin binaries) and its `skills` entries point at `SKILL.md`
8//! directories. See `docs/MODULES.md` for the design.
9//!
10//! This mirrors the structure and multi-format loader of [`crate::PluginManifest`].
11
12use anyhow::{Context, Result};
13use serde::{Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15
16/// Module manifest filenames the loader recognizes, in priority order.
17pub const MODULE_MANIFEST_FILENAMES: &[&str] = &[
18    "meta.module.toml",
19    "meta.module.yaml",
20    "meta.module.yml",
21    "meta.module.json",
22];
23
24/// Top-level `meta.module.*` structure. Everything lives under the `[module]`
25/// table, so the manifest reads as `[module]` + `[[module.plugins]]` +
26/// `[[module.skills]]`.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct MetaModuleManifest {
29    /// Module metadata and contributions.
30    pub module: ModuleInfo,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ModuleInfo {
35    pub name: String,
36    pub version: String,
37    #[serde(default)]
38    pub description: String,
39    #[serde(default)]
40    pub author: String,
41    #[serde(default)]
42    pub repository: String,
43    /// Minimum `meta` version required to wire this module up. Enforced by the
44    /// host at enable time (semantics live in the `meta` crate's verify module).
45    #[serde(default)]
46    pub min_meta_version: Option<String>,
47
48    /// Plugin(s) this module provides. May be empty for a skill-only module.
49    #[serde(default)]
50    pub plugins: Vec<ModulePluginRef>,
51
52    /// Skill(s) this module ships. May be empty for a plugin-only module.
53    #[serde(default)]
54    pub skills: Vec<ModuleSkillRef>,
55}
56
57/// A reference to one plugin provided by the module. Exactly one of `manifest`
58/// or `binary` must be set:
59/// - `manifest` — path (relative to the repo root) to a `plugin.manifest.*`
60///   (a manifest plugin, executed via argv dispatch).
61/// - `binary` — path to an executable; with `protocol = true` it speaks the
62///   stdio plugin protocol, otherwise it is treated as a manifest-less binary.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ModulePluginRef {
65    #[serde(default)]
66    pub manifest: Option<String>,
67    #[serde(default)]
68    pub binary: Option<String>,
69    #[serde(default)]
70    pub protocol: bool,
71}
72
73/// A reference to one skill shipped by the module: a directory (relative to the
74/// repo root) containing a `SKILL.md`.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ModuleSkillRef {
77    pub path: String,
78}
79
80impl ModulePluginRef {
81    /// The repo-relative source path the host should read for this plugin
82    /// (the manifest path when set, otherwise the binary path).
83    pub fn source(&self) -> Option<&str> {
84        self.manifest.as_deref().or(self.binary.as_deref())
85    }
86}
87
88impl MetaModuleManifest {
89    /// Load a manifest, choosing the parser by file extension
90    /// (`.toml`, `.yaml`/`.yml`, `.json`). Defaults to TOML for unknown
91    /// extensions. Validates before returning.
92    pub fn from_file_auto(path: &Path) -> Result<Self> {
93        let content = std::fs::read_to_string(path)
94            .with_context(|| format!("Failed to read module manifest {}", path.display()))?;
95        let ext = path
96            .extension()
97            .and_then(|e| e.to_str())
98            .unwrap_or("")
99            .to_ascii_lowercase();
100        let manifest: MetaModuleManifest = match ext.as_str() {
101            "json" => serde_json::from_str(&content)
102                .with_context(|| format!("Invalid JSON module manifest {}", path.display()))?,
103            "yaml" | "yml" => serde_yaml::from_str(&content)
104                .with_context(|| format!("Invalid YAML module manifest {}", path.display()))?,
105            _ => toml::from_str(&content)
106                .with_context(|| format!("Invalid TOML module manifest {}", path.display()))?,
107        };
108        manifest.validate()?;
109        Ok(manifest)
110    }
111
112    /// Find a `meta.module.*` file directly inside `dir`, if one exists.
113    pub fn find_in_dir(dir: &Path) -> Option<PathBuf> {
114        MODULE_MANIFEST_FILENAMES
115            .iter()
116            .map(|name| dir.join(name))
117            .find(|p| p.is_file())
118    }
119
120    /// Whether a path is a recognized module-manifest filename.
121    pub fn is_manifest_path(path: &Path) -> bool {
122        path.file_name()
123            .and_then(|n| n.to_str())
124            .map(|n| MODULE_MANIFEST_FILENAMES.contains(&n))
125            .unwrap_or(false)
126    }
127
128    /// Validate the manifest's internal consistency.
129    pub fn validate(&self) -> Result<()> {
130        if self.module.name.is_empty() {
131            return Err(anyhow::anyhow!("Module name cannot be empty"));
132        }
133        if self.module.version.is_empty() {
134            return Err(anyhow::anyhow!("Module version cannot be empty"));
135        }
136        if self.module.plugins.is_empty() && self.module.skills.is_empty() {
137            return Err(anyhow::anyhow!(
138                "Module '{}' contributes nothing: declare at least one [[module.plugins]] or [[module.skills]]",
139                self.module.name
140            ));
141        }
142        for (i, p) in self.module.plugins.iter().enumerate() {
143            match (p.manifest.is_some(), p.binary.is_some()) {
144                (true, true) => {
145                    return Err(anyhow::anyhow!(
146                        "Module '{}' plugin #{} sets both 'manifest' and 'binary' (set exactly one)",
147                        self.module.name,
148                        i + 1
149                    ));
150                }
151                (false, false) => {
152                    return Err(anyhow::anyhow!(
153                        "Module '{}' plugin #{} sets neither 'manifest' nor 'binary' (set exactly one)",
154                        self.module.name,
155                        i + 1
156                    ));
157                }
158                _ => {}
159            }
160        }
161        for (i, s) in self.module.skills.iter().enumerate() {
162            if s.path.is_empty() {
163                return Err(anyhow::anyhow!(
164                    "Module '{}' skill #{} has an empty path",
165                    self.module.name,
166                    i + 1
167                ));
168            }
169        }
170        Ok(())
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use tempfile::tempdir;
178
179    const TOML_SRC: &str = r#"
180[module]
181name = "example"
182version = "0.1.0"
183description = "An example module"
184
185[[module.plugins]]
186manifest = "plugin/plugin.manifest.toml"
187
188[[module.skills]]
189path = "skills/example-skill"
190"#;
191
192    const YAML_SRC: &str = r#"
193module:
194  name: example
195  version: 0.1.0
196  description: An example module
197  plugins:
198    - manifest: plugin/plugin.manifest.toml
199  skills:
200    - path: skills/example-skill
201"#;
202
203    const JSON_SRC: &str = r#"
204{
205  "module": {
206    "name": "example", "version": "0.1.0", "description": "An example module",
207    "plugins": [ { "manifest": "plugin/plugin.manifest.toml" } ],
208    "skills": [ { "path": "skills/example-skill" } ]
209  }
210}
211"#;
212
213    fn write(dir: &Path, name: &str, content: &str) -> PathBuf {
214        let p = dir.join(name);
215        std::fs::write(&p, content).unwrap();
216        p
217    }
218
219    #[test]
220    fn loads_all_three_formats_equivalently() {
221        let dir = tempdir().unwrap();
222        for (file, src) in [
223            ("meta.module.toml", TOML_SRC),
224            ("meta.module.yaml", YAML_SRC),
225            ("meta.module.json", JSON_SRC),
226        ] {
227            let path = write(dir.path(), file, src);
228            let m = MetaModuleManifest::from_file_auto(&path).unwrap();
229            assert_eq!(m.module.name, "example");
230            assert_eq!(m.module.plugins.len(), 1);
231            assert_eq!(
232                m.module.plugins[0].source(),
233                Some("plugin/plugin.manifest.toml")
234            );
235            assert_eq!(m.module.skills.len(), 1);
236            assert_eq!(m.module.skills[0].path, "skills/example-skill");
237        }
238    }
239
240    #[test]
241    fn find_in_dir_prefers_toml() {
242        let dir = tempdir().unwrap();
243        write(dir.path(), "meta.module.json", JSON_SRC);
244        assert!(MetaModuleManifest::find_in_dir(dir.path())
245            .unwrap()
246            .ends_with("meta.module.json"));
247        write(dir.path(), "meta.module.toml", TOML_SRC);
248        assert!(MetaModuleManifest::find_in_dir(dir.path())
249            .unwrap()
250            .ends_with("meta.module.toml"));
251    }
252
253    #[test]
254    fn rejects_empty_contribution() {
255        let src = "[module]\nname = \"x\"\nversion = \"0.1.0\"\n";
256        let err = MetaModuleManifest::from_toml_for_test(src).unwrap_err();
257        assert!(err.to_string().contains("contributes nothing"));
258    }
259
260    #[test]
261    fn rejects_plugin_with_both_manifest_and_binary() {
262        let src = "[module]\nname = \"x\"\nversion = \"0.1.0\"\n\
263                   [[module.plugins]]\nmanifest = \"a\"\nbinary = \"b\"\n";
264        let err = MetaModuleManifest::from_toml_for_test(src).unwrap_err();
265        assert!(err.to_string().contains("exactly one"));
266    }
267
268    #[test]
269    fn rejects_plugin_with_neither_manifest_nor_binary() {
270        let src = "[module]\nname = \"x\"\nversion = \"0.1.0\"\n\
271                   [[module.plugins]]\nprotocol = true\n";
272        let err = MetaModuleManifest::from_toml_for_test(src).unwrap_err();
273        assert!(err.to_string().contains("exactly one"));
274    }
275
276    impl MetaModuleManifest {
277        /// Parse-and-validate from a TOML string (test helper).
278        fn from_toml_for_test(content: &str) -> Result<Self> {
279            let m: MetaModuleManifest = toml::from_str(content)?;
280            m.validate()?;
281            Ok(m)
282        }
283    }
284}