1pub mod opencode;
2pub mod mcp;
5
6pub mod schema;
7
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11
12use crate::project::ProjectRoot;
13
14pub const PROJECT_CONFIG_FILE: &str = "config.toml";
17
18pub const USER_CONFIG_FILE: &str = "config.toml";
21
22pub fn load_config(path: &Path) -> Result<schema::WikiConfig> {
26 if !path.exists() {
27 anyhow::bail!(
30 "配置文件不存在: {}(可运行 `code-repo-wiki install` 确保用户级默认配置,或使用 --config 显式指定)",
31 path.display()
32 );
33 }
34 let content = std::fs::read_to_string(path)
35 .with_context(|| format!("读取配置文件失败: {}", path.display()))?;
36 let config: schema::WikiConfig = toml::from_str(&content)
37 .with_context(|| format!("解析配置文件失败: {}", path.display()))?;
38 validate_config(&config)?;
39 Ok(config)
40}
41
42pub fn create_default_config(path: &Path) -> Result<schema::WikiConfig> {
45 if let Some(parent) = path.parent() {
46 std::fs::create_dir_all(parent)?;
47 }
48 std::fs::write(path, include_str!("../../config.toml"))?;
49 load_config(path)
50}
51
52pub fn global_config_dir_from(userprofile: Option<&Path>, home: Option<&Path>) -> Result<PathBuf> {
62 match userprofile {
63 Some(p) if !p.as_os_str().is_empty() => Ok(p.join(".code-repo-wiki")),
64 _ => home
65 .filter(|h| !h.as_os_str().is_empty())
66 .ok_or_else(|| anyhow::anyhow!("无法确定用户级配置目录(USERPROFILE 与 HOME 均未设置)"))
67 .map(|h| h.join(".code-repo-wiki")),
68 }
69}
70
71pub fn global_config_dir() -> Result<PathBuf> {
80 if let Some(dir) = std::env::var("CODE_REPO_WIKI_HOME")
81 .ok()
82 .filter(|v| !v.is_empty())
83 {
84 return Ok(PathBuf::from(dir));
85 }
86 let userprofile = std::env::var("USERPROFILE").ok().map(PathBuf::from);
87 let home = std::env::var("HOME").ok().map(PathBuf::from);
88 global_config_dir_from(userprofile.as_deref(), home.as_deref())
89}
90pub fn ensure_global_config_dir() -> Result<(PathBuf, bool)> {
99 let dir = global_config_dir()?;
100 let legacy_dirs: Vec<PathBuf> = if std::env::var("CODE_REPO_WIKI_HOME").is_ok() {
101 Vec::new()
102 } else {
103 let mut legacy = Vec::new();
104 if let Some(appdata) = std::env::var("APPDATA").ok().filter(|v| !v.is_empty()) {
105 legacy.push(PathBuf::from(appdata).join("code-repo-wiki"));
106 }
107 if let Some(home) = std::env::var("HOME").ok().filter(|v| !v.is_empty()) {
108 legacy.push(PathBuf::from(home).join("code-repo-wiki"));
109 }
110 legacy
111 }; let migrated = migrate_global_config(&dir, &legacy_dirs)?;
112 Ok((dir, migrated))
113}
114
115pub fn migrate_global_config(new_dir: &Path, legacy_dirs: &[PathBuf]) -> Result<bool> {
121 if new_dir.join(USER_CONFIG_FILE).exists() {
122 return Ok(false);
123 }
124 for legacy in legacy_dirs {
125 if !legacy.join(USER_CONFIG_FILE).exists() {
126 continue;
127 }
128 std::fs::create_dir_all(new_dir)
129 .with_context(|| format!("创建全局配置目录失败: {}", new_dir.display()))?;
130 copy_dir_contents(legacy, new_dir)?;
131 return Ok(true);
132 }
133 Ok(false)
134}
135
136fn copy_dir_contents(from: &Path, to: &Path) -> Result<()> {
138 std::fs::create_dir_all(to)
139 .with_context(|| format!("创建目录失败: {}", to.display()))?;
140 for entry in std::fs::read_dir(from)
141 .with_context(|| format!("读取目录失败: {}", from.display()))?
142 {
143 let entry = entry?;
144 let src = entry.path();
145 let dst = to.join(entry.file_name());
146 if src.is_dir() {
147 copy_dir_contents(&src, &dst)?;
148 } else {
149 std::fs::copy(&src, &dst)
150 .with_context(|| format!("复制失败: {} → {}", src.display(), dst.display()))?;
151 }
152 }
153 Ok(())
154}
155
156fn merge_config(base: &toml::Value, overlay: &toml::Value) -> toml::Value {
161 match (base, overlay) {
162 (toml::Value::Table(base_tbl), toml::Value::Table(overlay_tbl)) => {
163 let mut merged = base_tbl.clone();
164 for (key, overlay_val) in overlay_tbl {
165 let recursive = merged
167 .get(key)
168 .is_some_and(|bv| bv.is_table() && overlay_val.is_table());
169 if recursive {
170 let base_child = merged.get(key).unwrap().clone();
171 merged.insert(key.clone(), merge_config(&base_child, overlay_val));
172 } else {
173 merged.insert(key.clone(), overlay_val.clone());
175 }
176 }
177 toml::Value::Table(merged)
178 }
179 _ => overlay.clone(),
180 }
181}
182
183pub fn load_default_config_with(
200 root: &ProjectRoot,
201 global_dir: &Path,
202) -> Result<(PathBuf, schema::WikiConfig)> {
203 let project_config = root.path().join(PROJECT_CONFIG_FILE);
204 let user_config = global_dir.join(USER_CONFIG_FILE);
205 if project_config.exists() {
206 let base_text = if user_config.exists() {
207 std::fs::read_to_string(&user_config)
208 .with_context(|| format!("读取用户级配置失败: {}", user_config.display()))?
209 } else {
210 include_str!("../../config.toml").to_string()
211 };
212 let base: toml::Value = toml::from_str(&base_text)
213 .with_context(|| "解析用户级配置(或模板)失败".to_string())?;
214 let project_text = std::fs::read_to_string(&project_config)
215 .with_context(|| format!("读取项目级配置失败: {}", project_config.display()))?;
216 let overlay: toml::Value = toml::from_str(&project_text)
217 .with_context(|| format!("解析项目级配置失败: {}", project_config.display()))?;
218 let merged = merge_config(&base, &overlay);
219 let text = toml::to_string(&merged).context("合并配置序列化失败")?;
220 let config: schema::WikiConfig = toml::from_str(&text)
221 .with_context(|| format!("解析合并后配置失败: {}", project_config.display()))?;
222 validate_config(&config)?;
223 Ok((project_config, config))
224 } else if user_config.exists() {
225 let config = load_config(&user_config)?;
226 Ok((user_config, config))
227 } else {
228 std::fs::create_dir_all(global_dir)
229 .with_context(|| format!("创建全局配置目录失败: {}", global_dir.display()))?;
230 create_default_config(&user_config)?;
231 let config = load_config(&user_config)?;
232 Ok((user_config, config))
233 }
234}
235
236pub fn load_default_config(root: &ProjectRoot) -> Result<(PathBuf, schema::WikiConfig)> {
238 let (global_dir, migrated) = ensure_global_config_dir()?;
239 if migrated {
240 println!(
241 "提示: 用户级配置已迁移到 {}(旧目录保留,未删除)",
242 global_dir.display()
243 );
244 }
245 load_default_config_with(root, &global_dir)
246}
247
248pub fn resolve_default_config_path_with(root: &ProjectRoot, global_dir: &Path) -> Result<PathBuf> {
263 let project_config = root.path().join(PROJECT_CONFIG_FILE);
264 if project_config.exists() {
265 return Ok(project_config);
266 }
267 let global_config = global_dir.join(USER_CONFIG_FILE);
268 if global_config.exists() {
269 return Ok(global_config);
270 }
271 std::fs::create_dir_all(global_dir)
272 .with_context(|| format!("创建全局配置目录失败: {}", global_dir.display()))?;
273 create_default_config(&global_config)?;
274 Ok(global_config)
275}
276
277pub fn resolve_default_config_path(root: &ProjectRoot) -> Result<PathBuf> {
279 let (global_dir, migrated) = ensure_global_config_dir()?;
280 if migrated {
281 println!(
282 "提示: 用户级配置已迁移到 {}(旧目录保留,未删除)",
283 global_dir.display()
284 );
285 }
286 resolve_default_config_path_with(root, &global_dir)
287}
288
289pub fn resolve_config_path(config: Option<&Path>, root: &ProjectRoot) -> Result<PathBuf> {
292 match config {
293 Some(p) => Ok(p.to_path_buf()),
294 None => resolve_default_config_path(root),
295 }
296}
297
298pub fn resolve_mcp_config(config: Option<&Path>, root: &ProjectRoot) -> Result<schema::WikiConfig> {
302 match config {
303 Some(p) => load_config(p),
304 None => load_default_config(root).map(|(_path, cfg)| cfg),
305 }
306}
307
308fn validate_config(_config: &schema::WikiConfig) -> Result<()> {
311 Ok(())
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn test_default_config_roundtrip() {
320 let config = schema::WikiConfig::default();
321 let toml_str = toml::to_string_pretty(&config).unwrap();
322 let parsed: schema::WikiConfig = toml::from_str(&toml_str).unwrap();
323 assert_eq!(parsed.llm.model, "deepseek-v4-flash");
325 assert_eq!(parsed.llm.api_key_env, "OPENCODEGO2_API_KEY");
326 assert_eq!(parsed.wiki.language, "zh");
327 assert_eq!(parsed.output_dir(), std::path::Path::new(crate::config::schema::OUTPUT_DIR));
328 }
329}
330
331
332 #[test]
337 fn test_global_config_dir_from_userprofile() {
338 let dir = global_config_dir_from(Some(Path::new("C:/Users/wenyu")), Some(Path::new("/home/wenyu")))
339 .unwrap();
340 assert_eq!(dir, PathBuf::from("C:/Users/wenyu/.code-repo-wiki"));
341 }
342
343 #[test]
345 fn test_global_config_dir_from_home_fallback() {
346 let dir = global_config_dir_from(None, Some(Path::new("/home/wenyu"))).unwrap();
347 assert_eq!(dir, PathBuf::from("/home/wenyu/.code-repo-wiki"));
348 }
349
350 #[test]
352 fn test_global_config_dir_from_missing_both_errors() {
353 assert!(global_config_dir_from(None, None).is_err());
354 assert!(global_config_dir_from(None, Some(Path::new(""))).is_err());
355 }
356
357 #[test]
359 fn test_migrate_global_config_migrates_legacy() {
360 let tmp = test_tmp_dir("migrate-legacy");
361 let legacy = tmp.join("legacy");
362 let new = tmp.join("new");
363 std::fs::create_dir_all(legacy.join("sub")).unwrap();
364 std::fs::write(legacy.join("config.toml"), "llm_model = 'deepseek'").unwrap();
365 std::fs::write(legacy.join("sub/notes.txt"), "abc").unwrap();
366
367 assert!(migrate_global_config(&new, std::slice::from_ref(&legacy)).unwrap());
368 assert_eq!(
369 std::fs::read_to_string(new.join("config.toml")).unwrap(),
370 "llm_model = 'deepseek'"
371 );
372 assert_eq!(std::fs::read_to_string(new.join("sub/notes.txt")).unwrap(), "abc");
373 assert!(legacy.join("config.toml").exists());
375 }
376
377 #[test]
379 fn test_migrate_global_config_skips_when_new_exists() {
380 let tmp = test_tmp_dir("migrate-new-exists");
381 let legacy = tmp.join("legacy");
382 let new = tmp.join("new");
383 std::fs::create_dir_all(&legacy).unwrap();
384 std::fs::write(legacy.join("config.toml"), "old").unwrap();
385 std::fs::create_dir_all(&new).unwrap();
386 std::fs::write(new.join("config.toml"), "new-content").unwrap();
387
388 assert!(!migrate_global_config(&new, &[legacy]).unwrap());
389 assert_eq!(std::fs::read_to_string(new.join("config.toml")).unwrap(), "new-content");
390 }
391
392 #[test]
394 fn test_migrate_global_config_skips_when_legacy_missing() {
395 let tmp = test_tmp_dir("migrate-legacy-missing");
396 let legacy = tmp.join("missing");
397 let new = tmp.join("new");
398 assert!(!migrate_global_config(&new, &[legacy]).unwrap());
399 assert!(!new.exists());
400 }
401
402 #[test]
404 fn test_migrate_global_config_uses_first_legacy_with_config() {
405 let tmp = test_tmp_dir("migrate-first-legacy");
406 let legacy_empty = tmp.join("empty");
407 let legacy_real = tmp.join("real");
408 let new = tmp.join("new");
409 std::fs::create_dir_all(&legacy_empty).unwrap();
410 std::fs::create_dir_all(&legacy_real).unwrap();
411 std::fs::write(legacy_real.join("config.toml"), "real-content").unwrap();
412
413 assert!(migrate_global_config(&new, &[legacy_empty, legacy_real]).unwrap());
414 assert_eq!(
415 std::fs::read_to_string(new.join("config.toml")).unwrap(),
416 "real-content"
417 );
418 }
419
420 #[allow(dead_code)]
427 fn test_tmp_dir(name: &str) -> PathBuf {
428 use std::sync::atomic::{AtomicUsize, Ordering};
429 static SEQ: AtomicUsize = AtomicUsize::new(0);
430 std::env::temp_dir().join(format!(
431 "code-repo-wiki-config-test-{}-{}-{}",
432 std::process::id(),
433 name,
434 SEQ.fetch_add(1, Ordering::SeqCst)
435 ))
436 }
437
438 #[test]
441 fn test_resolve_prefers_project_config() {
442 let dir = std::env::temp_dir().join(format!("code_repo_wiki_e_project_{}", std::process::id()));
443 let _ = std::fs::remove_dir_all(&dir);
444 std::fs::create_dir_all(&dir).unwrap();
445 std::fs::write(dir.join(PROJECT_CONFIG_FILE), "dummy").unwrap();
446 let global_dir = dir.join("global");
447 std::fs::create_dir_all(&global_dir).unwrap();
448 std::fs::write(global_dir.join(USER_CONFIG_FILE), "dummy-global").unwrap();
449
450 let resolved = resolve_default_config_path_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
451 assert_eq!(resolved, dir.join(PROJECT_CONFIG_FILE));
452
453 let _ = std::fs::remove_dir_all(&dir);
454 }
455
456 #[test]
460 fn test_load_default_config_project_overrides_user() {
461 let dir = std::env::temp_dir().join(format!("code_repo_wiki_merge_{}", std::process::id()));
462 let _ = std::fs::remove_dir_all(&dir);
463 std::fs::create_dir_all(&dir).unwrap();
464
465 let global_dir = dir.join("global");
467 std::fs::create_dir_all(&global_dir).unwrap();
468 let user_text = include_str!("../../config.toml")
469 .replace("model = \"deepseek-v4-flash\"", "model = \"user-model\"");
470 std::fs::write(global_dir.join(USER_CONFIG_FILE), &user_text).unwrap();
471
472 std::fs::write(
474 dir.join(PROJECT_CONFIG_FILE),
475 r#"
476[llm]
477provider = "anthropic"
478api_key_env = "ANTHROPIC_API_KEY"
479model = "claude-test"
480"#,
481 )
482 .unwrap();
483
484 let (path, config) = load_default_config_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
485 assert_eq!(path, dir.join(PROJECT_CONFIG_FILE));
487 assert_eq!(config.llm.model, "claude-test");
489 assert_eq!(config.llm.provider, schema::LlmProviderType::Anthropic);
491 assert_eq!(config.llm.api_key_env, "ANTHROPIC_API_KEY");
492
493 let _ = std::fs::remove_dir_all(&dir);
494 }
495
496 #[test]
499 fn test_load_default_config_user_only_or_creates() {
500 let dir = std::env::temp_dir().join(format!("code_repo_wiki_useronly_{}", std::process::id()));
501 let _ = std::fs::remove_dir_all(&dir);
502 std::fs::create_dir_all(&dir).unwrap();
503
504 let global_dir = dir.join("global");
506 std::fs::create_dir_all(&global_dir).unwrap();
507 let user_text = include_str!("../../config.toml")
508 .replace("model = \"deepseek-v4-flash\"", "model = \"user-only-model\"");
509 std::fs::write(global_dir.join(USER_CONFIG_FILE), &user_text).unwrap();
510 let (path, config) = load_default_config_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
511 assert_eq!(path, global_dir.join(USER_CONFIG_FILE));
512 assert_eq!(config.llm.model, "user-only-model");
513 assert!(!dir.join(PROJECT_CONFIG_FILE).exists());
515
516 let global2 = dir.join("global2");
518 let (path2, config2) = load_default_config_with(&ProjectRoot::new(dir.clone()), &global2).unwrap();
519 assert!(path2.ends_with(USER_CONFIG_FILE));
520 assert!(global2.join(USER_CONFIG_FILE).exists());
521 assert_eq!(config2.llm.model, "deepseek-v4-flash");
522 assert!(!dir.join(PROJECT_CONFIG_FILE).exists());
523
524 let _ = std::fs::remove_dir_all(&dir);
525 }
526
527
528 #[test]
532 fn test_load_project_config_keeps_sensitive_keys() {
533 let dir = std::env::temp_dir().join(format!("code_repo_wiki_projcfg_{}", std::process::id()));
534 let _ = std::fs::remove_dir_all(&dir);
535 std::fs::create_dir_all(&dir).unwrap();
536 let path = dir.join(PROJECT_CONFIG_FILE);
537 std::fs::write(
539 &path,
540 r#"
541[wiki]
542language = "en"
543
544[llm]
545provider = "anthropic"
546model = "claude-opus"
547base_url = "https://custom.example.com/v1"
548api_key_env = "HACKED_KEY"
549"#,
550 )
551 .unwrap();
552
553 let config = load_config(&path).unwrap();
554 assert_eq!(config.llm.provider, crate::config::schema::LlmProviderType::Anthropic);
556 assert_eq!(config.llm.model, "claude-opus");
557 assert_eq!(config.llm.base_url.as_deref(), Some("https://custom.example.com/v1"));
558 assert_eq!(config.llm.api_key_env, "HACKED_KEY");
559 assert_eq!(config.wiki.language, "en");
561 let _ = std::fs::remove_dir_all(&dir);
564 }
565
566 #[test]
569 fn test_load_project_config_defaults_for_missing_keys() {
570 let dir = std::env::temp_dir().join(format!("code_repo_wiki_projcfg_defaults_{}", std::process::id()));
571 let _ = std::fs::remove_dir_all(&dir);
572 std::fs::create_dir_all(&dir).unwrap();
573 let path = dir.join(PROJECT_CONFIG_FILE);
574 std::fs::write(
575 &path,
576 r#"
577[llm]
578provider = "mock"
579"#,
580 )
581 .unwrap();
582
583 let config = load_config(&path).unwrap();
584 assert_eq!(config.llm.provider, crate::config::schema::LlmProviderType::Mock);
585 assert_eq!(config.llm.base_url.as_deref(), Some("https://opencode.ai/zen/go/v1"));
587 assert_eq!(config.llm.api_key_env, "OPENCODEGO2_API_KEY");
588 assert_eq!(config.embed.model, "qwen3.7-text-embedding");
589
590 let _ = std::fs::remove_dir_all(&dir);
591 }
592
593 #[test]
596 fn test_load_explicit_config_keeps_sensitive_keys() {
597 let dir = std::env::temp_dir().join(format!("code_repo_wiki_anyname_{}", std::process::id()));
598 let _ = std::fs::remove_dir_all(&dir);
599 std::fs::create_dir_all(&dir).unwrap();
600 let path = dir.join("my.toml");
601 std::fs::write(
602 &path,
603 r#"
604[llm]
605provider = "anthropic"
606model = "claude-opus"
607api_key_env = "ANTHROPIC_API_KEY"
608"#,
609 )
610 .unwrap();
611
612 let config = load_config(&path).unwrap();
613 assert_eq!(config.llm.provider, crate::config::schema::LlmProviderType::Anthropic);
615 assert_eq!(config.llm.model, "claude-opus");
616
617 let _ = std::fs::remove_dir_all(&dir);
618 }
619 #[test]
620 fn test_resolve_falls_back_to_global() {
621 let dir = std::env::temp_dir().join(format!("code_repo_wiki_e_global_{}", std::process::id()));
622 let _ = std::fs::remove_dir_all(&dir);
623 std::fs::create_dir_all(&dir).unwrap();
624 let global_dir = dir.join("global");
625 std::fs::create_dir_all(&global_dir).unwrap();
626 std::fs::write(global_dir.join(USER_CONFIG_FILE), "dummy-global").unwrap();
627
628 let resolved = resolve_default_config_path_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
629 assert_eq!(resolved, global_dir.join(USER_CONFIG_FILE));
630
631 let _ = std::fs::remove_dir_all(&dir);
632 }
633
634 #[test]
636 fn test_resolve_creates_global_config_when_missing() {
637 let dir = std::env::temp_dir().join(format!("code_repo_wiki_e_create_{}", std::process::id()));
638 let _ = std::fs::remove_dir_all(&dir);
639 std::fs::create_dir_all(&dir).unwrap();
640 let global_dir = dir.join("global");
641
642 let resolved = resolve_default_config_path_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
643 assert_eq!(resolved, global_dir.join(USER_CONFIG_FILE));
644 assert!(global_dir.join(USER_CONFIG_FILE).exists(), "缺失时应创建全局默认配置");
645 assert!(load_config(&resolved).is_ok());
647
648 let resolved2 = resolve_default_config_path_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
650 assert_eq!(resolved2, resolved);
651
652 let _ = std::fs::remove_dir_all(&dir);
653 }
654
655 #[test]
657 fn test_resolve_config_path_explicit_wins() {
658 let dir = std::env::temp_dir().join(format!("code_repo_wiki_e_explicit_{}", std::process::id()));
659 let _ = std::fs::remove_dir_all(&dir);
660 std::fs::create_dir_all(&dir).unwrap();
661
662 let explicit = dir.join("custom.toml");
663 let resolved = resolve_config_path(Some(&explicit), &ProjectRoot::new(dir.clone())).unwrap();
664 assert_eq!(resolved, explicit);
665 assert!(!dir.join("global").exists());
667
668 let _ = std::fs::remove_dir_all(&dir);
669 }