Skip to main content

souk_core/validation/
plugin.rs

1use std::path::Path;
2
3use crate::error::{ValidationDiagnostic, ValidationResult};
4use crate::types::plugin::PluginManifest;
5use crate::validation::extends::validate_extends_plugin;
6
7/// Validates a plugin directory.
8///
9/// Checks that:
10/// - The path exists and is a directory
11/// - It contains a `.claude-plugin/` subdirectory
12/// - The `.claude-plugin/plugin.json` file exists and is valid JSON
13/// - Required fields (`name`, `version`, `description`) are present and non-null
14/// - The `version` field is valid semver
15/// - If an `extends-plugin.json` exists, it is also validated
16pub 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}