1use std::collections::HashSet;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use crate::discovery::{load_marketplace_config, MarketplaceConfig};
17use crate::error::SoukError;
18use crate::ops::AtomicGuard;
19use crate::resolution::{plugin_path_to_source, resolve_plugin};
20use crate::types::{Marketplace, PluginEntry, PluginManifest};
21use crate::validation::{validate_marketplace, validate_plugin};
22use crate::version::{bump_patch, generate_unique_name};
23
24#[derive(Debug, Clone)]
26pub struct AddAction {
27 pub plugin_path: PathBuf,
29 pub plugin_name: String,
31 pub source: String,
33 pub is_external: bool,
35 pub conflict: Option<ConflictResolution>,
37}
38
39#[derive(Debug, Clone)]
41pub enum ConflictResolution {
42 Skip,
44 Replace,
46 Rename(String),
48}
49
50#[derive(Debug, Clone)]
52pub struct AddPlan {
53 pub actions: Vec<AddAction>,
54}
55
56pub fn plan_add(
76 inputs: &[String],
77 config: &MarketplaceConfig,
78 strategy: &str,
79 no_copy: bool,
80) -> Result<AddPlan, SoukError> {
81 let existing_names: HashSet<String> = config
82 .marketplace
83 .plugins
84 .iter()
85 .map(|p| p.name.clone())
86 .collect();
87
88 let mut actions = Vec::new();
89 let mut errors: Vec<String> = Vec::new();
90
91 for input in inputs {
92 let plugin_path = match resolve_plugin_input(input, config) {
94 Ok(p) => p,
95 Err(e) => {
96 errors.push(format!("Plugin not found: {input} ({e})"));
97 continue;
98 }
99 };
100
101 let manifest = read_plugin_manifest(&plugin_path)?;
103 let plugin_name = manifest
104 .name_str()
105 .ok_or_else(|| {
106 SoukError::Other(format!(
107 "Plugin has no name in plugin.json: {}",
108 plugin_path.display()
109 ))
110 })?
111 .to_string();
112
113 let validation = validate_plugin(&plugin_path);
115 if validation.has_errors() {
116 errors.push(format!(
117 "Plugin validation failed: {plugin_name} ({})",
118 plugin_path.display()
119 ));
120 continue;
121 }
122
123 let (source, is_internal) = plugin_path_to_source(&plugin_path, config);
125 let is_external = !is_internal;
126
127 let final_source = if is_external && !no_copy {
129 plugin_name.clone()
131 } else {
132 source
133 };
134
135 let conflict = if existing_names.contains(&plugin_name) {
137 match strategy {
138 "abort" => {
139 return Err(SoukError::PluginAlreadyExists(plugin_name));
140 }
141 "skip" => Some(ConflictResolution::Skip),
142 "replace" => Some(ConflictResolution::Replace),
143 "rename" => {
144 let new_name = generate_unique_name(&plugin_name, &existing_names);
145 Some(ConflictResolution::Rename(new_name))
146 }
147 _ => {
148 return Err(SoukError::Other(format!(
149 "Invalid conflict strategy: {strategy}"
150 )));
151 }
152 }
153 } else {
154 None
155 };
156
157 actions.push(AddAction {
158 plugin_path,
159 plugin_name,
160 source: final_source,
161 is_external,
162 conflict,
163 });
164 }
165
166 if !errors.is_empty() {
167 return Err(SoukError::Other(errors.join("; ")));
168 }
169
170 Ok(AddPlan { actions })
171}
172
173fn execute_add_marketplace(
175 effective_actions: &[&AddAction],
176 config: &MarketplaceConfig,
177) -> Result<Vec<String>, SoukError> {
178 let guard = AtomicGuard::new(&config.marketplace_path)?;
179
180 let content = fs::read_to_string(&config.marketplace_path)?;
181 let mut marketplace: Marketplace = serde_json::from_str(&content)?;
182
183 let mut added_names = Vec::new();
184
185 for action in effective_actions {
186 let (final_name, final_source) = match &action.conflict {
187 Some(ConflictResolution::Replace) => {
188 marketplace.plugins.retain(|p| p.name != action.plugin_name);
189 (action.plugin_name.clone(), action.source.clone())
190 }
191 Some(ConflictResolution::Rename(new_name)) => (new_name.clone(), new_name.clone()),
192 Some(ConflictResolution::Skip) => continue,
193 None => (action.plugin_name.clone(), action.source.clone()),
194 };
195
196 let manifest = read_plugin_manifest(&action.plugin_path)?;
197 let tags = manifest.keywords;
198
199 marketplace.plugins.push(PluginEntry {
200 name: final_name.clone(),
201 source: final_source,
202 tags,
203 });
204
205 added_names.push(final_name);
206 }
207
208 marketplace.version = bump_patch(&marketplace.version)?;
209
210 let json = serde_json::to_string_pretty(&marketplace)?;
211 fs::write(&config.marketplace_path, format!("{json}\n"))?;
212
213 let updated_config = load_marketplace_config(&config.marketplace_path)?;
214 let validation = validate_marketplace(&updated_config, true);
215 if validation.has_errors() {
216 drop(guard);
217 return Err(SoukError::AtomicRollback(
218 "Final validation failed after add".to_string(),
219 ));
220 }
221
222 guard.commit()?;
223
224 Ok(added_names)
225}
226
227pub fn execute_add(
238 plan: &AddPlan,
239 config: &MarketplaceConfig,
240 dry_run: bool,
241) -> Result<Vec<String>, SoukError> {
242 let effective_actions: Vec<&AddAction> = plan
244 .actions
245 .iter()
246 .filter(|a| !matches!(a.conflict, Some(ConflictResolution::Skip)))
247 .collect();
248
249 if effective_actions.is_empty() {
250 return Ok(Vec::new());
251 }
252
253 if dry_run {
255 let names: Vec<String> = effective_actions
256 .iter()
257 .map(|a| match &a.conflict {
258 Some(ConflictResolution::Rename(new_name)) => new_name.clone(),
259 _ => a.plugin_name.clone(),
260 })
261 .collect();
262 return Ok(names);
263 }
264
265 let mut copied_dirs: Vec<PathBuf> = Vec::new();
268
269 for action in &effective_actions {
270 if action.is_external && !action.source.starts_with('/') {
271 let target_name = match &action.conflict {
272 Some(ConflictResolution::Rename(new_name)) => new_name.as_str(),
273 _ => &action.source,
274 };
275 let target_dir = config.plugin_root_abs.join(target_name);
276
277 if target_dir.exists() && !matches!(action.conflict, Some(ConflictResolution::Replace))
278 {
279 return Err(SoukError::Other(format!(
280 "Target directory already exists: {}",
281 target_dir.display()
282 )));
283 }
284
285 if matches!(action.conflict, Some(ConflictResolution::Replace)) && target_dir.exists() {
286 fs::remove_dir_all(&target_dir)?;
287 }
288
289 copied_dirs.push(target_dir.clone());
290 if let Err(e) = copy_dir_recursive(&action.plugin_path, &target_dir) {
291 for dir in &copied_dirs {
293 let _ = fs::remove_dir_all(dir);
294 }
295 return Err(e);
296 }
297 }
298 }
299
300 let result = execute_add_marketplace(&effective_actions, config);
302
303 if result.is_err() {
304 for dir in &copied_dirs {
306 let _ = fs::remove_dir_all(dir);
307 }
308 }
309
310 result
311}
312
313fn resolve_plugin_input(input: &str, config: &MarketplaceConfig) -> Result<PathBuf, SoukError> {
315 let input_path = PathBuf::from(input);
316
317 if input_path.is_dir() {
319 return input_path.canonicalize().map_err(SoukError::Io);
320 }
321
322 resolve_plugin(input, Some(config))
324}
325
326fn read_plugin_manifest(plugin_path: &Path) -> Result<PluginManifest, SoukError> {
328 let plugin_json = plugin_path.join(".claude-plugin").join("plugin.json");
329
330 let content = fs::read_to_string(&plugin_json).map_err(|e| {
331 SoukError::Other(format!(
332 "Cannot read plugin.json at {}: {e}",
333 plugin_json.display()
334 ))
335 })?;
336
337 let manifest: PluginManifest = serde_json::from_str(&content)?;
338 Ok(manifest)
339}
340
341fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), SoukError> {
345 fs::create_dir_all(dst)?;
346 for entry in fs::read_dir(src)? {
347 let entry = entry?;
348 let src_path = entry.path();
349 let dst_path = dst.join(entry.file_name());
350
351 let meta = fs::symlink_metadata(&src_path)?;
353 if meta.file_type().is_symlink() {
354 return Err(SoukError::Other(format!(
355 "Symlink detected at '{}': symlinks are not supported in plugin directories",
356 src_path.display()
357 )));
358 }
359
360 if src_path.is_dir() {
361 copy_dir_recursive(&src_path, &dst_path)?;
362 } else {
363 fs::copy(&src_path, &dst_path)?;
364 }
365 }
366 Ok(())
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use crate::discovery::load_marketplace_config;
373 use tempfile::TempDir;
374
375 fn setup_marketplace(tmp: &TempDir, plugins_json: &str) -> MarketplaceConfig {
377 let claude_dir = tmp.path().join(".claude-plugin");
378 fs::create_dir_all(&claude_dir).unwrap();
379 let plugins_dir = tmp.path().join("plugins");
380 fs::create_dir_all(&plugins_dir).unwrap();
381
382 let mp_json =
383 format!(r#"{{"version":"0.1.0","pluginRoot":"./plugins","plugins":[{plugins_json}]}}"#);
384 fs::write(claude_dir.join("marketplace.json"), &mp_json).unwrap();
385 load_marketplace_config(&claude_dir.join("marketplace.json")).unwrap()
386 }
387
388 fn create_plugin(base: &Path, name: &str) -> PathBuf {
390 let plugin_dir = base.join(name);
391 let claude_dir = plugin_dir.join(".claude-plugin");
392 fs::create_dir_all(&claude_dir).unwrap();
393 fs::write(
394 claude_dir.join("plugin.json"),
395 format!(
396 r#"{{"name":"{name}","version":"1.0.0","description":"A test plugin","keywords":["test"]}}"#
397 ),
398 )
399 .unwrap();
400 plugin_dir
401 }
402
403 #[test]
404 fn add_single_plugin_to_empty_marketplace() {
405 let tmp = TempDir::new().unwrap();
406 let config = setup_marketplace(&tmp, "");
407
408 create_plugin(&config.plugin_root_abs, "my-plugin");
410
411 let plan = plan_add(&["my-plugin".to_string()], &config, "abort", false).unwrap();
412
413 assert_eq!(plan.actions.len(), 1);
414 assert_eq!(plan.actions[0].plugin_name, "my-plugin");
415 assert!(!plan.actions[0].is_external);
416 assert!(plan.actions[0].conflict.is_none());
417
418 let added = execute_add(&plan, &config, false).unwrap();
419 assert_eq!(added, vec!["my-plugin"]);
420
421 let content = fs::read_to_string(&config.marketplace_path).unwrap();
423 let mp: Marketplace = serde_json::from_str(&content).unwrap();
424 assert_eq!(mp.plugins.len(), 1);
425 assert_eq!(mp.plugins[0].name, "my-plugin");
426 assert_eq!(mp.plugins[0].tags, vec!["test"]);
427 assert_eq!(mp.version, "0.1.1");
429 }
430
431 #[test]
432 fn add_with_conflict_abort_strategy() {
433 let tmp = TempDir::new().unwrap();
434 let config =
435 setup_marketplace(&tmp, r#"{"name":"existing","source":"existing","tags":[]}"#);
436 create_plugin(&config.plugin_root_abs, "existing");
437
438 let result = plan_add(&["existing".to_string()], &config, "abort", false);
439
440 assert!(result.is_err());
441 match result.unwrap_err() {
442 SoukError::PluginAlreadyExists(name) => assert_eq!(name, "existing"),
443 other => panic!("Expected PluginAlreadyExists, got: {other}"),
444 }
445 }
446
447 #[test]
448 fn add_with_skip_strategy() {
449 let tmp = TempDir::new().unwrap();
450 let config =
451 setup_marketplace(&tmp, r#"{"name":"existing","source":"existing","tags":[]}"#);
452 create_plugin(&config.plugin_root_abs, "existing");
453
454 let plan = plan_add(&["existing".to_string()], &config, "skip", false).unwrap();
455
456 assert_eq!(plan.actions.len(), 1);
457 assert!(matches!(
458 plan.actions[0].conflict,
459 Some(ConflictResolution::Skip)
460 ));
461
462 let added = execute_add(&plan, &config, false).unwrap();
464 assert!(added.is_empty());
465
466 let content = fs::read_to_string(&config.marketplace_path).unwrap();
468 let mp: Marketplace = serde_json::from_str(&content).unwrap();
469 assert_eq!(mp.plugins.len(), 1);
470 assert_eq!(mp.version, "0.1.0");
471 }
472
473 #[test]
474 fn add_with_replace_strategy() {
475 let tmp = TempDir::new().unwrap();
476 let config = setup_marketplace(
477 &tmp,
478 r#"{"name":"existing","source":"existing","tags":["old"]}"#,
479 );
480 create_plugin(&config.plugin_root_abs, "existing");
481
482 let plan = plan_add(&["existing".to_string()], &config, "replace", false).unwrap();
483
484 assert_eq!(plan.actions.len(), 1);
485 assert!(matches!(
486 plan.actions[0].conflict,
487 Some(ConflictResolution::Replace)
488 ));
489
490 let added = execute_add(&plan, &config, false).unwrap();
491 assert_eq!(added, vec!["existing"]);
492
493 let content = fs::read_to_string(&config.marketplace_path).unwrap();
495 let mp: Marketplace = serde_json::from_str(&content).unwrap();
496 assert_eq!(mp.plugins.len(), 1);
497 assert_eq!(mp.plugins[0].tags, vec!["test"]);
498 assert_eq!(mp.version, "0.1.1");
499 }
500
501 #[test]
502 fn add_with_rename_strategy() {
503 let tmp = TempDir::new().unwrap();
504 let config =
505 setup_marketplace(&tmp, r#"{"name":"existing","source":"existing","tags":[]}"#);
506 create_plugin(&config.plugin_root_abs, "existing");
507
508 let plan = plan_add(&["existing".to_string()], &config, "rename", false).unwrap();
509
510 assert_eq!(plan.actions.len(), 1);
511 match &plan.actions[0].conflict {
512 Some(ConflictResolution::Rename(new_name)) => {
513 assert_eq!(new_name, "existing-2");
514 }
515 other => panic!("Expected Rename, got: {other:?}"),
516 }
517 }
518
519 #[test]
520 fn dry_run_does_not_modify_files() {
521 let tmp = TempDir::new().unwrap();
522 let config = setup_marketplace(&tmp, "");
523 create_plugin(&config.plugin_root_abs, "my-plugin");
524
525 let plan = plan_add(&["my-plugin".to_string()], &config, "abort", false).unwrap();
526
527 let added = execute_add(&plan, &config, true).unwrap();
528 assert_eq!(added, vec!["my-plugin"]);
529
530 let content = fs::read_to_string(&config.marketplace_path).unwrap();
532 let mp: Marketplace = serde_json::from_str(&content).unwrap();
533 assert!(mp.plugins.is_empty());
534 assert_eq!(mp.version, "0.1.0");
535 }
536
537 #[test]
538 fn external_plugin_copy() {
539 let tmp = TempDir::new().unwrap();
540 let config = setup_marketplace(&tmp, "");
541
542 let external_dir = TempDir::new().unwrap();
544 create_plugin(external_dir.path(), "ext-plugin");
545 let ext_path = external_dir.path().join("ext-plugin");
546
547 let plan = plan_add(
548 &[ext_path.to_string_lossy().to_string()],
549 &config,
550 "abort",
551 false,
552 )
553 .unwrap();
554
555 assert_eq!(plan.actions.len(), 1);
556 assert!(plan.actions[0].is_external);
557
558 let added = execute_add(&plan, &config, false).unwrap();
559 assert_eq!(added, vec!["ext-plugin"]);
560
561 let copied = config.plugin_root_abs.join("ext-plugin");
563 assert!(copied.exists());
564 assert!(copied.join(".claude-plugin").join("plugin.json").exists());
565
566 let content = fs::read_to_string(&config.marketplace_path).unwrap();
568 let mp: Marketplace = serde_json::from_str(&content).unwrap();
569 assert_eq!(mp.plugins.len(), 1);
570 assert_eq!(mp.plugins[0].source, "ext-plugin");
571 }
572
573 #[test]
574 fn external_plugin_no_copy() {
575 let tmp = TempDir::new().unwrap();
576 let config = setup_marketplace(&tmp, "");
577
578 let external_dir = TempDir::new().unwrap();
580 create_plugin(external_dir.path(), "ext-plugin");
581 let ext_path = external_dir.path().join("ext-plugin");
582
583 let plan = plan_add(
584 &[ext_path.to_string_lossy().to_string()],
585 &config,
586 "abort",
587 true, )
589 .unwrap();
590
591 assert_eq!(plan.actions.len(), 1);
592 assert!(plan.actions[0].is_external);
593 assert!(std::path::Path::new(&plan.actions[0].source).is_absolute());
595 }
596
597 #[cfg(unix)]
598 #[test]
599 fn copy_dir_recursive_rejects_symlinks() {
600 let tmp = TempDir::new().unwrap();
601 let src = tmp.path().join("src_plugin");
602 let claude_dir = src.join(".claude-plugin");
603 fs::create_dir_all(&claude_dir).unwrap();
604 fs::write(
605 claude_dir.join("plugin.json"),
606 r#"{"name":"sym","version":"1.0.0","description":"test"}"#,
607 )
608 .unwrap();
609
610 std::os::unix::fs::symlink("/tmp", src.join("bad-link")).unwrap();
612
613 let dst = tmp.path().join("dst_plugin");
614 let result = copy_dir_recursive(&src, &dst);
615
616 assert!(result.is_err());
617 let err_msg = result.unwrap_err().to_string();
618 assert!(
619 err_msg.contains("Symlink"),
620 "Error should mention symlink: {err_msg}"
621 );
622 }
623
624 #[test]
625 fn add_cleans_up_copied_dir_on_marketplace_failure() {
626 let tmp = TempDir::new().unwrap();
627 let config = setup_marketplace(&tmp, "");
628
629 let external_dir = TempDir::new().unwrap();
631 create_plugin(external_dir.path(), "ext-plugin");
632 let ext_path = external_dir.path().join("ext-plugin");
633
634 let plan = plan_add(
635 &[ext_path.to_string_lossy().to_string()],
636 &config,
637 "abort",
638 false,
639 )
640 .unwrap();
641
642 fs::write(&config.marketplace_path, "not valid json").unwrap();
644
645 let result = execute_add(&plan, &config, false);
646 assert!(result.is_err());
647
648 let would_be_copied = config.plugin_root_abs.join("ext-plugin");
650 assert!(
651 !would_be_copied.exists(),
652 "Copied dir should be cleaned up on failure"
653 );
654 }
655
656 #[test]
657 fn add_multiple_plugins() {
658 let tmp = TempDir::new().unwrap();
659 let config = setup_marketplace(&tmp, "");
660 create_plugin(&config.plugin_root_abs, "plugin-a");
661 create_plugin(&config.plugin_root_abs, "plugin-b");
662
663 let plan = plan_add(
664 &["plugin-a".to_string(), "plugin-b".to_string()],
665 &config,
666 "abort",
667 false,
668 )
669 .unwrap();
670
671 assert_eq!(plan.actions.len(), 2);
672
673 let added = execute_add(&plan, &config, false).unwrap();
674 assert_eq!(added.len(), 2);
675
676 let content = fs::read_to_string(&config.marketplace_path).unwrap();
677 let mp: Marketplace = serde_json::from_str(&content).unwrap();
678 assert_eq!(mp.plugins.len(), 2);
679 }
680}