diff options
Diffstat (limited to 'src/integration/mod.rs')
| -rw-r--r-- | src/integration/mod.rs | 239 |
1 files changed, 218 insertions, 21 deletions
diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 402e29a7..1e5c2d68 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -1,6 +1,8 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; +#[cfg(test)] +use std::sync::{Mutex, MutexGuard, OnceLock}; use portable_pty::CommandBuilder; use serde_json::{json, Map, Value}; @@ -10,12 +12,17 @@ use crate::layout::PaneId; pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_ID"; const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts"; const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts"); +const PI_INTEGRATION_VERSION: u32 = 1; const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh"); +const CLAUDE_INTEGRATION_VERSION: u32 = 1; const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh"); +const CODEX_INTEGRATION_VERSION: u32 = 1; const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js"); +const OPENCODE_INTEGRATION_VERSION: u32 = 1; +const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; #[derive(Debug)] pub(crate) struct ClaudeInstallPaths { @@ -35,6 +42,22 @@ pub(crate) struct OpenCodeInstallPaths { pub plugin_path: PathBuf, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct IntegrationStatus { + pub target: crate::api::schema::IntegrationTarget, + pub path: PathBuf, + pub state: IntegrationStatusKind, + pub installed_version: Option<u32>, + pub expected_version: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum IntegrationStatusKind { + NotInstalled, + Current, + Outdated, +} + #[derive(Debug)] pub(crate) struct PiUninstallResult { pub extension_path: PathBuf, @@ -213,7 +236,9 @@ pub(crate) fn uninstall_target( Ok(messages) } -fn integration_target_label(target: crate::api::schema::IntegrationTarget) -> &'static str { +pub(crate) fn integration_target_label( + target: crate::api::schema::IntegrationTarget, +) -> &'static str { match target { crate::api::schema::IntegrationTarget::Pi => "pi", crate::api::schema::IntegrationTarget::Claude => "claude", @@ -222,6 +247,136 @@ fn integration_target_label(target: crate::api::schema::IntegrationTarget) -> &' } } +pub(crate) fn installed_integration_statuses() -> Vec<IntegrationStatus> { + integration_specs() + .into_iter() + .filter_map(|(target, path, expected_version)| { + Some(integration_status_at(target, path.ok()?, expected_version)) + }) + .collect() +} + +fn outdated_installed_integrations() -> Vec<IntegrationStatus> { + installed_integration_statuses() + .into_iter() + .filter(|status| status.state == IntegrationStatusKind::Outdated) + .collect() +} + +fn integration_specs() -> [( + crate::api::schema::IntegrationTarget, + io::Result<PathBuf>, + u32, +); 4] { + [ + ( + crate::api::schema::IntegrationTarget::Pi, + pi_extension_dir().map(|dir| dir.join(PI_EXTENSION_INSTALL_NAME)), + PI_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Claude, + claude_dir().map(|dir| dir.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME)), + CLAUDE_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Codex, + codex_dir().map(|dir| dir.join(CODEX_HOOK_INSTALL_NAME)), + CODEX_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Opencode, + opencode_dir().map(|dir| dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME)), + OPENCODE_INTEGRATION_VERSION, + ), + ] +} + +pub(crate) fn integration_update_instructions( + targets: &[crate::api::schema::IntegrationTarget], +) -> String { + let commands: Vec<String> = targets + .iter() + .map(|target| { + format!( + "`herdr integration install {}`", + integration_target_label(*target) + ) + }) + .collect(); + + match commands.as_slice() { + [] => String::new(), + [command] => format!("run {command}"), + [rest @ .., last] => format!("run {} and {last}", rest.join(", ")), + } +} + +pub(crate) fn print_outdated_update_notice() -> bool { + let outdated = outdated_installed_integrations(); + if outdated.is_empty() { + return false; + } + + let targets = outdated + .iter() + .map(|integration| integration.target) + .collect::<Vec<_>>(); + eprintln!( + "installed herdr integrations need updating; {}.", + integration_update_instructions(&targets).replace('`', "") + ); + true +} + +fn integration_status_at( + target: crate::api::schema::IntegrationTarget, + path: PathBuf, + expected_version: u32, +) -> IntegrationStatus { + if !path.is_file() { + return IntegrationStatus { + target, + path, + state: IntegrationStatusKind::NotInstalled, + installed_version: None, + expected_version, + }; + } + + let installed_version = fs::read_to_string(&path) + .ok() + .and_then(|content| parse_integration_version(&content)); + let state = if installed_version.is_some_and(|version| version >= expected_version) { + IntegrationStatusKind::Current + } else { + IntegrationStatusKind::Outdated + }; + + IntegrationStatus { + target, + path, + state, + installed_version, + expected_version, + } +} + +fn parse_integration_version(content: &str) -> Option<u32> { + content.lines().find_map(|line| { + let marker_line = line + .trim() + .trim_start_matches('/') + .trim_start_matches('#') + .trim(); + marker_line + .strip_prefix(INTEGRATION_VERSION_MARKER)? + .trim() + .parse() + .ok() + }) +} + pub(crate) fn install_pi() -> io::Result<PathBuf> { let dir = pi_extension_dir()?; if !dir.is_dir() { @@ -816,9 +971,14 @@ fn home_dir() -> io::Result<PathBuf> { } #[cfg(test)] +pub(crate) fn integration_env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() +} + +#[cfg(test)] mod tests { use super::*; - use std::sync::{Mutex, MutexGuard, OnceLock}; fn unique_base() -> PathBuf { std::env::temp_dir().join(format!( @@ -831,14 +991,9 @@ mod tests { )) } - fn env_lock() -> MutexGuard<'static, ()> { - static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() - } - #[test] fn install_pi_writes_embedded_asset_to_pi_extensions_dir() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let ext_dir = home.join(".pi/agent/extensions"); @@ -857,7 +1012,7 @@ mod tests { #[test] fn uninstall_pi_removes_embedded_extension_when_present() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let ext_dir = home.join(".pi/agent/extensions"); @@ -879,8 +1034,50 @@ mod tests { } #[test] + fn outdated_integrations_treat_missing_version_marker_as_legacy() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let ext_dir = home.join(".pi/agent/extensions"); + fs::create_dir_all(&ext_dir).unwrap(); + let extension_path = ext_dir.join(PI_EXTENSION_INSTALL_NAME); + fs::write(&extension_path, "// installed by herdr\n").unwrap(); + std::env::set_var("HOME", &home); + + let outdated = outdated_installed_integrations(); + + assert_eq!(outdated.len(), 1); + assert_eq!( + outdated[0].target, + crate::api::schema::IntegrationTarget::Pi + ); + assert_eq!(outdated[0].path, extension_path); + assert_eq!(outdated[0].installed_version, None); + assert_eq!(outdated[0].expected_version, PI_INTEGRATION_VERSION); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn outdated_integrations_accept_current_version_marker() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let ext_dir = home.join(".pi/agent/extensions"); + fs::create_dir_all(&ext_dir).unwrap(); + fs::write(ext_dir.join(PI_EXTENSION_INSTALL_NAME), PI_EXTENSION_ASSET).unwrap(); + std::env::set_var("HOME", &home); + + assert!(outdated_installed_integrations().is_empty()); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] fn install_pi_errors_when_extension_dir_missing() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); fs::create_dir_all(&home).unwrap(); @@ -896,7 +1093,7 @@ mod tests { #[test] fn install_claude_writes_hook_and_updates_settings() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let claude_dir = home.join(".claude"); @@ -965,7 +1162,7 @@ mod tests { #[test] fn install_claude_is_idempotent_for_hook_entries() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let claude_dir = home.join(".claude"); @@ -1017,7 +1214,7 @@ mod tests { #[test] fn uninstall_claude_removes_herdr_hooks_and_preserves_others() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let claude_dir = home.join(".claude"); @@ -1073,7 +1270,7 @@ mod tests { #[test] fn install_claude_errors_when_claude_dir_missing() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); fs::create_dir_all(&home).unwrap(); @@ -1089,7 +1286,7 @@ mod tests { #[test] fn install_codex_writes_hook_and_updates_hooks_and_config() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let codex_dir = home.join(".codex"); @@ -1133,7 +1330,7 @@ mod tests { #[test] fn install_codex_is_idempotent_for_hook_entries_and_feature_flag() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let codex_dir = home.join(".codex"); @@ -1169,7 +1366,7 @@ mod tests { #[test] fn uninstall_codex_removes_herdr_hooks_and_leaves_config_alone() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let codex_dir = home.join(".codex"); @@ -1224,7 +1421,7 @@ mod tests { #[test] fn install_codex_errors_when_config_dir_missing() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); fs::create_dir_all(&home).unwrap(); @@ -1240,7 +1437,7 @@ mod tests { #[test] fn install_opencode_writes_plugin_to_plugins_dir() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let opencode_dir = home.join(".config/opencode"); @@ -1264,7 +1461,7 @@ mod tests { #[test] fn uninstall_opencode_removes_plugin_when_present() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let opencode_dir = home.join(".config/opencode/plugins"); @@ -1287,7 +1484,7 @@ mod tests { #[test] fn install_opencode_errors_when_config_dir_missing() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); fs::create_dir_all(&home).unwrap(); |
