use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use crate::install::io;
use crate::install::target::MergeOutcome;
const SENTINEL: &str = "@pixtuoid-opencode-plugin";
const HOOK_PLACEHOLDER: &str = "{{HOOK_PATH_JSON}}";
const PLUGIN_TEMPLATE: &str = include_str!("opencode_plugin.ts");
const REMOVED_STUB: &str = "// pixtuoid opencode plugin removed by disconnecting opencode in pixtuoid's Connection panel (press c).\nexport {}\n";
fn opencode_config_dir() -> Result<PathBuf> {
config_dir_from(
io::nonempty_env("OPENCODE_CONFIG_DIR").as_deref(),
io::nonempty_env("XDG_CONFIG_HOME").as_deref(),
io::user_home().as_deref(),
)
}
fn config_dir_from(oc: Option<&str>, xdg: Option<&str>, home: Option<&str>) -> Result<PathBuf> {
if let Some(dir) = oc.filter(|s| !s.is_empty()) {
return Ok(PathBuf::from(dir));
}
if let Some(xdg) = xdg.filter(|s| !s.is_empty()) {
return Ok(PathBuf::from(xdg).join("opencode"));
}
home.filter(|s| !s.is_empty())
.map(|h| PathBuf::from(h).join(".config").join("opencode"))
.ok_or_else(|| {
anyhow!(
"cannot resolve the home directory (HOME/USERPROFILE unset); pass --config <path>"
)
})
}
pub fn default_config_path() -> Result<PathBuf> {
Ok(opencode_config_dir()?.join("plugins").join("pixtuoid.ts"))
}
pub fn detect_installed() -> bool {
opencode_config_dir().map(|d| d.exists()).unwrap_or(false)
|| io::home_relative(".local/share/opencode").exists()
}
pub fn hook_command(resolved: &Path, _explicit: bool) -> Result<String> {
resolved
.to_str()
.map(str::to_string)
.ok_or_else(|| anyhow!("pixtuoid-hook path is non-UTF-8: {}", resolved.display()))
}
pub fn merge_install(content: &str, hook_path: &str) -> Result<MergeOutcome> {
let baked = render_plugin(hook_path)?;
Ok(MergeOutcome {
changed: content != baked,
content: baked,
})
}
pub fn merge_uninstall(content: &str) -> Result<MergeOutcome> {
let ours = content.contains(SENTINEL);
Ok(MergeOutcome {
changed: ours,
content: if ours {
REMOVED_STUB.to_string()
} else {
content.to_string()
},
})
}
fn render_plugin(hook_path: &str) -> Result<String> {
let json = serde_json::to_string(hook_path)
.context("serializing the hook path into the opencode plugin")?;
Ok(PLUGIN_TEMPLATE.replace(HOOK_PLACEHOLDER, &json))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_bakes_the_hook_path_and_carries_the_sentinel() {
let out = merge_install("", "/opt/bin/pixtuoid-hook").unwrap();
assert!(out.changed);
assert!(
out.content.contains(SENTINEL),
"rendered plugin must carry the sentinel"
);
assert!(out.content.contains("\"/opt/bin/pixtuoid-hook\""));
assert!(
!out.content.contains(HOOK_PLACEHOLDER),
"placeholder must be replaced"
);
assert!(
out.content.contains("--source"),
"spawns the shim with --source opencode"
);
}
#[test]
fn install_is_idempotent_for_the_same_path() {
let a = merge_install("", "/opt/bin/pixtuoid-hook").unwrap();
let b = merge_install(&a.content, "/opt/bin/pixtuoid-hook").unwrap();
assert!(!b.changed, "same-path re-install is a content no-op");
}
#[test]
fn install_re_renders_on_a_path_change() {
let a = merge_install("", "/opt/bin/pixtuoid-hook").unwrap();
let b = merge_install(&a.content, "/usr/local/bin/pixtuoid-hook").unwrap();
assert!(b.changed);
assert!(b.content.contains("\"/usr/local/bin/pixtuoid-hook\""));
}
#[test]
fn a_path_with_special_chars_bakes_as_a_valid_escaped_literal() {
let out = merge_install("", r#"/weird/pi"x\hook"#).unwrap();
assert!(out.content.contains(r#""/weird/pi\"x\\hook""#));
}
#[test]
fn uninstall_replaces_our_plugin_with_a_sentinel_free_stub() {
let installed = merge_install("", "/opt/bin/pixtuoid-hook").unwrap();
let removed = merge_uninstall(&installed.content).unwrap();
assert!(removed.changed);
assert!(
!removed.content.contains(SENTINEL),
"stub must drop the sentinel so detection flips"
);
assert!(
removed.content.contains("export {}"),
"stub is a valid empty module"
);
}
#[test]
fn uninstall_of_a_foreign_or_removed_file_is_a_no_op() {
let foreign = "export const myPlugin = async () => ({})\n";
assert!(!merge_uninstall(foreign).unwrap().changed);
assert!(!merge_uninstall(REMOVED_STUB).unwrap().changed);
assert!(!merge_uninstall("").unwrap().changed);
}
#[test]
fn install_then_uninstall_round_trips_the_content_sentinel() {
let installed = merge_install("", "/opt/bin/pixtuoid-hook").unwrap();
assert!(installed.content.contains(SENTINEL));
let removed = merge_uninstall(&installed.content).unwrap();
assert!(!removed.content.contains(SENTINEL));
}
#[test]
fn config_dir_precedence_is_env_then_xdg_then_home() {
assert_eq!(
config_dir_from(Some("/custom/oc"), Some("/xdg"), Some("/home/u")).unwrap(),
PathBuf::from("/custom/oc")
);
assert_eq!(
config_dir_from(None, Some("/xdg"), Some("/home/u")).unwrap(),
PathBuf::from("/xdg/opencode")
);
assert_eq!(
config_dir_from(None, None, Some("/home/u")).unwrap(),
PathBuf::from("/home/u/.config/opencode")
);
assert_eq!(
config_dir_from(Some(""), Some(""), Some("/home/u")).unwrap(),
PathBuf::from("/home/u/.config/opencode")
);
assert!(config_dir_from(None, None, None).is_err());
}
#[test]
fn default_path_is_the_plugin_file_under_the_plural_plugins_dir() {
assert_eq!(
config_dir_from(None, Some("/xdg"), None)
.unwrap()
.join("plugins")
.join("pixtuoid.ts"),
PathBuf::from("/xdg/opencode/plugins/pixtuoid.ts")
);
}
#[test]
fn hook_command_returns_the_absolute_path() {
assert_eq!(
hook_command(Path::new("/opt/bin/pixtuoid-hook"), false).unwrap(),
"/opt/bin/pixtuoid-hook"
);
}
#[test]
#[cfg(unix)]
fn hook_command_errors_on_non_utf8_path() {
use std::os::unix::ffi::OsStrExt;
let bad = Path::new(std::ffi::OsStr::from_bytes(b"/x/\xff/pixtuoid-hook"));
assert!(hook_command(bad, false).is_err());
}
#[test]
fn every_forwarded_opencode_event_decodes() {
use pixtuoid_core::source::decoder::decode_hook_payload;
let payloads = [
serde_json::json!({"type": "session.created",
"properties": {"info": {"id": "ses_1", "directory": "/r"}}, "_pixtuoid_source": "opencode"}),
serde_json::json!({"type": "session.deleted",
"properties": {"info": {"id": "ses_1", "directory": "/r"}}, "_pixtuoid_source": "opencode"}),
serde_json::json!({"type": "permission.asked",
"properties": {"sessionID": "ses_1"}, "_pixtuoid_source": "opencode"}),
serde_json::json!({"type": "permission.v2.asked",
"properties": {"sessionID": "ses_1"}, "_pixtuoid_source": "opencode"}),
serde_json::json!({"type": "message.part.updated",
"properties": {"sessionID": "ses_1", "part": {"type": "tool", "callID": "c",
"tool": "bash", "state": {"status": "running"}}}, "_pixtuoid_source": "opencode"}),
];
for p in payloads {
let ty = p["type"].clone();
assert!(
decode_hook_payload(p).is_ok(),
"forwarded opencode event {ty} failed to decode — add an arm in source/opencode.rs"
);
}
}
}