use std::path::Path;
use toml_edit::DocumentMut;
use crate::config;
use crate::github::GitHubClient;
#[derive(Debug)]
pub enum UpdateStatus {
Updated { from: String, to: String },
AlreadyLatest { current: String },
Failed(String),
Skipped(SkipReason),
}
#[derive(Debug)]
pub enum SkipReason {
NotMatched,
LocalSource,
NoCurrentVersion,
}
#[derive(Debug)]
pub struct PluginUpdateResult {
pub name: String,
pub status: UpdateStatus,
}
#[derive(Debug)]
pub struct UpdateOutcome {
pub results: Vec<PluginUpdateResult>,
pub any_updated: bool,
}
pub fn update(
config_path: &Path,
name_filter: Option<&str>,
client: &GitHubClient,
) -> Result<UpdateOutcome, String> {
let content = std::fs::read_to_string(config_path)
.map_err(|e| format!("{}: {}", config_path.display(), e))?;
let mut doc: DocumentMut = content
.parse()
.map_err(|e| format!("{}: {}", config_path.display(), e))?;
let decls = config::load_config(config_path)?;
let mut results = Vec::with_capacity(decls.len());
let mut any_updated = false;
for decl in &decls {
if name_filter.is_some_and(|f| decl.name != f) {
results.push(PluginUpdateResult {
name: decl.name.clone(),
status: UpdateStatus::Skipped(SkipReason::NotMatched),
});
continue;
}
let (owner, repo) = match &decl.source {
config::PluginSource::GitHub { owner, repo } => (owner, repo),
config::PluginSource::Local { .. } => {
results.push(PluginUpdateResult {
name: decl.name.clone(),
status: UpdateStatus::Skipped(SkipReason::LocalSource),
});
continue;
}
};
let current = match decl.version.as_deref() {
Some(v) if !v.is_empty() => v.to_string(),
_ => {
results.push(PluginUpdateResult {
name: decl.name.clone(),
status: UpdateStatus::Skipped(SkipReason::NoCurrentVersion),
});
continue;
}
};
let status = match client.latest_version(owner, repo) {
Ok(latest) if latest == current => UpdateStatus::AlreadyLatest { current },
Ok(latest) => match set_plugin_version(&mut doc, &decl.name, &latest) {
Ok(()) => {
any_updated = true;
UpdateStatus::Updated {
from: current,
to: latest,
}
}
Err(e) => UpdateStatus::Failed(e),
},
Err(e) => UpdateStatus::Failed(e),
};
results.push(PluginUpdateResult {
name: decl.name.clone(),
status,
});
}
if any_updated {
std::fs::write(config_path, doc.to_string())
.map_err(|e| format!("write {}: {}", config_path.display(), e))?;
}
Ok(UpdateOutcome {
results,
any_updated,
})
}
pub fn set_plugin_version(
doc: &mut DocumentMut,
name: &str,
new_version: &str,
) -> Result<(), String> {
let plugin_item = doc
.get_mut("plugin")
.ok_or_else(|| "config has no [[plugin]] array".to_string())?;
let plugins = plugin_item
.as_array_of_tables_mut()
.ok_or_else(|| "config 'plugin' key is not an array of tables".to_string())?;
let matches: Vec<usize> = plugins
.iter()
.enumerate()
.filter_map(|(i, t)| {
if t.get("name").and_then(|v| v.as_str()) == Some(name) {
Some(i)
} else {
None
}
})
.collect();
match matches.as_slice() {
[] => Err(format!("plugin '{}' not found in config", name)),
[idx] => {
plugins
.get_mut(*idx)
.expect("index from filter_map is in-bounds")
.insert("version", toml_edit::value(new_version));
Ok(())
}
_ => Err(format!(
"plugin '{}' appears multiple times in config",
name
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::github::GitHubClientWithBase;
#[test]
fn set_version_basic_replaces_existing() {
let toml = r#"[[plugin]]
name = "foo"
source = "github:owner/foo"
version = "1.0.0"
enabled = true
"#;
let mut doc = toml.parse::<DocumentMut>().unwrap();
set_plugin_version(&mut doc, "foo", "2.0.0").unwrap();
let out = doc.to_string();
assert!(out.contains(r#"version = "2.0.0""#), "out:\n{}", out);
assert!(!out.contains(r#"version = "1.0.0""#), "out:\n{}", out);
}
#[test]
fn set_version_same_version_siblings_no_collision() {
let toml = r#"[[plugin]]
name = "alpha"
source = "github:owner/alpha"
version = "1.0.0"
enabled = true
[[plugin]]
name = "beta"
source = "github:owner/beta"
version = "1.0.0"
enabled = true
"#;
let mut doc = toml.parse::<DocumentMut>().unwrap();
set_plugin_version(&mut doc, "beta", "1.1.0").unwrap();
let out = doc.to_string();
let reparsed = out.parse::<DocumentMut>().unwrap();
let plugins = reparsed["plugin"].as_array_of_tables().unwrap();
assert_eq!(plugins.len(), 2);
let alpha = plugins
.iter()
.find(|t| t.get("name").and_then(|v| v.as_str()) == Some("alpha"))
.expect("alpha entry survives");
let beta = plugins
.iter()
.find(|t| t.get("name").and_then(|v| v.as_str()) == Some("beta"))
.expect("beta entry survives");
assert_eq!(
alpha.get("version").and_then(|v| v.as_str()),
Some("1.0.0"),
"sibling alpha was modified"
);
assert_eq!(
beta.get("version").and_then(|v| v.as_str()),
Some("1.1.0"),
"target beta was not updated"
);
}
#[test]
fn set_version_preserves_comments_and_layout() {
let toml = r#"# yosh plugin manifest
# managed by yosh-plugin
[[plugin]]
name = "foo"
source = "github:owner/foo"
version = "1.0.0"
enabled = true
"#;
let mut doc = toml.parse::<DocumentMut>().unwrap();
set_plugin_version(&mut doc, "foo", "1.1.0").unwrap();
let out = doc.to_string();
assert!(out.contains("# yosh plugin manifest"), "out:\n{}", out);
assert!(out.contains("# managed by yosh-plugin"), "out:\n{}", out);
assert!(out.contains(r#"version = "1.1.0""#), "out:\n{}", out);
}
#[test]
fn set_version_inserts_when_missing() {
let toml = r#"[[plugin]]
name = "foo"
source = "github:owner/foo"
enabled = true
"#;
let mut doc = toml.parse::<DocumentMut>().unwrap();
set_plugin_version(&mut doc, "foo", "1.0.0").unwrap();
let out = doc.to_string();
assert!(out.contains(r#"version = "1.0.0""#), "out:\n{}", out);
}
#[test]
fn set_version_unknown_name_errors() {
let toml = r#"[[plugin]]
name = "foo"
source = "github:owner/foo"
version = "1.0.0"
"#;
let mut doc = toml.parse::<DocumentMut>().unwrap();
let err = set_plugin_version(&mut doc, "nonexistent", "2.0.0").unwrap_err();
assert!(err.contains("nonexistent"), "err: {}", err);
assert!(err.contains("not found"), "err: {}", err);
}
#[test]
fn set_version_no_plugin_array_errors() {
let toml = "# empty config\n";
let mut doc = toml.parse::<DocumentMut>().unwrap();
let err = set_plugin_version(&mut doc, "foo", "1.0.0").unwrap_err();
assert!(err.contains("no [[plugin]] array"), "err: {}", err);
}
#[test]
fn set_version_plugin_key_wrong_type_errors() {
let toml = "plugin = \"not-an-array\"\n";
let mut doc = toml.parse::<DocumentMut>().unwrap();
let err = set_plugin_version(&mut doc, "foo", "1.0.0").unwrap_err();
assert!(err.contains("array of tables"), "err: {}", err);
}
#[test]
fn set_version_duplicate_name_errors() {
let toml = r#"[[plugin]]
name = "foo"
source = "github:owner/foo"
version = "1.0.0"
[[plugin]]
name = "foo"
source = "github:other/foo"
version = "2.0.0"
"#;
let mut doc = toml.parse::<DocumentMut>().unwrap();
let err = set_plugin_version(&mut doc, "foo", "3.0.0").unwrap_err();
assert!(err.contains("multiple"), "err: {}", err);
}
#[test]
fn update_skips_local_sources() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("plugins.toml");
let plugin_file = dir.path().join("local.wasm");
std::fs::write(&plugin_file, b"\0asm\x01\0\0\0").unwrap();
std::fs::write(
&config_path,
format!(
r#"[[plugin]]
name = "local-only"
source = "local:{}"
"#,
plugin_file.display()
),
)
.unwrap();
let client = GitHubClientWithBase::new("http://127.0.0.1:1").into_client();
let outcome = update(&config_path, None, &client).unwrap();
assert_eq!(outcome.results.len(), 1);
assert!(matches!(
outcome.results[0].status,
UpdateStatus::Skipped(SkipReason::LocalSource)
));
assert!(!outcome.any_updated);
}
#[test]
fn update_name_filter_only_matches() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("plugins.toml");
std::fs::write(
&config_path,
r#"[[plugin]]
name = "alpha"
source = "github:owner/alpha"
version = "1.0.0"
[[plugin]]
name = "beta"
source = "github:owner/beta"
version = "1.0.0"
"#,
)
.unwrap();
let mut server = mockito::Server::new();
let _m_beta = server
.mock("GET", "/repos/owner/beta/releases/latest")
.with_status(200)
.with_body(r#"{"tag_name": "v2.0.0"}"#)
.create();
let client = GitHubClientWithBase::new(&server.url()).into_client();
let outcome = update(&config_path, Some("beta"), &client).unwrap();
let alpha = outcome.results.iter().find(|r| r.name == "alpha").unwrap();
let beta = outcome.results.iter().find(|r| r.name == "beta").unwrap();
assert!(matches!(
alpha.status,
UpdateStatus::Skipped(SkipReason::NotMatched)
));
assert!(matches!(beta.status, UpdateStatus::Updated { .. }));
let after = std::fs::read_to_string(&config_path).unwrap();
let reparsed = after.parse::<DocumentMut>().unwrap();
let plugins = reparsed["plugin"].as_array_of_tables().unwrap();
let alpha_tbl = plugins
.iter()
.find(|t| t.get("name").and_then(|v| v.as_str()) == Some("alpha"))
.unwrap();
let beta_tbl = plugins
.iter()
.find(|t| t.get("name").and_then(|v| v.as_str()) == Some("beta"))
.unwrap();
assert_eq!(
alpha_tbl.get("version").and_then(|v| v.as_str()),
Some("1.0.0"),
"alpha should be untouched"
);
assert_eq!(
beta_tbl.get("version").and_then(|v| v.as_str()),
Some("2.0.0"),
"beta should be updated"
);
}
#[test]
fn update_no_changes_preserves_file_contents() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("plugins.toml");
let original = r#"[[plugin]]
name = "foo"
source = "github:owner/foo"
version = "1.0.0"
"#;
std::fs::write(&config_path, original).unwrap();
let before_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
std::thread::sleep(std::time::Duration::from_millis(1100));
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/repos/owner/foo/releases/latest")
.with_status(200)
.with_body(r#"{"tag_name": "v1.0.0"}"#)
.create();
let client = GitHubClientWithBase::new(&server.url()).into_client();
let outcome = update(&config_path, None, &client).unwrap();
assert!(!outcome.any_updated);
assert!(matches!(
outcome.results[0].status,
UpdateStatus::AlreadyLatest { .. }
));
let after = std::fs::read_to_string(&config_path).unwrap();
assert_eq!(after, original, "file content must be byte-identical");
let after_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
assert_eq!(
before_mtime, after_mtime,
"config mtime must be unchanged when no plugin was updated",
);
}
#[test]
fn update_partial_failure_persists_successes() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("plugins.toml");
std::fs::write(
&config_path,
r#"[[plugin]]
name = "good"
source = "github:owner/good"
version = "1.0.0"
[[plugin]]
name = "bad"
source = "github:owner/bad"
version = "1.0.0"
"#,
)
.unwrap();
let mut server = mockito::Server::new();
let _m_good = server
.mock("GET", "/repos/owner/good/releases/latest")
.with_status(200)
.with_body(r#"{"tag_name": "v2.0.0"}"#)
.create();
let _m_bad = server
.mock("GET", "/repos/owner/bad/releases/latest")
.with_status(404)
.create();
let client = GitHubClientWithBase::new(&server.url()).into_client();
let outcome = update(&config_path, None, &client).unwrap();
let good = outcome.results.iter().find(|r| r.name == "good").unwrap();
let bad = outcome.results.iter().find(|r| r.name == "bad").unwrap();
assert!(matches!(good.status, UpdateStatus::Updated { .. }));
assert!(
matches!(&bad.status, UpdateStatus::Failed(_)),
"bad should be Failed, got: {:?}",
bad.status
);
let after = std::fs::read_to_string(&config_path).unwrap();
assert!(
after.contains(r#"version = "2.0.0""#),
"good's update must be persisted, got:\n{}",
after
);
}
}