Skip to main content

souk_core/ops/
remove.rs

1//! Remove plugins from the marketplace.
2//!
3//! Removes one or more plugins by name from `marketplace.json`, with an
4//! optional flag to also delete the plugin directory from disk.
5
6use std::fs;
7
8use crate::discovery::{load_marketplace_config, MarketplaceConfig};
9use crate::error::SoukError;
10use crate::ops::AtomicGuard;
11use crate::resolution::resolve_source;
12use crate::types::Marketplace;
13use crate::validation::validate_marketplace;
14use crate::version::bump_patch;
15
16/// The result of a remove operation.
17#[derive(Debug)]
18pub struct RemoveResult {
19    /// Plugin names that were successfully removed from the marketplace.
20    pub removed: Vec<String>,
21    /// Non-fatal warnings (e.g., directory delete failures).
22    pub warnings: Vec<String>,
23}
24
25/// Removes the named plugins from the marketplace.
26///
27/// For each name in `names`:
28/// - Finds the matching entry in marketplace.json
29/// - If `delete_files` is true, also removes the plugin directory from disk
30/// - Bumps the marketplace version (patch)
31///
32/// Returns a [`RemoveResult`] with the removed names and any warnings
33/// (e.g., if a directory could not be deleted after the marketplace entry
34/// was removed).
35///
36/// # Errors
37///
38/// Returns [`SoukError::PluginNotFound`] if any name does not exist in
39/// the marketplace.
40///
41/// Returns [`SoukError::AtomicRollback`] if the post-removal validation fails.
42///
43/// # Example
44///
45/// ```no_run
46/// # use souk_core::ops::remove::remove_plugins;
47/// # fn example(config: &souk_core::discovery::MarketplaceConfig) {
48/// let result = remove_plugins(
49///     &["my-plugin".to_string()],
50///     true,  // delete files
51///     false, // don't allow external deletes
52///     config,
53/// ).unwrap();
54///
55/// for name in &result.removed {
56///     println!("Removed: {name}");
57/// }
58/// for warn in &result.warnings {
59///     eprintln!("Warning: {warn}");
60/// }
61/// # }
62pub fn remove_plugins(
63    names: &[String],
64    delete_files: bool,
65    allow_external_delete: bool,
66    config: &MarketplaceConfig,
67) -> Result<RemoveResult, SoukError> {
68    if names.is_empty() {
69        return Ok(RemoveResult {
70            removed: Vec::new(),
71            warnings: Vec::new(),
72        });
73    }
74
75    // Verify all names exist before making any changes
76    for name in names {
77        if !config.marketplace.plugins.iter().any(|p| p.name == *name) {
78            return Err(SoukError::PluginNotFound(name.clone()));
79        }
80    }
81
82    // Pre-compute delete targets and validate paths before any mutation
83    let mut delete_targets: Vec<(String, std::path::PathBuf)> = Vec::new();
84    if delete_files {
85        let plugin_root = config
86            .plugin_root_abs
87            .canonicalize()
88            .map_err(SoukError::Io)?;
89
90        for name in names {
91            let entry = config
92                .marketplace
93                .plugins
94                .iter()
95                .find(|p| p.name == *name)
96                .unwrap();
97
98            if let Ok(plugin_path) = resolve_source(&entry.source, config) {
99                if plugin_path.is_dir() {
100                    let resolved = plugin_path.canonicalize().map_err(SoukError::Io)?;
101                    let is_internal = resolved.starts_with(&plugin_root);
102
103                    if !is_internal && !allow_external_delete {
104                        return Err(SoukError::Other(format!(
105                            "Refusing to delete '{}': path is outside pluginRoot ({}). \
106                             Use --allow-external-delete to override.",
107                            resolved.display(),
108                            plugin_root.display()
109                        )));
110                    }
111
112                    delete_targets.push((name.clone(), resolved));
113                }
114            }
115        }
116    }
117
118    // Atomic update — marketplace.json changes first
119    let guard = AtomicGuard::new(&config.marketplace_path)?;
120
121    let content = fs::read_to_string(&config.marketplace_path)?;
122    let mut marketplace: Marketplace = serde_json::from_str(&content)?;
123
124    let mut removed = Vec::new();
125    for name in names {
126        if marketplace.plugins.iter().any(|p| p.name == *name) {
127            marketplace.plugins.retain(|p| p.name != *name);
128            removed.push(name.clone());
129        }
130    }
131
132    // Bump version
133    marketplace.version = bump_patch(&marketplace.version)?;
134
135    // Write back
136    let json = serde_json::to_string_pretty(&marketplace)?;
137    fs::write(&config.marketplace_path, format!("{json}\n"))?;
138
139    // Validate
140    let updated_config = load_marketplace_config(&config.marketplace_path)?;
141    let validation = validate_marketplace(&updated_config, true);
142    if validation.has_errors() {
143        drop(guard);
144        return Err(SoukError::AtomicRollback(
145            "Validation failed after remove".to_string(),
146        ));
147    }
148
149    guard.commit()?;
150
151    // Delete directories AFTER successful marketplace update
152    let mut warnings = Vec::new();
153    for (name, path) in &delete_targets {
154        if path.is_dir() {
155            if let Err(e) = fs::remove_dir_all(path) {
156                warnings.push(format!(
157                    "Removed '{name}' from marketplace but failed to delete directory {}: {e}",
158                    path.display()
159                ));
160            }
161        }
162    }
163
164    Ok(RemoveResult { removed, warnings })
165}
166
167/// Deletes a plugin directory from disk. Exposed for testing or direct use.
168pub fn delete_plugin_dir(
169    source: &str,
170    allow_external_delete: bool,
171    config: &MarketplaceConfig,
172) -> Result<(), SoukError> {
173    let plugin_path = resolve_source(source, config)?;
174    if plugin_path.is_dir() {
175        let resolved = plugin_path.canonicalize().map_err(SoukError::Io)?;
176        let plugin_root = config
177            .plugin_root_abs
178            .canonicalize()
179            .map_err(SoukError::Io)?;
180        let is_internal = resolved.starts_with(&plugin_root);
181
182        if !is_internal && !allow_external_delete {
183            return Err(SoukError::Other(format!(
184                "Refusing to delete '{}': path is outside pluginRoot ({}). \
185                 Use --allow-external-delete to override.",
186                resolved.display(),
187                plugin_root.display()
188            )));
189        }
190
191        fs::remove_dir_all(&resolved)?;
192    }
193    Ok(())
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::discovery::load_marketplace_config;
200    use tempfile::TempDir;
201
202    fn setup_marketplace_with_plugins(tmp: &TempDir, plugin_names: &[&str]) -> MarketplaceConfig {
203        let claude_dir = tmp.path().join(".claude-plugin");
204        fs::create_dir_all(&claude_dir).unwrap();
205        let plugins_dir = tmp.path().join("plugins");
206        fs::create_dir_all(&plugins_dir).unwrap();
207
208        let mut entries = Vec::new();
209        for name in plugin_names {
210            // Create plugin directory
211            let plugin_dir = plugins_dir.join(name);
212            let plugin_claude = plugin_dir.join(".claude-plugin");
213            fs::create_dir_all(&plugin_claude).unwrap();
214            fs::write(
215                plugin_claude.join("plugin.json"),
216                format!(r#"{{"name":"{name}","version":"1.0.0","description":"test plugin"}}"#),
217            )
218            .unwrap();
219
220            entries.push(format!(r#"{{"name":"{name}","source":"{name}"}}"#));
221        }
222
223        let plugins_json = entries.join(",");
224        let mp_json =
225            format!(r#"{{"version":"0.1.0","pluginRoot":"./plugins","plugins":[{plugins_json}]}}"#);
226        fs::write(claude_dir.join("marketplace.json"), &mp_json).unwrap();
227        load_marketplace_config(&claude_dir.join("marketplace.json")).unwrap()
228    }
229
230    #[test]
231    fn remove_existing_plugin() {
232        let tmp = TempDir::new().unwrap();
233        let config = setup_marketplace_with_plugins(&tmp, &["alpha", "beta"]);
234
235        let result = remove_plugins(&["alpha".to_string()], false, false, &config).unwrap();
236
237        assert_eq!(result.removed, vec!["alpha"]);
238        assert!(result.warnings.is_empty());
239
240        let content = fs::read_to_string(&config.marketplace_path).unwrap();
241        let mp: Marketplace = serde_json::from_str(&content).unwrap();
242        assert_eq!(mp.plugins.len(), 1);
243        assert_eq!(mp.plugins[0].name, "beta");
244        assert_eq!(mp.version, "0.1.1");
245
246        // Plugin directory should still exist
247        assert!(config.plugin_root_abs.join("alpha").exists());
248    }
249
250    #[test]
251    fn remove_nonexistent_plugin_returns_error() {
252        let tmp = TempDir::new().unwrap();
253        let config = setup_marketplace_with_plugins(&tmp, &["alpha"]);
254
255        let result = remove_plugins(&["nonexistent".to_string()], false, false, &config);
256
257        assert!(result.is_err());
258        match result.unwrap_err() {
259            SoukError::PluginNotFound(name) => assert_eq!(name, "nonexistent"),
260            other => panic!("Expected PluginNotFound, got: {other}"),
261        }
262    }
263
264    #[test]
265    fn remove_with_delete_removes_directory() {
266        let tmp = TempDir::new().unwrap();
267        let config = setup_marketplace_with_plugins(&tmp, &["alpha", "beta"]);
268
269        assert!(config.plugin_root_abs.join("alpha").exists());
270
271        let result = remove_plugins(
272            &["alpha".to_string()],
273            true, // delete files
274            false,
275            &config,
276        )
277        .unwrap();
278
279        assert_eq!(result.removed, vec!["alpha"]);
280        assert!(result.warnings.is_empty());
281
282        // Plugin directory should be gone
283        assert!(!config.plugin_root_abs.join("alpha").exists());
284        // Other plugin should still exist
285        assert!(config.plugin_root_abs.join("beta").exists());
286    }
287
288    #[test]
289    fn remove_without_delete_keeps_directory() {
290        let tmp = TempDir::new().unwrap();
291        let config = setup_marketplace_with_plugins(&tmp, &["alpha"]);
292
293        let result = remove_plugins(
294            &["alpha".to_string()],
295            false, // don't delete files
296            false,
297            &config,
298        )
299        .unwrap();
300
301        assert_eq!(result.removed, vec!["alpha"]);
302
303        // Plugin directory should still exist
304        assert!(config.plugin_root_abs.join("alpha").exists());
305    }
306
307    #[test]
308    fn remove_multiple_plugins() {
309        let tmp = TempDir::new().unwrap();
310        let config = setup_marketplace_with_plugins(&tmp, &["alpha", "beta", "gamma"]);
311
312        let result = remove_plugins(
313            &["alpha".to_string(), "gamma".to_string()],
314            false,
315            false,
316            &config,
317        )
318        .unwrap();
319
320        assert_eq!(result.removed.len(), 2);
321
322        let content = fs::read_to_string(&config.marketplace_path).unwrap();
323        let mp: Marketplace = serde_json::from_str(&content).unwrap();
324        assert_eq!(mp.plugins.len(), 1);
325        assert_eq!(mp.plugins[0].name, "beta");
326    }
327
328    #[test]
329    fn remove_empty_list_is_noop() {
330        let tmp = TempDir::new().unwrap();
331        let config = setup_marketplace_with_plugins(&tmp, &["alpha"]);
332
333        let result = remove_plugins(&[], false, false, &config).unwrap();
334        assert!(result.removed.is_empty());
335
336        let content = fs::read_to_string(&config.marketplace_path).unwrap();
337        let mp: Marketplace = serde_json::from_str(&content).unwrap();
338        assert_eq!(mp.plugins.len(), 1);
339    }
340
341    #[test]
342    fn remove_external_plugin_delete_refused_without_flag() {
343        let tmp = TempDir::new().unwrap();
344
345        // Create an external plugin directory
346        let external_dir = TempDir::new().unwrap();
347        let ext_plugin = external_dir.path().join("ext");
348        let ext_claude = ext_plugin.join(".claude-plugin");
349        fs::create_dir_all(&ext_claude).unwrap();
350        fs::write(
351            ext_claude.join("plugin.json"),
352            r#"{"name":"ext","version":"1.0.0","description":"test"}"#,
353        )
354        .unwrap();
355
356        // Set up marketplace with external source (absolute path)
357        let claude_dir = tmp.path().join(".claude-plugin");
358        fs::create_dir_all(&claude_dir).unwrap();
359        let plugins_dir = tmp.path().join("plugins");
360        fs::create_dir_all(&plugins_dir).unwrap();
361
362        let ext_path_str = ext_plugin.to_string_lossy().replace('\\', "/");
363        let mp_json = format!(
364            r#"{{"version":"0.1.0","pluginRoot":"./plugins","plugins":[{{"name":"ext","source":"{ext_path_str}"}}]}}"#
365        );
366        fs::write(claude_dir.join("marketplace.json"), &mp_json).unwrap();
367        let config = load_marketplace_config(&claude_dir.join("marketplace.json")).unwrap();
368
369        // Try to delete without allow flag — should fail
370        let result = remove_plugins(&["ext".to_string()], true, false, &config);
371        assert!(result.is_err());
372        let err = result.unwrap_err().to_string();
373        assert!(err.contains("outside pluginRoot"), "Error: {err}");
374
375        // External directory should still exist
376        assert!(ext_plugin.exists());
377    }
378
379    #[test]
380    fn remove_external_plugin_delete_allowed_with_flag() {
381        let tmp = TempDir::new().unwrap();
382
383        let external_dir = TempDir::new().unwrap();
384        let ext_plugin = external_dir.path().join("ext");
385        let ext_claude = ext_plugin.join(".claude-plugin");
386        fs::create_dir_all(&ext_claude).unwrap();
387        fs::write(
388            ext_claude.join("plugin.json"),
389            r#"{"name":"ext","version":"1.0.0","description":"test"}"#,
390        )
391        .unwrap();
392
393        let claude_dir = tmp.path().join(".claude-plugin");
394        fs::create_dir_all(&claude_dir).unwrap();
395        let plugins_dir = tmp.path().join("plugins");
396        fs::create_dir_all(&plugins_dir).unwrap();
397
398        let ext_path_str = ext_plugin.to_string_lossy().replace('\\', "/");
399        let mp_json = format!(
400            r#"{{"version":"0.1.0","pluginRoot":"./plugins","plugins":[{{"name":"ext","source":"{ext_path_str}"}}]}}"#
401        );
402        fs::write(claude_dir.join("marketplace.json"), &mp_json).unwrap();
403        let config = load_marketplace_config(&claude_dir.join("marketplace.json")).unwrap();
404
405        // Delete with allow flag — should succeed
406        let result = remove_plugins(&["ext".to_string()], true, true, &config).unwrap();
407        assert_eq!(result.removed, vec!["ext"]);
408        assert!(!ext_plugin.exists());
409    }
410
411    #[test]
412    fn remove_internal_plugin_delete_works_without_flag() {
413        let tmp = TempDir::new().unwrap();
414        let config = setup_marketplace_with_plugins(&tmp, &["alpha"]);
415
416        assert!(config.plugin_root_abs.join("alpha").exists());
417
418        let result = remove_plugins(&["alpha".to_string()], true, false, &config).unwrap();
419        assert_eq!(result.removed, vec!["alpha"]);
420        assert!(!config.plugin_root_abs.join("alpha").exists());
421    }
422}