use std::path::{Path, PathBuf};
use anyhow::Result;
pub type ExtraArtifactsFn = fn(hook_path: &Path) -> Result<Vec<(PathBuf, String)>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BinaryStrategy {
BareNameOnPath,
EmbedAbsolute,
}
pub struct MergeOutcome {
pub content: String,
pub changed: bool,
}
#[derive(Debug)]
pub struct Target {
pub name: &'static str,
pub core_source: &'static str,
pub display_name: &'static str,
pub default_config_path: fn() -> Result<PathBuf>,
pub hook_command: fn(resolved: &Path, explicit: bool) -> Result<String>,
pub merge_install: fn(content: &str, hook_cmd: &str) -> Result<MergeOutcome>,
pub merge_uninstall: fn(content: &str) -> Result<MergeOutcome>,
pub verify_schema: fn(content: &str) -> crate::install::verify::SchemaParse,
pub binary_strategy: BinaryStrategy,
pub presence_probe: Option<fn() -> bool>,
pub extra_artifacts: Option<ExtraArtifactsFn>,
}
pub const BACKUP_SUFFIX: &str = "pixtuoid.bak";
pub const CLAUDE: Target = Target {
name: "claude",
core_source: pixtuoid_core::source::claude_code::SOURCE_NAME,
display_name: "Claude Code",
default_config_path: crate::install::claude::default_config_path,
hook_command: crate::install::claude::hook_command,
merge_install: crate::install::claude::merge_install,
merge_uninstall: crate::install::claude::merge_uninstall,
verify_schema: crate::install::claude::verify_schema,
binary_strategy: if cfg!(windows) {
BinaryStrategy::EmbedAbsolute
} else {
BinaryStrategy::BareNameOnPath
},
presence_probe: None,
extra_artifacts: None,
};
pub const CODEX: Target = Target {
name: "codex",
core_source: pixtuoid_core::source::codex::SOURCE_NAME,
display_name: "Codex",
default_config_path: crate::install::codex::default_config_path,
hook_command: crate::install::codex::hook_command,
merge_install: crate::install::codex::merge_install,
merge_uninstall: crate::install::codex::merge_uninstall,
verify_schema: crate::install::codex::verify_schema,
binary_strategy: BinaryStrategy::EmbedAbsolute,
presence_probe: None,
extra_artifacts: None,
};
pub const REASONIX: Target = Target {
name: "reasonix",
core_source: pixtuoid_core::source::reasonix::SOURCE_NAME,
display_name: "Reasonix",
default_config_path: crate::install::reasonix::default_config_path,
hook_command: crate::install::reasonix::hook_command,
merge_install: crate::install::reasonix::merge_install,
merge_uninstall: crate::install::reasonix::merge_uninstall,
verify_schema: crate::install::reasonix::verify_schema,
binary_strategy: BinaryStrategy::EmbedAbsolute,
presence_probe: Some(crate::install::reasonix::detect_installed),
extra_artifacts: None,
};
pub const CODEWHALE: Target = Target {
name: "codewhale",
core_source: pixtuoid_core::source::codewhale::SOURCE_NAME,
display_name: "CodeWhale",
default_config_path: crate::install::codewhale::default_config_path,
hook_command: crate::install::codewhale::hook_command,
merge_install: crate::install::codewhale::merge_install,
merge_uninstall: crate::install::codewhale::merge_uninstall,
verify_schema: crate::install::codewhale::verify_schema,
binary_strategy: BinaryStrategy::EmbedAbsolute,
presence_probe: Some(crate::install::codewhale::detect_installed),
extra_artifacts: None,
};
pub const OPENCODE: Target = Target {
name: "opencode",
core_source: pixtuoid_core::source::opencode::SOURCE_NAME,
display_name: "opencode",
default_config_path: crate::install::opencode::default_config_path,
hook_command: crate::install::opencode::hook_command,
merge_install: crate::install::opencode::merge_install,
merge_uninstall: crate::install::opencode::merge_uninstall,
verify_schema: crate::install::opencode::verify_schema,
binary_strategy: BinaryStrategy::EmbedAbsolute,
presence_probe: Some(crate::install::opencode::detect_installed),
extra_artifacts: None,
};
pub const CURSOR: Target = Target {
name: "cursor",
core_source: pixtuoid_core::source::cursor::SOURCE_NAME,
display_name: "Cursor CLI",
default_config_path: crate::install::cursor::default_config_path,
hook_command: crate::install::cursor::hook_command,
merge_install: crate::install::cursor::merge_install,
merge_uninstall: crate::install::cursor::merge_uninstall,
verify_schema: crate::install::cursor::verify_schema,
binary_strategy: BinaryStrategy::EmbedAbsolute,
presence_probe: Some(crate::install::cursor::detect_installed),
extra_artifacts: None,
};
pub const OPENCLAW: Target = Target {
name: "openclaw",
core_source: pixtuoid_core::source::openclaw::SOURCE_NAME,
display_name: "OpenClaw",
default_config_path: crate::install::openclaw::default_config_path,
hook_command: crate::install::openclaw::hook_command,
merge_install: crate::install::openclaw::merge_install,
merge_uninstall: crate::install::openclaw::merge_uninstall,
binary_strategy: BinaryStrategy::EmbedAbsolute,
presence_probe: Some(crate::install::openclaw::detect_installed),
extra_artifacts: Some(crate::install::openclaw::plugin_artifacts),
verify_schema: crate::install::openclaw::verify_schema,
};
pub const TARGETS: &[&Target] = &[
&CLAUDE, &CODEX, &REASONIX, &CODEWHALE, &OPENCODE, &CURSOR, &OPENCLAW,
];
pub fn by_name(name: &str) -> Option<&'static Target> {
TARGETS.iter().copied().find(|t| t.name == name)
}
pub fn by_source(source_id: &str) -> Option<&'static Target> {
TARGETS.iter().copied().find(|t| t.core_source == source_id)
}
pub fn config_present(path: &Path) -> bool {
crate::install::io::resolve_symlink(path).exists()
}
pub fn is_present(t: &Target) -> bool {
match t.presence_probe {
Some(probe) => probe(),
None => (t.default_config_path)()
.map(|p| config_present(&p))
.unwrap_or(false),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn by_name_resolves_claude_and_rejects_unknown() {
assert_eq!(by_name("claude").unwrap().name, "claude");
assert_eq!(by_name("codex").unwrap().name, "codex");
assert_eq!(by_name("reasonix").unwrap().name, "reasonix");
assert_eq!(by_name("codewhale").unwrap().name, "codewhale");
assert_eq!(by_name("opencode").unwrap().name, "opencode");
assert_eq!(by_name("cursor").unwrap().name, "cursor");
assert_eq!(by_name("openclaw").unwrap().name, "openclaw");
assert!(by_name("nope").is_none());
assert!(by_name("all").is_none()); }
#[test]
fn by_source_resolves_claude_via_core_source_not_name() {
assert_eq!(by_source("claude-code").unwrap().name, "claude");
assert!(by_name("claude-code").is_none());
assert!(by_source("nope").is_none());
}
#[test]
fn config_present_checks_file_existence() {
let dir = tempfile::TempDir::new().unwrap();
let p = dir.path().join("x.json");
assert!(!config_present(&p));
std::fs::write(&p, "{}").unwrap();
assert!(config_present(&p));
}
#[test]
fn is_present_false_when_default_path_unresolvable() {
static NO_HOME: Target = Target {
name: "nohome",
core_source: "nohome",
display_name: "NoHome",
default_config_path: || Err(anyhow::anyhow!("cannot resolve the home directory")),
hook_command: |_, _| Ok("x".into()),
merge_install: |c, _| {
Ok(MergeOutcome {
content: c.to_string(),
changed: false,
})
},
merge_uninstall: |c| {
Ok(MergeOutcome {
content: c.to_string(),
changed: false,
})
},
verify_schema: |_| crate::install::verify::SchemaParse::broken("test fake"),
binary_strategy: BinaryStrategy::EmbedAbsolute,
presence_probe: None,
extra_artifacts: None,
};
assert!(!is_present(&NO_HOME));
}
#[test]
fn every_target_names_a_registered_source() {
use pixtuoid_core::source::REGISTERED_SOURCES;
for t in TARGETS {
assert!(
REGISTERED_SOURCES.contains(&t.core_source),
"install target {:?} names core_source {:?}, which is not a REGISTERED_SOURCE \
(typo, or a renamed source) — fix the target or register the source",
t.name,
t.core_source
);
}
}
#[test]
fn every_hook_only_source_has_an_install_target() {
use pixtuoid_core::source::{registry::descriptor_for, REGISTERED_SOURCES};
for &src in REGISTERED_SOURCES {
let d = descriptor_for(src).expect("registered source must have a descriptor row");
if d.line_decoder().is_none() {
assert!(
TARGETS.iter().any(|t| t.core_source == src),
"hook-only source {src:?} has no install target — its hooks would never \
install, so its sprite never appears. Add a Target in install/target.rs."
);
}
}
}
#[test]
fn target_core_sources_are_unique() {
use std::collections::HashSet;
let set: HashSet<&str> = TARGETS.iter().map(|t| t.core_source).collect();
assert_eq!(
set.len(),
TARGETS.len(),
"two install targets claim the same core_source — one source, double hooks"
);
}
}