use std::path::{Path, PathBuf};
use crate::core::hook_state::FileDescriptor;
use crate::error::{OlError, ERR_HOOK_WRITE_FAILED};
use crate::hooks::hook_files::{self, Existing};
pub const PLUGIN_FILE_MODE: u32 = 0o644;
pub const PLUGIN_ENTRY_EVENT: &str = "__plugin";
const PLUGIN_TEMPLATE: &str = include_str!("../../assets/opencode/openlatch-plugin.js");
const OL_DIR_PLACEHOLDER: &str = "__OPENLATCH_DIR__";
const CAP_PLACEHOLDER: &str = "__CAP_BYTES__";
pub const CAP_BYTES: usize = crate::hook_output::MAX_INPUT_SIZE - 4096;
#[must_use]
pub fn entry_path(plugins_dir: &Path) -> PathBuf {
plugins_dir.join(crate::hooks::opencode::PLUGIN_FILE_NAME)
}
#[must_use]
pub fn backup_path(ol_dir: &Path) -> PathBuf {
ol_dir.join("backups").join("opencode-plugin.js.bak")
}
#[must_use]
pub fn plugin_body(ol_dir: &Path) -> String {
let unmarked = PLUGIN_TEMPLATE
.replace(CAP_PLACEHOLDER, &CAP_BYTES.to_string())
.replace(OL_DIR_PLACEHOLDER, &js_string_literal(ol_dir));
let digest = hook_files::sha256_hex(&unmarked);
let (head, tail) = split_after_first_line(&unmarked);
format!("{head}// openlatch-hook {digest}\n{tail}")
}
fn js_string_literal(path: &Path) -> String {
serde_json::to_string(path.to_string_lossy().as_ref()).unwrap_or_else(|_| "\"\"".to_string())
}
fn split_after_first_line(body: &str) -> (&str, &str) {
match body.find('\n') {
Some(index) => body.split_at(index + 1),
None => (body, ""),
}
}
#[must_use]
pub fn expected_descriptor(plugins_dir: &Path, ol_dir: &Path) -> FileDescriptor {
FileDescriptor {
path: entry_path(plugins_dir).to_string_lossy().into_owned(),
sha256: hook_files::sha256_hex(&plugin_body(ol_dir)),
mode: PLUGIN_FILE_MODE,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginWrite {
Written {
descriptor: FileDescriptor,
replaced: bool,
},
AlreadyCurrent(FileDescriptor),
LeftAlone(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginRemoval {
Removed(PathBuf),
Nothing,
LeftAlone(PathBuf),
}
#[must_use]
pub fn is_ours(plugins_dir: &Path) -> bool {
matches!(
hook_files::whatever_is_there(&entry_path(plugins_dir)),
Existing::Ours
)
}
pub fn install(plugins_dir: &Path, ol_dir: &Path) -> Result<PluginWrite, OlError> {
let path = entry_path(plugins_dir);
if matches!(hook_files::whatever_is_there(&path), Existing::Theirs) {
tracing::warn!(
path = %path.display(),
"a file we did not write already holds OpenCode's OpenLatch plugin path; leaving it alone"
);
return Ok(PluginWrite::LeftAlone(path));
}
repair(plugins_dir, ol_dir)
}
pub fn repair(plugins_dir: &Path, ol_dir: &Path) -> Result<PluginWrite, OlError> {
let path = entry_path(plugins_dir);
let body = plugin_body(ol_dir);
let descriptor = FileDescriptor {
path: path.to_string_lossy().into_owned(),
sha256: hook_files::sha256_hex(&body),
mode: PLUGIN_FILE_MODE,
};
let present = std::fs::read(&path).ok();
if present.as_deref() == Some(body.as_bytes()) {
return Ok(PluginWrite::AlreadyCurrent(descriptor));
}
std::fs::create_dir_all(plugins_dir).map_err(|e| write_failed(plugins_dir, &e))?;
let replaced = present.is_some();
if let Some(previous) = present {
let backup = backup_path(ol_dir);
if let Some(parent) = backup.parent() {
std::fs::create_dir_all(parent).map_err(|e| write_failed(parent, &e))?;
}
std::fs::write(&backup, previous).map_err(|e| write_failed(&backup, &e))?;
}
crate::fs_secure::write_readable(&path, &body).map_err(|e| write_failed(&path, &e))?;
Ok(PluginWrite::Written {
descriptor,
replaced,
})
}
pub fn remove(plugins_dir: &Path) -> Result<PluginRemoval, OlError> {
let path = entry_path(plugins_dir);
match hook_files::whatever_is_there(&path) {
Existing::Nothing => Ok(PluginRemoval::Nothing),
Existing::Theirs => {
tracing::warn!(
path = %path.display(),
"OpenCode's OpenLatch plugin path holds a file that is not ours; leaving it in place"
);
Ok(PluginRemoval::LeftAlone(path))
}
Existing::Ours => {
std::fs::remove_file(&path).map_err(|e| write_failed(&path, &e))?;
Ok(PluginRemoval::Removed(path))
}
}
}
fn write_failed(path: &Path, error: &std::io::Error) -> OlError {
OlError::new(
ERR_HOOK_WRITE_FAILED,
format!("Cannot write OpenCode plugin '{}': {error}", path.display()),
)
.with_suggestion("Check that OpenCode's config folder exists and is writable.")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_rendered_body_is_ours_and_carries_the_marker_on_line_2() {
let body = plugin_body(Path::new("/home/dev/.openlatch"));
let mut lines = body.lines();
assert!(lines.next().unwrap().starts_with("// OpenLatch"));
assert!(lines.next().unwrap().starts_with("// openlatch-hook "));
assert!(hook_files::is_ours(&body));
assert!(!body.contains(OL_DIR_PLACEHOLDER));
assert!(!body.contains(CAP_PLACEHOLDER));
assert!(body.contains(&format!("const CAP_BYTES = {CAP_BYTES};")));
assert!(body.contains(r#"const OL_DIR = "/home/dev/.openlatch";"#));
}
#[test]
fn a_windows_path_with_spaces_and_backslashes_is_a_valid_js_string() {
let body = plugin_body(Path::new(r"C:\Users\Dev User\.openlatch"));
assert!(body.contains(r#"const OL_DIR = "C:\\Users\\Dev User\\.openlatch";"#));
}
#[test]
fn a_directory_containing_a_placeholder_is_rendered_verbatim() {
let body = plugin_body(Path::new("/tmp/__CAP_BYTES__/x"));
assert!(body.contains(r#"const OL_DIR = "/tmp/__CAP_BYTES__/x";"#));
assert!(body.contains(&format!("const CAP_BYTES = {CAP_BYTES};")));
}
#[test]
fn the_plugin_exports_exactly_one_default_function() {
let exports: Vec<&str> = PLUGIN_TEMPLATE
.lines()
.filter(|l| l.trim_start().starts_with("export "))
.collect();
assert_eq!(exports, ["export default async function openlatch(ctx) {"]);
assert!(
!PLUGIN_TEMPLATE.contains("import "),
"the plugin has no imports"
);
}
#[test]
fn install_writes_heal_restores_and_remove_keeps_the_folder() {
let root = tempfile::tempdir().unwrap();
let ol = root.path().join("ol");
let plugins = root.path().join("cfg").join("plugins");
let written = install(&plugins, &ol).unwrap();
assert!(matches!(
written,
PluginWrite::Written {
replaced: false,
..
}
));
assert!(is_ours(&plugins));
assert!(matches!(
install(&plugins, &ol).unwrap(),
PluginWrite::AlreadyCurrent(_)
));
let edited = plugin_body(&root.path().join("elsewhere"));
std::fs::write(entry_path(&plugins), &edited).unwrap();
assert!(is_ours(&plugins));
assert!(matches!(
repair(&plugins, &ol).unwrap(),
PluginWrite::Written { replaced: true, .. }
));
assert_eq!(
std::fs::read_to_string(entry_path(&plugins)).unwrap(),
plugin_body(&ol)
);
assert_eq!(std::fs::read_to_string(backup_path(&ol)).unwrap(), edited);
assert_eq!(
std::fs::read_dir(&plugins).unwrap().count(),
1,
"no backup beside the plugin"
);
assert_eq!(
remove(&plugins).unwrap(),
PluginRemoval::Removed(entry_path(&plugins))
);
assert!(plugins.is_dir(), "the plugins folder stays");
assert_eq!(std::fs::read_dir(&plugins).unwrap().count(), 0);
assert_eq!(remove(&plugins).unwrap(), PluginRemoval::Nothing);
}
#[test]
fn a_foreign_file_at_our_path_is_never_overwritten_or_removed() {
let root = tempfile::tempdir().unwrap();
let plugins = root.path().join("plugins");
std::fs::create_dir_all(&plugins).unwrap();
std::fs::write(entry_path(&plugins), "export default async () => ({})\n").unwrap();
assert!(matches!(
install(&plugins, root.path()).unwrap(),
PluginWrite::LeftAlone(_)
));
assert!(matches!(
remove(&plugins).unwrap(),
PluginRemoval::LeftAlone(_)
));
assert_eq!(
std::fs::read_to_string(entry_path(&plugins)).unwrap(),
"export default async () => ({})\n"
);
}
}