1use std::collections::HashMap;
7use std::fs;
8
9use crate::discovery::{load_marketplace_config, MarketplaceConfig};
10use crate::error::SoukError;
11use crate::ops::AtomicGuard;
12use crate::resolution::resolve_source;
13use crate::types::{Marketplace, PluginManifest};
14use crate::validation::{validate_marketplace, validate_plugin};
15use crate::version::{bump_major, bump_minor, bump_patch};
16
17pub fn update_plugins(
37 names: &[String],
38 bump_type: Option<&str>,
39 config: &MarketplaceConfig,
40) -> Result<Vec<String>, SoukError> {
41 if names.is_empty() {
42 return Ok(Vec::new());
43 }
44
45 for name in names {
47 if !config.marketplace.plugins.iter().any(|p| p.name == *name) {
48 return Err(SoukError::PluginNotFound(name.clone()));
49 }
50 }
51
52 let mut plugin_paths: Vec<(String, std::path::PathBuf)> = Vec::new();
54 for name in names {
55 let entry = config
56 .marketplace
57 .plugins
58 .iter()
59 .find(|p| p.name == *name)
60 .unwrap();
61 let plugin_path = resolve_source(&entry.source, config)?;
62 plugin_paths.push((name.clone(), plugin_path));
63 }
64
65 let mp_guard = AtomicGuard::new(&config.marketplace_path)?;
67
68 let mut plugin_guards: Vec<AtomicGuard> = Vec::new();
69 if bump_type.is_some() {
70 for (_name, plugin_path) in &plugin_paths {
71 let plugin_json_path = plugin_path.join(".claude-plugin").join("plugin.json");
72 let guard = AtomicGuard::new(&plugin_json_path)?;
73 plugin_guards.push(guard);
74 }
75 }
76
77 if let Some(bump) = bump_type {
79 for (name, plugin_path) in &plugin_paths {
80 let plugin_json_path = plugin_path.join(".claude-plugin").join("plugin.json");
81 let content = fs::read_to_string(&plugin_json_path).map_err(|e| {
82 SoukError::Other(format!("Cannot read plugin.json for {name}: {e}"))
83 })?;
84
85 let mut doc: serde_json::Value = serde_json::from_str(&content)?;
86
87 if let Some(version) = doc.get("version").and_then(|v| v.as_str()) {
88 let new_version = match bump {
89 "major" => bump_major(version)?,
90 "minor" => bump_minor(version)?,
91 "patch" => bump_patch(version)?,
92 _ => {
93 return Err(SoukError::Other(format!("Invalid bump type: {bump}")));
94 }
95 };
96 doc["version"] = serde_json::Value::String(new_version);
97 }
98
99 let updated_json = serde_json::to_string_pretty(&doc)?;
100 fs::write(&plugin_json_path, format!("{updated_json}\n"))?;
101 }
102 }
103
104 let content = fs::read_to_string(&config.marketplace_path)?;
106 let mut marketplace: Marketplace = serde_json::from_str(&content)?;
107
108 let mut updated = Vec::new();
109 let mut rename_targets: HashMap<String, String> = HashMap::new();
110
111 for (name, plugin_path) in &plugin_paths {
112 let plugin_json_path = plugin_path.join(".claude-plugin").join("plugin.json");
113 let pj_content = fs::read_to_string(&plugin_json_path)
114 .map_err(|e| SoukError::Other(format!("Cannot read plugin.json for {name}: {e}")))?;
115
116 let manifest: PluginManifest = serde_json::from_str(&pj_content)?;
117
118 if let Some(new_name) = manifest.name_str() {
120 if new_name != name.as_str() {
121 if let Some(prev) = rename_targets.get(new_name) {
123 return Err(SoukError::Other(format!(
124 "Plugins '{prev}' and '{name}' would both be renamed to '{new_name}'"
125 )));
126 }
127
128 let collides = marketplace
130 .plugins
131 .iter()
132 .any(|p| p.name == new_name && !names.contains(&p.name));
133 if collides {
134 return Err(SoukError::Other(format!(
135 "Plugin '{name}' would be renamed to '{new_name}' which conflicts with an existing plugin"
136 )));
137 }
138
139 rename_targets.insert(new_name.to_string(), name.clone());
140 }
141 }
142
143 if let Some(entry) = marketplace.plugins.iter_mut().find(|p| p.name == *name) {
144 entry.tags = manifest.keywords.clone();
145 if let Some(new_name) = manifest.name_str() {
146 if new_name != name.as_str() {
147 entry.name = new_name.to_string();
148 }
149 }
150 }
151
152 let validation = validate_plugin(plugin_path);
153 if validation.has_errors() {
154 return Err(SoukError::AtomicRollback(format!(
155 "Plugin validation failed for {name} after update"
156 )));
157 }
158
159 updated.push(name.clone());
160 }
161
162 marketplace.version = bump_patch(&marketplace.version)?;
164
165 let json = serde_json::to_string_pretty(&marketplace)?;
167 fs::write(&config.marketplace_path, format!("{json}\n"))?;
168
169 let updated_config = load_marketplace_config(&config.marketplace_path)?;
171 let validation = validate_marketplace(&updated_config, true);
172 if validation.has_errors() {
173 return Err(SoukError::AtomicRollback(
174 "Marketplace validation failed after update".to_string(),
175 ));
176 }
177
178 mp_guard.commit()?;
180 for g in plugin_guards {
181 g.commit()?;
182 }
183
184 Ok(updated)
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::discovery::load_marketplace_config;
191 use tempfile::TempDir;
192
193 fn setup_marketplace_with_plugins(tmp: &TempDir, plugin_names: &[&str]) -> MarketplaceConfig {
194 let claude_dir = tmp.path().join(".claude-plugin");
195 fs::create_dir_all(&claude_dir).unwrap();
196 let plugins_dir = tmp.path().join("plugins");
197 fs::create_dir_all(&plugins_dir).unwrap();
198
199 let mut entries = Vec::new();
200 for name in plugin_names {
201 let plugin_dir = plugins_dir.join(name);
202 let plugin_claude = plugin_dir.join(".claude-plugin");
203 fs::create_dir_all(&plugin_claude).unwrap();
204 fs::write(
205 plugin_claude.join("plugin.json"),
206 format!(
207 r#"{{"name":"{name}","version":"1.0.0","description":"test plugin","keywords":["original"]}}"#
208 ),
209 )
210 .unwrap();
211
212 entries.push(format!(
213 r#"{{"name":"{name}","source":"{name}","tags":["old"]}}"#
214 ));
215 }
216
217 let plugins_json = entries.join(",");
218 let mp_json =
219 format!(r#"{{"version":"0.1.0","pluginRoot":"./plugins","plugins":[{plugins_json}]}}"#);
220 fs::write(claude_dir.join("marketplace.json"), &mp_json).unwrap();
221 load_marketplace_config(&claude_dir.join("marketplace.json")).unwrap()
222 }
223
224 #[test]
225 fn update_refreshes_metadata_from_disk() {
226 let tmp = TempDir::new().unwrap();
227 let config = setup_marketplace_with_plugins(&tmp, &["alpha"]);
228
229 assert_eq!(config.marketplace.plugins[0].tags, vec!["old"]);
231
232 let updated = update_plugins(&["alpha".to_string()], None, &config).unwrap();
234
235 assert_eq!(updated, vec!["alpha"]);
236
237 let content = fs::read_to_string(&config.marketplace_path).unwrap();
238 let mp: Marketplace = serde_json::from_str(&content).unwrap();
239 assert_eq!(mp.plugins[0].tags, vec!["original"]);
240 assert_eq!(mp.version, "0.1.1");
241 }
242
243 #[test]
244 fn update_with_patch_bumps_version() {
245 let tmp = TempDir::new().unwrap();
246 let config = setup_marketplace_with_plugins(&tmp, &["alpha"]);
247
248 let updated = update_plugins(&["alpha".to_string()], Some("patch"), &config).unwrap();
249
250 assert_eq!(updated, vec!["alpha"]);
251
252 let plugin_json_path = config
254 .plugin_root_abs
255 .join("alpha")
256 .join(".claude-plugin")
257 .join("plugin.json");
258 let content = fs::read_to_string(&plugin_json_path).unwrap();
259 let manifest: PluginManifest = serde_json::from_str(&content).unwrap();
260 assert_eq!(manifest.version_str(), Some("1.0.1"));
261 }
262
263 #[test]
264 fn update_with_major_bumps_version() {
265 let tmp = TempDir::new().unwrap();
266 let config = setup_marketplace_with_plugins(&tmp, &["alpha"]);
267
268 update_plugins(&["alpha".to_string()], Some("major"), &config).unwrap();
269
270 let plugin_json_path = config
271 .plugin_root_abs
272 .join("alpha")
273 .join(".claude-plugin")
274 .join("plugin.json");
275 let content = fs::read_to_string(&plugin_json_path).unwrap();
276 let manifest: PluginManifest = serde_json::from_str(&content).unwrap();
277 assert_eq!(manifest.version_str(), Some("2.0.0"));
278 }
279
280 #[test]
281 fn update_with_minor_bumps_version() {
282 let tmp = TempDir::new().unwrap();
283 let config = setup_marketplace_with_plugins(&tmp, &["alpha"]);
284
285 update_plugins(&["alpha".to_string()], Some("minor"), &config).unwrap();
286
287 let plugin_json_path = config
288 .plugin_root_abs
289 .join("alpha")
290 .join(".claude-plugin")
291 .join("plugin.json");
292 let content = fs::read_to_string(&plugin_json_path).unwrap();
293 let manifest: PluginManifest = serde_json::from_str(&content).unwrap();
294 assert_eq!(manifest.version_str(), Some("1.1.0"));
295 }
296
297 #[test]
298 fn update_nonexistent_plugin_returns_error() {
299 let tmp = TempDir::new().unwrap();
300 let config = setup_marketplace_with_plugins(&tmp, &["alpha"]);
301
302 let result = update_plugins(&["nonexistent".to_string()], None, &config);
303
304 assert!(result.is_err());
305 match result.unwrap_err() {
306 SoukError::PluginNotFound(name) => assert_eq!(name, "nonexistent"),
307 other => panic!("Expected PluginNotFound, got: {other}"),
308 }
309 }
310
311 #[test]
312 fn update_multiple_plugins() {
313 let tmp = TempDir::new().unwrap();
314 let config = setup_marketplace_with_plugins(&tmp, &["alpha", "beta"]);
315
316 let updated = update_plugins(
317 &["alpha".to_string(), "beta".to_string()],
318 Some("patch"),
319 &config,
320 )
321 .unwrap();
322
323 assert_eq!(updated.len(), 2);
324
325 for name in &["alpha", "beta"] {
327 let plugin_json_path = config
328 .plugin_root_abs
329 .join(name)
330 .join(".claude-plugin")
331 .join("plugin.json");
332 let content = fs::read_to_string(&plugin_json_path).unwrap();
333 let manifest: PluginManifest = serde_json::from_str(&content).unwrap();
334 assert_eq!(manifest.version_str(), Some("1.0.1"));
335 }
336 }
337
338 #[test]
339 fn update_bump_rolls_back_plugin_json_on_validation_failure() {
340 let tmp = TempDir::new().unwrap();
341 let config = setup_marketplace_with_plugins(&tmp, &["alpha"]);
342
343 let plugin_json_path = config
345 .plugin_root_abs
346 .join("alpha")
347 .join(".claude-plugin")
348 .join("plugin.json");
349
350 let claude_dir = tmp.path().join(".claude-plugin");
352 let mp_json = r#"{"version":"0.1.0","pluginRoot":"./plugins","plugins":[
353 {"name":"alpha","source":"alpha","tags":["old"]},
354 {"name":"alpha","source":"alpha","tags":["dup"]}
355 ]}"#;
356 fs::write(claude_dir.join("marketplace.json"), mp_json).unwrap();
357 let bad_config = load_marketplace_config(&claude_dir.join("marketplace.json")).unwrap();
358
359 let result = update_plugins(&["alpha".to_string()], Some("patch"), &bad_config);
361 assert!(result.is_err());
362
363 let restored = fs::read_to_string(&plugin_json_path).unwrap();
365 let manifest: PluginManifest = serde_json::from_str(&restored).unwrap();
366 assert_eq!(
367 manifest.version_str(),
368 Some("1.0.0"),
369 "plugin.json should be rolled back to original version"
370 );
371 }
372
373 #[test]
374 fn update_detects_rename_collision() {
375 let tmp = TempDir::new().unwrap();
376 let config = setup_marketplace_with_plugins(&tmp, &["alpha", "beta"]);
377
378 let alpha_pj = config
380 .plugin_root_abs
381 .join("alpha")
382 .join(".claude-plugin")
383 .join("plugin.json");
384 fs::write(
385 &alpha_pj,
386 r#"{"name":"beta","version":"1.0.0","description":"test plugin","keywords":["original"]}"#,
387 )
388 .unwrap();
389
390 let result = update_plugins(&["alpha".to_string()], None, &config);
392 assert!(result.is_err());
393 let err = result.unwrap_err().to_string();
394 assert!(
395 err.contains("conflicts"),
396 "Should report rename collision: {err}"
397 );
398
399 let content = fs::read_to_string(&config.marketplace_path).unwrap();
401 let mp: Marketplace = serde_json::from_str(&content).unwrap();
402 assert_eq!(mp.plugins.len(), 2);
403 assert!(mp.plugins.iter().any(|p| p.name == "alpha"));
404 assert!(mp.plugins.iter().any(|p| p.name == "beta"));
405 }
406
407 #[test]
408 fn update_detects_intra_batch_rename_collision() {
409 let tmp = TempDir::new().unwrap();
410 let config = setup_marketplace_with_plugins(&tmp, &["alpha", "beta"]);
411
412 for name in &["alpha", "beta"] {
414 let pj = config
415 .plugin_root_abs
416 .join(name)
417 .join(".claude-plugin")
418 .join("plugin.json");
419 fs::write(
420 &pj,
421 r#"{"name":"gamma","version":"1.0.0","description":"test plugin","keywords":["original"]}"#,
422 )
423 .unwrap();
424 }
425
426 let result = update_plugins(&["alpha".to_string(), "beta".to_string()], None, &config);
427 assert!(result.is_err());
428 let err = result.unwrap_err().to_string();
429 assert!(
430 err.contains("both be renamed to 'gamma'"),
431 "Should report intra-batch collision: {err}"
432 );
433
434 let content = fs::read_to_string(&config.marketplace_path).unwrap();
436 let mp: Marketplace = serde_json::from_str(&content).unwrap();
437 assert_eq!(mp.plugins.len(), 2);
438 assert!(mp.plugins.iter().any(|p| p.name == "alpha"));
439 assert!(mp.plugins.iter().any(|p| p.name == "beta"));
440 }
441}