aboutsummaryrefslogtreecommitdiff
path: root/src/persist/plugin_registry.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 02:43:45 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 03:47:07 +0300
commitfbd20ad6e488fb3b9efc1612fdffee6e672976c1 (patch)
treed21daf93241786f8aa6fde23b41cdb9e15182b79 /src/persist/plugin_registry.rs
parentd35c642ef36ee5ac2afe7c1b18ad5a717bc807d8 (diff)
feat: add plugin v1 system
Add manifest-driven plugin actions, event hooks, pane entrypoints, link handlers, and host APIs. Support plugin link, GitHub install, build commands, installed plugin registry, pane movement, runtime context, and plugin docs.
Diffstat (limited to 'src/persist/plugin_registry.rs')
-rw-r--r--src/persist/plugin_registry.rs237
1 files changed, 237 insertions, 0 deletions
diff --git a/src/persist/plugin_registry.rs b/src/persist/plugin_registry.rs
new file mode 100644
index 00000000..19db842a
--- /dev/null
+++ b/src/persist/plugin_registry.rs
@@ -0,0 +1,237 @@
+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<T: serde::Serialize + ?Sized>(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)?;
+ 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<InstalledPluginInfo> {
+ load_from_path(&registry_path())
+}
+
+pub fn load_from_path(path: &Path) -> Vec<InstalledPluginInfo> {
+ 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::<Vec<InstalledPluginInfo>>(&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<InstalledPluginInfo>,
+ reload_fn: impl Fn(&str, bool) -> Result<InstalledPluginInfo, String>,
+) -> Vec<InstalledPluginInfo> {
+ 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(),
+ 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(),
+ 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());
+ }
+}