use std::path::{Path, PathBuf};
mod common;
use common::{run_bin_with_envs, unique_dir};
fn home_envs(home: &Path) -> Vec<(&'static str, String)> {
vec![
("HOME", home.to_string_lossy().into_owned()),
("USERPROFILE", home.to_string_lossy().into_owned()),
]
}
fn setup(tag: &str) -> (PathBuf, PathBuf, Vec<(&'static str, String)>) {
let work_dir = unique_dir(tag);
let _ = std::fs::remove_dir_all(&work_dir);
std::fs::create_dir_all(&work_dir).unwrap();
let home = unique_dir(&format!("{tag}_home"));
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
let envs = home_envs(&home);
(work_dir, home, envs)
}
fn user_plugin_file(home: &Path) -> PathBuf {
home.join(".config")
.join("opencode")
.join("plugins")
.join("code-repo-wiki.ts")
}
fn legacy_project_plugin_file(work_dir: &Path) -> PathBuf {
work_dir.join(".opencode").join("plugins").join("code-repo-wiki.ts")
}
fn opencode_config(home: &Path) -> PathBuf {
home.join(".config").join("opencode").join("opencode.json")
}
fn as_env_refs<'a>(envs: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a str)> {
envs.iter().map(|(k, v)| (*k, v.as_str())).collect()
}
#[test]
fn test_install_writes_plugin_file_and_registers_mcp() {
let (work_dir, home, envs) = setup("writes_plugin");
let envs_ref = as_env_refs(&envs);
let out = run_bin_with_envs(&work_dir, &["install"], &envs_ref);
assert!(
out.status.success(),
"install 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let path = user_plugin_file(&home);
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("用户级插件文件应写入 {}: {}", path.display(), e));
assert!(
content.contains("RepoWikiPlugin"),
"插件内容应含 RepoWikiPlugin 导出,实际: {}",
&content[..content.len().min(200)]
);
let mtime_before = std::fs::metadata(&path).unwrap().modified().unwrap();
assert!(
!legacy_project_plugin_file(&work_dir).exists(),
"install 不得写入项目级 .opencode/plugins/(v39 用户级语义)"
);
let oc_path = opencode_config(&home);
let oc_content = std::fs::read_to_string(&oc_path)
.unwrap_or_else(|e| panic!("用户级 opencode.json 应写入 {}: {}", oc_path.display(), e));
let oc: serde_json::Value = serde_json::from_str(&oc_content).expect("opencode.json 应为合法 JSON");
let entry = &oc["mcp"]["code-repo-wiki"];
assert_eq!(entry["type"], "local", "MCP 条目应为 local 类型");
assert_eq!(entry["command"][0], env!("CARGO_BIN_EXE_code-repo-wiki"), "command 应为当前可执行文件绝对路径");
assert_eq!(entry["command"][1], "mcp");
assert_eq!(entry["enabled"], true);
let out = run_bin_with_envs(&work_dir, &["install"], &envs_ref);
assert!(out.status.success(), "重复 install 应成功");
let content_after = std::fs::read_to_string(&path).unwrap();
let mtime_after = std::fs::metadata(&path).unwrap().modified().unwrap();
assert_eq!(
content_after, content,
"重复 install 不应重写内容一致的插件文件"
);
assert_eq!(mtime_after, mtime_before, "重复 install 不应触碰文件 mtime");
let _ = std::fs::remove_dir_all(&work_dir);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn test_install_does_not_create_project_config() {
let (work_dir, home, envs) = setup("no_default_config");
let envs_ref = as_env_refs(&envs);
let out = run_bin_with_envs(&work_dir, &["install"], &envs_ref);
assert!(
out.status.success(),
"install 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let config_path = work_dir.join("config.toml");
assert!(
!config_path.exists(),
"install 不得创建项目级配置: {}",
config_path.display()
);
assert!(
!work_dir.join(".code-repo-wiki").exists(),
"install 不得创建产物/配置目录: {}",
work_dir.join(".code-repo-wiki").display()
);
assert!(
opencode_config(&home).exists(),
"install 应确保用户级 opencode.json 就绪"
);
let _ = std::fs::remove_dir_all(&work_dir);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn test_uninstall_removes_plugin_file_and_mcp() {
let (work_dir, home, envs) = setup("remove_plugin");
let envs_ref = as_env_refs(&envs);
let out = run_bin_with_envs(&work_dir, &["install"], &envs_ref);
assert!(out.status.success(), "install 应成功");
assert!(user_plugin_file(&home).exists(), "install 后用户级插件文件应存在");
let oc_path = opencode_config(&home);
let user_cfg = serde_json::json!({ "plugin": ["user-plugin"], "mcp": { "other": { "type": "remote", "url": "https://example.com/mcp" } } });
std::fs::write(&oc_path, serde_json::to_string_pretty(&user_cfg).unwrap()).unwrap();
std::fs::create_dir_all(work_dir.join(".code-repo-wiki").join("wiki")).unwrap();
std::fs::write(work_dir.join(".code-repo-wiki").join("wiki").join("sentinel.md"), "data").unwrap();
let out = run_bin_with_envs(&work_dir, &["uninstall", "--force"], &envs_ref);
assert!(
out.status.success(),
"uninstall --force 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!user_plugin_file(&home).exists(),
"uninstall 后用户级插件文件应被删除"
);
assert!(
!legacy_project_plugin_file(&work_dir).exists(),
"uninstall 后旧版项目级插件文件也应被清理"
);
let oc_content = std::fs::read_to_string(opencode_config(&home)).unwrap();
let oc: serde_json::Value = serde_json::from_str(&oc_content).expect("opencode.json 应为合法 JSON");
assert!(
oc.get("mcp").and_then(|m| m.get("code-repo-wiki")).is_none(),
"uninstall 后 MCP 条目应移除"
);
assert_eq!(
oc.get("plugin").and_then(|p| p.as_array()).map(|a| a.len()).unwrap_or(0),
1,
"用户预置的其他键(plugin)应保留"
);
assert!(
work_dir.join(".code-repo-wiki").join("wiki").join("sentinel.md").exists(),
"uninstall 应保留 .code-repo-wiki/ 数据目录"
);
let _ = std::fs::remove_dir_all(&work_dir);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn test_install_upgrades_legacy_plugin_file() {
let (work_dir, home, envs) = setup("upgrade_legacy");
let envs_ref = as_env_refs(&envs);
let path = user_plugin_file(&home);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
"import { execa } from 'execa';\nconst runCli = (directory) => execa(\"code-repo-wiki\", [\"status\"], { cwd: directory });\nexport const RepoWikiPlugin = () => ({ tool: {} });\n",
)
.unwrap();
let legacy = legacy_project_plugin_file(&work_dir);
std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
std::fs::write(&legacy, "export const RepoWikiPlugin = () => ({});").unwrap();
let out = run_bin_with_envs(&work_dir, &["install"], &envs_ref);
assert!(
out.status.success(),
"install 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let upgraded = std::fs::read_to_string(&path).unwrap();
assert!(
!upgraded.contains("execa(\"code-repo-wiki\""),
"升级后不应再含 PATH 字面量 execa(\"code-repo-wiki\""
);
assert!(
upgraded.contains("execa(\""),
"升级后应注入绝对路径 execa(\"<abs path>\""
);
assert!(
upgraded.contains("RepoWikiPlugin"),
"升级后应含完整插件实现"
);
assert!(
!legacy.exists(),
"旧版项目级插件产物应被迁移清理(v39 用户级语义)"
);
let _ = std::fs::remove_dir_all(&work_dir);
let _ = std::fs::remove_dir_all(&home);
}