diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-25 20:50:13 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-25 20:50:13 +0300 |
| commit | 12cce7deeb2d27f7b11daece75881fcfd88ea607 (patch) | |
| tree | 843769b293a2e3cd9ad8c752637307741f6a702e /src/integration/registry.rs | |
| parent | 3c7327c267a352023797c8a13fd0b7f4a7a40591 (diff) | |
refactor: split integration module
Diffstat (limited to 'src/integration/registry.rs')
| -rw-r--r-- | src/integration/registry.rs | 410 |
1 files changed, 410 insertions, 0 deletions
diff --git a/src/integration/registry.rs b/src/integration/registry.rs new file mode 100644 index 00000000..e68dce0c --- /dev/null +++ b/src/integration/registry.rs @@ -0,0 +1,410 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use super::env::*; + +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::Omp => "omp", + crate::api::schema::IntegrationTarget::Claude => "claude", + crate::api::schema::IntegrationTarget::Codex => "codex", + crate::api::schema::IntegrationTarget::Copilot => "copilot", + crate::api::schema::IntegrationTarget::Devin => "devin", + crate::api::schema::IntegrationTarget::Droid => "droid", + crate::api::schema::IntegrationTarget::Kimi => "kimi", + crate::api::schema::IntegrationTarget::Opencode => "opencode", + crate::api::schema::IntegrationTarget::Kilo => "kilo", + crate::api::schema::IntegrationTarget::Hermes => "hermes", + crate::api::schema::IntegrationTarget::Qodercli => "qodercli", + crate::api::schema::IntegrationTarget::Cursor => "cursor", + } +} + +pub(crate) fn integration_target_command( + target: crate::api::schema::IntegrationTarget, +) -> &'static str { + integration_target_command_names(target)[0] +} + +pub(crate) fn integration_target_command_names( + target: crate::api::schema::IntegrationTarget, +) -> &'static [&'static str] { + match target { + crate::api::schema::IntegrationTarget::Pi => &["pi"], + crate::api::schema::IntegrationTarget::Omp => &["omp"], + crate::api::schema::IntegrationTarget::Claude => &["claude"], + crate::api::schema::IntegrationTarget::Codex => &["codex"], + crate::api::schema::IntegrationTarget::Copilot => &["copilot"], + crate::api::schema::IntegrationTarget::Devin => &["devin"], + crate::api::schema::IntegrationTarget::Droid => &["droid"], + crate::api::schema::IntegrationTarget::Kimi => &["kimi"], + crate::api::schema::IntegrationTarget::Opencode => &["opencode"], + crate::api::schema::IntegrationTarget::Kilo => &["kilo", "kilo-code"], + crate::api::schema::IntegrationTarget::Hermes => &["hermes"], + crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(), + crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(), + } +} + +pub(crate) fn cursor_command_names() -> &'static [&'static str] { + &["cursor-agent"] +} + +pub(crate) fn integration_target_supported(target: crate::api::schema::IntegrationTarget) -> bool { + #[cfg(windows)] + { + matches!( + target, + crate::api::schema::IntegrationTarget::Claude + | crate::api::schema::IntegrationTarget::Codex + | crate::api::schema::IntegrationTarget::Copilot + | crate::api::schema::IntegrationTarget::Droid + | crate::api::schema::IntegrationTarget::Kimi + | crate::api::schema::IntegrationTarget::Qodercli + ) + } + + #[cfg(not(windows))] + { + let _ = target; + true + } +} + +pub(crate) fn integration_target_available(target: crate::api::schema::IntegrationTarget) -> bool { + if !integration_target_supported(target) { + return false; + } + + integration_target_command_names(target) + .iter() + .any(|command| command_available(command)) + || integration_target_install_layout_available(target) +} + +#[cfg(windows)] +pub(crate) fn qodercli_command_names() -> &'static [&'static str] { + &["qodercli", "qoder", "qoderclicn", "qodercn"] +} + +#[cfg(not(windows))] +pub(crate) fn qodercli_command_names() -> &'static [&'static str] { + &["qodercli"] +} + +pub(crate) fn integration_target_install_layout_available( + target: crate::api::schema::IntegrationTarget, +) -> bool { + match target { + crate::api::schema::IntegrationTarget::Codex => codex_standalone_binary_available(), + crate::api::schema::IntegrationTarget::Hermes => hermes_install_layout_available(), + _ => false, + } +} + +pub(crate) fn command_available(command: &str) -> bool { + let Some(paths) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&paths).any(|dir| { + command_path_candidates(&dir, command) + .into_iter() + .any(|path| executable_file_exists(&path)) + }) +} + +pub(crate) fn command_path_candidates(dir: &Path, command: &str) -> Vec<PathBuf> { + let base = dir.join(command); + + #[cfg(not(windows))] + { + vec![base] + } + + #[cfg(windows)] + { + if Path::new(command).extension().is_some() { + return vec![base]; + } + + let mut candidates = vec![base]; + for extension in [".exe", ".cmd", ".bat", ".ps1"] { + candidates.push(dir.join(format!("{command}{extension}"))); + } + candidates + } +} + +pub(crate) fn executable_file_exists(path: &Path) -> bool { + let Ok(metadata) = path.metadata() else { + return false; + }; + if !metadata.is_file() { + return false; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + + #[cfg(not(unix))] + { + true + } +} + +pub(crate) fn codex_standalone_binary_available() -> bool { + let Ok(releases_dir) = + codex_dir().map(|dir| dir.join("packages").join("standalone").join("releases")) + else { + return false; + }; + let Ok(entries) = fs::read_dir(releases_dir) else { + return false; + }; + + entries.filter_map(Result::ok).any(|entry| { + executable_file_exists(&entry.path().join("bin").join(codex_executable_name())) + }) +} + +pub(crate) fn codex_executable_name() -> &'static str { + if cfg!(windows) { + "codex.exe" + } else { + "codex" + } +} + +pub(crate) fn hermes_install_layout_available() -> bool { + #[cfg(windows)] + { + let Some(local_app_data) = + std::env::var_os("LOCALAPPDATA").filter(|value| !value.is_empty()) + else { + return false; + }; + let dir = PathBuf::from(local_app_data).join("hermes"); + [ + dir.join("hermes.exe"), + dir.join("bin").join("hermes.exe"), + dir.join("Scripts").join("hermes.exe"), + ] + .into_iter() + .any(|path| executable_file_exists(&path)) + } + + #[cfg(not(windows))] + { + false + } +} + +pub(crate) fn installed_integration_statuses() -> Vec<super::IntegrationStatus> { + integration_specs() + .into_iter() + .filter_map(|(target, path, expected_version)| { + if !integration_target_supported(target) { + return None; + } + Some(integration_status_at(target, path.ok()?, expected_version)) + }) + .collect() +} + +pub(crate) fn integration_recommendations() -> Vec<super::IntegrationRecommendation> { + integration_specs() + .into_iter() + .filter_map(|(target, path, expected_version)| { + if !integration_target_supported(target) { + return None; + } + let path = path.ok()?; + let status = integration_status_at(target, path.clone(), expected_version); + Some(super::IntegrationRecommendation { + target, + label: integration_target_label(target), + command: integration_target_command(target), + available: integration_target_available(target) + || status.state != super::IntegrationStatusKind::NotInstalled, + path, + state: status.state, + }) + }) + .collect() +} + +pub(crate) fn outdated_installed_integrations() -> Vec<super::IntegrationStatus> { + installed_integration_statuses() + .into_iter() + .filter(|status| status.state == super::IntegrationStatusKind::Outdated) + .collect() +} + +fn integration_specs() -> [( + crate::api::schema::IntegrationTarget, + io::Result<PathBuf>, + u32, +); 13] { + [ + ( + crate::api::schema::IntegrationTarget::Pi, + pi_extension_dir().map(|dir| dir.join(super::PI_EXTENSION_INSTALL_NAME)), + super::PI_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Omp, + omp_extension_dir().map(|dir| dir.join(super::OMP_EXTENSION_INSTALL_NAME)), + super::OMP_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Claude, + claude_dir().map(|dir| dir.join("hooks").join(super::CLAUDE_HOOK_INSTALL_NAME)), + super::CLAUDE_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Codex, + codex_dir().map(|dir| dir.join(super::CODEX_HOOK_INSTALL_NAME)), + super::CODEX_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Copilot, + copilot_dir().map(|dir| dir.join("hooks").join(super::COPILOT_HOOK_INSTALL_NAME)), + super::COPILOT_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Devin, + devin_dir().map(|dir| dir.join(super::DEVIN_HOOK_INSTALL_NAME)), + super::DEVIN_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Droid, + droid_dir().map(|dir| dir.join("hooks").join(super::DROID_HOOK_INSTALL_NAME)), + super::DROID_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Kimi, + kimi_dir().map(|dir| dir.join("hooks").join(super::KIMI_HOOK_INSTALL_NAME)), + super::KIMI_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Opencode, + opencode_dir().map(|dir| { + dir.join("plugins") + .join(super::OPENCODE_PLUGIN_INSTALL_NAME) + }), + super::OPENCODE_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Kilo, + kilo_dir().map(|dir| dir.join("plugin").join(super::KILO_PLUGIN_INSTALL_NAME)), + super::KILO_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Hermes, + hermes_plugin_dir().map(|dir| dir.join(super::HERMES_PLUGIN_INIT_INSTALL_NAME)), + super::HERMES_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Qodercli, + qodercli_dir().map(|dir| dir.join("hooks").join(super::QODERCLI_HOOK_INSTALL_NAME)), + super::QODERCLI_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Cursor, + cursor_dir().map(|dir| dir.join(super::CURSOR_HOOK_INSTALL_NAME)), + super::CURSOR_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 +} + +pub(crate) fn integration_status_at( + target: crate::api::schema::IntegrationTarget, + path: PathBuf, + expected_version: u32, +) -> super::IntegrationStatus { + if !path.is_file() { + return super::IntegrationStatus { + target, + path, + state: super::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) { + super::IntegrationStatusKind::Current + } else { + super::IntegrationStatusKind::Outdated + }; + + super::IntegrationStatus { + target, + path, + state, + installed_version, + expected_version, + } +} + +pub(crate) 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(super::INTEGRATION_VERSION_MARKER)? + .trim() + .parse() + .ok() + }) +} |
