Skip to main content

code_repo_wiki/config/
mod.rs

1pub mod opencode;
2/// 多 Agent MCP 配置读写(v33;v39 落点统一用户级:opencode 全局 /
3/// Claude Code ~/.claude.json User scope / Codex config.toml)
4pub mod mcp;
5
6pub mod schema;
7
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11
12use crate::project::ProjectRoot;
13
14/// 项目级配置文件(v25 拍板:项目根 `config.toml`,字段级合并覆盖
15/// 用户级配置;v24 的 `.code-repo-wiki.toml` 与 v25 用户级默认文件更名,旧名不再读取)
16pub const PROJECT_CONFIG_FILE: &str = "config.toml";
17
18/// 用户级全局配置文件(v25 拍板:`config.toml`,与内置模板
19/// 同名同构;v24 及以前的全局 `config.toml` 已废弃不再读取)
20pub const USER_CONFIG_FILE: &str = "config.toml";
21
22/// 从文件加载配置(v30:原样解析,无净化无注入——缺失字段由 schema
23/// 字段级 serde 默认兜底,见 schema.rs LlmSection/EmbedSection 等;
24/// 项目级 config.toml 的 base_url/api_key_env 完整生效,用户拍板)
25pub fn load_config(path: &Path) -> Result<schema::WikiConfig> {
26    if !path.exists() {
27        // t05(v21):显式 --config 缺失时给出一键引导——裸报"文件不存在"
28        // 会让外部 Agent 无从下手;init 命令是创建默认配置的官方入口。
29        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
42/// 创建默认配置文件(写入 install 模板 config.toml,非 schema 默认值序列化)。
43/// 模板含注释与生产默认值(如 DeepSeek base_url),serde 序列化会丢失这些信息。
44pub 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
52/// 全局(用户级)配置目录的纯路径组装(可测试,不读环境变量)
53///
54/// 平台语义(用户拍板,v41——对齐 Codex/Claude Code/Azure CLI 等官方
55/// home 点目录惯例:`~/.codex`、`~/.claude`、`%USERPROFILE%\.azure`):
56/// - Windows:`%USERPROFILE%/.code-repo-wiki`(用户主目录点目录)
57/// - 其他平台:`$HOME/.code-repo-wiki`
58/// - USERPROFILE 缺失(非 Windows 环境)时退化 `$HOME/.code-repo-wiki`;
59///   USERPROFILE 与 HOME 都缺失时返回 Err——无法确定用户级目录时显式
60///   报错,不静默写当前目录(写错位置比报错更隐蔽)。
61pub 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
71/// 全局(用户级)配置目录(读环境变量,委托纯函数)
72///
73/// 解析优先级(v41 拍板):
74/// 1. `CODE_REPO_WIKI_HOME` 环境变量——显式重定位(对齐 CODEX_HOME 惯例,
75///    用户可自定义配置根;设置后不做旧路径迁移,旧目录由用户自行处理);
76/// 2. USERPROFILE(Windows 用户真实主目录——Git Bash/Cygwin/MSYS 常把
77///    HOME 指向临时值;N11 先例同 opencode.rs config_dir);
78/// 3. HOME。
79pub 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}
90/// 全局(用户级)配置目录就绪入口(v41):解析目录(含
91/// `CODE_REPO_WIKI_HOME` 重定位)+ 一次性迁移 v41 前的旧路径
92/// (`%APPDATA%/code-repo-wiki` 与 `$HOME/code-repo-wiki`——参考 git
93/// 双读与 Claude Code 弃用兼容先例:新路径优先、旧目录内容复制到新
94/// 路径、旧目录保留不删;`CODE_REPO_WIKI_HOME` 显式指定时跳过迁移)。
95///
96/// 返回(目录, 是否发生迁移)——迁移发生时调用方打印提示。
97/// 生产入口(lib.rs 配置加载、key 命令)用它替代 [`global_config_dir`]。
98pub 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
115/// 一次性迁移旧用户级配置目录(纯逻辑,可测试):新目录已有
116/// `config.toml` 时不迁移(新配置优先);否则按序检查候选旧目录,
117/// 第一个含 `config.toml` 的旧目录整个复制到新目录。
118///
119/// 返回是否发生了迁移。旧目录保留不删(配置属用户资产)。
120pub 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
136/// 递归复制目录内容(迁移用;简单文件复制——配置目录无符号链接场景)
137fn 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
156/// 字段级 TOML 合并(v25 拍板,主流工具语义,参考 uv/Claude Code/cargo
157/// 官方 merge 文档):表递归合并——overlay 命中的键整体取 overlay
158/// (标量/数组走 VS Code"完整清单"语义:数组整体覆盖而非追加),
159/// 未命中的键取 base;非表节点 overlay 整体覆盖。
160fn 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                // 两侧同构子表递归合并(如 [llm] 内只覆盖 model)
166                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                    // 其余(标量/数组/异构形态)整体覆盖
174                    merged.insert(key.clone(), overlay_val.clone());
175                }
176            }
177            toml::Value::Table(merged)
178        }
179        _ => overlay.clone(),
180    }
181}
182
183/// 默认配置链加载(v25 拍板,核心入口):项目级 `config.toml` 字段级
184/// 合并覆盖用户级 `config.toml`,返回(实际来源路径, 配置)。
185///
186/// 链:
187/// 1. 项目级存在 → base = 用户级(存在时)或内置模板;项目级原样解析
188///    后字段级合并覆盖 base(项目级只写要覆盖的键,其余继承用户级;
189///    数组整体覆盖;缺键由 schema 字段级 serde 默认兜底)
190/// 2. 项目级不存在 → 用户级存在 → 用之(原样加载不合并)
191/// 3. 都缺 → 创建用户级默认配置(模板)→ 用之(自动创建只发生在
192///    用户级目录,项目级永不自动创建——v24 用户要求延续)
193///
194/// 与 [`resolve_default_config_path`] 的区别:本函数返回合并后的完整
195/// 配置(合成内容不落盘),路径解析函数只做文件定位。
196///
197/// v30 用户拍板:彻底删除净化/注入规则——项目级配置原样解析,任何键
198/// (含 base_url/api_key_env)完整生效,缺失字段走 schema serde 默认。
199pub 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
236/// 默认配置链加载(生产入口,全局目录按环境变量解析 + 旧路径迁移)
237pub 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
248/// 默认配置文件解析:项目级 → 全局 → 创建全局(用户拍板,v13 E 组;
249/// v25 调整:项目级 `config.toml` 字段级合并覆盖用户级
250/// `config.toml`——完整合并语义见 [`load_default_config_with`])
251///
252/// 搜索链(无 `--config` 显式指定时):
253/// 1. `{项目根}/config.toml` 存在 → 用它(项目级配置优先,
254///    随 Git 提交共享,多项目隔离;原样解析,缺失字段由 schema 默认);
255/// 2. 全局 `{用户级目录}/config.toml` 存在 → 用它(用户默认偏好);
256/// 3. 都不存在 → 创建全局目录 + 写入默认配置模板,返回全局路径
257///    (引导式就绪:自动创建只发生在用户级目录,项目级永不自动创建——
258///    v24 用户要求,install 命令的项目级配置创建点已移除)。
259///
260/// global_dir 由调用方注入(测试传临时目录),生产入口传
261/// [`global_config_dir`] 的结果。
262pub 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
277/// 默认配置文件解析(生产入口,全局目录按环境变量解析 + 旧路径迁移)
278pub 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
289/// `--config` 参数解析:显式指定原样使用(不存在时由 load_config 报错);
290/// 缺省走默认配置链(见 [`resolve_default_config_path`])。
291pub 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
298/// 配置加载统一入口:显式路径单文件加载;None 走默认配置链
299/// (项目级 config.toml 字段级合并覆盖用户级 config.toml)。
300/// MCP server 与 CLI 各命令共用,保证 None 语义一致。
301pub 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
308/// 校验配置合法性(v30+:扫描范围等算法项已硬编码,无可校验键——
309/// 保留入口便于未来新增约束;当前恒通过)
310fn 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        // v17 t05:schema 默认值统一到模板阵营(DeepSeek)
324        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    // ============ E 组:全局配置链 ============
333
334    /// 全局目录路径组装:USERPROFILE 提供时拼 %USERPROFILE%/.code-repo-wiki
335    /// (v41 拍板——home 点目录惯例,对齐 ~/.codex、~/.claude)
336    #[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    /// 全局目录路径组装:USERPROFILE 缺失(非 Windows)时退化 $HOME/.code-repo-wiki
344    #[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    /// USERPROFILE 与 HOME 都缺失:显式报错(不静默写当前目录)
351    #[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    /// 一次性迁移:新目录无 config.toml 且旧目录存在 → 复制内容 + 返回 true
358    #[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        // 旧目录保留不删
374        assert!(legacy.join("config.toml").exists());
375    }
376
377    /// 一次性迁移:新目录已有 config.toml → 不迁移(新配置优先)
378    #[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    /// 一次性迁移:新目录空且旧目录不存在 → 不迁移(正常全新安装)
393    #[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    /// 一次性迁移:多个候选旧目录按序取第一个有效的
403    #[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    /// 测试用唯一临时目录(std 实现——Cargo.toml 无 dev-dependencies;
421    /// 进程 id + 原子序号防并行测试冲突——v19 教训)
422    ///
423    /// clippy 在非测试视角下对 cfg(test) 模块内被测试调用的 helper 会
424    /// 误报 never used(rustc dead_code 以 lib 编译单元分析);4 个迁移
425    /// 测试均调用它(cargo test 全绿),非死代码。
426    #[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    /// E 组搜索链:项目级配置存在 → 返回项目级(项目级优先;v24 起为
439    /// 独立文件 `config.toml`,不再混入产物目录)
440    #[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    /// v25:三链加载——项目级 config.toml 存在时,以用户级
457    /// config.toml(缺则模板)为基,字段级合并覆盖;
458    /// v30:项目级 llm/embed 键(base_url/api_key_env)完整覆盖用户级值
459    #[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        // 用户级:模板 + 自定义 model(v30:scope/output 等段已硬编码,模板即全量默认)
466        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        // 项目级:写 model + api_key_env 覆盖
473        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        // 项目级路径胜出(返回项目级文件位置)
486        assert_eq!(path, dir.join(PROJECT_CONFIG_FILE));
487        // model 字段级覆盖生效
488        assert_eq!(config.llm.model, "claude-test");
489        // v30:项目级 provider/api_key_env 完整覆盖用户级(不再净化剥离)
490        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    /// v25:无项目级配置时,用户级存在则直接用(无合并无净化);
497    /// 用户级缺失时创建(模板),绝不自动创建项目级文件。
498    #[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        // 用户级存在:直接使用
505        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        // 项目级文件未被创建
514        assert!(!dir.join(PROJECT_CONFIG_FILE).exists());
515
516        // 用户级缺失:创建模板;项目级仍不创建
517        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    /// v30:项目级配置文件加载时 base_url/api_key_env 完整生效——
529    /// 净化/注入规则已整体删除(端点/变量名非密钥明文,项目级可用
530    /// 配置即写即用);缺失字段由 schema serde 默认兜底
531    #[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        // 项目级配置声明项目契约(语言)+ 完整端点与变量名
538        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        // 项目级覆盖值全部保留(v30:不再剥离)
555        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        // 项目契约保留
560        assert_eq!(config.wiki.language, "en");
561        // v30: output.dir 已硬编码,项目级不可写
562
563        let _ = std::fs::remove_dir_all(&dir);
564    }
565
566    /// v30:缺键由 schema serde 默认兜底——项目级配置省略
567    /// base_url/api_key_env 等字段仍可加载(使用默认可用阵营)
568    #[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        // 缺失字段由 schema 字段级 serde 默认兜底(v29 可用阵营)
586        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    /// 任意文件名(显式 --config)均原样加载(v30:净化/注入已整体删除,
594    /// 文件名不再有语义差异);缺失字段同样由 schema serde 默认兜底
595    #[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        // 用户级/显式配置完整保留敏感键
614        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    /// E 组搜索链:项目级与全局都缺失 → 创建全局目录 + 默认配置,返回全局路径
635    #[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        // 创建的配置必须可加载(模板完整)
646        assert!(load_config(&resolved).is_ok());
647
648        // 幂等:再次解析仍返回同一路径,不重复创建
649        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    /// resolve_config_path:显式指定原样返回(不触发创建)
656    #[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        // 显式指定不创建全局目录/文件
666        assert!(!dir.join("global").exists());
667
668        let _ = std::fs::remove_dir_all(&dir);
669    }