1use std::process::Command;
8
9use crate::discovery::MarketplaceConfig;
10use crate::error::{SoukError, ValidationDiagnostic, ValidationResult};
11use crate::validation::{validate_marketplace, validate_plugin};
12
13pub fn detect_changed_plugins(config: &MarketplaceConfig) -> Result<Vec<String>, SoukError> {
24 let output = Command::new("git")
25 .args(["diff", "--cached", "--name-only"])
26 .current_dir(&config.project_root)
27 .output()
28 .map_err(|e| SoukError::Other(format!("Failed to run git: {e}")))?;
29
30 if !output.status.success() {
31 return Err(SoukError::Other("git diff failed".into()));
32 }
33
34 let stdout = String::from_utf8_lossy(&output.stdout);
35 let plugin_root_rel = config.marketplace.normalized_plugin_root();
36 let prefix = plugin_root_rel
38 .strip_prefix("./")
39 .unwrap_or(&plugin_root_rel);
40
41 let mut plugin_names: Vec<String> = stdout
42 .lines()
43 .filter_map(|line| {
44 let line = line.trim();
45 if line.starts_with(prefix) {
46 let rest = line.strip_prefix(prefix)?.trim_start_matches('/');
48 let name = rest.split('/').next()?;
49 if name.is_empty() {
50 None
51 } else {
52 Some(name.to_string())
53 }
54 } else {
55 None
56 }
57 })
58 .collect();
59
60 plugin_names.sort();
61 plugin_names.dedup();
62
63 Ok(plugin_names)
64}
65
66pub fn is_marketplace_staged(config: &MarketplaceConfig) -> Result<bool, SoukError> {
76 let output = Command::new("git")
77 .args(["diff", "--cached", "--name-only"])
78 .current_dir(&config.project_root)
79 .output()
80 .map_err(|e| SoukError::Other(format!("Failed to run git: {e}")))?;
81
82 if !output.status.success() {
83 return Err(SoukError::Other("git diff failed".into()));
84 }
85
86 let stdout = String::from_utf8_lossy(&output.stdout);
87 Ok(stdout.lines().any(|line| line.contains("marketplace.json")))
88}
89
90pub fn run_pre_commit(config: &MarketplaceConfig) -> ValidationResult {
100 let mut result = ValidationResult::new();
101
102 let changed = match detect_changed_plugins(config) {
104 Ok(names) => names,
105 Err(e) => {
106 result.push(ValidationDiagnostic::error(format!(
107 "Failed to detect changed plugins: {e}"
108 )));
109 return result;
110 }
111 };
112
113 for name in &changed {
115 let plugin_path = config.plugin_root_abs.join(name);
116 if plugin_path.is_dir() {
117 let plugin_result = validate_plugin(&plugin_path);
118 result.merge(plugin_result);
119 }
120 }
121
122 if let Ok(true) = is_marketplace_staged(config) {
124 let mp_result = validate_marketplace(config, true); result.merge(mp_result);
126 }
127
128 result
129}
130
131pub fn run_pre_push(config: &MarketplaceConfig) -> ValidationResult {
137 validate_marketplace(config, false)
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use crate::discovery::load_marketplace_config;
144 use tempfile::TempDir;
145
146 fn setup_git_marketplace(
148 tmp: &TempDir,
149 plugin_dirs: &[&str],
150 plugins_json: &[(&str, &str)],
151 ) -> MarketplaceConfig {
152 Command::new("git")
154 .args(["init"])
155 .current_dir(tmp.path())
156 .output()
157 .expect("git init failed");
158
159 let claude_dir = tmp.path().join(".claude-plugin");
160 std::fs::create_dir_all(&claude_dir).unwrap();
161 let plugins_dir = tmp.path().join("plugins");
162 std::fs::create_dir_all(&plugins_dir).unwrap();
163
164 for name in plugin_dirs {
166 let p = plugins_dir.join(name).join(".claude-plugin");
167 std::fs::create_dir_all(&p).unwrap();
168 std::fs::write(
169 p.join("plugin.json"),
170 format!(r#"{{"name":"{name}","version":"1.0.0","description":"test plugin"}}"#),
171 )
172 .unwrap();
173 }
174
175 let entries: Vec<String> = plugins_json
177 .iter()
178 .map(|(name, source)| format!(r#"{{"name":"{name}","source":"{source}"}}"#))
179 .collect();
180 let plugins_arr = entries.join(",");
181
182 std::fs::write(
183 claude_dir.join("marketplace.json"),
184 format!(r#"{{"version":"0.1.0","pluginRoot":"./plugins","plugins":[{plugins_arr}]}}"#),
185 )
186 .unwrap();
187
188 load_marketplace_config(&claude_dir.join("marketplace.json")).unwrap()
189 }
190
191 #[test]
192 fn pre_push_validates_entire_marketplace() {
193 let tmp = TempDir::new().unwrap();
194 let config = setup_git_marketplace(&tmp, &["my-plugin"], &[("my-plugin", "my-plugin")]);
195
196 let result = run_pre_push(&config);
197 assert!(
198 !result.has_errors(),
199 "Expected no errors, got: {:?}",
200 result.diagnostics
201 );
202 }
203
204 #[test]
205 fn pre_push_catches_invalid_plugin() {
206 let tmp = TempDir::new().unwrap();
207 let config = setup_git_marketplace(&tmp, &["bad-plugin"], &[("bad-plugin", "bad-plugin")]);
208
209 let plugin_json = tmp
211 .path()
212 .join("plugins/bad-plugin/.claude-plugin/plugin.json");
213 std::fs::write(&plugin_json, "not valid json").unwrap();
214
215 let result = run_pre_push(&config);
216 assert!(result.has_errors());
217 }
218
219 #[test]
220 fn pre_commit_returns_empty_when_no_staged_changes() {
221 let tmp = TempDir::new().unwrap();
222 let config = setup_git_marketplace(&tmp, &["my-plugin"], &[("my-plugin", "my-plugin")]);
223
224 let result = run_pre_commit(&config);
226 assert!(
227 !result.has_errors(),
228 "Expected no errors for clean pre-commit, got: {:?}",
229 result.diagnostics
230 );
231 assert_eq!(
232 result.diagnostics.len(),
233 0,
234 "Expected zero diagnostics when nothing is staged"
235 );
236 }
237
238 #[test]
239 fn detect_changed_plugins_with_no_staged_files() {
240 let tmp = TempDir::new().unwrap();
241 let config = setup_git_marketplace(&tmp, &["alpha"], &[("alpha", "alpha")]);
242
243 let changed = detect_changed_plugins(&config).unwrap();
244 assert!(changed.is_empty());
245 }
246
247 #[test]
248 fn detect_changed_plugins_with_staged_plugin_file() {
249 let tmp = TempDir::new().unwrap();
250 let config = setup_git_marketplace(
251 &tmp,
252 &["alpha", "beta"],
253 &[("alpha", "alpha"), ("beta", "beta")],
254 );
255
256 let test_file = tmp.path().join("plugins/alpha/test.txt");
258 std::fs::write(&test_file, "hello").unwrap();
259 Command::new("git")
260 .args(["add", "plugins/alpha/test.txt"])
261 .current_dir(tmp.path())
262 .output()
263 .expect("git add failed");
264
265 let changed = detect_changed_plugins(&config).unwrap();
266 assert_eq!(changed, vec!["alpha"]);
267 }
268
269 #[test]
270 fn detect_changed_plugins_deduplicates() {
271 let tmp = TempDir::new().unwrap();
272 let config = setup_git_marketplace(&tmp, &["alpha"], &[("alpha", "alpha")]);
273
274 let file1 = tmp.path().join("plugins/alpha/a.txt");
276 let file2 = tmp.path().join("plugins/alpha/b.txt");
277 std::fs::write(&file1, "a").unwrap();
278 std::fs::write(&file2, "b").unwrap();
279 Command::new("git")
280 .args(["add", "plugins/alpha/a.txt", "plugins/alpha/b.txt"])
281 .current_dir(tmp.path())
282 .output()
283 .expect("git add failed");
284
285 let changed = detect_changed_plugins(&config).unwrap();
286 assert_eq!(changed, vec!["alpha"]);
287 }
288
289 #[test]
290 fn is_marketplace_staged_returns_false_when_not_staged() {
291 let tmp = TempDir::new().unwrap();
292 let config = setup_git_marketplace(&tmp, &["alpha"], &[("alpha", "alpha")]);
293
294 assert!(!is_marketplace_staged(&config).unwrap());
295 }
296
297 #[test]
298 fn is_marketplace_staged_returns_true_when_staged() {
299 let tmp = TempDir::new().unwrap();
300 let config = setup_git_marketplace(&tmp, &["alpha"], &[("alpha", "alpha")]);
301
302 Command::new("git")
304 .args(["add", ".claude-plugin/marketplace.json"])
305 .current_dir(tmp.path())
306 .output()
307 .expect("git add failed");
308
309 assert!(is_marketplace_staged(&config).unwrap());
310 }
311
312 #[test]
313 fn pre_commit_validates_staged_plugin() {
314 let tmp = TempDir::new().unwrap();
315 let config = setup_git_marketplace(&tmp, &["alpha"], &[("alpha", "alpha")]);
316
317 let test_file = tmp.path().join("plugins/alpha/readme.txt");
319 std::fs::write(&test_file, "some content").unwrap();
320 Command::new("git")
321 .args(["add", "plugins/alpha/readme.txt"])
322 .current_dir(tmp.path())
323 .output()
324 .expect("git add failed");
325
326 let result = run_pre_commit(&config);
328 assert!(
329 !result.has_errors(),
330 "Expected valid plugin to pass pre-commit: {:?}",
331 result.diagnostics
332 );
333 }
334
335 #[test]
336 fn pre_commit_catches_invalid_staged_plugin() {
337 let tmp = TempDir::new().unwrap();
338 let config = setup_git_marketplace(&tmp, &["broken"], &[("broken", "broken")]);
339
340 let plugin_json = tmp.path().join("plugins/broken/.claude-plugin/plugin.json");
342 std::fs::write(&plugin_json, "not json").unwrap();
343
344 let test_file = tmp.path().join("plugins/broken/file.txt");
346 std::fs::write(&test_file, "content").unwrap();
347 Command::new("git")
348 .args(["add", "plugins/broken/file.txt"])
349 .current_dir(tmp.path())
350 .output()
351 .expect("git add failed");
352
353 let result = run_pre_commit(&config);
354 assert!(result.has_errors());
355 }
356
357 #[test]
358 fn pre_commit_validates_marketplace_when_staged() {
359 let tmp = TempDir::new().unwrap();
360 let config = setup_git_marketplace(&tmp, &["alpha"], &[("alpha", "alpha")]);
361
362 Command::new("git")
364 .args(["add", ".claude-plugin/marketplace.json"])
365 .current_dir(tmp.path())
366 .output()
367 .expect("git add failed");
368
369 let result = run_pre_commit(&config);
371 assert!(
372 !result.has_errors(),
373 "Expected valid marketplace to pass pre-commit: {:?}",
374 result.diagnostics
375 );
376 }
377}