1use std::path::PathBuf;
20
21use anyhow::{Context, Result};
22
23pub struct OpenCodeConfig {
25 pub config_path: PathBuf,
27 pub project_root: PathBuf,
29}
30
31impl OpenCodeConfig {
32 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 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 if !value.is_object() {
62 anyhow::bail!(
63 "opencode.json 顶层应为 JSON 对象: {}",
64 self.config_path.display()
65 );
66 }
67
68 if value.get_mut("plugins").is_some() {
70 tracing::info!(
71 "清理 opencode.json 中无效的 plugins 键(官方仅认单数 plugin): {}",
72 self.config_path.display()
73 );
74 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 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 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 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 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 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 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 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 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 let raw = include_str!("plugin-template.ts");
233 raw.replace(
234 "execa(\"code-repo-wiki\"",
235 &format!("execa({exe_json}"),
236 )
237 };
238
239 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
443 fn test_is_installed_when_plugin_file_missing() {
444 let (dir, _) = setup_temp_config(None);
445 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 #[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 #[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 #[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 #[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 #[test]
527 fn test_config_dir_prefers_userprofile() {
528 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 let dir2 = OpenCodeConfig::config_dir_from(None, Some("/home/t")).unwrap();
537 assert_eq!(dir2, PathBuf::from("/home/t").join(".config").join("opencode"));
538 assert!(OpenCodeConfig::config_dir_from(None, None).is_err());
540 }
541
542 #[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 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 #[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 #[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 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 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}