use super::*;
use super::{
backup::{TEST_BACKUP_STATE_DIR, backup_state_dir, copy_path},
config_file::{
config_has_current_package_only, format_json, install_owned_config,
remove_oy_config_entries, update_config,
},
};
use crate::opencode::OY_AGENT;
use serde_json::Value;
use std::ffi::OsString;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
key: &'static str,
previous: Option<OsString>,
}
impl EnvGuard {
fn set(key: &'static str, value: &Path) -> Self {
let previous = std::env::var_os(key);
unsafe {
std::env::set_var(key, value);
}
Self { key, previous }
}
fn remove(key: &'static str) -> Self {
let previous = std::env::var_os(key);
unsafe {
std::env::remove_var(key);
}
Self { key, previous }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
if let Some(value) = &self.previous {
std::env::set_var(self.key, value);
} else {
std::env::remove_var(self.key);
}
}
}
}
struct BackupStateGuard;
impl BackupStateGuard {
fn set(path: PathBuf) -> Self {
TEST_BACKUP_STATE_DIR.with(|state| {
assert!(state.replace(Some(path)).is_none());
});
Self
}
}
impl Drop for BackupStateGuard {
fn drop(&mut self) {
TEST_BACKUP_STATE_DIR.with(|state| {
state.replace(None);
});
}
}
fn backup_dirs(_config_dir: &Path) -> Vec<PathBuf> {
let base = backup_state_dir().unwrap().join("oy/backups");
let mut backups = fs::read_dir(base)
.map(|entries| {
entries
.map(|entry| entry.unwrap().path())
.collect::<Vec<_>>()
})
.unwrap_or_default();
backups.sort();
backups
}
fn assert_plugin_installed(dir: &Path) {
let path = config_path_in(dir);
let config: Value = parse_opencode_config(&fs::read_to_string(path).unwrap()).unwrap();
let expected = format!("@oy-cli/opencode@{}", env!("CARGO_PKG_VERSION"));
assert!(
config["plugins"]
.as_array()
.unwrap()
.iter()
.any(|entry| entry.as_str() == Some(&expected))
);
assert!(!dir.join("plugins/oy.js").exists());
assert!(integration_complete(dir));
}
fn assert_cursor_attribution_disabled(config_home: &Path) {
let path = config_home.join("cursor/cli-config.json");
let config: Value = serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(
config["attribution"]["attributeCommitsToAgent"],
Value::Bool(false)
);
assert_eq!(
config["attribution"]["attributePRsToAgent"],
Value::Bool(false)
);
}
#[test]
fn setup_defaults_to_global_opencode_plugin_files() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
setup_command(false, false, false).unwrap();
let global = config_home.path().join("opencode");
assert_plugin_installed(&global);
assert!(global.join("opencode.json").exists());
assert!(!global.join("agents").exists());
assert!(!workspace.path().join(".opencode").exists());
assert_cursor_attribution_disabled(config_home.path());
}
#[test]
fn workspace_setup_is_explicit() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
setup_command(true, false, false).unwrap();
assert_plugin_installed(&workspace.path().join(".opencode"));
assert!(workspace.path().join(".opencode/opencode.json").exists());
assert!(!config_home.path().join("opencode").exists());
assert_cursor_attribution_disabled(config_home.path());
}
#[test]
fn setup_honors_opencode_config_dir_override() {
let _lock = ENV_LOCK.lock().unwrap();
let config_home = tempfile::tempdir().unwrap();
let config_dir = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _config_dir = EnvGuard::set("OPENCODE_CONFIG_DIR", config_dir.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
setup_command(false, false, false).unwrap();
assert_plugin_installed(config_dir.path());
assert!(!config_home.path().join("opencode").exists());
}
#[test]
fn setup_dry_run_does_not_write_files() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
setup_command(false, true, false).unwrap();
assert!(!config_home.path().join("opencode/opencode.json").exists());
assert!(!config_home.path().join("opencode/plugins/oy.js").exists());
assert!(!config_home.path().join("cursor/cli-config.json").exists());
}
#[test]
fn setup_preserves_cursor_config_and_explicit_attribution_choices() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
let cursor = config_home.path().join("cursor/cli-config.json");
fs::create_dir_all(cursor.parent().unwrap()).unwrap();
let original = r#"{
"version": 1,
"editor": { "vimMode": true },
"permissions": { "allow": ["Shell(ls)"], "deny": [] },
"attribution": { "attributeCommitsToAgent": true },
"hints": false
}
"#;
fs::write(&cursor, original).unwrap();
setup_command(false, false, false).unwrap();
let updated: Value = serde_json::from_str(&fs::read_to_string(&cursor).unwrap()).unwrap();
assert_eq!(
updated["attribution"]["attributeCommitsToAgent"],
Value::Bool(true)
);
assert_eq!(
updated["attribution"]["attributePRsToAgent"],
Value::Bool(false)
);
assert_eq!(updated["editor"]["vimMode"], Value::Bool(true));
assert_eq!(updated["permissions"]["allow"], json!(["Shell(ls)"]));
assert_eq!(updated["hints"], Value::Bool(false));
let backups = backup_dirs(config_home.path());
assert_eq!(backups.len(), 1);
assert_eq!(
fs::read_to_string(backups[0].join("cursor/cli-config.json")).unwrap(),
original
);
}
#[test]
fn setup_preserves_user_config_while_registering_the_package() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
let dir = config_home.path().join("opencode");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("opencode.json");
let original = r#"{
"$schema": "https://opencode.ai/config.json",
"model": "test/model",
"command": { "keep": { "template": "keep me" } },
"mcp": { "other": { "type": "local", "command": ["other"] } }
}
"#;
fs::write(&path, original).unwrap();
setup_command(false, false, false).unwrap();
let config: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(config["model"], "test/model");
assert_eq!(config["command"]["keep"]["template"], "keep me");
assert_eq!(config["mcp"]["other"]["command"], json!(["other"]));
assert_plugin_installed(&dir);
assert_eq!(backup_dirs(&dir).len(), 1);
}
#[test]
fn setup_is_idempotent_without_churn() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
let dir = config_home.path().join("opencode");
setup_command(false, false, false).unwrap();
let first = fs::read(dir.join("opencode.json")).unwrap();
setup_command(false, false, false).unwrap();
let second = fs::read(dir.join("opencode.json")).unwrap();
assert_eq!(second, first);
assert!(backup_dirs(&dir).is_empty());
}
#[test]
fn setup_keeps_matching_user_config_byte_for_byte() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
let dir = config_home.path().join("opencode");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("opencode.jsonc");
let original = format!(
"{{\n // preserve this comment\n \"plugins\": [\"@oy-cli/opencode@{}\"],\n \"model\": \"test/model\",\n}}\n",
env!("CARGO_PKG_VERSION")
);
fs::write(&path, &original).unwrap();
setup_command(false, false, false).unwrap();
assert_eq!(fs::read_to_string(path).unwrap(), original);
assert!(backup_dirs(&dir).is_empty());
}
#[test]
fn setup_keeps_matching_object_form_plugin_byte_for_byte() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.jsonc");
let original = format!(
"{{\n // preserve this comment\n \"plugins\": [{{ \"package\": \"@oy-cli/opencode@{}\", \"options\": {{ \"keep\": true }} }}],\n}}\n",
env!("CARGO_PKG_VERSION")
);
fs::write(&path, &original).unwrap();
let config = parse_opencode_config(&original).unwrap();
assert!(config_has_current_package_only(&config));
assert!(install_owned_config(&path).unwrap().is_none());
assert_eq!(fs::read_to_string(path).unwrap(), original);
assert!(integration_complete(dir.path()));
}
#[test]
fn setup_updates_object_form_plugin_without_dropping_options() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.json");
fs::write(
&path,
r#"{
"plugins": [
{ "package": "@oy-cli/opencode@0.1.0", "options": { "keep": true } }
],
"commands": { "oy-review": {} }
}"#,
)
.unwrap();
let updated = install_owned_config(&path).unwrap().unwrap();
let config = parse_opencode_config(&updated).unwrap();
assert_eq!(
config["plugins"][0]["package"],
format!("@oy-cli/opencode@{}", env!("CARGO_PKG_VERSION"))
);
assert_eq!(config["plugins"][0]["options"]["keep"], true);
assert!(config.get("commands").is_none());
}
#[test]
fn setup_moves_modified_oy_files_to_backup() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
let dir = config_home.path().join("opencode");
let agent = dir.join("agents/oy.md");
fs::create_dir_all(agent.parent().unwrap()).unwrap();
fs::write(&agent, "user-owned agent\n").unwrap();
setup_command(false, false, false).unwrap();
assert!(!agent.exists());
let backups = backup_dirs(&dir);
assert_eq!(backups.len(), 1);
assert_eq!(
fs::read_to_string(backups[0].join("agents/oy.md")).unwrap(),
"user-owned agent\n"
);
assert_plugin_installed(&dir);
}
#[test]
fn setup_preserves_generic_tool_output_settings() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.json");
let original = r#"{
"$schema": "https://opencode.ai/config.json",
"tool_output": { "max_bytes": 262144, "max_lines": 20000, "extra_user_key": true }
}
"#;
fs::write(&path, original).unwrap();
update_config(&path).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), original);
}
#[test]
fn setup_accepts_opencode_jsonc_without_rewriting_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.json");
let original = r#"{
// opencode allows comments and trailing commas.
"$schema": "https://opencode.ai/config.json",
"model": "test/model",
"command": {
"keep": { "template": "https://example.com//not-a-comment" },
},
}
"#;
fs::write(&path, original).unwrap();
update_config(&path).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), original);
}
#[test]
fn setup_cleans_oy_entries_from_both_config_files() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
let dir = config_home.path().join("opencode");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("opencode.json"),
r#"{ "model": "lower", "commands": { "oy-modified": { "custom": true } } }"#,
)
.unwrap();
let jsonc = r#"{ "model": "upper" }"#;
fs::write(dir.join("opencode.jsonc"), jsonc).unwrap();
setup_command(false, false, false).unwrap();
let lower: Value =
serde_json::from_str(&fs::read_to_string(dir.join("opencode.json")).unwrap()).unwrap();
assert_eq!(lower["model"], "lower");
assert!(lower.get("commands").is_none());
assert!(lower.get("plugins").is_none());
let upper: Value =
serde_json::from_str(&fs::read_to_string(dir.join("opencode.jsonc")).unwrap()).unwrap();
assert_eq!(
upper["plugins"],
json!([format!("@oy-cli/opencode@{}", env!("CARGO_PKG_VERSION"))])
);
let backups = backup_dirs(&dir);
assert_eq!(backups.len(), 1);
assert!(backups[0].join("opencode.json").exists());
assert!(backups[0].join("opencode.jsonc").exists());
assert_plugin_installed(&dir);
}
#[test]
fn setup_accepts_native_v2_config_and_preserves_entries() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.json");
fs::write(
&path,
r#"{
"commands": { "keep": { "template": "keep me" } },
"mcp": { "servers": {} }
}
"#,
)
.unwrap();
update_config(&path).unwrap();
let updated: Value = serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(updated["commands"]["keep"]["template"], "keep me");
assert!(updated["commands"].get("oy-audit").is_none());
assert!(updated.get("plugins").is_none());
assert!(updated.pointer("/mcp/servers/oy").is_none());
}
#[test]
fn setup_leaves_unrelated_legacy_fields_untouched() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.json");
let original =
r#"{ "permission": { "edit": "ask" }, "experimental": { "mcp_timeout": 30000 } }"#;
fs::write(&path, original).unwrap();
update_config(&path).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), original);
}
#[test]
fn explicit_setup_backs_up_all_oy_named_agents() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
setup_command(true, false, false).unwrap();
let dir = workspace.path().join(".opencode");
let agent = dir.join("agents/oy.md");
fs::create_dir_all(agent.parent().unwrap()).unwrap();
fs::write(&agent, OY_AGENT).unwrap();
let reviewer = dir.join("agents/oy-reviewer.md");
fs::write(
&reviewer,
"<!-- Generated by oy setup -->\nold generated reviewer\n",
)
.unwrap();
setup_opencode(SetupScope::Workspace, false, false).unwrap();
assert!(!reviewer.exists());
assert!(!agent.exists());
let backups = backup_dirs(&dir);
assert_eq!(backups.len(), 1);
assert_eq!(
fs::read_to_string(backups[0].join("agents/oy.md")).unwrap(),
OY_AGENT
);
assert!(backups[0].join("agents/oy-reviewer.md").exists());
assert!(integration_complete(&dir));
assert!(!config_home.path().join("opencode").exists());
}
#[test]
fn setup_migrates_legacy_assets_commands_and_config_entries() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
let dir = config_home.path().join("opencode");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("opencode.json"),
format_json(&json!({
"model": "test/model",
"commands": {
"oy-audit": { "modified": true },
"oy-custom": { "template": "old custom command" }
},
"plugins": ["keep-plugin", "@oy-cli/opencode@0.13.0"]
}))
.unwrap(),
)
.unwrap();
let agent = dir.join("agents/oy-local.md");
let skill = dir.join("skills/oy-custom/SKILL.md");
let unrelated = dir.join("agents/keep.md");
let custom_plugin = dir.join("plugins/custom.js");
fs::create_dir_all(agent.parent().unwrap()).unwrap();
fs::create_dir_all(skill.parent().unwrap()).unwrap();
fs::create_dir_all(custom_plugin.parent().unwrap()).unwrap();
fs::write(&agent, "locally modified agent\n").unwrap();
fs::write(&skill, "locally modified skill\n").unwrap();
fs::write(&unrelated, "keep\n").unwrap();
fs::write(&custom_plugin, "export default {}\n").unwrap();
setup_command(false, false, false).unwrap();
let updated: Value =
serde_json::from_str(&fs::read_to_string(dir.join("opencode.json")).unwrap()).unwrap();
assert_eq!(updated["model"], "test/model");
assert!(updated.get("commands").is_none());
assert_eq!(
updated["plugins"],
json!([
"keep-plugin",
format!("@oy-cli/opencode@{}", env!("CARGO_PKG_VERSION"))
])
);
assert!(!agent.exists());
assert!(!skill.exists());
assert_eq!(fs::read_to_string(unrelated).unwrap(), "keep\n");
assert_eq!(
fs::read_to_string(custom_plugin).unwrap(),
"export default {}\n"
);
let backups = backup_dirs(&dir);
assert_eq!(backups.len(), 1);
assert!(backups[0].starts_with(config_home.path().join("state/oy/backups")));
{
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(
fs::metadata(&backups[0]).unwrap().permissions().mode() & 0o777,
0o700
);
}
assert_eq!(
fs::read_to_string(backups[0].join("agents/oy-local.md")).unwrap(),
"locally modified agent\n"
);
assert_eq!(
fs::read_to_string(backups[0].join("skills/oy-custom/SKILL.md")).unwrap(),
"locally modified skill\n"
);
let previous: Value =
serde_json::from_str(&fs::read_to_string(backups[0].join("opencode.json")).unwrap())
.unwrap();
assert_eq!(previous["commands"]["oy-audit"]["modified"], true);
assert_plugin_installed(&dir);
}
#[test]
fn setup_backs_up_modified_plugin_files_and_reinstalls() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
let dir = config_home.path().join("opencode");
setup_command(false, false, false).unwrap();
let plugin = dir.join("plugins");
let agent = plugin.join("assets/agents/oy.md");
fs::create_dir_all(agent.parent().unwrap()).unwrap();
fs::write(plugin.join("oy.js"), "modified plugin\n").unwrap();
fs::write(&agent, "modified agent\n").unwrap();
setup_command(false, false, false).unwrap();
assert!(!plugin.join("oy.js").exists());
assert!(!agent.exists());
let backups = backup_dirs(&dir);
assert_eq!(backups.len(), 1);
assert_eq!(
fs::read_to_string(backups[0].join("plugins/oy.js")).unwrap(),
"modified plugin\n"
);
assert_eq!(
fs::read_to_string(backups[0].join("plugins/assets/agents/oy.md")).unwrap(),
"modified agent\n"
);
assert!(integration_complete(&dir));
}
#[test]
fn integration_complete_requires_the_matching_package_version() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
let dir = config_home.path().join("opencode");
setup_command(false, false, false).unwrap();
fs::write(
dir.join("opencode.json"),
r#"{ "plugins": ["@oy-cli/opencode@0.0.0"] }"#,
)
.unwrap();
assert!(integration_present(&dir).unwrap());
assert!(!integration_complete(&dir));
setup_command(false, false, false).unwrap();
assert!(integration_complete(&dir));
}
#[test]
fn integration_complete_rejects_legacy_entries_and_dirty_lower_precedence_config() {
let dir = tempfile::tempdir().unwrap();
let expected = format!("@oy-cli/opencode@{}", env!("CARGO_PKG_VERSION"));
fs::write(dir.path().join("opencode.json"), r#"{ "model": "lower" }"#).unwrap();
fs::write(
dir.path().join("opencode.jsonc"),
format!(r#"{{ "plugins": ["{expected}"], "commands": {{ "oy-review": {{}} }} }}"#),
)
.unwrap();
assert!(!integration_complete(dir.path()));
fs::write(
dir.path().join("opencode.jsonc"),
format!(r#"{{ "plugins": ["{expected}"] }}"#),
)
.unwrap();
assert!(integration_complete(dir.path()));
fs::write(
dir.path().join("opencode.json"),
r#"{ "commands": { "oy-audit": {} } }"#,
)
.unwrap();
assert!(!integration_complete(dir.path()));
}
#[test]
fn setup_rejects_invalid_lower_precedence_config_without_changes() {
let dir = tempfile::tempdir().unwrap();
let json = dir.path().join("opencode.json");
let jsonc = dir.path().join("opencode.jsonc");
let invalid = "{ invalid\n";
let selected = format!(
"{{\n \"plugins\": [\"@oy-cli/opencode@{}\"]\n}}\n",
env!("CARGO_PKG_VERSION")
);
fs::write(&json, invalid).unwrap();
fs::write(&jsonc, &selected).unwrap();
assert!(!integration_complete(dir.path()));
let error = install_oy_config_updates(dir.path())
.err()
.expect("invalid lower-precedence config must fail setup");
assert!(
error
.to_string()
.contains("must be valid opencode JSON/JSONC")
);
assert_eq!(fs::read_to_string(json).unwrap(), invalid);
assert_eq!(fs::read_to_string(jsonc).unwrap(), selected);
}
#[test]
fn removal_strips_oy_entries_from_both_config_files() {
let config = tempfile::tempdir().unwrap();
let state = tempfile::tempdir().unwrap();
let _backup_state = BackupStateGuard::set(state.path().to_path_buf());
fs::write(
config.path().join("opencode.json"),
r#"{ "model": "keep/lower", "plugins": ["@oy-cli/opencode@0.1.0"] }"#,
)
.unwrap();
fs::write(
config.path().join("opencode.jsonc"),
r#"{ "model": "keep/upper", "commands": { "oy-audit": {} } }"#,
)
.unwrap();
let updates = strip_oy_config_updates(config.path()).unwrap();
apply_integration_update(config.path(), &[], &updates).unwrap();
for (name, model) in [
("opencode.json", "keep/lower"),
("opencode.jsonc", "keep/upper"),
] {
let body = fs::read_to_string(config.path().join(name)).unwrap();
let updated = parse_opencode_config(&body).unwrap();
assert_eq!(updated["model"], model);
assert!(!config_has_oy_entries(&updated));
}
}
#[test]
fn cross_filesystem_copy_helper_preserves_nested_backup_contents() {
let source = tempfile::tempdir().unwrap();
let destination_root = tempfile::tempdir().unwrap();
let nested = source.path().join("oy-custom/nested");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("SKILL.md"), "modified\n").unwrap();
let destination = destination_root.path().join("oy-custom");
copy_path(&source.path().join("oy-custom"), &destination).unwrap();
assert_eq!(
fs::read_to_string(destination.join("nested/SKILL.md")).unwrap(),
"modified\n"
);
assert!(source.path().join("oy-custom/nested/SKILL.md").exists());
}
#[test]
fn failed_config_update_restores_files_and_retains_snapshot() {
let config = tempfile::tempdir().unwrap();
let state = tempfile::tempdir().unwrap();
let _backup_state = BackupStateGuard::set(state.path().to_path_buf());
let old_file = config.path().join("agents/oy-modified.md");
fs::create_dir_all(old_file.parent().unwrap()).unwrap();
fs::write(&old_file, "modified\n").unwrap();
let invalid_config = config.path().join("opencode.json");
fs::create_dir(&invalid_config).unwrap();
let updates = [ConfigUpdate {
path: invalid_config,
body: "{}\n".to_string(),
current: Some(b"old config\n".to_vec()),
}];
let error = apply_integration_update(config.path(), std::slice::from_ref(&old_file), &updates)
.unwrap_err();
assert!(error.to_string().contains("backup retained"));
assert_eq!(fs::read_to_string(old_file).unwrap(), "modified\n");
let backups = backup_dirs(config.path());
assert_eq!(backups.len(), 1);
assert_eq!(
fs::read_to_string(backups[0].join("opencode.json")).unwrap(),
"old config\n"
);
}
#[test]
fn rollback_attempts_all_moved_paths_after_one_restore_fails() {
let dir = tempfile::tempdir().unwrap();
let good_source = dir.path().join("agents/oy-good.md");
let good_backup = dir.path().join("backup-good.md");
fs::write(&good_backup, "good\n").unwrap();
let blocked_parent = dir.path().join("blocked");
fs::write(&blocked_parent, "not a directory\n").unwrap();
let bad_source = blocked_parent.join("oy-bad.md");
let bad_backup = dir.path().join("backup-bad.md");
fs::write(&bad_backup, "bad\n").unwrap();
let error = restore_moved_paths(&[
(good_source.clone(), good_backup),
(bad_source, bad_backup.clone()),
])
.unwrap_err();
assert!(!error.to_string().is_empty());
assert_eq!(fs::read_to_string(good_source).unwrap(), "good\n");
assert!(bad_backup.exists());
}
#[test]
fn cross_filesystem_copy_helper_does_not_follow_symlinks() {
use std::os::unix::fs::symlink;
let source = tempfile::tempdir().unwrap();
let destination_root = tempfile::tempdir().unwrap();
let destination = destination_root.path().join("oy-link.md");
let link = source.path().join("oy-link.md");
symlink("../outside.md", &link).unwrap();
copy_path(&link, &destination).unwrap();
assert_eq!(
fs::read_link(destination).unwrap(),
PathBuf::from("../outside.md")
);
}
#[test]
fn namespace_scan_rejects_symlinked_directories() {
use std::os::unix::fs::symlink;
let config = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let victim = outside.path().join("oy-victim.md");
fs::write(&victim, "keep\n").unwrap();
symlink(outside.path(), config.path().join("agents")).unwrap();
let error = legacy_oy_paths(config.path()).unwrap_err();
assert!(error.to_string().contains("symlinked OpenCode namespace"));
assert_eq!(fs::read_to_string(victim).unwrap(), "keep\n");
}
#[test]
fn config_update_rejects_symlinked_file() {
use std::os::unix::fs::symlink;
let config = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("config.json");
fs::write(&target, r#"{ "model": "keep/me" }"#).unwrap();
let link = config.path().join("opencode.json");
symlink(&target, &link).unwrap();
let error = update_config(&link).unwrap_err();
assert!(error.to_string().contains("symlinked OpenCode config"));
assert_eq!(
fs::read_to_string(target).unwrap(),
r#"{ "model": "keep/me" }"#
);
}
#[test]
fn setup_strips_object_form_oy_plugin_entries() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.json");
fs::write(
&path,
r#"{ "plugins": [{ "package": "@oy-cli/opencode", "options": { "custom": true } }, "keep-plugin"] }"#,
)
.unwrap();
update_config(&path).unwrap();
let updated: Value = serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(updated["plugins"], json!(["keep-plugin"]));
}
#[test]
fn missing_integration_check_does_not_create_files() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
assert!(!integration_complete(&config_home.path().join("opencode")));
assert!(!integration_complete(&workspace.path().join(".opencode")));
assert!(!integration_present(&config_home.path().join("opencode")).unwrap());
assert!(!integration_present(&workspace.path().join(".opencode")).unwrap());
assert!(!config_home.path().join("opencode/opencode.json").exists());
assert!(!workspace.path().join(".opencode/opencode.json").exists());
}
#[test]
fn setup_prompt_defaults_to_yes_and_accepts_explicit_yes() {
assert!(setup_answer_is_yes(""));
assert!(setup_answer_is_yes("Y\n"));
assert!(setup_answer_is_yes("yes"));
assert!(!setup_answer_is_yes("n"));
assert!(!setup_answer_is_yes("later"));
}
#[test]
fn setup_remove_round_trip_preserves_unrelated_config() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
let dir = config_home.path().join("opencode");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("opencode.json"), r#"{ "model": "test/model" }"#).unwrap();
setup_command(false, false, false).unwrap();
setup_command(false, false, true).unwrap();
let config: Value =
serde_json::from_str(&fs::read_to_string(dir.join("opencode.json")).unwrap()).unwrap();
assert_eq!(config["model"], "test/model");
assert!(config.pointer("/mcp/servers/oy").is_none());
assert!(config.get("plugins").is_none());
assert!(!dir.join("plugins/oy").exists());
assert!(!dir.join("agents").exists());
assert!(!integration_present(&dir).unwrap());
}
#[test]
fn setup_remove_moves_plugin_files_to_backup() {
let _lock = ENV_LOCK.lock().unwrap();
let _config_dir = EnvGuard::remove("OPENCODE_CONFIG_DIR");
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _backup_state = BackupStateGuard::set(config_home.path().join("state"));
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let _host = EnvGuard::set("OY_OPENCODE", &workspace.path().join("missing-opencode"));
let dir = config_home.path().join("opencode");
setup_command(false, false, false).unwrap();
let plugin = dir.join("plugins/oy.js");
let agent = dir.join("plugins/assets/agents/oy.md");
fs::create_dir_all(agent.parent().unwrap()).unwrap();
fs::write(&plugin, "legacy plugin\n").unwrap();
fs::write(&agent, OY_AGENT).unwrap();
setup_command(false, false, true).unwrap();
let backups = backup_dirs(&dir);
assert_eq!(backups.len(), 1);
assert!(backups[0].join("plugins/oy.js").exists());
assert!(backups[0].join("plugins/assets/agents/oy.md").exists());
}
#[test]
fn removal_uses_oy_namespace_without_matching_old_contents() {
let mut config = json!({
"command": {
"oy-old": { "modified": true },
"keep": { "template": "keep" }
},
"commands": { "oy-new": "any shape" },
"mcp": {
"oy": { "modified": true },
"servers": {
"oy": { "modified": true },
"keep": { "type": "local" }
}
},
"plugins": [
{ "package": "@oy-cli/opencode", "options": { "custom": true } },
"keep-plugin"
]
});
remove_oy_config_entries(config.as_object_mut().unwrap()).unwrap();
assert_eq!(config["command"]["keep"]["template"], "keep");
assert!(config.get("commands").is_none());
assert!(config["mcp"].get("oy").is_none());
assert!(config["mcp"]["servers"].get("oy").is_none());
assert_eq!(config["mcp"]["servers"]["keep"]["type"], "local");
assert_eq!(config["plugins"], json!(["keep-plugin"]));
}