code-repo-wiki 0.4.0

自动分析代码仓库结构,通过 LLM 生成结构化项目文档(Code Repo Wiki)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
pub mod opencode;
/// 多 Agent MCP 配置读写(v33;v39 落点统一用户级:opencode 全局 /
/// Claude Code ~/.claude.json User scope / Codex config.toml)
pub mod mcp;

pub mod schema;

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

use crate::project::ProjectRoot;

/// 项目级配置文件(v25 拍板:项目根 `config.toml`,字段级合并覆盖
/// 用户级配置;v24 的 `.code-repo-wiki.toml` 与 v25 用户级默认文件更名,旧名不再读取)
pub const PROJECT_CONFIG_FILE: &str = "config.toml";

/// 用户级全局配置文件(v25 拍板:`config.toml`,与内置模板
/// 同名同构;v24 及以前的全局 `config.toml` 已废弃不再读取)
pub const USER_CONFIG_FILE: &str = "config.toml";

/// 从文件加载配置(v30:原样解析,无净化无注入——缺失字段由 schema
/// 字段级 serde 默认兜底,见 schema.rs LlmSection/EmbedSection 等;
/// 项目级 config.toml 的 base_url/api_key_env 完整生效,用户拍板)
pub fn load_config(path: &Path) -> Result<schema::WikiConfig> {
    if !path.exists() {
        // t05(v21):显式 --config 缺失时给出一键引导——裸报"文件不存在"
        // 会让外部 Agent 无从下手;init 命令是创建默认配置的官方入口。
        anyhow::bail!(
            "配置文件不存在: {}(可运行 `code-repo-wiki install` 确保用户级默认配置,或使用 --config 显式指定)",
            path.display()
        );
    }
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("读取配置文件失败: {}", path.display()))?;
    let config: schema::WikiConfig = toml::from_str(&content)
        .with_context(|| format!("解析配置文件失败: {}", path.display()))?;
    validate_config(&config)?;
    Ok(config)
}

/// 创建默认配置文件(写入 install 模板 config.toml,非 schema 默认值序列化)。
/// 模板含注释与生产默认值(如 DeepSeek base_url),serde 序列化会丢失这些信息。
pub fn create_default_config(path: &Path) -> Result<schema::WikiConfig> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(path, include_str!("../../config.toml"))?;
    load_config(path)
}

/// 全局(用户级)配置目录的纯路径组装(可测试,不读环境变量)
///
/// 平台语义(用户拍板,v41——对齐 Codex/Claude Code/Azure CLI 等官方
/// home 点目录惯例:`~/.codex`、`~/.claude`、`%USERPROFILE%\.azure`):
/// - Windows:`%USERPROFILE%/.code-repo-wiki`(用户主目录点目录)
/// - 其他平台:`$HOME/.code-repo-wiki`
/// - USERPROFILE 缺失(非 Windows 环境)时退化 `$HOME/.code-repo-wiki`;
///   USERPROFILE 与 HOME 都缺失时返回 Err——无法确定用户级目录时显式
///   报错,不静默写当前目录(写错位置比报错更隐蔽)。
pub fn global_config_dir_from(userprofile: Option<&Path>, home: Option<&Path>) -> Result<PathBuf> {
    match userprofile {
        Some(p) if !p.as_os_str().is_empty() => Ok(p.join(".code-repo-wiki")),
        _ => home
            .filter(|h| !h.as_os_str().is_empty())
            .ok_or_else(|| anyhow::anyhow!("无法确定用户级配置目录(USERPROFILE 与 HOME 均未设置)"))
            .map(|h| h.join(".code-repo-wiki")),
    }
}

/// 全局(用户级)配置目录(读环境变量,委托纯函数)
///
/// 解析优先级(v41 拍板):
/// 1. `CODE_REPO_WIKI_HOME` 环境变量——显式重定位(对齐 CODEX_HOME 惯例,
///    用户可自定义配置根;设置后不做旧路径迁移,旧目录由用户自行处理);
/// 2. USERPROFILE(Windows 用户真实主目录——Git Bash/Cygwin/MSYS 常把
///    HOME 指向临时值;N11 先例同 opencode.rs config_dir);
/// 3. HOME。
pub fn global_config_dir() -> Result<PathBuf> {
    if let Some(dir) = std::env::var("CODE_REPO_WIKI_HOME")
        .ok()
        .filter(|v| !v.is_empty())
    {
        return Ok(PathBuf::from(dir));
    }
    let userprofile = std::env::var("USERPROFILE").ok().map(PathBuf::from);
    let home = std::env::var("HOME").ok().map(PathBuf::from);
    global_config_dir_from(userprofile.as_deref(), home.as_deref())
}
/// 全局(用户级)配置目录就绪入口(v41):解析目录(含
/// `CODE_REPO_WIKI_HOME` 重定位)+ 一次性迁移 v41 前的旧路径
/// (`%APPDATA%/code-repo-wiki` 与 `$HOME/code-repo-wiki`——参考 git
/// 双读与 Claude Code 弃用兼容先例:新路径优先、旧目录内容复制到新
/// 路径、旧目录保留不删;`CODE_REPO_WIKI_HOME` 显式指定时跳过迁移)。
///
/// 返回(目录, 是否发生迁移)——迁移发生时调用方打印提示。
/// 生产入口(lib.rs 配置加载、key 命令)用它替代 [`global_config_dir`]。
pub fn ensure_global_config_dir() -> Result<(PathBuf, bool)> {
    let dir = global_config_dir()?;
    let legacy_dirs: Vec<PathBuf> = if std::env::var("CODE_REPO_WIKI_HOME").is_ok() {
        Vec::new()
    } else {
        let mut legacy = Vec::new();
        if let Some(appdata) = std::env::var("APPDATA").ok().filter(|v| !v.is_empty()) {
            legacy.push(PathBuf::from(appdata).join("code-repo-wiki"));
        }
        if let Some(home) = std::env::var("HOME").ok().filter(|v| !v.is_empty()) {
            legacy.push(PathBuf::from(home).join("code-repo-wiki"));
        }
        legacy
    };    let migrated = migrate_global_config(&dir, &legacy_dirs)?;
    Ok((dir, migrated))
}

/// 一次性迁移旧用户级配置目录(纯逻辑,可测试):新目录已有
/// `config.toml` 时不迁移(新配置优先);否则按序检查候选旧目录,
/// 第一个含 `config.toml` 的旧目录整个复制到新目录。
///
/// 返回是否发生了迁移。旧目录保留不删(配置属用户资产)。
pub fn migrate_global_config(new_dir: &Path, legacy_dirs: &[PathBuf]) -> Result<bool> {
    if new_dir.join(USER_CONFIG_FILE).exists() {
        return Ok(false);
    }
    for legacy in legacy_dirs {
        if !legacy.join(USER_CONFIG_FILE).exists() {
            continue;
        }
        std::fs::create_dir_all(new_dir)
            .with_context(|| format!("创建全局配置目录失败: {}", new_dir.display()))?;
        copy_dir_contents(legacy, new_dir)?;
        return Ok(true);
    }
    Ok(false)
}

/// 递归复制目录内容(迁移用;简单文件复制——配置目录无符号链接场景)
fn copy_dir_contents(from: &Path, to: &Path) -> Result<()> {
    std::fs::create_dir_all(to)
        .with_context(|| format!("创建目录失败: {}", to.display()))?;
    for entry in std::fs::read_dir(from)
        .with_context(|| format!("读取目录失败: {}", from.display()))?
    {
        let entry = entry?;
        let src = entry.path();
        let dst = to.join(entry.file_name());
        if src.is_dir() {
            copy_dir_contents(&src, &dst)?;
        } else {
            std::fs::copy(&src, &dst)
                .with_context(|| format!("复制失败: {}{}", src.display(), dst.display()))?;
        }
    }
    Ok(())
}

/// 字段级 TOML 合并(v25 拍板,主流工具语义,参考 uv/Claude Code/cargo
/// 官方 merge 文档):表递归合并——overlay 命中的键整体取 overlay
/// (标量/数组走 VS Code"完整清单"语义:数组整体覆盖而非追加),
/// 未命中的键取 base;非表节点 overlay 整体覆盖。
fn merge_config(base: &toml::Value, overlay: &toml::Value) -> toml::Value {
    match (base, overlay) {
        (toml::Value::Table(base_tbl), toml::Value::Table(overlay_tbl)) => {
            let mut merged = base_tbl.clone();
            for (key, overlay_val) in overlay_tbl {
                // 两侧同构子表递归合并(如 [llm] 内只覆盖 model)
                let recursive = merged
                    .get(key)
                    .is_some_and(|bv| bv.is_table() && overlay_val.is_table());
                if recursive {
                    let base_child = merged.get(key).unwrap().clone();
                    merged.insert(key.clone(), merge_config(&base_child, overlay_val));
                } else {
                    // 其余(标量/数组/异构形态)整体覆盖
                    merged.insert(key.clone(), overlay_val.clone());
                }
            }
            toml::Value::Table(merged)
        }
        _ => overlay.clone(),
    }
}

/// 默认配置链加载(v25 拍板,核心入口):项目级 `config.toml` 字段级
/// 合并覆盖用户级 `config.toml`,返回(实际来源路径, 配置)。
///
/// 链:
/// 1. 项目级存在 → base = 用户级(存在时)或内置模板;项目级原样解析
///    后字段级合并覆盖 base(项目级只写要覆盖的键,其余继承用户级;
///    数组整体覆盖;缺键由 schema 字段级 serde 默认兜底)
/// 2. 项目级不存在 → 用户级存在 → 用之(原样加载不合并)
/// 3. 都缺 → 创建用户级默认配置(模板)→ 用之(自动创建只发生在
///    用户级目录,项目级永不自动创建——v24 用户要求延续)
///
/// 与 [`resolve_default_config_path`] 的区别:本函数返回合并后的完整
/// 配置(合成内容不落盘),路径解析函数只做文件定位。
///
/// v30 用户拍板:彻底删除净化/注入规则——项目级配置原样解析,任何键
/// (含 base_url/api_key_env)完整生效,缺失字段走 schema serde 默认。
pub fn load_default_config_with(
    root: &ProjectRoot,
    global_dir: &Path,
) -> Result<(PathBuf, schema::WikiConfig)> {
    let project_config = root.path().join(PROJECT_CONFIG_FILE);
    let user_config = global_dir.join(USER_CONFIG_FILE);
    if project_config.exists() {
        let base_text = if user_config.exists() {
            std::fs::read_to_string(&user_config)
                .with_context(|| format!("读取用户级配置失败: {}", user_config.display()))?
        } else {
            include_str!("../../config.toml").to_string()
        };
        let base: toml::Value = toml::from_str(&base_text)
            .with_context(|| "解析用户级配置(或模板)失败".to_string())?;
        let project_text = std::fs::read_to_string(&project_config)
            .with_context(|| format!("读取项目级配置失败: {}", project_config.display()))?;
        let overlay: toml::Value = toml::from_str(&project_text)
            .with_context(|| format!("解析项目级配置失败: {}", project_config.display()))?;
        let merged = merge_config(&base, &overlay);
        let text = toml::to_string(&merged).context("合并配置序列化失败")?;
        let config: schema::WikiConfig = toml::from_str(&text)
            .with_context(|| format!("解析合并后配置失败: {}", project_config.display()))?;
        validate_config(&config)?;
        Ok((project_config, config))
    } else if user_config.exists() {
        let config = load_config(&user_config)?;
        Ok((user_config, config))
    } else {
        std::fs::create_dir_all(global_dir)
            .with_context(|| format!("创建全局配置目录失败: {}", global_dir.display()))?;
        create_default_config(&user_config)?;
        let config = load_config(&user_config)?;
        Ok((user_config, config))
    }
}

/// 默认配置链加载(生产入口,全局目录按环境变量解析 + 旧路径迁移)
pub fn load_default_config(root: &ProjectRoot) -> Result<(PathBuf, schema::WikiConfig)> {
    let (global_dir, migrated) = ensure_global_config_dir()?;
    if migrated {
        println!(
            "提示: 用户级配置已迁移到 {}(旧目录保留,未删除)",
            global_dir.display()
        );
    }
    load_default_config_with(root, &global_dir)
}

/// 默认配置文件解析:项目级 → 全局 → 创建全局(用户拍板,v13 E 组;
/// v25 调整:项目级 `config.toml` 字段级合并覆盖用户级
/// `config.toml`——完整合并语义见 [`load_default_config_with`])
///
/// 搜索链(无 `--config` 显式指定时):
/// 1. `{项目根}/config.toml` 存在 → 用它(项目级配置优先,
///    随 Git 提交共享,多项目隔离;原样解析,缺失字段由 schema 默认);
/// 2. 全局 `{用户级目录}/config.toml` 存在 → 用它(用户默认偏好);
/// 3. 都不存在 → 创建全局目录 + 写入默认配置模板,返回全局路径
///    (引导式就绪:自动创建只发生在用户级目录,项目级永不自动创建——
///    v24 用户要求,install 命令的项目级配置创建点已移除)。
///
/// global_dir 由调用方注入(测试传临时目录),生产入口传
/// [`global_config_dir`] 的结果。
pub fn resolve_default_config_path_with(root: &ProjectRoot, global_dir: &Path) -> Result<PathBuf> {
    let project_config = root.path().join(PROJECT_CONFIG_FILE);
    if project_config.exists() {
        return Ok(project_config);
    }
    let global_config = global_dir.join(USER_CONFIG_FILE);
    if global_config.exists() {
        return Ok(global_config);
    }
    std::fs::create_dir_all(global_dir)
        .with_context(|| format!("创建全局配置目录失败: {}", global_dir.display()))?;
    create_default_config(&global_config)?;
    Ok(global_config)
}

/// 默认配置文件解析(生产入口,全局目录按环境变量解析 + 旧路径迁移)
pub fn resolve_default_config_path(root: &ProjectRoot) -> Result<PathBuf> {
    let (global_dir, migrated) = ensure_global_config_dir()?;
    if migrated {
        println!(
            "提示: 用户级配置已迁移到 {}(旧目录保留,未删除)",
            global_dir.display()
        );
    }
    resolve_default_config_path_with(root, &global_dir)
}

/// `--config` 参数解析:显式指定原样使用(不存在时由 load_config 报错);
/// 缺省走默认配置链(见 [`resolve_default_config_path`])。
pub fn resolve_config_path(config: Option<&Path>, root: &ProjectRoot) -> Result<PathBuf> {
    match config {
        Some(p) => Ok(p.to_path_buf()),
        None => resolve_default_config_path(root),
    }
}

/// 配置加载统一入口:显式路径单文件加载;None 走默认配置链
/// (项目级 config.toml 字段级合并覆盖用户级 config.toml)。
/// MCP server 与 CLI 各命令共用,保证 None 语义一致。
pub fn resolve_mcp_config(config: Option<&Path>, root: &ProjectRoot) -> Result<schema::WikiConfig> {
    match config {
        Some(p) => load_config(p),
        None => load_default_config(root).map(|(_path, cfg)| cfg),
    }
}

/// 校验配置合法性(v30+:扫描范围等算法项已硬编码,无可校验键——
/// 保留入口便于未来新增约束;当前恒通过)
fn validate_config(_config: &schema::WikiConfig) -> Result<()> {
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_config_roundtrip() {
        let config = schema::WikiConfig::default();
        let toml_str = toml::to_string_pretty(&config).unwrap();
        let parsed: schema::WikiConfig = toml::from_str(&toml_str).unwrap();
        // v17 t05:schema 默认值统一到模板阵营(DeepSeek)
        assert_eq!(parsed.llm.model, "deepseek-v4-flash");
        assert_eq!(parsed.llm.api_key_env, "OPENCODEGO2_API_KEY");
        assert_eq!(parsed.wiki.language, "zh");
        assert_eq!(parsed.output_dir(), std::path::Path::new(crate::config::schema::OUTPUT_DIR));
    }
}


    // ============ E 组:全局配置链 ============

    /// 全局目录路径组装:USERPROFILE 提供时拼 %USERPROFILE%/.code-repo-wiki
    /// (v41 拍板——home 点目录惯例,对齐 ~/.codex、~/.claude)
    #[test]
    fn test_global_config_dir_from_userprofile() {
        let dir = global_config_dir_from(Some(Path::new("C:/Users/wenyu")), Some(Path::new("/home/wenyu")))
            .unwrap();
        assert_eq!(dir, PathBuf::from("C:/Users/wenyu/.code-repo-wiki"));
    }

    /// 全局目录路径组装:USERPROFILE 缺失(非 Windows)时退化 $HOME/.code-repo-wiki
    #[test]
    fn test_global_config_dir_from_home_fallback() {
        let dir = global_config_dir_from(None, Some(Path::new("/home/wenyu"))).unwrap();
        assert_eq!(dir, PathBuf::from("/home/wenyu/.code-repo-wiki"));
    }

    /// USERPROFILE 与 HOME 都缺失:显式报错(不静默写当前目录)
    #[test]
    fn test_global_config_dir_from_missing_both_errors() {
        assert!(global_config_dir_from(None, None).is_err());
        assert!(global_config_dir_from(None, Some(Path::new(""))).is_err());
    }

    /// 一次性迁移:新目录无 config.toml 且旧目录存在 → 复制内容 + 返回 true
    #[test]
    fn test_migrate_global_config_migrates_legacy() {
        let tmp = test_tmp_dir("migrate-legacy");
        let legacy = tmp.join("legacy");
        let new = tmp.join("new");
        std::fs::create_dir_all(legacy.join("sub")).unwrap();
        std::fs::write(legacy.join("config.toml"), "llm_model = 'deepseek'").unwrap();
        std::fs::write(legacy.join("sub/notes.txt"), "abc").unwrap();

        assert!(migrate_global_config(&new, std::slice::from_ref(&legacy)).unwrap());
        assert_eq!(
            std::fs::read_to_string(new.join("config.toml")).unwrap(),
            "llm_model = 'deepseek'"
        );
        assert_eq!(std::fs::read_to_string(new.join("sub/notes.txt")).unwrap(), "abc");
        // 旧目录保留不删
        assert!(legacy.join("config.toml").exists());
    }

    /// 一次性迁移:新目录已有 config.toml → 不迁移(新配置优先)
    #[test]
    fn test_migrate_global_config_skips_when_new_exists() {
        let tmp = test_tmp_dir("migrate-new-exists");
        let legacy = tmp.join("legacy");
        let new = tmp.join("new");
        std::fs::create_dir_all(&legacy).unwrap();
        std::fs::write(legacy.join("config.toml"), "old").unwrap();
        std::fs::create_dir_all(&new).unwrap();
        std::fs::write(new.join("config.toml"), "new-content").unwrap();

        assert!(!migrate_global_config(&new, &[legacy]).unwrap());
        assert_eq!(std::fs::read_to_string(new.join("config.toml")).unwrap(), "new-content");
    }

    /// 一次性迁移:新目录空且旧目录不存在 → 不迁移(正常全新安装)
    #[test]
    fn test_migrate_global_config_skips_when_legacy_missing() {
        let tmp = test_tmp_dir("migrate-legacy-missing");
        let legacy = tmp.join("missing");
        let new = tmp.join("new");
        assert!(!migrate_global_config(&new, &[legacy]).unwrap());
        assert!(!new.exists());
    }

    /// 一次性迁移:多个候选旧目录按序取第一个有效的
    #[test]
    fn test_migrate_global_config_uses_first_legacy_with_config() {
        let tmp = test_tmp_dir("migrate-first-legacy");
        let legacy_empty = tmp.join("empty");
        let legacy_real = tmp.join("real");
        let new = tmp.join("new");
        std::fs::create_dir_all(&legacy_empty).unwrap();
        std::fs::create_dir_all(&legacy_real).unwrap();
        std::fs::write(legacy_real.join("config.toml"), "real-content").unwrap();

        assert!(migrate_global_config(&new, &[legacy_empty, legacy_real]).unwrap());
        assert_eq!(
            std::fs::read_to_string(new.join("config.toml")).unwrap(),
            "real-content"
        );
    }

    /// 测试用唯一临时目录(std 实现——Cargo.toml 无 dev-dependencies;
    /// 进程 id + 原子序号防并行测试冲突——v19 教训)
    ///
    /// clippy 在非测试视角下对 cfg(test) 模块内被测试调用的 helper 会
    /// 误报 never used(rustc dead_code 以 lib 编译单元分析);4 个迁移
    /// 测试均调用它(cargo test 全绿),非死代码。
    #[allow(dead_code)]
    fn test_tmp_dir(name: &str) -> PathBuf {
        use std::sync::atomic::{AtomicUsize, Ordering};
        static SEQ: AtomicUsize = AtomicUsize::new(0);
        std::env::temp_dir().join(format!(
            "code-repo-wiki-config-test-{}-{}-{}",
            std::process::id(),
            name,
            SEQ.fetch_add(1, Ordering::SeqCst)
        ))
    }

    /// E 组搜索链:项目级配置存在 → 返回项目级(项目级优先;v24 起为
    /// 独立文件 `config.toml`,不再混入产物目录)
    #[test]
    fn test_resolve_prefers_project_config() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_e_project_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join(PROJECT_CONFIG_FILE), "dummy").unwrap();
        let global_dir = dir.join("global");
        std::fs::create_dir_all(&global_dir).unwrap();
        std::fs::write(global_dir.join(USER_CONFIG_FILE), "dummy-global").unwrap();

        let resolved = resolve_default_config_path_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
        assert_eq!(resolved, dir.join(PROJECT_CONFIG_FILE));

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// v25:三链加载——项目级 config.toml 存在时,以用户级
    /// config.toml(缺则模板)为基,字段级合并覆盖;
    /// v30:项目级 llm/embed 键(base_url/api_key_env)完整覆盖用户级值
    #[test]
    fn test_load_default_config_project_overrides_user() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_merge_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        // 用户级:模板 + 自定义 model(v30:scope/output 等段已硬编码,模板即全量默认)
        let global_dir = dir.join("global");
        std::fs::create_dir_all(&global_dir).unwrap();
        let user_text = include_str!("../../config.toml")
            .replace("model = \"deepseek-v4-flash\"", "model = \"user-model\"");
        std::fs::write(global_dir.join(USER_CONFIG_FILE), &user_text).unwrap();

        // 项目级:写 model + api_key_env 覆盖
        std::fs::write(
            dir.join(PROJECT_CONFIG_FILE),
            r#"
[llm]
provider = "anthropic"
api_key_env = "ANTHROPIC_API_KEY"
model = "claude-test"
"#,
        )
        .unwrap();

        let (path, config) = load_default_config_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
        // 项目级路径胜出(返回项目级文件位置)
        assert_eq!(path, dir.join(PROJECT_CONFIG_FILE));
        // model 字段级覆盖生效
        assert_eq!(config.llm.model, "claude-test");
        // v30:项目级 provider/api_key_env 完整覆盖用户级(不再净化剥离)
        assert_eq!(config.llm.provider, schema::LlmProviderType::Anthropic);
        assert_eq!(config.llm.api_key_env, "ANTHROPIC_API_KEY");

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// v25:无项目级配置时,用户级存在则直接用(无合并无净化);
    /// 用户级缺失时创建(模板),绝不自动创建项目级文件。
    #[test]
    fn test_load_default_config_user_only_or_creates() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_useronly_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        // 用户级存在:直接使用
        let global_dir = dir.join("global");
        std::fs::create_dir_all(&global_dir).unwrap();
        let user_text = include_str!("../../config.toml")
            .replace("model = \"deepseek-v4-flash\"", "model = \"user-only-model\"");
        std::fs::write(global_dir.join(USER_CONFIG_FILE), &user_text).unwrap();
        let (path, config) = load_default_config_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
        assert_eq!(path, global_dir.join(USER_CONFIG_FILE));
        assert_eq!(config.llm.model, "user-only-model");
        // 项目级文件未被创建
        assert!(!dir.join(PROJECT_CONFIG_FILE).exists());

        // 用户级缺失:创建模板;项目级仍不创建
        let global2 = dir.join("global2");
        let (path2, config2) = load_default_config_with(&ProjectRoot::new(dir.clone()), &global2).unwrap();
        assert!(path2.ends_with(USER_CONFIG_FILE));
        assert!(global2.join(USER_CONFIG_FILE).exists());
        assert_eq!(config2.llm.model, "deepseek-v4-flash");
        assert!(!dir.join(PROJECT_CONFIG_FILE).exists());

        let _ = std::fs::remove_dir_all(&dir);
    }


    /// v30:项目级配置文件加载时 base_url/api_key_env 完整生效——
    /// 净化/注入规则已整体删除(端点/变量名非密钥明文,项目级可用
    /// 配置即写即用);缺失字段由 schema serde 默认兜底
    #[test]
    fn test_load_project_config_keeps_sensitive_keys() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_projcfg_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join(PROJECT_CONFIG_FILE);
        // 项目级配置声明项目契约(语言)+ 完整端点与变量名
        std::fs::write(
            &path,
            r#"
[wiki]
language = "en"

[llm]
provider = "anthropic"
model = "claude-opus"
base_url = "https://custom.example.com/v1"
api_key_env = "HACKED_KEY"
"#,
        )
        .unwrap();

        let config = load_config(&path).unwrap();
        // 项目级覆盖值全部保留(v30:不再剥离)
        assert_eq!(config.llm.provider, crate::config::schema::LlmProviderType::Anthropic);
        assert_eq!(config.llm.model, "claude-opus");
        assert_eq!(config.llm.base_url.as_deref(), Some("https://custom.example.com/v1"));
        assert_eq!(config.llm.api_key_env, "HACKED_KEY");
        // 项目契约保留
        assert_eq!(config.wiki.language, "en");
        // v30: output.dir 已硬编码,项目级不可写

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// v30:缺键由 schema serde 默认兜底——项目级配置省略
    /// base_url/api_key_env 等字段仍可加载(使用默认可用阵营)
    #[test]
    fn test_load_project_config_defaults_for_missing_keys() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_projcfg_defaults_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join(PROJECT_CONFIG_FILE);
        std::fs::write(
            &path,
            r#"
[llm]
provider = "mock"
"#,
        )
        .unwrap();

        let config = load_config(&path).unwrap();
        assert_eq!(config.llm.provider, crate::config::schema::LlmProviderType::Mock);
        // 缺失字段由 schema 字段级 serde 默认兜底(v29 可用阵营)
        assert_eq!(config.llm.base_url.as_deref(), Some("https://opencode.ai/zen/go/v1"));
        assert_eq!(config.llm.api_key_env, "OPENCODEGO2_API_KEY");
        assert_eq!(config.embed.model, "qwen3.7-text-embedding");

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 任意文件名(显式 --config)均原样加载(v30:净化/注入已整体删除,
    /// 文件名不再有语义差异);缺失字段同样由 schema serde 默认兜底
    #[test]
    fn test_load_explicit_config_keeps_sensitive_keys() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_anyname_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("my.toml");
        std::fs::write(
            &path,
            r#"
[llm]
provider = "anthropic"
model = "claude-opus"
api_key_env = "ANTHROPIC_API_KEY"
"#,
        )
        .unwrap();

        let config = load_config(&path).unwrap();
        // 用户级/显式配置完整保留敏感键
        assert_eq!(config.llm.provider, crate::config::schema::LlmProviderType::Anthropic);
        assert_eq!(config.llm.model, "claude-opus");

        let _ = std::fs::remove_dir_all(&dir);
    }
    #[test]
    fn test_resolve_falls_back_to_global() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_e_global_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let global_dir = dir.join("global");
        std::fs::create_dir_all(&global_dir).unwrap();
        std::fs::write(global_dir.join(USER_CONFIG_FILE), "dummy-global").unwrap();

        let resolved = resolve_default_config_path_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
        assert_eq!(resolved, global_dir.join(USER_CONFIG_FILE));

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// E 组搜索链:项目级与全局都缺失 → 创建全局目录 + 默认配置,返回全局路径
    #[test]
    fn test_resolve_creates_global_config_when_missing() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_e_create_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let global_dir = dir.join("global");

        let resolved = resolve_default_config_path_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
        assert_eq!(resolved, global_dir.join(USER_CONFIG_FILE));
        assert!(global_dir.join(USER_CONFIG_FILE).exists(), "缺失时应创建全局默认配置");
        // 创建的配置必须可加载(模板完整)
        assert!(load_config(&resolved).is_ok());

        // 幂等:再次解析仍返回同一路径,不重复创建
        let resolved2 = resolve_default_config_path_with(&ProjectRoot::new(dir.clone()), &global_dir).unwrap();
        assert_eq!(resolved2, resolved);

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// resolve_config_path:显式指定原样返回(不触发创建)
    #[test]
    fn test_resolve_config_path_explicit_wins() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_e_explicit_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        let explicit = dir.join("custom.toml");
        let resolved = resolve_config_path(Some(&explicit), &ProjectRoot::new(dir.clone())).unwrap();
        assert_eq!(resolved, explicit);
        // 显式指定不创建全局目录/文件
        assert!(!dir.join("global").exists());

        let _ = std::fs::remove_dir_all(&dir);
    }