use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::panic::{self, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::config::{Config, ConfigError, EngineConfig};
use crate::hook::{self, WITHHELD};
use crate::scrub::{ScrubResult, Scrubber};
pub const PLUGIN_SOURCE: &str = include_str!("../plugins/opencode/redacted.ts");
pub const PLUGIN_BIN_DEFAULT: &str = r#"process.env.REDACTED_BIN || "redacted""#;
pub const PLUGIN_MARKER: &str = "// redacted: OpenCode plugin";
type OpenCodeResult = (Vec<u8>, BTreeMap<String, usize>);
pub fn respond(data: &[u8], loaded: Result<(Config, EngineConfig), ConfigError>) -> OpenCodeResult {
let (config, engine_config) = match loaded {
Ok(loaded) => loaded,
Err(error) => {
eprintln!("{error}");
return (withheld_output(data), BTreeMap::new());
}
};
let Ok(scrubber) = Scrubber::new(&config, &engine_config) else {
return (withheld_output(data), BTreeMap::new());
};
let skips_non_bash = config.ignore_internal_tools;
let scrubbed = panic::catch_unwind(AssertUnwindSafe(|| {
scrub_output(data, skips_non_bash, &|text| scrubber.scrub(text))
}));
match scrubbed {
Ok(Ok(result)) => result,
_ => (withheld_output(data), BTreeMap::new()),
}
}
fn scrub_output(
data: &[u8],
skips_non_bash: bool,
scrub: &dyn Fn(&str) -> ScrubResult,
) -> Result<OpenCodeResult, String> {
let payload: Value =
serde_json::from_slice(data).map_err(|e| format!("parse opencode payload: {e}"))?;
let output = match payload.get("output") {
None | Some(Value::Null) => return Err("parse opencode payload: no output".into()),
Some(output) => output,
};
let is_named_non_bash_tool =
matches!(payload.get("tool"), Some(Value::String(tool)) if tool != "bash");
if skips_non_bash && is_named_non_bash_tool {
return Ok((hook::encode(output)?, BTreeMap::new()));
}
let counts = scrub(&hook::text_to_scrub(output)?).counts_by_pattern;
if counts.is_empty() {
return Ok((hook::encode(output)?, counts));
}
Ok((hook::encode(&hook::scrub_json(output, scrub))?, counts))
}
fn withheld_output(data: &[u8]) -> Vec<u8> {
let payload: Value = serde_json::from_slice(data).unwrap_or(Value::Null);
let withheld = match payload.get("output") {
None | Some(Value::Null) => Value::String(WITHHELD.into()),
Some(output) => hook::withhold_strings(output),
};
hook::encode(&withheld).expect("a parsed Value always serializes")
}
pub fn global_plugin_path(home: &Path) -> PathBuf {
home.join(".config/opencode/plugins/redacted.ts")
}
pub fn local_plugin_path(cwd: &Path) -> PathBuf {
cwd.join(".opencode/plugins/redacted.ts")
}
pub fn is_our_plugin(path: &Path) -> bool {
fs::read_to_string(path).is_ok_and(|text| text.starts_with(PLUGIN_MARKER))
}
pub fn remove_plugin(path: &Path) -> Result<bool, String> {
if !is_our_plugin(path) {
return Ok(false);
}
match fs::remove_file(path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(format!("removing {}: {e}", path.display())),
}
}
#[cfg(test)]
mod tests;