diff options
Diffstat (limited to 'src/integration/opencode_config.rs')
| -rw-r--r-- | src/integration/opencode_config.rs | 298 |
1 files changed, 298 insertions, 0 deletions
diff --git a/src/integration/opencode_config.rs b/src/integration/opencode_config.rs new file mode 100644 index 00000000..57431f93 --- /dev/null +++ b/src/integration/opencode_config.rs @@ -0,0 +1,298 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use jsonc_parser::cst::{CstInputValue, CstRootNode}; +use jsonc_parser::ParseOptions; +use serde_json::Value; + +const TUI_CONFIG_NAME: &str = "tui.jsonc"; + +pub(crate) fn tui_config_path(config_dir: &Path) -> PathBuf { + config_dir.join(TUI_CONFIG_NAME) +} + +pub(crate) fn validate_tui_plugin_config(config_dir: &Path) -> io::Result<()> { + let config_path = tui_config_path(config_dir); + if !config_path.is_file() { + return Ok(()); + } + + let content = fs::read_to_string(&config_path)?; + let root = parse_root(&content, &config_path)?; + let object = root_object(&root, &config_path)?; + if object + .get("plugin") + .is_some_and(|property| property.array_value().is_none()) + { + return Err(invalid_plugin_list(&config_path)); + } + Ok(()) +} + +pub(crate) fn add_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<PathBuf> { + let config_path = tui_config_path(config_dir); + let content = if config_path.is_file() { + fs::read_to_string(&config_path)? + } else { + "{}\n".to_string() + }; + let root = parse_root(&content, &config_path)?; + let object = root_object(&root, &config_path)?; + + match object.get("plugin") { + Some(property) => { + let plugins = property + .array_value() + .ok_or_else(|| invalid_plugin_list(&config_path))?; + if plugins.elements().iter().any(|entry| { + entry + .to_serde_value() + .is_some_and(|entry| plugin_entry_matches(&entry, plugin_spec)) + }) { + return Ok(config_path); + } + plugins.append(CstInputValue::String(plugin_spec.to_string())); + } + None => { + object.append( + "plugin", + CstInputValue::Array(vec![CstInputValue::String(plugin_spec.to_string())]), + ); + } + } + + fs::write(&config_path, root.to_string())?; + Ok(config_path) +} + +pub(crate) fn remove_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<bool> { + let config_path = tui_config_path(config_dir); + if !config_path.is_file() { + return Ok(false); + } + + let content = fs::read_to_string(&config_path)?; + let root = parse_root(&content, &config_path)?; + let object = root_object(&root, &config_path)?; + let Some(property) = object.get("plugin") else { + return Ok(false); + }; + let plugins = property + .array_value() + .ok_or_else(|| invalid_plugin_list(&config_path))?; + let mut removed = false; + for entry in plugins.elements() { + if entry + .to_serde_value() + .is_some_and(|entry| plugin_entry_matches(&entry, plugin_spec)) + { + entry.remove(); + removed = true; + } + } + if !removed { + return Ok(false); + } + if plugins.elements().is_empty() { + property.remove(); + } + + fs::write(&config_path, root.to_string())?; + Ok(true) +} + +pub(crate) fn tui_plugin_is_configured(config_dir: &Path, plugin_spec: &str) -> bool { + let config_path = tui_config_path(config_dir); + let Ok(content) = fs::read_to_string(&config_path) else { + return false; + }; + let Ok(root) = parse_root(&content, &config_path) else { + return false; + }; + let Ok(object) = root_object(&root, &config_path) else { + return false; + }; + object + .get("plugin") + .and_then(|property| property.array_value()) + .is_some_and(|plugins| { + plugins.elements().iter().any(|entry| { + entry + .to_serde_value() + .is_some_and(|entry| plugin_entry_matches(&entry, plugin_spec)) + }) + }) +} + +fn parse_root(content: &str, path: &Path) -> io::Result<CstRootNode> { + CstRootNode::parse(content, &jsonc_parse_options()).map_err(|err| { + io::Error::other(format!( + "failed to parse OpenCode TUI config at {}: {err}", + path.display() + )) + }) +} + +fn root_object(root: &CstRootNode, path: &Path) -> io::Result<jsonc_parser::cst::CstObject> { + root.value() + .and_then(|value| value.as_object()) + .ok_or_else(|| invalid_root(path)) +} + +fn jsonc_parse_options() -> ParseOptions { + ParseOptions { + allow_comments: true, + allow_loose_object_property_names: false, + allow_trailing_commas: true, + allow_missing_commas: false, + allow_single_quoted_strings: false, + allow_hexadecimal_numbers: false, + allow_unary_plus_numbers: false, + } +} + +fn plugin_entry_matches(entry: &Value, plugin_spec: &str) -> bool { + entry.as_str() == Some(plugin_spec) + || entry + .as_array() + .and_then(|parts| parts.first()) + .and_then(Value::as_str) + == Some(plugin_spec) +} + +fn invalid_root(path: &Path) -> io::Error { + io::Error::other(format!( + "OpenCode TUI config at {} must be a JSON object", + path.display() + )) +} + +fn invalid_plugin_list(path: &Path) -> io::Error { + io::Error::other(format!( + "OpenCode TUI config plugin list at {} must be an array", + path.display() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn unique_dir() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "herdr-opencode-config-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos() + )); + fs::create_dir_all(&dir).expect("temporary config directory should be created"); + dir + } + + fn parse_config(path: &Path) -> Value { + let content = fs::read_to_string(path).unwrap(); + parse_root(&content, path) + .unwrap() + .value() + .and_then(|value| value.to_serde_value()) + .unwrap() + } + + #[test] + fn add_and_remove_tui_plugin_preserves_jsonc_config() { + let dir = unique_dir(); + let config_path = dir.join(TUI_CONFIG_NAME); + fs::write( + &config_path, + concat!( + "{\n", + " // Keep this comment.\n", + " \"theme\": \"system\",\n", + " \"plugin\": [\"example\", [\"configured\", {\"enabled\": true}]],\n", + "}\n", + ), + ) + .unwrap(); + + add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(); + add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(); + let installed_content = fs::read_to_string(&config_path).unwrap(); + assert!(installed_content.contains("// Keep this comment.")); + let installed = parse_config(&config_path); + assert_eq!(installed["theme"], "system"); + assert_eq!( + installed["plugin"], + json!([ + "example", + ["configured", {"enabled": true}], + "./herdr-tui-state.js" + ]) + ); + + assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap()); + let removed_content = fs::read_to_string(&config_path).unwrap(); + assert!(removed_content.contains("// Keep this comment.")); + let removed = parse_config(&config_path); + assert_eq!(removed["theme"], "system"); + assert_eq!( + removed["plugin"], + json!(["example", ["configured", {"enabled": true}]]) + ); + + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn managed_jsonc_leaves_opencode_migration_target_absent() { + let dir = unique_dir(); + let legacy_config_path = dir.join("opencode.json"); + let legacy_config = "{\n \"theme\": \"system\"\n}\n"; + fs::write(&legacy_config_path, legacy_config).unwrap(); + + let config_path = add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(); + + assert_eq!(config_path, dir.join("tui.jsonc")); + assert!(!dir.join("tui.json").exists()); + assert_eq!( + fs::read_to_string(legacy_config_path).unwrap(), + legacy_config + ); + assert_eq!( + parse_config(&config_path), + json!({ "plugin": ["./herdr-tui-state.js"] }) + ); + + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn remove_tui_plugin_leaves_empty_managed_config() { + let dir = unique_dir(); + let config_path = add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(); + + assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap()); + assert!(config_path.is_file()); + assert_eq!(parse_config(&config_path), json!({})); + + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn configured_tui_plugin_accepts_option_tuple() { + let dir = unique_dir(); + fs::write( + dir.join(TUI_CONFIG_NAME), + r#"{"plugin":[["./herdr-tui-state.js",{"enabled":true}]]}"#, + ) + .unwrap(); + + assert!(tui_plugin_is_configured(&dir, "./herdr-tui-state.js")); + assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap()); + + fs::remove_dir_all(dir).unwrap(); + } +} |
