Skip to main content

code_repo_wiki/config/
opencode.rs

1//! OpenCode 配置读写模块
2//!
3//! 管理 code-repo-wiki 插件在 opencode.json 中的注册状态。
4//! 搜索顺序:项目根 .opencode.json → ~/.config/opencode/opencode.json
5//!
6//! 使用 serde_json::Value 操作 JSON,不依赖 OpenCode 的 schema 类型,
7//! 避免与 OpenCode 版本耦合。
8//!
9//! ## 插件加载机制(opencode 1.18.10,实测验证)
10//!
11//! - `.opencode/plugins/*.ts` 目录**自动扫描加载**,无需任何 config 条目
12//!   (官方加载器 glob `{plugin,plugins}/*.{ts,js}`)
13//! - 官方配置仅认单数 `plugin` 字段(字符串数组);**不存在 `plugins` 复数键**,
14//!   多余顶层键会触发配置解析 `Unrecognized key` 错误
15//! - 因此本模块不再向配置写入插件条目;install/uninstall 仅负责
16//!   **幂等清理历史遗留的无效 `plugins` 键**(旧版本曾错误写入),
17//!   is_installed 以插件文件存在性为准
18
19use std::path::PathBuf;
20
21use anyhow::{Context, Result};
22
23/// OpenCode 配置管理器
24pub struct OpenCodeConfig {
25    /// 全局 opencode.json 路径(`~/.config/opencode/opencode.json`)
26    pub config_path: PathBuf,
27    /// 项目根目录(仅用于清理 v39 之前的旧版项目级插件产物)
28    pub project_root: PathBuf,
29}
30
31impl OpenCodeConfig {
32    /// 创建管理器,配置路径固定为用户级全局 opencode.json
33    ///
34    /// v39 起(官方文档+源码查证):opencode 用户级配置根为
35    /// `~/.config/opencode`(全平台一致,含 Windows——xdg-basedir 无平台
36    /// 分支);插件自动加载目录为配置根下 `plugins/`。因此插件文件与
37    /// 配置清理全部落在用户级(一次 install 全仓库 opencode 会话可用),
38    /// 不再读写项目根 `.opencode.json`——那是用户自建文件,不属于本工具。
39    pub fn new(root: &crate::project::ProjectRoot) -> Result<Self> {
40        let project_root = root.path().to_path_buf();
41        let global_config = Self::config_dir()?.join("opencode.json");
42        Ok(Self {
43            config_path: global_config,
44            project_root,
45        })
46    }
47
48    /// 安装 code-repo-wiki 插件
49    ///
50    /// 插件目录自动加载,无需配置条目;本方法仅**幂等清理**配置中
51    /// 历史遗留的无效 `plugins` 键(opencode 1.18.10 解析会报
52    /// `Unrecognized key` 错误)。配置不存在时静默创建空对象。
53    pub fn install_plugin(&mut self) -> Result<()> {
54        let content = std::fs::read_to_string(&self.config_path)
55            .unwrap_or_else(|_| "{}".to_string());
56        let mut value: serde_json::Value = serde_json::from_str(&content)
57            .with_context(|| format!("解析配置文件失败: {}", self.config_path.display()))?;
58        // N12:顶层必须是 JSON 对象——数组/标量/字符串配置本身就是
59        // 损坏(opencode 顶层只有对象合法),此前仅在 plugins 键存在时
60        // 检查,数组 JSON 无键会静默通过并写回原样(错误配置被保留)
61        if !value.is_object() {
62            anyhow::bail!(
63                "opencode.json 顶层应为 JSON 对象: {}",
64                self.config_path.display()
65            );
66        }
67
68        // 移除无效的 plugins 键(无论是否数组,都不是官方字段)
69        if value.get_mut("plugins").is_some() {
70            tracing::info!(
71                "清理 opencode.json 中无效的 plugins 键(官方仅认单数 plugin): {}",
72                self.config_path.display()
73            );
74            // 顶层必须是对象(数组/标量 JSON 无键可清,属配置错误,显式报错而非兜底)
75            value
76                .as_object_mut()
77                .with_context(|| format!("opencode.json 顶层应为 JSON 对象: {}", self.config_path.display()))?
78                .remove("plugins");
79        }
80
81        let output = serde_json::to_string_pretty(&value)
82            .with_context(|| "序列化 opencode.json 失败")?;
83        // 父目录(如 ~/.config/opencode/)可能不存在(全新环境),写入前创建
84        if let Some(parent) = self.config_path.parent() {
85            std::fs::create_dir_all(parent)
86                .with_context(|| format!("创建配置目录失败: {}", parent.display()))?;
87        }
88        std::fs::write(&self.config_path, &output)
89            .with_context(|| format!("写入配置文件失败: {}", self.config_path.display()))?;
90
91        tracing::info!("code-repo-wiki 插件已就绪(目录自动加载,无需配置条目)");
92        Ok(())
93    }
94
95    /// 从 opencode.json 卸载 code-repo-wiki 插件(清理无效 plugins 键)
96    ///
97    /// opencode 对插件是目录自动加载,卸载插件的实际动作是删除
98    /// `.opencode/plugins/code-repo-wiki.ts` 文件(由用户决定,不在此处执行);
99    /// 本方法仅保证配置不含历史遗留的无效键。
100    pub fn uninstall_plugin(&mut self) -> Result<()> {
101        if !self.config_path.exists() {
102            return Ok(());
103        }
104
105        let content = std::fs::read_to_string(&self.config_path)
106            .with_context(|| format!("读取配置文件失败: {}", self.config_path.display()))?;
107        let mut value: serde_json::Value = serde_json::from_str(&content)
108            .with_context(|| format!("解析配置文件失败: {}", self.config_path.display()))?;
109        // N12:顶层非对象(数组/标量)直接报错——与 install_plugin 同规则
110        if !value.is_object() {
111            anyhow::bail!(
112                "opencode.json 顶层应为 JSON 对象: {}",
113                self.config_path.display()
114            );
115        }
116
117        let had_plugins = value.get("plugins").is_some();
118        value
119            .as_object_mut()
120            .with_context(|| format!("opencode.json 顶层应为 JSON 对象: {}", self.config_path.display()))?
121            .remove("plugins");
122        // 清理后若配置文件已无任何键(空对象——含从未有过 plugins 键的
123        // 历史空壳),直接删除文件:保留 `{}` 只会让用户疑惑,且下次
124        // install 会重新创建。非空配置(无 plugins 键)原样保留不动。
125        if value.as_object().is_some_and(|o| o.is_empty()) {
126            std::fs::remove_file(&self.config_path)
127                .with_context(|| format!("删除空配置文件失败: {}", self.config_path.display()))?;
128        } else if had_plugins {
129            let output = serde_json::to_string_pretty(&value)
130                .with_context(|| "序列化 opencode.json 失败")?;
131            std::fs::write(&self.config_path, &output)
132                .with_context(|| format!("写入配置文件失败: {}", self.config_path.display()))?;
133        }
134
135        tracing::info!("code-repo-wiki 插件配置已清理: {}", self.config_path.display());
136        Ok(())
137    }
138
139    /// 检查插件是否已安装(插件文件 `.opencode/plugins/code-repo-wiki.ts` 是否存在)
140    ///
141    /// 以文件存在性为准:opencode 目录自动加载,配置文件不再承载注册信息。
142    /// N10:官方加载器 glob 为 `{plugin,plugins}/*.{ts,js}`——单复数目录
143    /// 都要查(此前只查 plugins/,用户手工放在 plugin/ 时误报未安装)。
144    ///
145    /// v39:插件已移至用户级配置根(`~/.config/opencode/plugins/`),
146    /// 安装状态以用户级文件存在性为准(项目级旧产物由迁移逻辑清理,
147    /// 不再视为已安装)。
148    pub fn is_installed(&self) -> Result<bool> {
149        let config_root = self
150            .config_path
151            .parent()
152            .ok_or_else(|| anyhow::anyhow!("无法定位 OpenCode 配置根目录"))?;
153        for dir in ["plugins", "plugin"] {
154            let plugin_file = config_root
155                .join(dir)
156                .join("code-repo-wiki.ts");
157            if plugin_file.exists() {
158                return Ok(true);
159            }
160        }
161        Ok(false)
162    }
163
164    /// 清理 v39 之前的旧版项目级插件产物
165    ///
166    /// v33-v38 把插件写入 `{project_root}/.opencode/{plugins,plugin}/`;
167    /// v39 起改用户级配置根。旧文件残留会被 opencode 项目级目录继续
168    /// 自动加载(且内容含旧 exe 路径),install/uninstall 时幂等清理:
169    /// 存在即删除并返回 true(供提示),不存在静默返回 false。
170    fn remove_legacy_project_plugin(&self) -> Result<bool> {
171        let mut removed = false;
172        for dir in ["plugins", "plugin"] {
173            let legacy = self
174                .project_root
175                .join(".opencode")
176                .join(dir)
177                .join("code-repo-wiki.ts");
178            match std::fs::remove_file(&legacy) {
179                Ok(()) => {
180                    tracing::info!("已清理旧版项目级插件: {}", legacy.display());
181                    removed = true;
182                }
183                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
184                Err(e) => return Err(e.into()),
185            }
186        }
187        Ok(removed)
188    }
189
190    /// 将插件模板写入 `{config_root}/plugins/code-repo-wiki.ts`(用户级)
191    ///
192    /// v39:插件是「用户级内容」——装进 Agent 配置根目录
193    /// `~/.config/opencode/plugins/`(官方文档:该目录下 `{plugin,plugins}/*.{ts,js}`
194    /// 启动时自动加载),一次 install 全仓库 opencode 会话可用,不再写入
195    /// 项目 `.opencode/plugins/`。写入前清理 v39 之前的旧版项目级产物。
196    ///
197    /// 升级语义(用户拍板「带标记则升级」):插件文件是 code-repo-wiki
198    /// 专属产物(文件名即标记),内容与最新模板(注入当前 exe 绝对路径)
199    /// 不同即覆盖升级(旧版本模板/二进制路径变化);相同则跳过。
200    /// 返回是否实际写入。模板经 include_str 内嵌编译(见下方实现注释:
201    /// v33 修复自举缺陷——模板源不再依赖仓库内安装产物文件)。
202    pub fn install_plugin_file(&mut self) -> Result<bool> {
203        if self.remove_legacy_project_plugin()? {
204            println!("  ✓ 已清理旧版项目级插件(v39 起插件改用户级安装)");
205        }
206        let plugin_path = self
207            .config_path
208            .parent()
209            .ok_or_else(|| anyhow::anyhow!("无法定位 OpenCode 配置根目录"))?
210            .join("plugins")
211            .join("code-repo-wiki.ts");
212        // t02(v16):PATH 硬依赖根治——把模板中 execa 的二进制名替换为
213        // 当前进程的绝对路径。插件经 execa("code-repo-wiki", ...) 调 CLI,二进制
214        // 不在 PATH 时(cargo install 目标目录未入 PATH、便携部署等)16 个
215        // 工具全部 ENOENT 失效。install 时注入 current_exe() 绝对路径,
216        // 插件不再依赖 PATH。只替换 execa 首参(模板中该字面量唯一);
217        // 路径经 JSON 字符串转义(Windows 反斜杠/引号安全)。
218        let exe_path = std::env::current_exe()
219            .with_context(|| "无法定位当前可执行文件路径(插件无法绑定绝对路径)")?;
220        let exe_json =
221            serde_json::to_string(&exe_path.to_string_lossy().to_string())
222                .with_context(|| "序列化可执行文件路径失败")?;
223        let template = {
224            // 模板内嵌编译(include_str):插件模板只含 execa("code-repo-wiki")
225            // 占位(下方注入 current_exe 绝对路径),不含任何编译期路径,
226            // 因此发布安装/仓库移动后仍可生成。模板源固定为源码目录内
227            // src/config/plugin-template.ts——与安装产物(用户级
228            // ~/.config/opencode/plugins/)完全分离,uninstall 删除产物
229            // 不影响编译与再次 install
230            // (v38 修复:v33 注释声称已修复自举缺陷但 include_str 仍指向
231            // 仓库内安装产物路径——真实环境 uninstall 删除产物后编译失败)
232            let raw = include_str!("plugin-template.ts");
233            raw.replace(
234                "execa(\"code-repo-wiki\"",
235                &format!("execa({exe_json}"),
236            )
237        };
238
239        // v33:内容比对决定升级或跳过(幂等跳过 = 内容完全一致)
240        if let Ok(existing) = std::fs::read_to_string(&plugin_path) {
241            if existing == template {
242                tracing::info!("插件文件已是最新,跳过: {}", plugin_path.display());
243                return Ok(false);
244            }
245            tracing::info!("插件文件内容与模板不一致,升级覆盖: {}", plugin_path.display());
246        }
247        std::fs::create_dir_all(plugin_path.parent().unwrap())
248            .with_context(|| format!("创建插件目录失败: {}", plugin_path.display()))?;
249        std::fs::write(&plugin_path, template)
250            .with_context(|| format!("写入插件文件失败: {}", plugin_path.display()))?;
251        tracing::info!("插件文件已写入: {}", plugin_path.display());
252        Ok(true)
253    }
254
255    /// 删除用户级插件文件(`plugins/` 与 `plugin/` 双目录,与官方自动加载
256    /// glob `{plugin,plugins}/*.{ts,js}` 及 [`Self::is_installed`] 对称),
257    /// 并清理 v39 之前的旧版项目级产物。
258    ///
259    /// 文件不存在时静默成功(幂等,与 uninstall_plugin 语义一致)。
260    pub fn uninstall_plugin_file(&mut self) -> Result<()> {
261        self.remove_legacy_project_plugin()?;
262        let config_root = self
263            .config_path
264            .parent()
265            .ok_or_else(|| anyhow::anyhow!("无法定位 OpenCode 配置根目录"))?;
266        for dir in ["plugins", "plugin"] {
267            let plugin_path = config_root.join(dir).join("code-repo-wiki.ts");
268            match std::fs::remove_file(&plugin_path) {
269                Ok(()) => {
270                    tracing::info!("插件文件已删除: {}", plugin_path.display());
271                }
272                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
273                Err(e) => return Err(e.into()),
274            }
275        }
276        Ok(())
277    }
278
279    /// 获取 OpenCode 配置的根目录 (~/.config/opencode/)
280    ///
281    /// v33:USERPROFILE 与 HOME 都缺失时显式报错(与
282    /// [`crate::config::global_config_dir`] 的「写错位置比报错更隐蔽」
283    /// 语义统一),不再回退 `.` 静默写当前目录。
284    pub fn config_dir() -> Result<PathBuf> {
285        let userprofile = std::env::var("USERPROFILE").ok();
286        let home = std::env::var("HOME").ok();
287        Self::config_dir_from(userprofile.as_deref(), home.as_deref())
288    }
289
290    /// 纯函数版配置根目录解析(N11/v33 语义)——Windows 下
291    /// USERPROFILE 优先于 HOME(两者都存在时 HOME 可能是
292    /// Cygwin/残留值)。拆成纯函数以便测试不依赖进程级环境变量
293    /// (并行测试对全局 env 的读写是竞态)。
294    pub fn config_dir_from(
295        userprofile: Option<&str>,
296        home: Option<&str>,
297    ) -> Result<PathBuf> {
298        let user_home = userprofile
299            .or(home)
300            .map(PathBuf::from)
301            .ok_or_else(|| {
302                anyhow::anyhow!("无法确定用户级配置目录(USERPROFILE 与 HOME 均未设置)")
303            })?;
304        Ok(user_home.join(".config").join("opencode"))
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use std::path::{Path, PathBuf};
311    use super::*;
312    use std::sync::atomic::{AtomicU64, Ordering};
313
314    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
315
316    /// 在临时目录中创建模拟的 opencode.json(每个测试独立目录,防并行冲突)
317    fn setup_temp_config(initial: Option<&str>) -> (PathBuf, PathBuf) {
318        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
319        let dir = std::env::temp_dir().join(format!("code-repo-wiki-opencode-test-{}-{}", std::process::id(), id));
320        let _ = std::fs::remove_dir_all(&dir);
321        std::fs::create_dir_all(&dir).expect("创建临时目录失败");
322        let path = dir.join("opencode.json");
323
324        if let Some(content) = initial {
325            std::fs::write(&path, content).expect("写入临时配置文件失败");
326        }
327
328        (dir, path)
329    }
330
331    /// 在临时目录创建用户级插件文件({dir}/plugins/code-repo-wiki.ts——
332    /// v39 起插件装 Agent 配置根目录),返回文件路径
333    fn setup_plugin_file(dir: &Path) -> PathBuf {
334        let plugin_dir = dir.join("plugins");
335        std::fs::create_dir_all(&plugin_dir).expect("创建插件目录失败");
336        let path = plugin_dir.join("code-repo-wiki.ts");
337        std::fs::write(&path, "export const RepoWikiPlugin = () => ({});").expect("写入插件文件失败");
338        path
339    }
340
341    /// 在临时目录创建 v39 之前的旧版项目级插件文件
342    /// ({dir}/.opencode/plugins/code-repo-wiki.ts——旧 install 产物),
343    /// 供迁移清理断言使用
344    fn setup_legacy_project_plugin(dir: &Path) -> PathBuf {
345        let plugin_dir = dir.join(".opencode").join("plugins");
346        std::fs::create_dir_all(&plugin_dir).expect("创建插件目录失败");
347        let path = plugin_dir.join("code-repo-wiki.ts");
348        std::fs::write(&path, "export const RepoWikiPlugin = () => ({});").expect("写入插件文件失败");
349        path
350    }
351
352    /// install 应幂等清理历史遗留的无效 plugins 键(旧版本错误写入的复数对象数组)
353    #[test]
354    fn test_install_plugin_removes_invalid_plugins_key() {
355        let initial = r#"{"plugins":[{"name":"code-repo-wiki","path":".opencode/plugins/code-repo-wiki.ts","enabled":true}]}"#;
356        let (dir, path) = setup_temp_config(Some(initial));
357        let mut config = OpenCodeConfig { config_path: path.clone(), project_root: dir.clone() };
358
359        config.install_plugin().unwrap();
360
361        let content = std::fs::read_to_string(&path).unwrap();
362        let value: serde_json::Value = serde_json::from_str(&content).unwrap();
363        assert!(value.get("plugins").is_none(), "install 后不应残留无效的 plugins 键");
364
365        let _ = std::fs::remove_dir_all(&dir);
366    }
367
368    /// install 对干净配置幂等(不写入任何条目)
369    #[test]
370    fn test_install_plugin_noop_when_clean() {
371        let (dir, path) = setup_temp_config(Some(r#"{}"#));
372        let mut config = OpenCodeConfig { config_path: path.clone(), project_root: dir.clone() };
373
374        config.install_plugin().unwrap();
375
376        let content = std::fs::read_to_string(&path).unwrap();
377        let value: serde_json::Value = serde_json::from_str(&content).unwrap();
378        assert!(value.get("plugins").is_none());
379        assert_eq!(value.as_object().unwrap().len(), 0, "干净配置不应被写入内容");
380
381        let _ = std::fs::remove_dir_all(&dir);
382    }
383
384    /// 配置缺失时 install 创建空对象且无无效键
385    #[test]
386    fn test_install_plugin_creates_config_when_missing() {
387        let (dir, path) = setup_temp_config(None);
388        let mut config = OpenCodeConfig { config_path: path.clone(), project_root: dir.clone() };
389
390        config.install_plugin().unwrap();
391
392        let content = std::fs::read_to_string(&path).unwrap();
393        let value: serde_json::Value = serde_json::from_str(&content).unwrap();
394        assert!(value.get("plugins").is_none());
395
396        let _ = std::fs::remove_dir_all(&dir);
397    }
398
399    /// uninstall 清理无效 plugins 键且保留其他合法键
400    #[test]
401    fn test_uninstall_plugin_removes_invalid_key_preserves_others() {
402        let initial = r#"{"plugins":[{"name":"code-repo-wiki","enabled":true}],"theme":"dark"}"#;
403        let (dir, path) = setup_temp_config(Some(initial));
404        let mut config = OpenCodeConfig { config_path: path.clone(), project_root: dir.clone() };
405
406        config.uninstall_plugin().unwrap();
407
408        let content = std::fs::read_to_string(&path).unwrap();
409        let value: serde_json::Value = serde_json::from_str(&content).unwrap();
410        assert!(value.get("plugins").is_none(), "卸载后不应残留 plugins 键");
411        assert_eq!(value["theme"], "dark", "其他合法键应保留");
412
413        let _ = std::fs::remove_dir_all(&dir);
414    }
415
416    /// 配置文件缺失时 uninstall 静默成功(幂等)
417    #[test]
418    fn test_uninstall_plugin_noop_when_file_missing() {
419        let (dir, path) = setup_temp_config(None);
420        let mut config = OpenCodeConfig { config_path: path, project_root: dir.clone() };
421
422        config.uninstall_plugin().unwrap();
423
424        let _ = std::fs::remove_dir_all(&dir);
425    }
426
427    /// is_installed 以插件文件存在性为准:用户级文件存在 → true
428    #[test]
429    fn test_is_installed_when_plugin_file_present() {
430        let (dir, _) = setup_temp_config(None);
431        setup_plugin_file(&dir);
432        let config = OpenCodeConfig {
433            config_path: dir.join("opencode.json"),
434            project_root: dir.clone(),
435        };
436        assert!(config.is_installed().unwrap());
437
438        let _ = std::fs::remove_dir_all(&dir);
439    }
440
441    /// is_installed 在无插件文件的项目返回 false
442    #[test]
443    fn test_is_installed_when_plugin_file_missing() {
444        let (dir, _) = setup_temp_config(None);
445        // 临时配置根下没有 plugins/code-repo-wiki.ts
446        let config = OpenCodeConfig {
447            config_path: dir.join("opencode.json"),
448            project_root: dir.clone(),
449        };
450        assert!(!config.is_installed().unwrap());
451
452        let _ = std::fs::remove_dir_all(&dir);
453    }
454
455    /// N10:插件文件在单数 plugin/ 目录时同样判定已安装(官方加载器 glob {plugin,plugins})
456    #[test]
457    fn test_is_installed_singular_plugin_dir() {
458        let (dir, _) = setup_temp_config(None);
459        let plugin_dir = dir.join("plugin");
460        std::fs::create_dir_all(&plugin_dir).unwrap();
461        std::fs::write(plugin_dir.join("code-repo-wiki.ts"), "export const RepoWikiPlugin = () => ({});").unwrap();
462        let config = OpenCodeConfig {
463            config_path: dir.join("opencode.json"),
464            project_root: dir.clone(),
465        };
466        assert!(config.is_installed().unwrap());
467
468        let _ = std::fs::remove_dir_all(&dir);
469    }
470
471    /// v39:旧版项目级插件文件(.opencode/plugins/)不算已安装——安装
472    /// 状态以用户级配置根为准;旧文件由迁移逻辑清理
473    #[test]
474    fn test_is_installed_ignores_legacy_project_plugin() {
475        let (dir, _) = setup_temp_config(None);
476        setup_legacy_project_plugin(&dir);
477        let config = OpenCodeConfig {
478            config_path: dir.join("opencode.json"),
479            project_root: dir.clone(),
480        };
481        assert!(!config.is_installed().unwrap(), "旧版项目级插件不应视为已安装");
482
483        let _ = std::fs::remove_dir_all(&dir);
484    }
485
486    /// v39:install 前清理旧版项目级插件产物(迁移到用户级配置根)
487    #[test]
488    fn test_install_plugin_file_migrates_legacy_project_plugin() {
489        let (dir, _) = setup_temp_config(None);
490        let legacy = setup_legacy_project_plugin(&dir);
491        let mut config = OpenCodeConfig { config_path: dir.join("opencode.json"), project_root: dir.clone() };
492
493        let wrote = config.install_plugin_file().unwrap();
494        assert!(wrote, "迁移时应实际写入用户级插件文件");
495        assert!(!legacy.exists(), "旧版项目级插件文件应被清理");
496
497        let user_plugin = dir.join("plugins").join("code-repo-wiki.ts");
498        assert!(user_plugin.exists(), "用户级插件文件应写入");
499        assert!(config.is_installed().unwrap());
500
501        let _ = std::fs::remove_dir_all(&dir);
502    }
503
504    /// v39:uninstall 同时清理用户级插件与旧版项目级残留
505    #[test]
506    fn test_uninstall_plugin_file_removes_user_and_legacy() {
507        let (dir, _) = setup_temp_config(None);
508        let user_plugin = setup_plugin_file(&dir);
509        let legacy = setup_legacy_project_plugin(&dir);
510        let mut config = OpenCodeConfig { config_path: dir.join("opencode.json"), project_root: dir.clone() };
511
512        config.uninstall_plugin_file().unwrap();
513
514        assert!(!user_plugin.exists(), "用户级插件文件应删除");
515        assert!(!legacy.exists(), "旧版项目级插件文件应删除");
516        assert!(!config.is_installed().unwrap());
517
518        let _ = std::fs::remove_dir_all(&dir);
519    }
520
521    /// N11:config_dir 优先 USERPROFILE(Windows 语义)
522    ///
523    /// 纯函数调用——不触碰进程级环境变量(并行测试下 env 是全局竞态,
524    /// unsafe set_var/remove_var 的窗口会让其他测试读到被移除的 HOME,
525    /// ubuntu 无 APPDATA 兜底时必现——v36 修复后同模式)
526    #[test]
527    fn test_config_dir_prefers_userprofile() {
528        // USERPROFILE 优先于 HOME
529        let dir = OpenCodeConfig::config_dir_from(Some("C:\\Users\\testuser"), None).unwrap();
530        assert_eq!(
531            dir,
532            PathBuf::from("C:\\Users\\testuser").join(".config").join("opencode"),
533            "USERPROFILE 应优先于 HOME"
534        );
535        // HOME 兜底(USERPROFILE 缺失)
536        let dir2 = OpenCodeConfig::config_dir_from(None, Some("/home/t")).unwrap();
537        assert_eq!(dir2, PathBuf::from("/home/t").join(".config").join("opencode"));
538        // 双缺失 → 显式报错
539        assert!(OpenCodeConfig::config_dir_from(None, None).is_err());
540    }
541
542    /// v33:config_dir 双缺失(USERPROFILE 与 HOME 均未设置)→ 显式报错
543    /// (与 config::global_config_dir 语义统一,不再回退 "." 写当前目录)。
544    /// 纯函数调用——不触碰进程级环境变量(并行测试下 env 是全局竞态)。
545    #[test]
546    fn test_config_dir_errors_without_home() {
547        assert!(OpenCodeConfig::config_dir_from(None, None).is_err());
548        assert!(OpenCodeConfig::config_dir_from(Some("C:/Users/t"), None).is_ok());
549        assert!(OpenCodeConfig::config_dir_from(None, Some("/home/t")).is_ok());
550        // USERPROFILE 优先于 HOME
551        let p = OpenCodeConfig::config_dir_from(Some("C:/Users/t"), Some("/home/x")).unwrap();
552        assert_eq!(p, PathBuf::from("C:/Users/t/.config/opencode"));
553    }
554
555    /// N12:顶层非对象 JSON(数组/标量)→ install/uninstall 显式报错
556    #[test]
557    fn test_non_object_config_errors() {
558        for (tag, initial) in [("arr", "[1,2,3]"), ("str", "\"oops\"")] {
559            let (dir, path) = setup_temp_config(Some(initial));
560            let mut config = OpenCodeConfig { config_path: path.clone(), project_root: dir.clone() };
561            assert!(config.install_plugin().is_err(), "install 对非对象配置应报错 ({tag})");
562            assert!(config.uninstall_plugin().is_err(), "uninstall 对非对象配置应报错 ({tag})");
563            let _ = std::fs::remove_dir_all(&dir);
564        }
565    }
566
567    /// t02(v16):install_plugin_file 注入当前可执行文件绝对路径——
568    /// 插件不再依赖 PATH(exec 目标为注入路径而非 "code-repo-wiki" 字面量)
569    /// v39:落点为用户级配置根 {dir}/plugins/(dir=测试注入的临时配置根)
570    #[test]
571    fn test_install_plugin_file_injects_absolute_exe_path() {
572        let (dir, _) = setup_temp_config(None);
573        let mut config = OpenCodeConfig { config_path: dir.join("opencode.json"), project_root: dir.clone() };
574
575        let wrote = config.install_plugin_file().unwrap();
576        assert!(wrote, "首次安装应实际写入插件文件");
577
578        let plugin_path = dir.join("plugins").join("code-repo-wiki.ts");
579        let content = std::fs::read_to_string(&plugin_path).unwrap();
580
581        // 注入的路径 = 测试进程可执行文件(current_exe 语义),JSON 转义后嵌入
582        let exe_path = std::env::current_exe().unwrap();
583        let exe_json = serde_json::to_string(&exe_path.to_string_lossy().to_string()).unwrap();
584        assert!(
585            content.contains(&format!("execa({exe_json}")),
586            "插件应绑定注入的绝对路径(JSON 转义), 实际: {}",
587            // char 安全切片:字节索引可能落在多字节 UTF-8 中间(panic)
588            content.chars().take(400).collect::<String>()
589        );
590        assert!(
591            !content.contains("execa(\"code-repo-wiki\""),
592            "PATH 字面量版本不应残留"
593        );
594
595        let _ = std::fs::remove_dir_all(&dir);
596    }
597}