Skip to main content

souk_core/ops/
update.rs

1//! Update plugin metadata in the marketplace.
2//!
3//! Re-reads plugin.json from disk to refresh the marketplace entry, and
4//! optionally bumps the plugin version.
5
6use 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
17/// Updates the named plugins in the marketplace by re-reading their
18/// plugin.json from disk.
19///
20/// For each name in `names`:
21/// - Resolves the plugin to its directory via the marketplace source
22/// - Re-reads plugin.json
23/// - Updates the marketplace entry (name, tags)
24/// - If `bump_type` is specified ("major", "minor", or "patch"), bumps
25///   the version in the plugin's plugin.json file
26/// - Re-validates the plugin after update
27///
28/// The marketplace version is always bumped (patch) at the end.
29///
30/// # Errors
31///
32/// Returns [`SoukError::PluginNotFound`] if any name does not exist in
33/// the marketplace.
34///
35/// Returns [`SoukError::AtomicRollback`] if post-update validation fails.
36pub 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    // Verify all names exist
46    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    // Resolve all plugin paths first (fail fast)
53    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    // Create ALL guards BEFORE any writes
66    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    // Now perform version bumps (protected by guards)
78    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    // Update marketplace entries
105    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        // Check for rename collisions
119        if let Some(new_name) = manifest.name_str() {
120            if new_name != name.as_str() {
121                // Check against other renames within this batch
122                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                // Check against plugins outside this batch
129                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    // Bump marketplace version
163    marketplace.version = bump_patch(&marketplace.version)?;
164
165    // Write back
166    let json = serde_json::to_string_pretty(&marketplace)?;
167    fs::write(&config.marketplace_path, format!("{json}\n"))?;
168
169    // Final validation
170    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    // Success — commit all guards
179    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        // Verify initial tags are "old"
230        assert_eq!(config.marketplace.plugins[0].tags, vec!["old"]);
231
232        // Update should refresh tags from plugin.json (which has "original")
233        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        // Check plugin.json version was bumped
253        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        // Both plugins should have bumped versions
326        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        // Record original plugin.json content
344        let plugin_json_path = config
345            .plugin_root_abs
346            .join("alpha")
347            .join(".claude-plugin")
348            .join("plugin.json");
349
350        // Create a marketplace with duplicate names (alpha appears twice) which will fail validation
351        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        // This should fail because the marketplace has duplicate names
360        let result = update_plugins(&["alpha".to_string()], Some("patch"), &bad_config);
361        assert!(result.is_err());
362
363        // plugin.json should be restored to original version
364        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        // Modify alpha's plugin.json to have name "beta" (which already exists)
379        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        // Update alpha — should detect the rename collision with beta
391        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        // marketplace.json should be unchanged (rolled back)
400        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        // Modify both plugins to rename to the same target "gamma"
413        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        // marketplace.json should be unchanged (rolled back)
435        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}