Skip to main content

yosh_plugin_manager/
update.rs

1//! `yosh-plugin update`: structural TOML rewrite of `[[plugin]].version`
2//! by plugin `name`, replacing the legacy `String::replacen` flow.
3//!
4//! See `docs/superpowers/specs/2026-04-28-plugin-update-toml-edit-design.md`.
5
6use std::path::Path;
7
8use toml_edit::DocumentMut;
9
10use crate::config;
11use crate::github::GitHubClient;
12
13/// Result of trying to update a single plugin.
14#[derive(Debug)]
15pub enum UpdateStatus {
16    /// Latest differs from current; manifest was rewritten in-memory.
17    Updated { from: String, to: String },
18    /// Current already matches latest; no rewrite.
19    AlreadyLatest { current: String },
20    /// Per-plugin GitHub or TOML helper error; loop continues.
21    Failed(String),
22    /// Plugin was not considered for update for one of the SkipReason variants.
23    Skipped(SkipReason),
24}
25
26#[derive(Debug)]
27pub enum SkipReason {
28    /// `name_filter` was Some(X) and this plugin's name was not X.
29    NotMatched,
30    /// Plugin source is `local:`, not GitHub.
31    LocalSource,
32    /// GitHub plugin has an empty `version` field (`version = ""` in TOML).
33    /// `config::load_config` rejects a missing `version` key for GitHub
34    /// sources but accepts an empty string; this branch surfaces the empty
35    /// case explicitly rather than treating it as `"" → latest`.
36    NoCurrentVersion,
37}
38
39#[derive(Debug)]
40pub struct PluginUpdateResult {
41    pub name: String,
42    pub status: UpdateStatus,
43}
44
45#[derive(Debug)]
46pub struct UpdateOutcome {
47    pub results: Vec<PluginUpdateResult>,
48    /// True iff at least one `UpdateStatus::Updated` was produced.
49    /// `cmd_update` reads this to decide whether to invoke `cmd_sync(false)`.
50    pub any_updated: bool,
51}
52
53/// Orchestration entry point. Reads `config_path`, fetches the latest
54/// version of each GitHub plugin (filtered by `name_filter` if set),
55/// rewrites matching `[[plugin]].version` fields in a single
56/// `DocumentMut`, and writes the result back exactly once if anything
57/// changed.
58pub fn update(
59    config_path: &Path,
60    name_filter: Option<&str>,
61    client: &GitHubClient,
62) -> Result<UpdateOutcome, String> {
63    let content = std::fs::read_to_string(config_path)
64        .map_err(|e| format!("{}: {}", config_path.display(), e))?;
65    let mut doc: DocumentMut = content
66        .parse()
67        .map_err(|e| format!("{}: {}", config_path.display(), e))?;
68
69    let decls = config::load_config(config_path)?;
70
71    let mut results = Vec::with_capacity(decls.len());
72    let mut any_updated = false;
73
74    for decl in &decls {
75        if name_filter.is_some_and(|f| decl.name != f) {
76            results.push(PluginUpdateResult {
77                name: decl.name.clone(),
78                status: UpdateStatus::Skipped(SkipReason::NotMatched),
79            });
80            continue;
81        }
82
83        let (owner, repo) = match &decl.source {
84            config::PluginSource::GitHub { owner, repo } => (owner, repo),
85            config::PluginSource::Local { .. } => {
86                results.push(PluginUpdateResult {
87                    name: decl.name.clone(),
88                    status: UpdateStatus::Skipped(SkipReason::LocalSource),
89                });
90                continue;
91            }
92        };
93
94        let current = match decl.version.as_deref() {
95            Some(v) if !v.is_empty() => v.to_string(),
96            _ => {
97                results.push(PluginUpdateResult {
98                    name: decl.name.clone(),
99                    status: UpdateStatus::Skipped(SkipReason::NoCurrentVersion),
100                });
101                continue;
102            }
103        };
104
105        let status = match client.latest_version(owner, repo) {
106            Ok(latest) if latest == current => UpdateStatus::AlreadyLatest { current },
107            Ok(latest) => match set_plugin_version(&mut doc, &decl.name, &latest) {
108                Ok(()) => {
109                    any_updated = true;
110                    UpdateStatus::Updated {
111                        from: current,
112                        to: latest,
113                    }
114                }
115                Err(e) => UpdateStatus::Failed(e),
116            },
117            Err(e) => UpdateStatus::Failed(e),
118        };
119
120        results.push(PluginUpdateResult {
121            name: decl.name.clone(),
122            status,
123        });
124    }
125
126    if any_updated {
127        std::fs::write(config_path, doc.to_string())
128            .map_err(|e| format!("write {}: {}", config_path.display(), e))?;
129    }
130
131    Ok(UpdateOutcome {
132        results,
133        any_updated,
134    })
135}
136
137/// Pure TOML helper: locate the `[[plugin]]` table whose `name` equals
138/// `name`, then set its `version` field to `new_version`. Returns `Err`
139/// on missing/duplicate match or on structural anomalies in the
140/// `plugin` key.
141pub fn set_plugin_version(
142    doc: &mut DocumentMut,
143    name: &str,
144    new_version: &str,
145) -> Result<(), String> {
146    let plugin_item = doc
147        .get_mut("plugin")
148        .ok_or_else(|| "config has no [[plugin]] array".to_string())?;
149    let plugins = plugin_item
150        .as_array_of_tables_mut()
151        .ok_or_else(|| "config 'plugin' key is not an array of tables".to_string())?;
152
153    let matches: Vec<usize> = plugins
154        .iter()
155        .enumerate()
156        .filter_map(|(i, t)| {
157            if t.get("name").and_then(|v| v.as_str()) == Some(name) {
158                Some(i)
159            } else {
160                None
161            }
162        })
163        .collect();
164
165    match matches.as_slice() {
166        [] => Err(format!("plugin '{}' not found in config", name)),
167        [idx] => {
168            plugins
169                .get_mut(*idx)
170                .expect("index from filter_map is in-bounds")
171                .insert("version", toml_edit::value(new_version));
172            Ok(())
173        }
174        _ => Err(format!(
175            "plugin '{}' appears multiple times in config",
176            name
177        )),
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::github::GitHubClientWithBase;
185
186    #[test]
187    fn set_version_basic_replaces_existing() {
188        let toml = r#"[[plugin]]
189name = "foo"
190source = "github:owner/foo"
191version = "1.0.0"
192enabled = true
193"#;
194        let mut doc = toml.parse::<DocumentMut>().unwrap();
195        set_plugin_version(&mut doc, "foo", "2.0.0").unwrap();
196        let out = doc.to_string();
197        assert!(out.contains(r#"version = "2.0.0""#), "out:\n{}", out);
198        assert!(!out.contains(r#"version = "1.0.0""#), "out:\n{}", out);
199    }
200
201    #[test]
202    fn set_version_same_version_siblings_no_collision() {
203        let toml = r#"[[plugin]]
204name = "alpha"
205source = "github:owner/alpha"
206version = "1.0.0"
207enabled = true
208
209[[plugin]]
210name = "beta"
211source = "github:owner/beta"
212version = "1.0.0"
213enabled = true
214"#;
215        let mut doc = toml.parse::<DocumentMut>().unwrap();
216        set_plugin_version(&mut doc, "beta", "1.1.0").unwrap();
217        let out = doc.to_string();
218
219        let reparsed = out.parse::<DocumentMut>().unwrap();
220        let plugins = reparsed["plugin"].as_array_of_tables().unwrap();
221        assert_eq!(plugins.len(), 2);
222
223        let alpha = plugins
224            .iter()
225            .find(|t| t.get("name").and_then(|v| v.as_str()) == Some("alpha"))
226            .expect("alpha entry survives");
227        let beta = plugins
228            .iter()
229            .find(|t| t.get("name").and_then(|v| v.as_str()) == Some("beta"))
230            .expect("beta entry survives");
231
232        assert_eq!(
233            alpha.get("version").and_then(|v| v.as_str()),
234            Some("1.0.0"),
235            "sibling alpha was modified"
236        );
237        assert_eq!(
238            beta.get("version").and_then(|v| v.as_str()),
239            Some("1.1.0"),
240            "target beta was not updated"
241        );
242    }
243
244    #[test]
245    fn set_version_preserves_comments_and_layout() {
246        let toml = r#"# yosh plugin manifest
247# managed by yosh-plugin
248
249[[plugin]]
250name = "foo"
251source = "github:owner/foo"
252version = "1.0.0"
253enabled = true
254"#;
255        let mut doc = toml.parse::<DocumentMut>().unwrap();
256        set_plugin_version(&mut doc, "foo", "1.1.0").unwrap();
257        let out = doc.to_string();
258        assert!(out.contains("# yosh plugin manifest"), "out:\n{}", out);
259        assert!(out.contains("# managed by yosh-plugin"), "out:\n{}", out);
260        assert!(out.contains(r#"version = "1.1.0""#), "out:\n{}", out);
261    }
262
263    #[test]
264    fn set_version_inserts_when_missing() {
265        let toml = r#"[[plugin]]
266name = "foo"
267source = "github:owner/foo"
268enabled = true
269"#;
270        let mut doc = toml.parse::<DocumentMut>().unwrap();
271        set_plugin_version(&mut doc, "foo", "1.0.0").unwrap();
272        let out = doc.to_string();
273        assert!(out.contains(r#"version = "1.0.0""#), "out:\n{}", out);
274    }
275
276    #[test]
277    fn set_version_unknown_name_errors() {
278        let toml = r#"[[plugin]]
279name = "foo"
280source = "github:owner/foo"
281version = "1.0.0"
282"#;
283        let mut doc = toml.parse::<DocumentMut>().unwrap();
284        let err = set_plugin_version(&mut doc, "nonexistent", "2.0.0").unwrap_err();
285        assert!(err.contains("nonexistent"), "err: {}", err);
286        assert!(err.contains("not found"), "err: {}", err);
287    }
288
289    #[test]
290    fn set_version_no_plugin_array_errors() {
291        let toml = "# empty config\n";
292        let mut doc = toml.parse::<DocumentMut>().unwrap();
293        let err = set_plugin_version(&mut doc, "foo", "1.0.0").unwrap_err();
294        assert!(err.contains("no [[plugin]] array"), "err: {}", err);
295    }
296
297    #[test]
298    fn set_version_plugin_key_wrong_type_errors() {
299        let toml = "plugin = \"not-an-array\"\n";
300        let mut doc = toml.parse::<DocumentMut>().unwrap();
301        let err = set_plugin_version(&mut doc, "foo", "1.0.0").unwrap_err();
302        assert!(err.contains("array of tables"), "err: {}", err);
303    }
304
305    #[test]
306    fn set_version_duplicate_name_errors() {
307        let toml = r#"[[plugin]]
308name = "foo"
309source = "github:owner/foo"
310version = "1.0.0"
311
312[[plugin]]
313name = "foo"
314source = "github:other/foo"
315version = "2.0.0"
316"#;
317        let mut doc = toml.parse::<DocumentMut>().unwrap();
318        let err = set_plugin_version(&mut doc, "foo", "3.0.0").unwrap_err();
319        assert!(err.contains("multiple"), "err: {}", err);
320    }
321
322    #[test]
323    fn update_skips_local_sources() {
324        let dir = tempfile::tempdir().unwrap();
325        let config_path = dir.path().join("plugins.toml");
326        // Stage a local plugin file so config::load_config doesn't trip on the path.
327        let plugin_file = dir.path().join("local.wasm");
328        std::fs::write(&plugin_file, b"\0asm\x01\0\0\0").unwrap();
329        std::fs::write(
330            &config_path,
331            format!(
332                r#"[[plugin]]
333name = "local-only"
334source = "local:{}"
335"#,
336                plugin_file.display()
337            ),
338        )
339        .unwrap();
340
341        // Point at an unreachable base; if update tries to call out, the
342        // test would either hang or fail. LocalSource skip should bypass.
343        let client = GitHubClientWithBase::new("http://127.0.0.1:1").into_client();
344        let outcome = update(&config_path, None, &client).unwrap();
345
346        assert_eq!(outcome.results.len(), 1);
347        assert!(matches!(
348            outcome.results[0].status,
349            UpdateStatus::Skipped(SkipReason::LocalSource)
350        ));
351        assert!(!outcome.any_updated);
352    }
353
354    #[test]
355    fn update_name_filter_only_matches() {
356        let dir = tempfile::tempdir().unwrap();
357        let config_path = dir.path().join("plugins.toml");
358        std::fs::write(
359            &config_path,
360            r#"[[plugin]]
361name = "alpha"
362source = "github:owner/alpha"
363version = "1.0.0"
364
365[[plugin]]
366name = "beta"
367source = "github:owner/beta"
368version = "1.0.0"
369"#,
370        )
371        .unwrap();
372
373        let mut server = mockito::Server::new();
374        // Only beta should be queried.
375        let _m_beta = server
376            .mock("GET", "/repos/owner/beta/releases/latest")
377            .with_status(200)
378            .with_body(r#"{"tag_name": "v2.0.0"}"#)
379            .create();
380
381        let client = GitHubClientWithBase::new(&server.url()).into_client();
382        let outcome = update(&config_path, Some("beta"), &client).unwrap();
383
384        let alpha = outcome.results.iter().find(|r| r.name == "alpha").unwrap();
385        let beta = outcome.results.iter().find(|r| r.name == "beta").unwrap();
386        assert!(matches!(
387            alpha.status,
388            UpdateStatus::Skipped(SkipReason::NotMatched)
389        ));
390        assert!(matches!(beta.status, UpdateStatus::Updated { .. }));
391
392        let after = std::fs::read_to_string(&config_path).unwrap();
393        let reparsed = after.parse::<DocumentMut>().unwrap();
394        let plugins = reparsed["plugin"].as_array_of_tables().unwrap();
395        let alpha_tbl = plugins
396            .iter()
397            .find(|t| t.get("name").and_then(|v| v.as_str()) == Some("alpha"))
398            .unwrap();
399        let beta_tbl = plugins
400            .iter()
401            .find(|t| t.get("name").and_then(|v| v.as_str()) == Some("beta"))
402            .unwrap();
403        assert_eq!(
404            alpha_tbl.get("version").and_then(|v| v.as_str()),
405            Some("1.0.0"),
406            "alpha should be untouched"
407        );
408        assert_eq!(
409            beta_tbl.get("version").and_then(|v| v.as_str()),
410            Some("2.0.0"),
411            "beta should be updated"
412        );
413    }
414
415    #[test]
416    fn update_no_changes_preserves_file_contents() {
417        let dir = tempfile::tempdir().unwrap();
418        let config_path = dir.path().join("plugins.toml");
419        let original = r#"[[plugin]]
420name = "foo"
421source = "github:owner/foo"
422version = "1.0.0"
423"#;
424        std::fs::write(&config_path, original).unwrap();
425
426        // Capture mtime before the call so we can assert no write happened.
427        // Byte-identical content alone would not catch a regression that
428        // dropped the `if any_updated` guard at update.rs:122 — the rewrite
429        // would still produce identical bytes (set_plugin_version was not
430        // called), but the mtime would advance.
431        let before_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
432        // Sleep just long enough that a re-write would produce a distinct
433        // mtime on filesystems with second-resolution timestamps (HFS+).
434        std::thread::sleep(std::time::Duration::from_millis(1100));
435
436        let mut server = mockito::Server::new();
437        // Latest equals current: no rewrite.
438        let _m = server
439            .mock("GET", "/repos/owner/foo/releases/latest")
440            .with_status(200)
441            .with_body(r#"{"tag_name": "v1.0.0"}"#)
442            .create();
443
444        let client = GitHubClientWithBase::new(&server.url()).into_client();
445        let outcome = update(&config_path, None, &client).unwrap();
446
447        assert!(!outcome.any_updated);
448        assert!(matches!(
449            outcome.results[0].status,
450            UpdateStatus::AlreadyLatest { .. }
451        ));
452
453        let after = std::fs::read_to_string(&config_path).unwrap();
454        assert_eq!(after, original, "file content must be byte-identical");
455        let after_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
456        assert_eq!(
457            before_mtime, after_mtime,
458            "config mtime must be unchanged when no plugin was updated",
459        );
460    }
461
462    #[test]
463    fn update_partial_failure_persists_successes() {
464        let dir = tempfile::tempdir().unwrap();
465        let config_path = dir.path().join("plugins.toml");
466        std::fs::write(
467            &config_path,
468            r#"[[plugin]]
469name = "good"
470source = "github:owner/good"
471version = "1.0.0"
472
473[[plugin]]
474name = "bad"
475source = "github:owner/bad"
476version = "1.0.0"
477"#,
478        )
479        .unwrap();
480
481        let mut server = mockito::Server::new();
482        let _m_good = server
483            .mock("GET", "/repos/owner/good/releases/latest")
484            .with_status(200)
485            .with_body(r#"{"tag_name": "v2.0.0"}"#)
486            .create();
487        let _m_bad = server
488            .mock("GET", "/repos/owner/bad/releases/latest")
489            .with_status(404)
490            .create();
491
492        let client = GitHubClientWithBase::new(&server.url()).into_client();
493        let outcome = update(&config_path, None, &client).unwrap();
494
495        let good = outcome.results.iter().find(|r| r.name == "good").unwrap();
496        let bad = outcome.results.iter().find(|r| r.name == "bad").unwrap();
497        assert!(matches!(good.status, UpdateStatus::Updated { .. }));
498        assert!(
499            matches!(&bad.status, UpdateStatus::Failed(_)),
500            "bad should be Failed, got: {:?}",
501            bad.status
502        );
503
504        let after = std::fs::read_to_string(&config_path).unwrap();
505        assert!(
506            after.contains(r#"version = "2.0.0""#),
507            "good's update must be persisted, got:\n{}",
508            after
509        );
510    }
511}