metarepo_core/
module_manifest.rs1use anyhow::{Context, Result};
13use serde::{Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15
16pub const MODULE_MANIFEST_FILENAMES: &[&str] = &[
18 "meta.module.toml",
19 "meta.module.yaml",
20 "meta.module.yml",
21 "meta.module.json",
22];
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct MetaModuleManifest {
29 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 #[serde(default)]
46 pub min_meta_version: Option<String>,
47
48 #[serde(default)]
50 pub plugins: Vec<ModulePluginRef>,
51
52 #[serde(default)]
54 pub skills: Vec<ModuleSkillRef>,
55}
56
57#[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#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ModuleSkillRef {
77 pub path: String,
78}
79
80impl ModulePluginRef {
81 pub fn source(&self) -> Option<&str> {
84 self.manifest.as_deref().or(self.binary.as_deref())
85 }
86}
87
88impl MetaModuleManifest {
89 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 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 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 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 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}