1use 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#[derive(Debug)]
18pub struct RemoveResult {
19 pub removed: Vec<String>,
21 pub warnings: Vec<String>,
23}
24
25pub 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 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 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 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 marketplace.version = bump_patch(&marketplace.version)?;
134
135 let json = serde_json::to_string_pretty(&marketplace)?;
137 fs::write(&config.marketplace_path, format!("{json}\n"))?;
138
139 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 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
167pub 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 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 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, false,
275 &config,
276 )
277 .unwrap();
278
279 assert_eq!(result.removed, vec!["alpha"]);
280 assert!(result.warnings.is_empty());
281
282 assert!(!config.plugin_root_abs.join("alpha").exists());
284 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, false,
297 &config,
298 )
299 .unwrap();
300
301 assert_eq!(result.removed, vec!["alpha"]);
302
303 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 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 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 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 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 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}