1use std::path::Path;
7
8use toml_edit::DocumentMut;
9
10use crate::config;
11use crate::github::GitHubClient;
12
13#[derive(Debug)]
15pub enum UpdateStatus {
16 Updated { from: String, to: String },
18 AlreadyLatest { current: String },
20 Failed(String),
22 Skipped(SkipReason),
24}
25
26#[derive(Debug)]
27pub enum SkipReason {
28 NotMatched,
30 LocalSource,
32 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 pub any_updated: bool,
51}
52
53pub 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
137pub 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 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 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 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 let before_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
432 std::thread::sleep(std::time::Duration::from_millis(1100));
435
436 let mut server = mockito::Server::new();
437 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}