Skip to main content

souk_core/ops/
add.rs

1//! Add plugins to the marketplace.
2//!
3//! Implements the 7-phase pipeline for adding plugins:
4//! 1. Preflight: Resolve each plugin path, validate it
5//! 2. Plan: Determine if internal or external, check for conflicts
6//! 3. Dry-run gate: If dry run, report planned actions and stop
7//! 4. Copy: For external plugins, copy to pluginRoot
8//! 5. Atomic update: Use AtomicGuard, add entries, write back
9//! 6. Version bump: Bump marketplace version (patch)
10//! 7. Final validation: Re-validate the marketplace
11
12use 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/// A planned action for adding a single plugin.
25#[derive(Debug, Clone)]
26pub struct AddAction {
27    /// Resolved path to the plugin directory on disk.
28    pub plugin_path: PathBuf,
29    /// The name of the plugin (from plugin.json).
30    pub plugin_name: String,
31    /// The source value for the marketplace entry.
32    pub source: String,
33    /// Whether the plugin is already under pluginRoot.
34    pub is_external: bool,
35    /// How to resolve a name conflict, if one exists.
36    pub conflict: Option<ConflictResolution>,
37}
38
39/// How a name conflict should be resolved for a single plugin.
40#[derive(Debug, Clone)]
41pub enum ConflictResolution {
42    /// Skip this plugin entirely.
43    Skip,
44    /// Replace the existing entry with the new one.
45    Replace,
46    /// Rename the new plugin to avoid conflict.
47    Rename(String),
48}
49
50/// The full plan produced by the planning phase.
51#[derive(Debug, Clone)]
52pub struct AddPlan {
53    pub actions: Vec<AddAction>,
54}
55
56/// Plans the add operation without modifying the filesystem.
57///
58/// Resolves each input to a plugin path, reads its plugin.json, determines
59/// internal vs external, and applies the conflict resolution strategy.
60///
61/// # Arguments
62///
63/// * `inputs` - Plugin paths or names to add.
64/// * `config` - The loaded marketplace configuration.
65/// * `strategy` - One of "abort", "skip", "replace", or "rename".
66/// * `no_copy` - If true, external plugins will be referenced by absolute path
67///   instead of being copied into pluginRoot.
68///
69/// # Errors
70///
71/// Returns [`SoukError::PluginNotFound`] if a plugin cannot be resolved.
72/// Returns [`SoukError::PluginAlreadyExists`] if the strategy is "abort" and a
73/// conflict is detected.
74/// Returns [`SoukError::ValidationFailed`] if preflight validation fails.
75pub 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        // Phase 1: Resolve plugin path
93        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        // Read plugin.json to get the name
102        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        // Validate the plugin
114        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        // Phase 2: Determine internal vs external
124        let (source, is_internal) = plugin_path_to_source(&plugin_path, config);
125        let is_external = !is_internal;
126
127        // Determine the final source for the marketplace entry
128        let final_source = if is_external && !no_copy {
129            // Will be copied to pluginRoot; source = the plugin name (directory name)
130            plugin_name.clone()
131        } else {
132            source
133        };
134
135        // Check for conflicts
136        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
173/// Inner marketplace mutation, separated for cleanup-on-failure in execute_add.
174fn 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
227/// Executes the add plan, modifying the filesystem and marketplace.json.
228///
229/// If `dry_run` is true, no changes are made and the function returns early
230/// after the planning phase.
231///
232/// # Errors
233///
234/// Returns an error if copying, atomic update, version bump, or final
235/// validation fails. On atomic update failure, the AtomicGuard restores
236/// the original marketplace.json. Copied directories are cleaned up on failure.
237pub fn execute_add(
238    plan: &AddPlan,
239    config: &MarketplaceConfig,
240    dry_run: bool,
241) -> Result<Vec<String>, SoukError> {
242    // Collect the effective actions (skip those marked Skip)
243    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    // Phase 3: Dry-run gate
254    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    // Phase 4: Copy external plugins
266    // Track directories we copy so we can clean up on failure
267    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                // Clean up all previously copied dirs plus the partial one
292                for dir in &copied_dirs {
293                    let _ = fs::remove_dir_all(dir);
294                }
295                return Err(e);
296            }
297        }
298    }
299
300    // Phase 5-7: Atomic update, version bump, validation
301    let result = execute_add_marketplace(&effective_actions, config);
302
303    if result.is_err() {
304        // Clean up copied directories on failure
305        for dir in &copied_dirs {
306            let _ = fs::remove_dir_all(dir);
307        }
308    }
309
310    result
311}
312
313/// Resolves a plugin input (path or name) to an absolute path.
314fn resolve_plugin_input(input: &str, config: &MarketplaceConfig) -> Result<PathBuf, SoukError> {
315    let input_path = PathBuf::from(input);
316
317    // Try as a direct path first
318    if input_path.is_dir() {
319        return input_path.canonicalize().map_err(SoukError::Io);
320    }
321
322    // Try resolving via plugin resolution
323    resolve_plugin(input, Some(config))
324}
325
326/// Reads and parses plugin.json from a plugin directory.
327fn 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
341/// Recursively copies a directory from `src` to `dst`.
342///
343/// Returns an error if any symlinks are encountered.
344fn 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        // Check for symlinks before processing
352        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    /// Creates a minimal marketplace setup in a temp directory.
376    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    /// Creates a valid plugin directory.
389    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 a plugin inside pluginRoot
409        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        // Verify marketplace was updated
422        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        // Version should be bumped
428        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        // Execute should not add anything
463        let added = execute_add(&plan, &config, false).unwrap();
464        assert!(added.is_empty());
465
466        // Marketplace should be unchanged
467        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        // Tags should be updated from plugin.json
494        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        // Marketplace should be unchanged
531        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        // Create plugin outside pluginRoot
543        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        // Plugin should be copied to pluginRoot
562        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        // Marketplace should reference it
567        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        // Create plugin outside pluginRoot
579        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, // no_copy
588        )
589        .unwrap();
590
591        assert_eq!(plan.actions.len(), 1);
592        assert!(plan.actions[0].is_external);
593        // Source should be absolute path since no_copy is true
594        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        // Create a symlink inside the plugin directory
611        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        // Create external plugin
630        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        // Corrupt marketplace.json so validation will fail after copy
643        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        // The copied directory should have been cleaned up
649        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}