use std::path::PathBuf;
use anyhow::{Context, Result};
pub struct OpenCodeConfig {
pub config_path: PathBuf,
pub project_root: PathBuf,
}
impl OpenCodeConfig {
pub fn new(root: &crate::project::ProjectRoot) -> Result<Self> {
let project_root = root.path().to_path_buf();
let global_config = Self::config_dir()?.join("opencode.json");
Ok(Self {
config_path: global_config,
project_root,
})
}
pub fn install_plugin(&mut self) -> Result<()> {
let content = std::fs::read_to_string(&self.config_path)
.unwrap_or_else(|_| "{}".to_string());
let mut value: serde_json::Value = serde_json::from_str(&content)
.with_context(|| format!("解析配置文件失败: {}", self.config_path.display()))?;
if !value.is_object() {
anyhow::bail!(
"opencode.json 顶层应为 JSON 对象: {}",
self.config_path.display()
);
}
if value.get_mut("plugins").is_some() {
tracing::info!(
"清理 opencode.json 中无效的 plugins 键(官方仅认单数 plugin): {}",
self.config_path.display()
);
value
.as_object_mut()
.with_context(|| format!("opencode.json 顶层应为 JSON 对象: {}", self.config_path.display()))?
.remove("plugins");
}
let output = serde_json::to_string_pretty(&value)
.with_context(|| "序列化 opencode.json 失败")?;
if let Some(parent) = self.config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("创建配置目录失败: {}", parent.display()))?;
}
std::fs::write(&self.config_path, &output)
.with_context(|| format!("写入配置文件失败: {}", self.config_path.display()))?;
tracing::info!("code-repo-wiki 插件已就绪(目录自动加载,无需配置条目)");
Ok(())
}
pub fn uninstall_plugin(&mut self) -> Result<()> {
if !self.config_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&self.config_path)
.with_context(|| format!("读取配置文件失败: {}", self.config_path.display()))?;
let mut value: serde_json::Value = serde_json::from_str(&content)
.with_context(|| format!("解析配置文件失败: {}", self.config_path.display()))?;
if !value.is_object() {
anyhow::bail!(
"opencode.json 顶层应为 JSON 对象: {}",
self.config_path.display()
);
}
let had_plugins = value.get("plugins").is_some();
value
.as_object_mut()
.with_context(|| format!("opencode.json 顶层应为 JSON 对象: {}", self.config_path.display()))?
.remove("plugins");
if value.as_object().is_some_and(|o| o.is_empty()) {
std::fs::remove_file(&self.config_path)
.with_context(|| format!("删除空配置文件失败: {}", self.config_path.display()))?;
} else if had_plugins {
let output = serde_json::to_string_pretty(&value)
.with_context(|| "序列化 opencode.json 失败")?;
std::fs::write(&self.config_path, &output)
.with_context(|| format!("写入配置文件失败: {}", self.config_path.display()))?;
}
tracing::info!("code-repo-wiki 插件配置已清理: {}", self.config_path.display());
Ok(())
}
pub fn is_installed(&self) -> Result<bool> {
let config_root = self
.config_path
.parent()
.ok_or_else(|| anyhow::anyhow!("无法定位 OpenCode 配置根目录"))?;
for dir in ["plugins", "plugin"] {
let plugin_file = config_root
.join(dir)
.join("code-repo-wiki.ts");
if plugin_file.exists() {
return Ok(true);
}
}
Ok(false)
}
fn remove_legacy_project_plugin(&self) -> Result<bool> {
let mut removed = false;
for dir in ["plugins", "plugin"] {
let legacy = self
.project_root
.join(".opencode")
.join(dir)
.join("code-repo-wiki.ts");
match std::fs::remove_file(&legacy) {
Ok(()) => {
tracing::info!("已清理旧版项目级插件: {}", legacy.display());
removed = true;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
}
Ok(removed)
}
pub fn install_plugin_file(&mut self) -> Result<bool> {
if self.remove_legacy_project_plugin()? {
println!(" ✓ 已清理旧版项目级插件(v39 起插件改用户级安装)");
}
let plugin_path = self
.config_path
.parent()
.ok_or_else(|| anyhow::anyhow!("无法定位 OpenCode 配置根目录"))?
.join("plugins")
.join("code-repo-wiki.ts");
let exe_path = std::env::current_exe()
.with_context(|| "无法定位当前可执行文件路径(插件无法绑定绝对路径)")?;
let exe_json =
serde_json::to_string(&exe_path.to_string_lossy().to_string())
.with_context(|| "序列化可执行文件路径失败")?;
let template = {
let raw = include_str!("plugin-template.ts");
raw.replace(
"execa(\"code-repo-wiki\"",
&format!("execa({exe_json}"),
)
};
if let Ok(existing) = std::fs::read_to_string(&plugin_path) {
if existing == template {
tracing::info!("插件文件已是最新,跳过: {}", plugin_path.display());
return Ok(false);
}
tracing::info!("插件文件内容与模板不一致,升级覆盖: {}", plugin_path.display());
}
std::fs::create_dir_all(plugin_path.parent().unwrap())
.with_context(|| format!("创建插件目录失败: {}", plugin_path.display()))?;
std::fs::write(&plugin_path, template)
.with_context(|| format!("写入插件文件失败: {}", plugin_path.display()))?;
tracing::info!("插件文件已写入: {}", plugin_path.display());
Ok(true)
}
pub fn uninstall_plugin_file(&mut self) -> Result<()> {
self.remove_legacy_project_plugin()?;
let config_root = self
.config_path
.parent()
.ok_or_else(|| anyhow::anyhow!("无法定位 OpenCode 配置根目录"))?;
for dir in ["plugins", "plugin"] {
let plugin_path = config_root.join(dir).join("code-repo-wiki.ts");
match std::fs::remove_file(&plugin_path) {
Ok(()) => {
tracing::info!("插件文件已删除: {}", plugin_path.display());
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
}
Ok(())
}
pub fn config_dir() -> Result<PathBuf> {
let userprofile = std::env::var("USERPROFILE").ok();
let home = std::env::var("HOME").ok();
Self::config_dir_from(userprofile.as_deref(), home.as_deref())
}
pub fn config_dir_from(
userprofile: Option<&str>,
home: Option<&str>,
) -> Result<PathBuf> {
let user_home = userprofile
.or(home)
.map(PathBuf::from)
.ok_or_else(|| {
anyhow::anyhow!("无法确定用户级配置目录(USERPROFILE 与 HOME 均未设置)")
})?;
Ok(user_home.join(".config").join("opencode"))
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn setup_temp_config(initial: Option<&str>) -> (PathBuf, PathBuf) {
let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("code-repo-wiki-opencode-test-{}-{}", std::process::id(), id));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("创建临时目录失败");
let path = dir.join("opencode.json");
if let Some(content) = initial {
std::fs::write(&path, content).expect("写入临时配置文件失败");
}
(dir, path)
}
fn setup_plugin_file(dir: &Path) -> PathBuf {
let plugin_dir = dir.join("plugins");
std::fs::create_dir_all(&plugin_dir).expect("创建插件目录失败");
let path = plugin_dir.join("code-repo-wiki.ts");
std::fs::write(&path, "export const RepoWikiPlugin = () => ({});").expect("写入插件文件失败");
path
}
fn setup_legacy_project_plugin(dir: &Path) -> PathBuf {
let plugin_dir = dir.join(".opencode").join("plugins");
std::fs::create_dir_all(&plugin_dir).expect("创建插件目录失败");
let path = plugin_dir.join("code-repo-wiki.ts");
std::fs::write(&path, "export const RepoWikiPlugin = () => ({});").expect("写入插件文件失败");
path
}
#[test]
fn test_install_plugin_removes_invalid_plugins_key() {
let initial = r#"{"plugins":[{"name":"code-repo-wiki","path":".opencode/plugins/code-repo-wiki.ts","enabled":true}]}"#;
let (dir, path) = setup_temp_config(Some(initial));
let mut config = OpenCodeConfig { config_path: path.clone(), project_root: dir.clone() };
config.install_plugin().unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let value: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(value.get("plugins").is_none(), "install 后不应残留无效的 plugins 键");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_install_plugin_noop_when_clean() {
let (dir, path) = setup_temp_config(Some(r#"{}"#));
let mut config = OpenCodeConfig { config_path: path.clone(), project_root: dir.clone() };
config.install_plugin().unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let value: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(value.get("plugins").is_none());
assert_eq!(value.as_object().unwrap().len(), 0, "干净配置不应被写入内容");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_install_plugin_creates_config_when_missing() {
let (dir, path) = setup_temp_config(None);
let mut config = OpenCodeConfig { config_path: path.clone(), project_root: dir.clone() };
config.install_plugin().unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let value: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(value.get("plugins").is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_uninstall_plugin_removes_invalid_key_preserves_others() {
let initial = r#"{"plugins":[{"name":"code-repo-wiki","enabled":true}],"theme":"dark"}"#;
let (dir, path) = setup_temp_config(Some(initial));
let mut config = OpenCodeConfig { config_path: path.clone(), project_root: dir.clone() };
config.uninstall_plugin().unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let value: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(value.get("plugins").is_none(), "卸载后不应残留 plugins 键");
assert_eq!(value["theme"], "dark", "其他合法键应保留");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_uninstall_plugin_noop_when_file_missing() {
let (dir, path) = setup_temp_config(None);
let mut config = OpenCodeConfig { config_path: path, project_root: dir.clone() };
config.uninstall_plugin().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_is_installed_when_plugin_file_present() {
let (dir, _) = setup_temp_config(None);
setup_plugin_file(&dir);
let config = OpenCodeConfig {
config_path: dir.join("opencode.json"),
project_root: dir.clone(),
};
assert!(config.is_installed().unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_is_installed_when_plugin_file_missing() {
let (dir, _) = setup_temp_config(None);
let config = OpenCodeConfig {
config_path: dir.join("opencode.json"),
project_root: dir.clone(),
};
assert!(!config.is_installed().unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_is_installed_singular_plugin_dir() {
let (dir, _) = setup_temp_config(None);
let plugin_dir = dir.join("plugin");
std::fs::create_dir_all(&plugin_dir).unwrap();
std::fs::write(plugin_dir.join("code-repo-wiki.ts"), "export const RepoWikiPlugin = () => ({});").unwrap();
let config = OpenCodeConfig {
config_path: dir.join("opencode.json"),
project_root: dir.clone(),
};
assert!(config.is_installed().unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_is_installed_ignores_legacy_project_plugin() {
let (dir, _) = setup_temp_config(None);
setup_legacy_project_plugin(&dir);
let config = OpenCodeConfig {
config_path: dir.join("opencode.json"),
project_root: dir.clone(),
};
assert!(!config.is_installed().unwrap(), "旧版项目级插件不应视为已安装");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_install_plugin_file_migrates_legacy_project_plugin() {
let (dir, _) = setup_temp_config(None);
let legacy = setup_legacy_project_plugin(&dir);
let mut config = OpenCodeConfig { config_path: dir.join("opencode.json"), project_root: dir.clone() };
let wrote = config.install_plugin_file().unwrap();
assert!(wrote, "迁移时应实际写入用户级插件文件");
assert!(!legacy.exists(), "旧版项目级插件文件应被清理");
let user_plugin = dir.join("plugins").join("code-repo-wiki.ts");
assert!(user_plugin.exists(), "用户级插件文件应写入");
assert!(config.is_installed().unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_uninstall_plugin_file_removes_user_and_legacy() {
let (dir, _) = setup_temp_config(None);
let user_plugin = setup_plugin_file(&dir);
let legacy = setup_legacy_project_plugin(&dir);
let mut config = OpenCodeConfig { config_path: dir.join("opencode.json"), project_root: dir.clone() };
config.uninstall_plugin_file().unwrap();
assert!(!user_plugin.exists(), "用户级插件文件应删除");
assert!(!legacy.exists(), "旧版项目级插件文件应删除");
assert!(!config.is_installed().unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_config_dir_prefers_userprofile() {
let dir = OpenCodeConfig::config_dir_from(Some("C:\\Users\\testuser"), None).unwrap();
assert_eq!(
dir,
PathBuf::from("C:\\Users\\testuser").join(".config").join("opencode"),
"USERPROFILE 应优先于 HOME"
);
let dir2 = OpenCodeConfig::config_dir_from(None, Some("/home/t")).unwrap();
assert_eq!(dir2, PathBuf::from("/home/t").join(".config").join("opencode"));
assert!(OpenCodeConfig::config_dir_from(None, None).is_err());
}
#[test]
fn test_config_dir_errors_without_home() {
assert!(OpenCodeConfig::config_dir_from(None, None).is_err());
assert!(OpenCodeConfig::config_dir_from(Some("C:/Users/t"), None).is_ok());
assert!(OpenCodeConfig::config_dir_from(None, Some("/home/t")).is_ok());
let p = OpenCodeConfig::config_dir_from(Some("C:/Users/t"), Some("/home/x")).unwrap();
assert_eq!(p, PathBuf::from("C:/Users/t/.config/opencode"));
}
#[test]
fn test_non_object_config_errors() {
for (tag, initial) in [("arr", "[1,2,3]"), ("str", "\"oops\"")] {
let (dir, path) = setup_temp_config(Some(initial));
let mut config = OpenCodeConfig { config_path: path.clone(), project_root: dir.clone() };
assert!(config.install_plugin().is_err(), "install 对非对象配置应报错 ({tag})");
assert!(config.uninstall_plugin().is_err(), "uninstall 对非对象配置应报错 ({tag})");
let _ = std::fs::remove_dir_all(&dir);
}
}
#[test]
fn test_install_plugin_file_injects_absolute_exe_path() {
let (dir, _) = setup_temp_config(None);
let mut config = OpenCodeConfig { config_path: dir.join("opencode.json"), project_root: dir.clone() };
let wrote = config.install_plugin_file().unwrap();
assert!(wrote, "首次安装应实际写入插件文件");
let plugin_path = dir.join("plugins").join("code-repo-wiki.ts");
let content = std::fs::read_to_string(&plugin_path).unwrap();
let exe_path = std::env::current_exe().unwrap();
let exe_json = serde_json::to_string(&exe_path.to_string_lossy().to_string()).unwrap();
assert!(
content.contains(&format!("execa({exe_json}")),
"插件应绑定注入的绝对路径(JSON 转义), 实际: {}",
content.chars().take(400).collect::<String>()
);
assert!(
!content.contains("execa(\"code-repo-wiki\""),
"PATH 字面量版本不应残留"
);
let _ = std::fs::remove_dir_all(&dir);
}
}