souk_core/validation/
extends.rs1use 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
13pub 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
119fn 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}