use std::path::{Path, PathBuf}; use tracing::warn; use crate::api::schema::InstalledPluginInfo; pub const MANIFEST_UNAVAILABLE_WARNING_PREFIX: &str = "manifest unavailable: "; fn registry_path() -> PathBuf { crate::session::data_dir().join("plugins.json") } fn save_json_to_path(path: &Path, value: &T) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let json = serde_json::to_string_pretty(value)?; let tmp_path = path.with_extension("json.tmp"); std::fs::write(&tmp_path, &json)?; #[cfg(windows)] if path.exists() { if let Err(err) = std::fs::remove_file(path) { let _ = std::fs::remove_file(&tmp_path); return Err(err); } } if let Err(err) = std::fs::rename(&tmp_path, path) { let _ = std::fs::remove_file(&tmp_path); return Err(err); } Ok(()) } /// Atomically write `plugins.json` next to `session.json`. pub fn save(plugins: &[InstalledPluginInfo]) -> std::io::Result<()> { let path = registry_path(); save_to_path(&path, plugins) } pub fn save_to_path(path: &Path, plugins: &[InstalledPluginInfo]) -> std::io::Result<()> { save_json_to_path(path, plugins) } /// Load `plugins.json`. Returns an empty vec on any failure so a corrupt or /// missing file never blocks server startup. pub fn load() -> Vec { load_from_path(®istry_path()) } pub fn load_from_path(path: &Path) -> Vec { if !path.exists() { return Vec::new(); } let content = match std::fs::read_to_string(path) { Ok(c) => c, Err(err) => { warn!(path = %path.display(), err = %err, "failed to read plugin registry"); return Vec::new(); } }; match serde_json::from_str::>(&content) { Ok(entries) => entries, Err(err) => { warn!(path = %path.display(), err = %err, "failed to parse plugin registry, starting with empty registry"); Vec::new() } } } /// Re-read each entry's manifest from disk using the provided reload function. /// /// If the manifest parses successfully, replace cached fields but keep the /// stored `enabled` flag. If the file is gone or unparseable, keep the stored /// entry and append a warning so `plugin.list` surfaces it. pub fn reload_manifests( mut entries: Vec, reload_fn: impl Fn(&str, bool) -> Result, ) -> Vec { for entry in &mut entries { entry.warnings.clear(); match reload_fn(&entry.manifest_path, entry.enabled) { Ok(mut fresh) => { fresh.enabled = entry.enabled; fresh.source = entry.source.clone(); *entry = fresh; } Err(warn_msg) => { entry .warnings .push(format!("{MANIFEST_UNAVAILABLE_WARNING_PREFIX}{warn_msg}")); } } } entries } #[cfg(test)] mod tests { use super::*; fn temp_registry_path(name: &str) -> PathBuf { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); std::env::temp_dir() .join(format!( "herdr-registry-{name}-{}-{nanos}", std::process::id() )) .join("plugins.json") } fn sample_plugin(id: &str) -> InstalledPluginInfo { InstalledPluginInfo { plugin_id: id.to_string(), name: "Test Plugin".to_string(), version: "0.1.0".to_string(), min_herdr_version: crate::build_info::BASE_VERSION.to_string(), description: None, manifest_path: format!("/tmp/{id}/herdr-plugin.toml"), plugin_root: format!("/tmp/{id}"), enabled: true, platforms: None, build: vec![], actions: vec![], events: vec![], panes: vec![], link_handlers: vec![], source: Default::default(), warnings: vec![], } } #[test] fn save_and_load_roundtrip() { let path = temp_registry_path("roundtrip"); let plugins = vec![sample_plugin("example.a"), sample_plugin("example.b")]; save_to_path(&path, &plugins).unwrap(); let loaded = load_from_path(&path); assert_eq!(loaded.len(), 2); let ids: Vec<_> = loaded.iter().map(|p| p.plugin_id.as_str()).collect(); assert!(ids.contains(&"example.a")); assert!(ids.contains(&"example.b")); } #[test] fn missing_file_returns_empty() { let path = temp_registry_path("missing"); let loaded = load_from_path(&path); assert!(loaded.is_empty()); } #[test] fn corrupt_file_returns_empty_without_panic() { let path = temp_registry_path("corrupt"); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).unwrap(); } std::fs::write(&path, b"this is not valid json {{{{").unwrap(); let loaded = load_from_path(&path); assert!(loaded.is_empty()); } #[test] fn reload_manifests_keeps_entry_with_warning_on_missing_manifest() { let entry = sample_plugin("example.missing"); let entries = vec![entry]; let result = reload_manifests(entries, |path, _enabled| { Err(format!("manifest not found at {path}")) }); assert_eq!(result.len(), 1); assert_eq!(result[0].plugin_id, "example.missing"); assert!(!result[0].warnings.is_empty()); assert!(result[0].warnings[0].contains("manifest not found")); } #[test] fn reload_manifests_uses_fresh_parse_and_keeps_enabled_flag() { let mut entry = sample_plugin("example.reload"); entry.enabled = false; entry.source = crate::api::schema::PluginSourceInfo { kind: crate::api::schema::PluginSourceKind::Github, owner: Some("ogulcancelik".into()), repo: Some("herdr-plugin-examples".into()), subdir: Some("worktree-bootstrap".into()), requested_ref: Some("main".into()), resolved_commit: Some("abc123".into()), managed_path: Some("/tmp/herdr/plugins/github/example.reload".into()), installed_unix_ms: Some(42), }; let result = reload_manifests(vec![entry], |_path, _enabled| { Ok(InstalledPluginInfo { plugin_id: "example.reload".to_string(), name: "Fresh Name".to_string(), version: "0.2.0".to_string(), min_herdr_version: crate::build_info::BASE_VERSION.to_string(), description: Some("refreshed".to_string()), manifest_path: "/tmp/example.reload/herdr-plugin.toml".to_string(), plugin_root: "/tmp/example.reload".to_string(), enabled: true, // caller would pass stored enabled; fresh parse returns true platforms: None, build: vec![], actions: vec![], events: vec![], panes: vec![], link_handlers: vec![], source: Default::default(), warnings: vec![], }) }); assert_eq!(result[0].name, "Fresh Name"); assert_eq!(result[0].version, "0.2.0"); // enabled preserved from stored entry assert!(!result[0].enabled); assert_eq!( result[0].source.kind, crate::api::schema::PluginSourceKind::Github ); assert_eq!(result[0].source.owner.as_deref(), Some("ogulcancelik")); assert!(result[0].warnings.is_empty()); } #[test] fn atomic_write_temp_file_is_cleaned_up_on_rename_failure() { // Write to a path whose parent does not yet exist, then verify the // tmp file is removed when the write fails mid-way. Here we just // confirm a successful write leaves no .tmp file behind. let path = temp_registry_path("cleanup"); save_to_path(&path, &[sample_plugin("example.cleanup")]).unwrap(); let tmp = path.with_extension("json.tmp"); assert!( !tmp.exists(), "tmp file should be cleaned up after successful rename" ); assert!(path.exists()); } #[test] fn save_replaces_existing_registry_file() { let path = temp_registry_path("replace-existing"); save_to_path(&path, &[sample_plugin("example.first")]).unwrap(); save_to_path(&path, &[sample_plugin("example.second")]).unwrap(); let loaded = load_from_path(&path); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].plugin_id, "example.second"); } }