Skip to main content

souk_core/validation/
extends.rs

1use std::path::Path;
2
3use crate::error::{ValidationDiagnostic, ValidationResult};
4use crate::types::version_constraint::is_valid_version_constraint;
5
6const ALLOWED_KEYS: &[&str] = &[
7    "dependencies",
8    "optionalDependencies",
9    "systemDependencies",
10    "optionalSystemDependencies",
11];
12
13/// Validates the `extends-plugin.json` file within a plugin directory.
14///
15/// This file allows a plugin to declare dependencies on other plugins or
16/// system packages. Each section must be a JSON object mapping dependency
17/// names to version constraints (either a string or an object with a
18/// `version` field).
19///
20/// Returns an empty result if the file does not exist (it is optional).
21pub fn validate_extends_plugin(plugin_path: &Path) -> ValidationResult {
22    let mut result = ValidationResult::new();
23    let extends_path = plugin_path
24        .join(".claude-plugin")
25        .join("extends-plugin.json");
26
27    if !extends_path.is_file() {
28        return result;
29    }
30
31    let content = match std::fs::read_to_string(&extends_path) {
32        Ok(c) => c,
33        Err(e) => {
34            result.push(
35                ValidationDiagnostic::error(format!("Cannot read extends-plugin.json: {e}"))
36                    .with_path(&extends_path),
37            );
38            return result;
39        }
40    };
41
42    let doc: serde_json::Value = match serde_json::from_str(&content) {
43        Ok(v) => v,
44        Err(e) => {
45            result.push(
46                ValidationDiagnostic::error(format!("Invalid JSON in extends-plugin.json: {e}"))
47                    .with_path(&extends_path),
48            );
49            return result;
50        }
51    };
52
53    let Some(obj) = doc.as_object() else {
54        result.push(
55            ValidationDiagnostic::error("extends-plugin.json must be a JSON object")
56                .with_path(&extends_path),
57        );
58        return result;
59    };
60
61    for key in obj.keys() {
62        if !ALLOWED_KEYS.contains(&key.as_str()) {
63            result.push(
64                ValidationDiagnostic::error(format!("Invalid key in extends-plugin.json: {key}"))
65                    .with_path(&extends_path)
66                    .with_field(key.clone()),
67            );
68        }
69    }
70
71    for section_name in ALLOWED_KEYS {
72        if let Some(section) = obj.get(*section_name) {
73            if section.is_null() {
74                continue;
75            }
76            let Some(section_obj) = section.as_object() else {
77                result.push(
78                    ValidationDiagnostic::error(format!(
79                        "Invalid {section_name} in extends-plugin.json: expected object, got {}",
80                        value_type_name(section)
81                    ))
82                    .with_path(&extends_path)
83                    .with_field(section_name.to_string()),
84                );
85                continue;
86            };
87
88            for (dep_name, dep_value) in section_obj {
89                let version = extract_version(dep_value);
90                match version {
91                    Some(v) => {
92                        if !is_valid_version_constraint(&v) {
93                            result.push(
94                                ValidationDiagnostic::error(format!(
95                                    "Invalid version constraint in {section_name}: {v} (for {dep_name})"
96                                ))
97                                .with_path(&extends_path)
98                                .with_field(format!("{section_name}.{dep_name}")),
99                            );
100                        }
101                    }
102                    None => {
103                        result.push(
104                            ValidationDiagnostic::error(format!(
105                                "Invalid dependency value in {section_name}: must be string or object with version (for {dep_name})"
106                            ))
107                            .with_path(&extends_path)
108                            .with_field(format!("{section_name}.{dep_name}")),
109                        );
110                    }
111                }
112            }
113        }
114    }
115
116    result
117}
118
119/// Extracts a version constraint string from a dependency value.
120///
121/// A dependency value can be:
122/// - A string (the version constraint itself)
123/// - An object with an optional `version` field (defaults to `"*"`)
124/// - Anything else returns `None` (invalid)
125fn extract_version(value: &serde_json::Value) -> Option<String> {
126    if let Some(s) = value.as_str() {
127        Some(s.to_string())
128    } else {
129        value.as_object().map(|obj| {
130            obj.get("version")
131                .and_then(|v| v.as_str())
132                .unwrap_or("*")
133                .to_string()
134        })
135    }
136}
137
138fn value_type_name(v: &serde_json::Value) -> &'static str {
139    match v {
140        serde_json::Value::Array(_) => "array",
141        serde_json::Value::Bool(_) => "boolean",
142        serde_json::Value::Number(_) => "number",
143        serde_json::Value::String(_) => "string",
144        serde_json::Value::Null => "null",
145        serde_json::Value::Object(_) => "object",
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use tempfile::TempDir;
153
154    fn write_extends(tmp: &TempDir, content: &str) -> std::path::PathBuf {
155        let plugin = tmp.path().join("test-plugin");
156        let claude = plugin.join(".claude-plugin");
157        std::fs::create_dir_all(&claude).unwrap();
158        std::fs::write(claude.join("extends-plugin.json"), content).unwrap();
159        plugin
160    }
161
162    #[test]
163    fn valid_extends() {
164        let tmp = TempDir::new().unwrap();
165        let plugin = write_extends(
166            &tmp,
167            r#"{
168            "dependencies": {"foo": "^1.0.0"},
169            "optionalDependencies": {"bar": {"version": "~2.0.0"}},
170            "systemDependencies": {"baz": "*"}
171        }"#,
172        );
173        let result = validate_extends_plugin(&plugin);
174        assert!(!result.has_errors());
175    }
176
177    #[test]
178    fn missing_file_is_ok() {
179        let tmp = TempDir::new().unwrap();
180        let result = validate_extends_plugin(tmp.path());
181        assert!(!result.has_errors());
182    }
183
184    #[test]
185    fn invalid_json() {
186        let tmp = TempDir::new().unwrap();
187        let plugin = write_extends(&tmp, "not json");
188        let result = validate_extends_plugin(&plugin);
189        assert!(result.has_errors());
190    }
191
192    #[test]
193    fn invalid_top_level_key() {
194        let tmp = TempDir::new().unwrap();
195        let plugin = write_extends(&tmp, r#"{"badKey": {}}"#);
196        let result = validate_extends_plugin(&plugin);
197        assert!(result.has_errors());
198        assert!(result.diagnostics[0].message.contains("Invalid key"));
199    }
200
201    #[test]
202    fn section_must_be_object() {
203        let tmp = TempDir::new().unwrap();
204        let plugin = write_extends(&tmp, r#"{"dependencies": ["not", "an", "object"]}"#);
205        let result = validate_extends_plugin(&plugin);
206        assert!(result.has_errors());
207        assert!(result.diagnostics[0].message.contains("expected object"));
208    }
209
210    #[test]
211    fn invalid_version_constraint() {
212        let tmp = TempDir::new().unwrap();
213        let plugin = write_extends(&tmp, r#"{"dependencies": {"foo": "latest"}}"#);
214        let result = validate_extends_plugin(&plugin);
215        assert!(result.has_errors());
216        assert!(result.diagnostics[0]
217            .message
218            .contains("Invalid version constraint"));
219    }
220
221    #[test]
222    fn object_value_without_version_defaults_to_star() {
223        let tmp = TempDir::new().unwrap();
224        let plugin = write_extends(&tmp, r#"{"dependencies": {"foo": {"notes": "optional"}}}"#);
225        let result = validate_extends_plugin(&plugin);
226        assert!(!result.has_errors());
227    }
228
229    #[test]
230    fn non_string_non_object_value() {
231        let tmp = TempDir::new().unwrap();
232        let plugin = write_extends(&tmp, r#"{"dependencies": {"foo": 42}}"#);
233        let result = validate_extends_plugin(&plugin);
234        assert!(result.has_errors());
235        assert!(result.diagnostics[0]
236            .message
237            .contains("must be string or object"));
238    }
239}