souk_core/validation/
plugin.rs1use std::path::Path;
2
3use crate::error::{ValidationDiagnostic, ValidationResult};
4use crate::types::plugin::PluginManifest;
5use crate::validation::extends::validate_extends_plugin;
6
7pub fn validate_plugin(plugin_path: &Path) -> ValidationResult {
17 let mut result = ValidationResult::new();
18
19 if !plugin_path.is_dir() {
20 result.push(
21 ValidationDiagnostic::error(format!(
22 "Plugin path does not exist or is not a directory: {}",
23 plugin_path.display()
24 ))
25 .with_path(plugin_path),
26 );
27 return result;
28 }
29
30 let claude_dir = plugin_path.join(".claude-plugin");
31
32 if !claude_dir.is_dir() {
33 result.push(
34 ValidationDiagnostic::error("Missing .claude-plugin directory").with_path(plugin_path),
35 );
36 return result;
37 }
38
39 let plugin_json_path = claude_dir.join("plugin.json");
40
41 if !plugin_json_path.is_file() {
42 result.push(ValidationDiagnostic::error("Missing plugin.json").with_path(&claude_dir));
43 return result;
44 }
45
46 let content = match std::fs::read_to_string(&plugin_json_path) {
47 Ok(c) => c,
48 Err(e) => {
49 result.push(
50 ValidationDiagnostic::error(format!("Cannot read plugin.json: {e}"))
51 .with_path(&plugin_json_path),
52 );
53 return result;
54 }
55 };
56
57 let manifest: PluginManifest = match serde_json::from_str(&content) {
58 Ok(m) => m,
59 Err(e) => {
60 result.push(
61 ValidationDiagnostic::error(format!("Invalid JSON in plugin.json: {e}"))
62 .with_path(&plugin_json_path),
63 );
64 return result;
65 }
66 };
67
68 if manifest.name_str().is_none() {
69 result.push(
70 ValidationDiagnostic::error("Missing or null required field: name")
71 .with_path(&plugin_json_path)
72 .with_field("name"),
73 );
74 }
75
76 let version_str = manifest.version_str();
77 if version_str.is_none() {
78 result.push(
79 ValidationDiagnostic::error("Missing or null required field: version")
80 .with_path(&plugin_json_path)
81 .with_field("version"),
82 );
83 }
84
85 if manifest.description_str().is_none() {
86 result.push(
87 ValidationDiagnostic::error("Missing or null required field: description")
88 .with_path(&plugin_json_path)
89 .with_field("description"),
90 );
91 }
92
93 if let Some(v) = version_str {
94 if semver::Version::parse(v).is_err() {
95 result.push(
96 ValidationDiagnostic::error(format!("Invalid semver version: {v}"))
97 .with_path(&plugin_json_path)
98 .with_field("version"),
99 );
100 }
101 }
102
103 let extends_result = validate_extends_plugin(plugin_path);
104 result.merge(extends_result);
105
106 result
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use std::path::Path;
113 use tempfile::TempDir;
114
115 fn make_valid_plugin(tmp: &TempDir) -> std::path::PathBuf {
116 let plugin = tmp.path().join("good-plugin");
117 let claude = plugin.join(".claude-plugin");
118 std::fs::create_dir_all(&claude).unwrap();
119 std::fs::write(
120 claude.join("plugin.json"),
121 r#"{"name": "good-plugin", "version": "1.0.0", "description": "A good plugin"}"#,
122 )
123 .unwrap();
124 plugin
125 }
126
127 #[test]
128 fn valid_plugin_passes() {
129 let tmp = TempDir::new().unwrap();
130 let plugin = make_valid_plugin(&tmp);
131 let result = validate_plugin(&plugin);
132 assert!(
133 !result.has_errors(),
134 "diagnostics: {:?}",
135 result.diagnostics
136 );
137 }
138
139 #[test]
140 fn nonexistent_path() {
141 let result = validate_plugin(Path::new("/tmp/nonexistent-plugin-xyz"));
142 assert!(result.has_errors());
143 assert!(result.diagnostics[0].message.contains("does not exist"));
144 }
145
146 #[test]
147 fn missing_claude_plugin_dir() {
148 let tmp = TempDir::new().unwrap();
149 let plugin = tmp.path().join("bare-dir");
150 std::fs::create_dir_all(&plugin).unwrap();
151 let result = validate_plugin(&plugin);
152 assert!(result.has_errors());
153 assert!(result.diagnostics[0].message.contains(".claude-plugin"));
154 }
155
156 #[test]
157 fn missing_plugin_json() {
158 let tmp = TempDir::new().unwrap();
159 let plugin = tmp.path().join("no-json");
160 std::fs::create_dir_all(plugin.join(".claude-plugin")).unwrap();
161 let result = validate_plugin(&plugin);
162 assert!(result.has_errors());
163 assert!(result.diagnostics[0].message.contains("plugin.json"));
164 }
165
166 #[test]
167 fn invalid_json() {
168 let tmp = TempDir::new().unwrap();
169 let plugin = tmp.path().join("bad-json");
170 let claude = plugin.join(".claude-plugin");
171 std::fs::create_dir_all(&claude).unwrap();
172 std::fs::write(claude.join("plugin.json"), "not json").unwrap();
173 let result = validate_plugin(&plugin);
174 assert!(result.has_errors());
175 }
176
177 #[test]
178 fn missing_required_fields() {
179 let tmp = TempDir::new().unwrap();
180 let plugin = tmp.path().join("empty-fields");
181 let claude = plugin.join(".claude-plugin");
182 std::fs::create_dir_all(&claude).unwrap();
183 std::fs::write(claude.join("plugin.json"), r#"{}"#).unwrap();
184 let result = validate_plugin(&plugin);
185 assert_eq!(result.error_count(), 3);
186 }
187
188 #[test]
189 fn null_name() {
190 let tmp = TempDir::new().unwrap();
191 let plugin = tmp.path().join("null-name");
192 let claude = plugin.join(".claude-plugin");
193 std::fs::create_dir_all(&claude).unwrap();
194 std::fs::write(
195 claude.join("plugin.json"),
196 r#"{"name": null, "version": "1.0.0", "description": "desc"}"#,
197 )
198 .unwrap();
199 let result = validate_plugin(&plugin);
200 assert!(result.has_errors());
201 assert!(result
202 .diagnostics
203 .iter()
204 .any(|d| d.field.as_deref() == Some("name")));
205 }
206
207 #[test]
208 fn invalid_semver() {
209 let tmp = TempDir::new().unwrap();
210 let plugin = tmp.path().join("bad-version");
211 let claude = plugin.join(".claude-plugin");
212 std::fs::create_dir_all(&claude).unwrap();
213 std::fs::write(
214 claude.join("plugin.json"),
215 r#"{"name": "test", "version": "not.semver", "description": "desc"}"#,
216 )
217 .unwrap();
218 let result = validate_plugin(&plugin);
219 assert!(result.has_errors());
220 assert!(result
221 .diagnostics
222 .iter()
223 .any(|d| d.message.contains("semver")));
224 }
225
226 #[test]
227 fn valid_plugin_with_extends() {
228 let tmp = TempDir::new().unwrap();
229 let plugin = make_valid_plugin(&tmp);
230 std::fs::write(
231 plugin.join(".claude-plugin").join("extends-plugin.json"),
232 r#"{"dependencies": {"foo": "^1.0.0"}}"#,
233 )
234 .unwrap();
235 let result = validate_plugin(&plugin);
236 assert!(!result.has_errors());
237 }
238}