Skip to main content

code_repo_wiki/
lib.rs

1pub mod config;
2pub mod model;
3pub mod ingest;
4pub mod analysis;
5pub mod generate;
6pub mod output;
7pub mod incremental;
8pub mod search;
9pub mod commands;
10pub mod fs;
11pub mod mcp;
12pub mod project;
13pub mod bench;
14pub mod doctor;
15pub mod key;
16
17use std::collections::HashMap;
18use std::path::Path;
19
20use std::sync::{Arc, OnceLock};
21use tokio::runtime::Runtime;
22
23use anyhow::{bail, Context};
24
25/// 仓库分析结果,完整流水线的输出
26pub struct AnalysisResult {
27    pub graph: model::KnowledgeGraph,
28    pub documents: Vec<model::WikiDocument>,
29    pub cards: Vec<model::KnowledgeCard>,
30    pub stats: AnalysisStats,
31}
32
33/// 分析统计信息
34#[derive(Debug, Clone, Default)]
35pub struct AnalysisStats {
36    pub files_scanned: usize,
37    pub files_parsed: usize,
38    /// 扫描范围内解析失败的文件数(非 UTF-8 / tree-sitter 解析错误;
39    /// B5 失败可观测——此前失败文件仅在日志出现,统计无法反映)
40    pub files_failed: usize,
41    pub total_entities: usize,
42    pub total_edges: usize,
43    pub modules_detected: usize,
44    pub generation_time_ms: u64,
45    /// 本次生成失败被隔离的模块名(卡片或页面生成失败,v22 修复):
46    /// 写入生成状态供下次 update 补偿重试;也供调用方(doctor/报告)观测
47    pub failed_modules: Vec<String>,
48}
49
50/// 全局 tokio 运行时(流水线与 MCP server 共用,避免重复初始化)
51pub fn get_global_runtime() -> &'static Arc<Runtime> {
52    static RT: OnceLock<Arc<Runtime>> = OnceLock::new();
53    RT.get_or_init(|| Arc::new(Runtime::new().expect("创建 tokio Runtime 失败")))
54}
55
56/// 加载配置,并用 CLI 传入的 output 路径覆盖配置文件中的 output.dir
57///
58/// output.dir 是相对路径(默认 .code-repo-wiki),覆盖后渲染、搜索索引、状态目录
59/// 等所有下游引用自然指向新目录。
60/// 加载配置并统一注入输出目录(v30:output.dir 已硬编码,运行时注入
61/// `--output` 覆盖或 root 化绝对路径,见 schema::WikiConfig::output_dir)。
62fn load_config_with_output(
63    config_path: Option<&Path>,
64    output: Option<&Path>,
65    root: &project::ProjectRoot,
66) -> anyhow::Result<config::schema::WikiConfig> {
67    // v25:None 走默认配置链(项目级 config.toml 字段级合并覆盖用户级
68    // config.toml,见 config::load_default_config);Some 为显式
69    // --config 单文件原样加载
70    let mut config = match config_path {
71        Some(p) => config::load_config(p)?,
72        None => config::load_default_config(root)?.1,
73    };
74    // root 统一(v17 F 组,t09 实测发现):输出目录默认相对路径
75    // (.code-repo-wiki)时必须解析到 root,否则 --root 场景(cwd ≠ root)
76    // 产物写到进程 cwd 错位。--output 覆盖与 root 化都注入运行时字段,
77    // 下游统一走 config.output_dir()(见 schema.rs 注释)。
78    let output_dir = match output {
79        Some(out) => root.path().join(out),
80        None => root.path().join(crate::config::schema::OUTPUT_DIR),
81    };
82    config.output_dir = Some(output_dir);
83    Ok(config)
84}
85
86/// 加载配置并统一解析 output.dir 相对路径到 root(main.rs 各命令入口用;
87/// 与 run_pipeline 内部的 load_config_with_output 同源,保证 CLI 层与
88/// pipeline 层对产物目录的解析一致)
89pub fn load_config_rooted(
90    config_path: Option<&Path>,
91    root: &project::ProjectRoot,
92) -> anyhow::Result<config::schema::WikiConfig> {
93    load_config_with_output(config_path, None, root)
94}
95
96/// 加载保护集:旧 state 的 protected_docs + 新检测出的人工修改;force 时清空
97///
98/// 损坏语义(票 02,fail-loud 裁决):state 文件不存在 = 合法首次运行
99/// (返回空保护);存在但读取/解析失败 = 状态损坏(半截写、被外部
100/// 改动)——损坏状态会让 protected_docs 静默丢失,人工修改保护失效,
101/// 故显式报错中断(与 sync_from_git 对损坏状态的拒绝行为一致),
102/// 由用户删除 .state/ 后重新 generate 重建。
103fn load_protection(
104    config: &config::schema::WikiConfig,
105    force: bool,
106) -> anyhow::Result<(std::collections::HashSet<String>, Option<incremental::state::GenerationState>)> {
107    if force {
108        return Ok((std::collections::HashSet::new(), None));
109    }
110    let state_dir = config.output_dir().join(".state");
111    let state_path = state_dir.join("generation_state.json");
112    if !state_path.exists() {
113        // 无状态文件 = 从未生成过,合法空保护
114        return Ok((std::collections::HashSet::new(), None));
115    }
116    let state = incremental::state::GenerationState::load(&state_dir).with_context(|| {
117        format!(
118            "状态文件损坏或不可读: {}(删除该文件后重新运行 generate 可重建)",
119            state_path.display()
120        )
121    })?;
122    let mut protected: std::collections::HashSet<String> = state
123        .protected_docs
124        .iter()
125        .cloned()
126        .collect();
127    for p in state.detect_manually_modified() {
128        protected.insert(p);
129    }
130    Ok((protected, Some(state)))
131}
132
133/// 保存生成状态:doc_fingerprints 只记录实际写盘的文档(跳过保护集),
134/// protected_docs 合并本次保护集写回;failed_modules 记录本次失败隔离的
135/// 模块(v22:下次 update 并入变更集重试,防止失败模块永远无法补生成)
136///
137/// 8 个参数均为不同来源的独立输入(无共享结构可归并),与
138/// generate_global_documents 同一例外模式,保留平铺参数。
139#[allow(clippy::too_many_arguments)]
140fn save_generation_state(
141    root: &project::ProjectRoot,
142    config: &config::schema::WikiConfig,
143    insights: &[ingest::parser::FileInsight],
144    documents: &[model::WikiDocument],
145    cards: &[model::KnowledgeCard],
146    protected: &std::collections::HashSet<String>,
147    commit_hash: &str,
148    failed_modules: &[String],
149) {
150    let output_dir = config.output_dir();
151    let state_dir = output_dir.join(".state");
152    // t02/P1-2:三处落盘失败全部告警(此前静默——状态写失败会导致下次 update
153    // 无指纹基线,人工修改保护与反向同步**静默失效**,与模块头"不静默丢失保护"
154    // 的目标矛盾;与 incremental/mod.rs 前置保存的 warn 处理对齐)。
155    match incremental::state::GenerationState::from_insights(root, insights, commit_hash) {
156        Ok(mut state) => {
157            state.failed_modules = failed_modules.to_vec();
158            let mut protected_docs: Vec<String> = protected.iter().cloned().collect();
159            protected_docs.sort();
160            state.protected_docs = protected_docs;
161            match incremental::state::GenerationState::record_doc_fingerprints(
162                documents,
163                cards,
164                output_dir,
165                &output::wiki_languages(config),
166            ) {
167                Ok((fps, modules)) => {
168                    // 全量记录指纹与模块归属(含保护集文档):受保护文档本轮被跳过
169                    // 写盘,磁盘上仍是人工版,记录的即人工版指纹——下次再被人为修改
170                    // 时指纹比对仍能命中检测,反向同步可持续生效;卡片侧的记录注入
171                    // 自带去重(contains 检查),同一修改不会重复同步。
172                    state.doc_fingerprints = fps;
173                    state.doc_modules = modules;
174                }
175                Err(e) => tracing::warn!(
176                    "产物指纹记录失败(下次 update 人工修改检测可能失效): {e}"
177                ),
178            }
179            if let Err(e) = state.save(&state_dir) {
180                tracing::warn!("生成状态保存失败(下次 update 无指纹基线,人工修改保护失效): {e}");
181            }
182        }
183        Err(e) => tracing::warn!("生成状态构造失败(本次状态未落盘): {e}"),
184    }
185}
186
187/// 流水线进度事件(供 CLI --progress-json 与插件进度展示使用)
188#[derive(Debug, Clone, Copy)]
189pub struct ProgressEvent {
190    /// 阶段名:scanning/analyzing/chunking/cards/wiki/output/index/done
191    pub stage: &'static str,
192    /// 进度百分比(0-100)
193    pub percent: u8,
194}
195
196/// 生成模式(票 12:双流水线合并为单入口的 mode 区分)
197///
198/// - `Full`:全量扫描解析 + 全量 LLM 生成 + 全量索引重建(generate 命令)
199/// - `Incremental`:parse 层增量(解析缓存)+ 过滤生成 + 增量索引(update 命令)
200#[derive(Debug, Clone)]
201pub enum GenerationMode {
202    Full,
203    Incremental {
204        /// 外部监听事件路径(watch 传入;普通增量更新传空)
205        watch_paths: Vec<std::path::PathBuf>,
206        /// 监听事件携带的变更类型(Deleted 直入删除清理)
207        change_kind: Option<incremental::watch::ChangeKind>,
208    },
209}
210
211/// 运行完整的分析流水线(配置文件路径)
212///
213/// `output` 非空时覆盖配置文件中的 output.dir(对应 CLI 的 --output 参数),
214/// 后续渲染、搜索索引、状态目录全部使用覆盖后的值。
215/// `force` 为 true 时清空人工修改保护集并覆盖所有文档(对应 CLI 的 --force)。
216/// `root` 为项目根(扫描根 + git 定位 + watch 根的注入载体,--root 参数)
217/// `mode` 区分全量生成与增量更新(两者共享本函数的主干,差异点在
218/// 扫描缓存、变更分析、生成过滤、索引更新四处)。
219pub fn run_pipeline(
220    config_path: Option<&Path>,
221    output: Option<&Path>,
222    force: bool,
223    root: &project::ProjectRoot,
224    mode: &GenerationMode,
225) -> anyhow::Result<AnalysisResult> {
226    run_pipeline_with_progress(config_path, output, force, root, mode, &|_| {})
227}
228
229/// 生成流水线分段计时(v32 8.1 FR-301 数据驱动剖析)
230///
231/// 各段毫秒:扫描/解析、图构建、增量分析、分块、卡片生成、Wiki 页生成、
232/// 阅读指南、渲染写盘、搜索索引、状态保存。由 run_pipeline_with_progress
233/// 收集(chunk/card/wiki 三段的内部值来自 generate::GenerationOutput.timings),
234/// 完成时写入 .state/last_timings.json 供 bench 回放后读取——评测可定位
235/// 大仓各阶段瓶颈(cal.com mock 372s 先例)。.state 非产物页,写入不影响
236/// test_determinism 内容级哈希;serde 全默认,文件缺失/损坏按 None 处理。
237#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
238#[serde(default)]
239pub struct GenerationTimings {
240    pub scan_parse_ms: u64,
241    pub graph_ms: u64,
242    pub incremental_ms: u64,
243    pub chunk_ms: u64,
244    pub card_ms: u64,
245    pub wiki_ms: u64,
246    pub index_guide_ms: u64,
247    pub render_ms: u64,
248    pub index_ms: u64,
249    pub state_ms: u64,
250    pub total_ms: u64,
251}
252
253/// 写入最近一次生成的分段计时(v32 8.1)
254///
255/// bench 的 measure_update_recall 回放生成后读取该文件以获得各段耗时。
256/// 写失败只告警(计时是诊断信息,不阻断主流程)。
257pub(crate) fn write_last_timings(config: &config::schema::WikiConfig, timings: &GenerationTimings) {
258    let state_dir = config.output_dir().join(".state");
259    if let Err(e) = std::fs::create_dir_all(&state_dir) {
260        tracing::warn!("分段计时目录创建失败: {e}");
261        return;
262    }
263    let path = state_dir.join("last_timings.json");
264    match serde_json::to_string_pretty(timings) {
265        Ok(text) => {
266            if let Err(e) = std::fs::write(&path, text) {
267                tracing::warn!("分段计时写盘失败: {e}");
268            }
269        }
270        Err(e) => tracing::warn!("分段计时序列化失败: {e}"),
271    }
272}
273
274/// 运行完整的分析流水线,并在各阶段边界回调进度事件
275///
276/// 事件点:scanning 10 / analyzing 25 / chunking 30 / cards 60 / wiki 90 /
277/// output 95 / index 98 / done 100,对应扫描、分析、生成、渲染、索引、保存阶段。
278pub fn run_pipeline_with_progress(
279    config_path: Option<&Path>,
280    output: Option<&Path>,
281    force: bool,
282    root: &project::ProjectRoot,
283    mode: &GenerationMode,
284    on_progress: &dyn Fn(ProgressEvent),
285) -> anyhow::Result<AnalysisResult> {
286    let config = load_config_with_output(config_path, output, root)?;
287    // v36 D4:单实例运行锁——并发 generate/update/watch 会把状态/索引/
288    // 产物互相覆盖(最后写入者胜)。锁作用域=本次生成全程(Drop 释放),
289    // 崩溃残留由报错指引人工删除(不自动清,见 fs.rs acquire_run_lock)。
290    let _run_lock = crate::fs::acquire_run_lock(&config)?;
291    let _span = tracing::info_span!("pipeline", config = %config_path.map(|p| p.display().to_string()).unwrap_or_else(|| "默认链".into()));
292    let _enter = _span.enter();
293    let start = std::time::Instant::now();
294    // v32 8.1:分段计时收集(各阶段边界打点;chunk/card/wiki 内部段在
295    // generate 侧计时,见 GenerationOutput.timings)
296    let mut timings = GenerationTimings::default();
297    let mut is_incremental = matches!(mode, GenerationMode::Incremental { .. });
298    // U06/D11:force 语义补全——force 时无论增量/全量都按全量重生成。
299    // 旧实现 force 只清保护集,增量仍按 diff 过滤生成,未变更的文档
300    // 不会被重生成,"force 覆盖所有文档"(本函数顶部注释)名不副实。
301    if force && is_incremental {
302        tracing::info!("--force 与增量模式同时使用:退化为全量重生成");
303        is_incremental = false;
304    }
305
306    // 保护集:旧 state 的 protected_docs + 检测出的人工修改;force 时清空。
307    // old_state 同时供人工修改反向同步组装(collect_manual_edits → 生成前注入)
308    let (protected, old_state) = load_protection(&config, force)?;
309
310    // v19 t06:no-op 快速跳过(OpenWiki git-head 模式)——增量模式且上次
311    // 成功生成到同一 commit 且源码工作树无未提交变更(产物目录除外)且
312    // 产物存在时,在扫描之前直接跳过(定时 CI/watch 免费空转;判定细节
313    // 与保守边界见 incremental::should_skip_noop 注释)。与下方"无代码
314    // 变更短路"同一出口:人工修改反向同步照常执行。
315    if is_incremental && incremental::should_skip_noop(root, &config)? {
316        tracing::info!("无文件变更,跳过更新(no-op 快速判定)");
317        if let Some(state) = &old_state {
318            let synced = sync_manual_edits_to_cards(&config, state)?;
319            if synced > 0 {
320                tracing::info!("人工修改已反向同步到 {} 张卡片", synced);
321            }
322        }
323        let stats = AnalysisStats {
324            files_scanned: 0,
325            generation_time_ms: start.elapsed().as_millis() as u64,
326            ..Default::default()
327        };
328        return Ok(AnalysisResult {
329            graph: model::KnowledgeGraph::default(),
330            documents: Vec::new(),
331            cards: Vec::new(),
332            stats,
333        });
334    }
335
336    // Phase 1: 扫描。增量模式启用解析缓存(parse 层增量:内容指纹未变复用
337    // 缓存结果,仅变更文件重新 tree-sitter 解析);全量模式直接全量解析。
338    let watch_list: Vec<std::path::PathBuf> = match mode {
339        GenerationMode::Incremental { watch_paths, .. } => watch_paths.clone(),
340        GenerationMode::Full => Vec::new(),
341    };
342    // 事件路径统一相对化(相对项目根):scan 产出的 insight 路径已是相对
343    // 扫描根,watch 层外部传入的路径必须对齐同一基准,否则路径比较
344    // (缓存判定/变更集判定)对绝对路径恒不命中。
345    let watch_paths: Vec<std::path::PathBuf> = watch_list
346        .iter()
347        .map(|p| p.strip_prefix(root.path()).map(|r| r.to_path_buf()).unwrap_or_else(|_| p.clone()))
348        .collect();
349    let watch_set: std::collections::HashSet<std::path::PathBuf> =
350        watch_paths.iter().cloned().collect();
351    let scan = if is_incremental {
352        let cache_path = config.output_dir().join(".state").join("insights_cache.json");
353        ingest::scan_and_parse_cached_at(root, &Some(cache_path), &watch_set)?
354    } else {
355        ingest::scan_and_parse_at(root)?
356    };
357    let file_insights = scan.insights;
358    let files_failed = scan.files_failed;
359    timings.scan_parse_ms = start.elapsed().as_millis() as u64;
360    on_progress(ProgressEvent { stage: "scanning", percent: 10 });
361    if file_insights.is_empty() {
362        bail!("未找到任何源文件");
363    }
364    let mut stats = AnalysisStats {
365        files_scanned: file_insights.len(),
366        files_parsed: file_insights.iter().filter(|f| !f.entities.is_empty()).count(),
367        files_failed,
368        ..Default::default()
369    };
370
371    // Phase 2: 分析(build_graph 内部完成模块检测并写回 graph.modules,
372    // 此处直接读结果供 stats,不重复运行检测)
373    let mut graph = analysis::build_graph(&file_insights)?;
374    attach_features(&mut graph, &config);
375    timings.graph_ms = start.elapsed().as_millis() as u64 - timings.scan_parse_ms;
376    on_progress(ProgressEvent { stage: "analyzing", percent: 25 });
377    stats.total_entities = graph.graph.node_count();
378    stats.total_edges = graph.graph.edge_count();
379    stats.modules_detected = graph.modules.len();
380
381    // Phase 2b: 增量变更分析(git diff + 实体级变化分类 + 语义传播;
382    // 全量模式跳过。diff 超限/非 git 仓库时内部回退全量语义)
383    let inc_result = if is_incremental {
384        Some(incremental::run_incremental_update_at(root, &file_insights, &graph, &config, &watch_paths)?)
385    } else {
386        None
387    };
388    timings.incremental_ms = start.elapsed().as_millis() as u64
389        - timings.scan_parse_ms
390        - timings.graph_ms;
391
392    // 无代码变更短路:仅增量模式存在;此时若有新检测的人工修改仍需
393    // 反向同步到卡片文件(生成路径跳过时此处的直接写盘是唯一落卡途径,
394    // 记录在下次有变更的生成时经 extract_pending_manual_edits 注入 LLM 输入)
395    if let Some(inc) = &inc_result
396        && inc.changed_files.is_empty()
397    {
398        if let Some(state) = &old_state {
399            let synced = sync_manual_edits_to_cards(&config, state)?;
400            if synced > 0 {
401                tracing::info!("人工修改已反向同步到 {} 张卡片", synced);
402            }
403        }
404        tracing::info!("无变更,跳过生成");
405        let stats = AnalysisStats {
406            files_scanned: file_insights.len(),
407            generation_time_ms: start.elapsed().as_millis() as u64,
408            ..Default::default()
409        };
410        return Ok(AnalysisResult {
411            graph,
412            documents: Vec::new(),
413            cards: Vec::new(),
414            stats,
415        });
416    }
417
418    // Phase 3: 生成(需要 tokio 运行时)。人工修改记录在生成前注入
419    // LLM 输入(collect_manual_edits:旧状态指纹比对 + 模块归属精确匹配)。
420    // 增量模式只对变更文件 + 语义传播判定的受影响模块过滤生成
421    //(run_generation_filtered),全量模式全量生成。
422    on_progress(ProgressEvent { stage: "chunking", percent: 30 });
423    let rt = get_global_runtime();
424    let extra_edits = collect_manual_edits(old_state.as_ref());
425    let mut gen_output = if let Some(inc) = &inc_result {
426        rt.block_on(generate::run_generation_filtered(
427            &graph, &file_insights, &config, root, inc, &extra_edits,
428        ))?
429    } else {
430        rt.block_on(generate::run_generation(&graph, &file_insights, &config, root, &extra_edits))?
431    };
432    on_progress(ProgressEvent { stage: "cards", percent: 60 });
433    // v32 8.1:generate 侧内部段(chunk/card/wiki)合并进总计时
434    timings.chunk_ms = gen_output.timings.chunk_ms;
435    timings.card_ms = gen_output.timings.card_ms;
436    timings.wiki_ms = gen_output.timings.wiki_ms;
437
438    // Phase 3b: 阅读指南 index.md(仅主语言,写盘路径 wiki/{主语言}/index.md)。
439    // LLM 失败重试 1 次仍失败 → 降级确定性骨架(模块入度中心度降序的链接列表);
440    // provider 构建失败(理论不可达:run_generation 已保证 LLM 配置可用)同样降级。
441    // 错误处理与全局文档(架构/概览)一致:失败只告警,不中断主流程。
442    //
443    // U04/D8 增量门控:受影响模块为空(纯实现级变更)时 index 内容(模块列表
444    // + 描述)不会变化,从导出快照回填旧 index(零 LLM 调用),与架构/概览的
445    // backfill 语义一致;快照不可用(首次增量/损坏)时回退正常生成。
446    // v21 验证轮:含已删除文件时**不放行**回填——纯删除场景 index 必须
447    // 重生成,否则模块列表继续列出已删模块(与架构/概览的 has_deleted_files
448    // 例外同一语义)。
449    let gated = if is_incremental
450        && inc_result
451            .as_ref()
452            .is_some_and(|i| i.affected_modules.is_empty() && !i.has_deleted_files)
453    {
454        generate::backfill_global_docs(
455            &config,
456            &mut gen_output.documents,
457            &[crate::model::DocumentKind::TableOfContents],
458        )
459    } else {
460        false
461    };
462    if !gated {
463        let index_doc = match generate::create_provider(&config) {
464            Ok(provider) => rt.block_on(generate::index::generate_index_guide(
465                &provider,
466                &graph,
467                &gen_output.cards,
468                &config,
469            )),
470            Err(e) => {
471                tracing::warn!("阅读指南 LLM 不可用,降级为确定性骨架: {e}");
472                generate::index::fallback_index_guide(&graph, &config)
473            }
474        };
475        gen_output.documents.push(index_doc);
476    }
477    timings.index_guide_ms = start.elapsed().as_millis() as u64
478        - timings.scan_parse_ms
479        - timings.graph_ms
480        - timings.incremental_ms
481        - timings.chunk_ms
482        - timings.card_ms
483        - timings.wiki_ms;
484
485    // v17 t06:mock 模式告警——占位内容页脚标注(产物可辨识,防误读为
486    // 真实文档)。mock 产物的页面内容是占位 JSON(MockProvider 固定返回),
487    // 生成层追加页脚(render 层保持纯渲染不感知 provider 类型;合成页
488    // api.md 的注入在 render_all 内,共用 MOCK_FOOTER_MARK 单一来源)。
489    if matches!(
490        config.llm.provider,
491        crate::config::schema::LlmProviderType::Mock
492    ) {
493        tracing::warn!("使用 mock provider:产物为占位内容,非真实文档(仅供测试/CI 演示)");
494        for doc in &mut gen_output.documents {
495            // 幂等追加:纯删除场景(增量快照回填)的旧文档已含页脚,
496            // 重复注入会产出双页脚——已以页脚结尾的跳过(v21 F 组实测)
497            if !doc.content.ends_with(crate::output::MOCK_FOOTER_MARK) {
498                doc.content.push_str(crate::output::MOCK_FOOTER_MARK);
499            }
500        }
501    }
502
503    // Phase 4: 输出(render_all 内部同步写导出快照;产物集合 diff 清理
504    // 全量/增量统一:旧状态记录过但本次未生成的产物(含已删模块的
505    // 旧页面/卡片)一律清理,module_{n} 档不再漏删)
506    on_progress(ProgressEvent { stage: "wiki", percent: 90 });
507    output::render_all(&gen_output.documents, &gen_output.cards, &graph, &config, &protected)?;
508    // 保留集 = 当前扫描的全部模块(graph.modules 基于全部 insights 检测,
509    // 含增量未受影响的模块):增量只重新生成受影响模块,未受影响模块的
510    // 旧页面须保留(v17 F 组,t09 实测修复——误删会制造断链)
511    let preserved_modules: std::collections::HashSet<String> = graph
512        .modules
513        .iter()
514        .map(|m| m.name.clone())
515        .collect();
516    cleanup_stale_outputs(
517        old_state.as_ref(),
518        &output::rendered_paths(&gen_output.documents, &gen_output.cards, &config),
519        &preserved_modules,
520    );
521    timings.render_ms = start.elapsed().as_millis() as u64
522        - timings.scan_parse_ms
523        - timings.graph_ms
524        - timings.incremental_ms
525        - timings.chunk_ms
526        - timings.card_ms
527        - timings.wiki_ms
528        - timings.index_guide_ms;
529    on_progress(ProgressEvent { stage: "output", percent: 95 });
530
531    // Phase 5: 构建/增量更新搜索索引
532    let index_result = if is_incremental {
533        let changed_set: std::collections::HashSet<std::path::PathBuf> = inc_result
534            .as_ref()
535            .map(|i| i.changed_files.iter().cloned().collect())
536            .unwrap_or_default();
537        update_search_index_incremental(&graph, &file_insights, &config, &changed_set)
538    } else {
539        build_search_index(&graph, &file_insights, &config)
540    };
541    if let Err(e) = index_result {
542        tracing::warn!("搜索索引构建失败(不影响主流程): {}", e);
543    }
544    timings.index_ms = start.elapsed().as_millis() as u64
545        - timings.scan_parse_ms
546        - timings.graph_ms
547        - timings.incremental_ms
548        - timings.chunk_ms
549        - timings.card_ms
550        - timings.wiki_ms
551        - timings.index_guide_ms
552        - timings.render_ms;
553    on_progress(ProgressEvent { stage: "index", percent: 98 });
554
555    // Phase 6: 保存增量状态(含文档指纹用于人工修改保护)
556    // A3(v14):git 基线获取失败显式区分——非 git 仓库(info:预期
557    // 场景,无基线则状态不推进、下次 update 回退全量)与 git 仓库内
558    // 失败(warn:仓库损坏/无 HEAD/HEAD 无目标等)。此前 unwrap_or_default
559    // 把两者混为一谈静默吞掉,git 命令失败时用户无从知晓状态为何不推进。
560    let head_hash = match incremental::diff::get_head_commit_hash_at(root) {
561        Ok(h) => h,
562        Err(e) => {
563            if e.downcast_ref::<git2::Error>()
564                .map(|g| g.code() == git2::ErrorCode::NotFound)
565                .unwrap_or(false)
566            {
567                tracing::info!("非 git 仓库,无 git 基线(增量状态不推进): {}", e);
568            } else {
569                tracing::warn!("获取 git HEAD 失败(增量状态不推进): {}", e);
570            }
571            String::new()
572        }
573    };
574    save_generation_state(root, &config, &file_insights, &gen_output.documents, &gen_output.cards, &protected, &head_hash, &gen_output.generation_stats.failed_modules);
575
576    timings.state_ms = start.elapsed().as_millis() as u64
577        - timings.scan_parse_ms
578        - timings.graph_ms
579        - timings.incremental_ms
580        - timings.chunk_ms
581        - timings.card_ms
582        - timings.wiki_ms
583        - timings.index_guide_ms
584        - timings.render_ms
585        - timings.index_ms;
586    timings.total_ms = start.elapsed().as_millis() as u64;
587    write_last_timings(&config, &timings);
588
589    on_progress(ProgressEvent { stage: "done", percent: 100 });
590    stats.generation_time_ms = start.elapsed().as_millis() as u64;
591    // 展示用统计(失败模块真源在 generation_stats;save 调用已直接使用
592    // generation_stats.failed_modules——顺序修复:此前在此处才赋值,晚于
593    // Phase 6 的 save_generation_state,导致失败模块恒为空数组落盘,
594    // v22 补偿机制对全量 generate 的失败静默失效(v23 C 组实测发现))
595    stats.failed_modules = gen_output.generation_stats.failed_modules.clone();
596    tracing::info!("流水线完成: {} 个文件, {} 个实体, {} 条边, {} 个模块, 耗时 {}ms",
597        stats.files_scanned, stats.total_entities, stats.total_edges,
598        stats.modules_detected, stats.generation_time_ms);
599
600    Ok(AnalysisResult {
601        graph,
602        documents: gen_output.documents,
603        cards: gen_output.cards,
604        stats,
605    })
606}
607
608/// 知识卡片操作(CLI card 子命令与 Qoder /knowledge 对等)
609pub fn run_card_command(
610    config_path: Option<&Path>,
611    root: &project::ProjectRoot,
612    action: &generate::card::CardAction,
613) -> anyhow::Result<()> {
614    let config = load_config_with_output(config_path, None, root)?;
615    // 编辑类动作要求卡片已存在:先校验(错误信息优先于 LLM API Key 检查)
616    match action {
617        generate::card::CardAction::Generate { .. } => {}
618        generate::card::CardAction::Modify { module, .. }
619        | generate::card::CardAction::Supplement { module, .. }
620        | generate::card::CardAction::Rewrite { module, .. } => {
621            if generate::card::read_card(&config, module)?.is_none() {
622                anyhow::bail!("模块 {module} 的卡片不存在,请先运行 `code-repo-wiki generate` 或 `code-repo-wiki card generate {module}` 生成");
623            }
624        }
625    }
626    let provider = generate::create_provider(&config)?;
627    let rt = get_global_runtime();
628    match action {
629        generate::card::CardAction::Generate { module } => {
630            rt.block_on(generate::card::generate_module_card(&provider, &config, root, module))
631        }
632        generate::card::CardAction::Modify { module, instruction, references } => {
633            rt.block_on(generate::card::edit_card(
634                &provider, &config, module, instruction, references,
635                generate::card::CardEditMode::Modify,
636            ))
637        }
638        generate::card::CardAction::Supplement { module, instruction, references } => {
639            rt.block_on(generate::card::edit_card(
640                &provider, &config, module, instruction, references,
641                generate::card::CardEditMode::Supplement,
642            ))
643        }
644        generate::card::CardAction::Rewrite { module, instruction, references } => {
645            rt.block_on(generate::card::edit_card(
646                &provider, &config, module, instruction, references,
647                generate::card::CardEditMode::Rewrite,
648            ))
649        }
650    }
651}
652
653
654/// 清理过期产物(票 10:产物集合 diff 语义,全量/增量统一)
655///
656/// 语义:状态中记录过的旧产物路径(doc_fingerprints/doc_modules 键,即
657/// 上次生成写盘的 wiki 页与卡片全集)减去本次实际生成的产物集合
658/// (output::rendered_paths:含受保护文档路径——受保护文档属于生成集,
659/// 磁盘上是人工版,diff 后天然不在待删集合,不会误删人工编辑内容)。
660/// 差集 = 已消失模块/重命名模块的旧产物,一律删除。
661///
662/// 与旧实现(cleanup_deleted_outputs 按被删文件路径推导模块名)相比:
663/// 不依赖模块名路径推导,module_{n}(无目录社区)档不再漏删;全量
664/// generate 也清理旧产物(旧实现仅增量路径调用)。
665///
666/// 删除失败显式告警(文件被占用等),不静默吞错。
667pub(crate) fn cleanup_stale_outputs(
668    old_state: Option<&incremental::state::GenerationState>,
669    rendered: &[std::path::PathBuf],
670    preserved_modules: &std::collections::HashSet<String>,
671) {
672    let Some(state) = old_state else {
673        return; // 无旧状态(首次生成):不存在可清理的旧产物
674    };
675    let mut stale: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
676    stale.extend(state.doc_fingerprints.keys().map(String::as_str));
677    stale.extend(state.doc_modules.keys().map(String::as_str));
678    let rendered_set: std::collections::BTreeSet<String> = rendered
679        .iter()
680        .map(|p| p.to_string_lossy().to_string())
681        .collect();
682    let mut removed = 0usize;
683    for path in stale {
684        if rendered_set.contains(path) {
685            continue;
686        }
687        // v17 F 组(t09 实测):root 统一(output.dir 绝对化)后,旧状态
688        // 键可能仍是相对路径(迁移前的生成记录)——相对键无法与绝对
689        // rendered 集可靠比较(旧 cwd 已不可考),保守保留,避免把合成页
690        // 等无模块归属的产物误删(实测:api/architecture/index/overview
691        // 四页被误删)。一次全量生成后状态键全部更新为绝对,后续增量
692        // 的清理语义恢复正常(收敛点明确,非兜底)。
693        if Path::new(path).is_relative() {
694            continue;
695        }
696        // v17 F 组(t09 实测修复):增量模式下本次只重新生成受影响模块,
697        // 未受影响模块的旧页面是**有效产物**(源码仍在),不能当过期
698        // 清理——否则引用它的页面断链(实测:src_fs.md 被清理后 6 页
699        // broken)。判据:该页面归属的模块仍在当前扫描结果中(preserved
700        // 集合来自 graph.modules——基于全部 insights 的模块检测,未受
701        // 影响模块也在内)→ 保留;模块已从扫描消失(源文件删除)→ 清理。
702        if state
703            .doc_modules
704            .get(path)
705            .is_some_and(|m| preserved_modules.contains(m))
706        {
707            continue;
708        }
709        let p = Path::new(path);
710        if p.exists() {
711            match std::fs::remove_file(p) {
712                Ok(()) => removed += 1,
713                Err(e) => tracing::warn!("清理过期产物失败 {}: {}", p.display(), e),
714            }
715        }
716    }
717    if removed > 0 {
718        tracing::info!("清理过期产物 {} 个", removed);
719    }
720}
721
722/// 组装"人工修改 → 卡片记录"映射(模块名 → 记录文本列表)
723///
724/// 官方语义:"人工修改反向同步到对应知识卡片"——被人工编辑过的页面不
725/// 被自动更新覆盖,且修改被记录到卡片,下次生成时作为 LLM 输入提示。
726///
727/// 来源 = 状态中指纹不匹配的产物路径(detect_manually_modified)+ 其模块
728/// 归属(doc_modules 精确映射:产物路径 → 模块名)。精确匹配杜绝了旧实现
729/// stem 匹配在模块名含下划线时(src::foo_bar vs src::foo::bar 均压平为
730/// src_foo_bar)的串卡片歧义;无模块归属的全局文档(api/overview/toc)跳过。
731/// 记录在生成层(CardGenerator::generate_all_cards)于 LLM 输入前合并注入。
732pub fn collect_manual_edits(
733    state: Option<&incremental::state::GenerationState>,
734) -> HashMap<String, Vec<String>> {
735    let mut out: HashMap<String, Vec<String>> = HashMap::new();
736    let Some(state) = state else { return out };
737    for path in state.detect_manually_modified() {
738        let Some(module) = state.doc_modules.get(&path) else {
739            continue;
740        };
741        let summary = std::fs::read_to_string(&path)
742            .map(|content| content.chars().take(200).collect::<String>())
743            .unwrap_or_default();
744        let note = format!("人工修改待同步: {path} 内容摘要: {summary}");
745        out.entry(module.clone()).or_default().push(note);
746    }
747    out
748}
749
750/// 将检测到的人工修改记录直接同步到磁盘卡片(无代码变更时的反向同步路径)
751///
752/// 生成路径(有代码变更)由 CardGenerator 在 LLM 输入前注入记录并随卡片
753/// 重写落盘;本函数覆盖"无代码变更但有人工修改"的场景——update 因
754/// changed_files 为空而跳过生成时,人工修改记录也必须落到卡片文件:
755/// 读现有卡片文本 → 合并记录(去重,含已有"人工修改待同步"节时在节内
756/// 追加,否则在文件末尾新建节)→ 重写。记录下次生成时经
757/// extract_pending_manual_edits 恢复为 LLM 输入,两条路径最终都收敛于
758/// 卡片文件,保证反向同步语义在任何更新形态下都不丢。
759pub fn sync_manual_edits_to_cards(
760    config: &config::schema::WikiConfig,
761    state: &incremental::state::GenerationState,
762) -> anyhow::Result<usize> {
763    let edits = collect_manual_edits(Some(state));
764    if edits.is_empty() {
765        return Ok(0);
766    }
767    let mut synced = 0usize;
768    for (module, notes) in &edits {
769        let card_path =
770            output::card_page_path(config.output_dir(), &config.wiki.language, module);
771        // 卡片读取失败(含不存在/损坏/权限)显式告警并跳过该卡片——
772        // 原实现 unwrap_or_default 会把"读不到"当作"空卡片",随后追加
773        // 人工修改节写盘,凭空重建被删除的卡片,且吞掉损坏错误。
774        let mut content = match std::fs::read_to_string(&card_path) {
775            Ok(c) => c,
776            Err(e) => {
777                tracing::warn!("读取卡片失败,跳过人工修改反向同步 {}: {}", card_path.display(), e);
778                continue;
779            }
780        };
781        let mut changed = false;
782        for note in notes {
783            if content.contains(note.as_str()) {
784                continue;
785            }
786            changed = true;
787            if let Some(section) = content.find("## 人工修改待同步") {
788                // 节内追加:定位节后第一个空白行(节标题与列表之间)
789                let insert_at = content[section..]
790                    .find("\n\n")
791                    .map(|i| section + i + 2)
792                    .unwrap_or(content.len());
793                content.insert_str(insert_at, &format!("- {note}\n"));
794            } else {
795                content.push_str(&format!("\n## 人工修改待同步\n\n- {note}\n"));
796            }
797        }
798        if changed {
799            crate::fs::write_file_atomic(&card_path, &content)?;
800            synced += 1;
801        }
802    }
803    Ok(synced)
804}
805
806/// 启动文件监听模式
807///
808/// `root` 为注入的项目根:首次全量生成与监听根均以它为基准
809/// (扫描根一致,watch 常驻进程的 cwd 漂移不影响监听范围)。
810pub fn run_watch(config_path: Option<&Path>, root: &project::ProjectRoot) -> anyhow::Result<()> {
811    // 配置在此 fail-fast 校验(无效配置提前报错);监听循环本身不再读取配置
812    let _config = match config_path {
813        Some(p) => config::load_config(p)?,
814        None => config::load_default_config(root)?.1,
815    };
816    tracing::info!("首次全量生成...");
817    run_pipeline(config_path, None, false, root, &GenerationMode::Full)?;
818    tracing::info!("全量生成完成,开始监听文件变化...");
819
820    let config_path = config_path.map(|p| p.to_path_buf());
821    // 监听根 = 注入的项目根(与 scan_and_parse_at 的扫描根一致)
822    let watch_root = root.path().to_path_buf();
823    let watch_root_for_loop = watch_root.clone();
824    // v14 F 组(t06 拍板):Ctrl-C 优雅退出——专用线程等待 SIGINT 后置
825    // 停止标记;run_watch_loop 每 500ms 轮询标记,置位时等当前增量
826    // 生成完成再退出(不会在状态落盘中途打断)。
827    let stop_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
828    {
829        let flag = stop_flag.clone();
830        let rt = get_global_runtime();
831        std::thread::spawn(move || {
832            rt.block_on(async {
833                let _ = tokio::signal::ctrl_c().await;
834                flag.store(true, std::sync::atomic::Ordering::Relaxed);
835                tracing::info!("收到 Ctrl-C,等待当前增量更新完成后退出...");
836            });
837        });
838    }
839    incremental::watch::run_watch_loop(
840        &watch_root_for_loop,
841        stop_flag,
842        move |events| {
843            for event in events {
844                tracing::info!(
845                    "检测到 {:?} {} 个文件变更,触发增量更新...",
846                    event.kind,
847                    event.paths.len()
848                );
849                // 事件类型显式传递:Deleted 直入删除清理(pipeline 内处理),
850                // 其余 kind 走常规增量更新
851                let change_kind = (event.kind == incremental::watch::ChangeKind::Deleted)
852                    .then_some(event.kind);
853                let root = project::ProjectRoot::new(watch_root.clone());
854                let mode = GenerationMode::Incremental {
855                    watch_paths: event.paths.clone(),
856                    change_kind,
857                };
858                if let Err(e) = run_pipeline(config_path.as_deref(), None, false, &root, &mode) {
859                    tracing::error!("增量更新失败: {}", e);
860                } else {
861                    tracing::info!("增量更新完成");
862                }
863            }
864        },
865    )
866}
867
868// ==================== 搜索索引集成 ====================
869
870/// 获取搜索索引目录的绝对路径
871fn search_index_dir(config: &config::schema::WikiConfig) -> std::path::PathBuf {
872    config.output_dir().join(config::schema::SEARCH_INDEX_DIR)
873}
874
875// ==================== 调用索引磁盘缓存(v36 C3)====================
876//
877// hybrid 自 v36 起为默认引擎,若每次搜索都重建知识图谱(实测约 1.2s)
878// 会让默认体验劣化。调用索引是「源码变化才变」的派生数据,可用指纹
879// 判失效后落盘复用。缓存语义刻意与「文档时代的图」对齐:缓存中保存的
880// 是上次文档生成时的调用图——未提交的源码改动不触发重建(文档未更新
881// 时展示与文档同代的补全,一致性优于每次现扫的现状)。
882
883/// 调用索引缓存指纹:git 仓库取 HEAD 提交;非 git 仓库取生成状态文件
884/// 的 (字节数, 修改时间)——generate/update 每次落盘状态文件,其变化即
885/// 代表文档生成状态变化,可保守判定调用图是否需要重建。
886fn call_index_fingerprint(config: &config::schema::WikiConfig) -> Option<String> {
887    let root = config.output_dir().parent()?;
888    // git 优先:HEAD 精确代表「源码版本」
889    if let Ok(repo) = git2::Repository::discover(root)
890        && let Ok(head) = repo.head()
891        && let Some(target) = head.target()
892    {
893        return Some(format!("git:{}", target));
894    }
895    // 非 git:生成状态文件 (len, mtime) 作为粗指纹
896    let state_path = config.output_dir().join(".state").join("generation_state.json");
897    let meta = std::fs::metadata(&state_path).ok()?;
898    let mtime = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
899    // 亚秒精度:文件系统 mtime 精度远高于秒(NTFS 100ns),
900    // 同一秒内的状态重写也必须判失效(generate/update 连续落盘场景)
901    Some(format!("state:{}:{}", meta.len(), mtime.as_millis()))
902}
903
904/// 加载调用索引缓存:指纹匹配且 JSON 可解析才命中,否则返回 None
905///(未命中/损坏/无法计算指纹均视为无缓存,调用方走重建路径)。
906fn load_call_index_cache(config: &config::schema::WikiConfig) -> Option<search::callgraph::CallIndex> {
907    let fp = call_index_fingerprint(config)?;
908    let state_dir = config.output_dir().join(".state");
909    let fp_file = std::fs::read_to_string(state_dir.join("call_index.fingerprint")).ok()?;
910    if fp_file.trim() != fp {
911        return None;
912    }
913    let data = std::fs::read_to_string(state_dir.join("call_index.json")).ok()?;
914    serde_json::from_str(&data).ok()
915}
916
917/// 写调用索引缓存(尽力而为:失败仅告警——搜索主功能不受影响,
918/// 下次搜索会走重建路径)。指纹与索引内容同写,保证原子判失效。
919fn save_call_index_cache(config: &config::schema::WikiConfig, index: &search::callgraph::CallIndex) {
920    let Some(fp) = call_index_fingerprint(config) else {
921        return;
922    };
923    let state_dir = config.output_dir().join(".state");
924    if let Err(e) = std::fs::create_dir_all(&state_dir) {
925        tracing::warn!("调用索引缓存目录创建失败: {}", e);
926        return;
927    }
928    match serde_json::to_string(index) {
929        Ok(json) => {
930            if let Err(e) = std::fs::write(state_dir.join("call_index.json"), json) {
931                tracing::warn!("调用索引缓存写入失败: {}", e);
932                return;
933            }
934            if let Err(e) = std::fs::write(state_dir.join("call_index.fingerprint"), fp) {
935                tracing::warn!("调用索引指纹写入失败: {}", e);
936            }
937        }
938        Err(e) => tracing::warn!("调用索引序列化失败: {}", e),
939    }
940}
941
942// ==================== 语义降级标记(v32 10.1)====================
943//
944// 语义索引是「附加能力」:embed 初始化/运行期失败时生成流程降级保留旧索引
945// (见 build_search_index)。降级只写日志会让用户误以为语义搜索可用——
946// 本标记把降级事实持久化(.search/semantic_degraded,内容=原因),
947// search/status 命令读取后输出显式提示行。每次生成重新求值:
948// 成功→清标记,降级→写标记;命令只读不写。
949
950/// 降级标记文件路径(.search/semantic_degraded,内容=降级原因)
951fn semantic_degraded_marker(config: &config::schema::WikiConfig) -> std::path::PathBuf {
952    search_index_dir(config).join("semantic_degraded")
953}
954
955/// 写入降级标记(尽力而为——标记失败不影响主流程,下次生成会重试)
956fn mark_semantic_degraded(config: &config::schema::WikiConfig, reason: &anyhow::Error) {
957    let marker = semantic_degraded_marker(config);
958    if let Err(e) = std::fs::write(&marker, reason.to_string()) {
959        tracing::warn!("写语义降级标记失败 {}: {}", marker.display(), e);
960    }
961}
962
963/// 清除降级标记(语义索引本次成功构建/更新后调用)
964fn clear_semantic_degraded(config: &config::schema::WikiConfig) {
965    let _ = std::fs::remove_file(semantic_degraded_marker(config));
966}
967
968/// 读取降级原因(无标记返回 None;命令层输出「语义索引:正常/已降级」)
969pub fn semantic_degraded_reason(config: &config::schema::WikiConfig) -> Option<String> {
970    let marker = semantic_degraded_marker(config);
971    std::fs::read_to_string(&marker).ok()
972}
973
974/// embedding 模型标记文件路径(.search/embed_model.json)
975///
976/// 用途:embedding 模型升级(同维度)时强制全量重建语义索引。维度探测
977/// (U04/D2)只覆盖「维度变化」;同维度模型(如 qwen3 → qwen3.7 同为
978/// 1024 维)的向量语义空间不同,新旧向量混存会静默劣化检索结果。
979/// 本标记持久化「索引构建时的模型名」,增量路径与当前配置比对,
980/// 不一致即回退全量重建语义索引。
981fn embed_model_marker(config: &config::schema::WikiConfig) -> std::path::PathBuf {
982    search_index_dir(config).join("embed_model.json")
983}
984
985/// 读取索引构建时的 embedding 模型名
986///
987/// - 标记缺失(旧版本构建的索引,模型未知)→ None
988/// - 标记损坏(非 JSON / 缺 model 字段)→ None
989///
990/// 两者都按「未知模型」保守处理:增量路径视为不匹配并回退全量重建,
991/// 重建成功后写入新标记,自愈收敛(不会反复重建)。
992fn read_embed_model(config: &config::schema::WikiConfig) -> Option<String> {
993    let path = embed_model_marker(config);
994    let text = std::fs::read_to_string(&path).ok()?;
995    serde_json::from_str::<serde_json::Value>(&text)
996        .ok()
997        .and_then(|v| v.get("model")?.as_str().map(|s| s.to_string()))
998}
999
1000/// 记录当前 embedding 模型名(只在全量重建语义索引成功后调用)
1001///
1002/// 失败仅告警:标记缺失会让下次增量再次走全量重建(保守正确,
1003/// 且全量重建会再次尝试写标记,幂等收敛)。
1004fn write_embed_model(config: &config::schema::WikiConfig) {
1005    let path = embed_model_marker(config);
1006    let content = serde_json::json!({ "model": config.embed.model }).to_string();
1007    if let Err(e) = crate::fs::write_file_atomic(&path, &content) {
1008        tracing::warn!("embedding 模型标记写入失败(下次增量将回退全量重建): {}", e);
1009    }
1010}
1011
1012/// 判定索引模型与当前配置是否不匹配(不匹配需回退全量重建语义索引)
1013///
1014/// 标记缺失/损坏一律视为不匹配(旧版本构建的索引模型未知,保守重建),
1015/// 重建成功后写入新标记自愈收敛。纯文件比对、无网络依赖,可单测。
1016fn embed_model_mismatch(config: &config::schema::WikiConfig) -> bool {
1017    read_embed_model(config).as_deref() != Some(config.embed.model.as_str())
1018}
1019
1020/// 实体级特征聚类接线(演进计划 T1.2b)
1021///
1022/// 在 build_graph 之后调用:embed 未启用或 EmbeddingEngine 初始化失败时
1023/// 降级为纯结构聚类(detect_features 的 embedder 参数传 None)。
1024/// 特征聚类失败只告警不中断主流程(特征是附加信息,不影响生成主链路)。
1025fn attach_features(graph: &mut model::KnowledgeGraph, config: &config::schema::WikiConfig) {
1026    let embedder: Option<std::sync::Arc<dyn analysis::feature::Embedder>> =
1027        match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
1028        Ok(e) => {
1029            // 显式经中间 let 触发 unsize coercion(Option 内不自动转换)
1030            let engine: std::sync::Arc<dyn analysis::feature::Embedder> = std::sync::Arc::new(e);
1031            Some(engine)
1032        }
1033        Err(e) => {
1034            tracing::warn!("特征聚类 Embedding 初始化失败,降级为纯结构聚类: {e}");
1035            None
1036        }
1037    };
1038    match analysis::feature::detect_features(graph, embedder.as_deref()) {
1039        Ok(features) => {
1040            graph.features = features;
1041            tracing::info!("特征聚类完成: {} 个特征", graph.features.len());
1042        }
1043        Err(e) => {
1044            tracing::warn!("特征聚类失败(不影响主流程): {e}");
1045        }
1046    }
1047}
1048
1049/// 全量构建搜索索引
1050///
1051/// 遍历知识图谱中所有实体节点,从 FileInsight 中提取对应源码片段,
1052/// 批量索引到 TextEngine。如果 embed 已启用则同时构建 SemanticEngine。
1053fn build_search_index(
1054    graph: &model::KnowledgeGraph,
1055    file_insights: &[ingest::parser::FileInsight],
1056    config: &config::schema::WikiConfig,
1057) -> anyhow::Result<()> {
1058    let index_dir = search_index_dir(config);
1059    std::fs::create_dir_all(&index_dir)?;
1060
1061    // 构建文件路径 → 源码的查找表
1062    let source_map = build_source_map(file_insights);
1063
1064    // 收集所有需要索引的实体(U04/D2:与增量路径共用 collect_index_items,
1065    // 过滤规则单一来源)
1066    let items = collect_index_items(graph, &source_map);
1067
1068    // 全量重建 TextEngine
1069    let text_path = index_dir.join("text_index.db");
1070    let _ = std::fs::remove_file(&text_path); // 清除旧索引
1071    let (mut text_engine, _) = search::text::TextEngine::open(&text_path)?;
1072    text_engine.index_batch(&items)?;
1073
1074    // 如果 embed 已启用,构建语义索引
1075    let semantic_path = index_dir.join("semantic_index.db");
1076    // 票 10 时序修正:先初始化 Embedding 引擎、成功后再删旧索引——
1077    // 旧实现先删后初始化,key 缺失时旧索引已丢且引导误导
1078    //("请启用 embed"掩盖了真实原因是 key 未配置)。
1079    // 失败时保留旧索引(可回退旧语义结果),并在引导中区分两种失败。
1080    match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
1081        Ok(embedder) => {
1082            let _ = std::fs::remove_file(&semantic_path);
1083            let embedder = std::sync::Arc::new(embedder);
1084            // 运行期失败(key 缺失/网络不可达)同样降级保留旧索引,不得
1085            // `?` 中断主流程——与上方初始化失败的降级语义一致(v30 前
1086            // embed.enabled=false 时整段跳过,无此失败路径;恒启用后
1087            // 必须把两类失败都按"附加能力"对待)。
1088            // v32 10.1:降级同时写标记(search/status 显式提示),
1089            // 成功则清标记。
1090            match search::semantic::SemanticEngine::open(&semantic_path, embedder, get_global_runtime().clone()) {
1091                Ok(mut semantic_engine) => match semantic_engine.index_batch(&items) {
1092                    Ok(()) => {
1093                        tracing::info!("语义索引构建完成: {} 个实体已向量化", items.len());
1094                        clear_semantic_degraded(config);
1095                        // v33:记录构建时的 embedding 模型名(模型升级检测基准)
1096                        write_embed_model(config);
1097                    }
1098                    Err(e) => {
1099                        tracing::warn!("语义索引构建失败(保留旧索引,搜索回退纯文本): {}", e);
1100                        let _ = std::fs::remove_file(&semantic_path);
1101                        mark_semantic_degraded(config, &e);
1102                    }
1103                },
1104                Err(e) => {
1105                    tracing::warn!("语义索引构建失败(保留旧索引,搜索回退纯文本): {}", e);
1106                    mark_semantic_degraded(config, &e);
1107                }
1108            }
1109        }
1110        Err(e) => {
1111            tracing::warn!("语义索引构建跳过(Embedding 引擎初始化失败,保留旧索引): {}", e);
1112            mark_semantic_degraded(config, &e);
1113        }
1114    }
1115
1116    tracing::info!("搜索索引构建完成: {} 个实体已索引", items.len());
1117    Ok(())
1118}
1119
1120/// 增量更新搜索索引
1121///
1122/// 只删除变更文件的旧实体,再重新索引变更文件中的新实体。
1123/// 同时处理 TextEngine 和 SemanticEngine(如已启用)。
1124fn update_search_index_incremental(
1125    graph: &model::KnowledgeGraph,
1126    file_insights: &[ingest::parser::FileInsight],
1127    config: &config::schema::WikiConfig,
1128    changed_files: &std::collections::HashSet<std::path::PathBuf>,
1129) -> anyhow::Result<()> {
1130    let index_dir = search_index_dir(config);
1131    let text_path = index_dir.join("text_index.db");
1132
1133    // 索引不存在时回退到全量构建
1134    if !text_path.exists() {
1135        return build_search_index(graph, file_insights, config);
1136    }
1137
1138    let (mut text_engine, need_reindex) = search::text::TextEngine::open(&text_path)?;
1139
1140    // 分支内统计量提升到外层:函数末尾的汇总日志需要(Rust 作用域);
1141    // source_map/items 同样提升:语义增量段(下方)需要引用。
1142    // 延迟初始化(两个分支必赋值其一):避免空值占位引发
1143    // unused_assignments 误报,也杜绝「空 Vec 兜底」掩盖逻辑。
1144    let source_map = build_source_map(file_insights);
1145    let mut total_removed = 0;
1146    let indexed_count;
1147    let items: Vec<(model::CodeNode, String)>;
1148
1149    if need_reindex {
1150        // v36 schema 迁移:旧版 text 索引(无 CJK tokens 列)被 open 时
1151        // 重建为空表,增量补 changed_files 会丢失全部旧实体——回退
1152        // 全量文本重索引(纯文本、无 LLM,成本低;语义索引不受影响,
1153        // 继续走下方增量路径)
1154        tracing::warn!("文本索引 schema 已升级(CJK tokens 列),重建全量文本索引");
1155        items = collect_index_items(graph, &source_map);
1156        indexed_count = items.len();
1157        text_engine.index_batch(&items)?;
1158    } else {
1159        // 删除变更文件的旧索引
1160        for file in changed_files {
1161            let file_str = file.to_string_lossy();
1162            total_removed += text_engine.remove_by_file(&file_str)?;
1163        }
1164
1165        // 重新索引变更文件中的实体(与全量路径共用 collect_index_items,
1166        // 过滤规则单一来源)
1167        items = incremental_index_items(graph, file_insights, changed_files);
1168        indexed_count = items.len();
1169        text_engine.index_batch(&items)?;
1170    }
1171
1172    // 增量更新语义索引(如已启用)
1173    let semantic_path = index_dir.join("semantic_index.db");
1174    // A1(v14):入口失败显式告警——此前两处 `if let Ok(...)` 静默吞掉
1175    // EmbeddingEngine::new(key 缺失)与 SemanticEngine::open(DB 损坏)的
1176    // 失败,增量语义更新在用户不知情时整段跳过(与全量路径 :702/:707 的
1177    // warn 语义对齐:保留旧索引可观测,不静默)。
1178    if semantic_path.exists() {
1179        match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
1180            Ok(embedder) => {
1181                let embedder = std::sync::Arc::new(embedder);
1182                match search::semantic::SemanticEngine::open(&semantic_path, embedder.clone(), get_global_runtime().clone()) {
1183                    Ok(mut semantic_engine) => {
1184                        // v33:embedding 模型版本化——同维度模型升级强制全量重建。
1185                        // 维度探测(U04/D2)只覆盖维度变化;同维度模型(维度相同)
1186                        // 混用旧向量会静默劣化检索。标记缺失/损坏视为未知模型
1187                        // (旧版构建),保守回退全量重建一次并写回新标记自愈。
1188                        let stored_model = read_embed_model(config);
1189                        let model_mismatch = embed_model_mismatch(config);
1190                        // 模型不匹配时无需探测维度(直接全量重建)
1191                        let dim_changed = if model_mismatch {
1192                            false
1193                        } else {
1194                            // U04/D2:embedding 维度探测——换模型(维度变化)时,增量
1195                            // 删除 + 只回填变更集会把既有全部向量丢掉(vecdb 维度不匹配
1196                            // 重建 DROP 全表,仅 warn)。探测到维度变化则回退全量重建
1197                            // 语义索引(clear + 全量 items),与全量路径行为一致。
1198                            let probe_dim = if items.is_empty() {
1199                                None
1200                            } else {
1201                                match get_global_runtime().block_on(embedder.embed(&items[0].1)) {
1202                                    Ok(v) => Some(v.len()),
1203                                    Err(e) => {
1204                                        tracing::warn!("embedding 维度探测失败,跳过维度重建检查: {}", e);
1205                                        None
1206                                    }
1207                                }
1208                            };
1209                            // 维度探测失败(数据库损坏/权限)显式告警并跳过重建检查,
1210                            // 不静默当作"维度未变"——保持行为的同时错误可见
1211                            match semantic_engine.table_dimension() {
1212                                Ok(existing_dim) => probe_dim
1213                                    .zip(existing_dim)
1214                                    .is_some_and(|(new_dim, existing)| new_dim != existing),
1215                                Err(e) => {
1216                                    tracing::warn!("读取语义索引维度失败,跳过维度重建检查: {}", e);
1217                                    false
1218                                }
1219                            }
1220                        };
1221                        if model_mismatch {
1222                            tracing::warn!(
1223                                "embedding 模型变化(标记 {:?} → 配置 {}),回退全量重建语义索引(新旧模型向量空间不兼容)",
1224                                stored_model,
1225                                config.embed.model
1226                            );
1227                            let all_items = collect_index_items(graph, &source_map);
1228                            semantic_engine.clear()?;
1229                            semantic_engine.index_batch(&all_items)?;
1230                            write_embed_model(config);
1231                        } else if dim_changed {
1232                            tracing::warn!(
1233                                "embedding 维度变化,回退全量重建语义索引(增量删除+回填会丢全部既有向量)"
1234                            );
1235                            let all_items = collect_index_items(graph, &source_map);
1236                            semantic_engine.clear()?;
1237                            semantic_engine.index_batch(&all_items)?;
1238                        } else {
1239                            // t01/P1-1:删除与回填错误显式传播(与同函数 text 路径一致)。
1240                            // 此前 `let _` 吞错:文本索引已更新而向量库停留旧态(新旧混存),
1241                            // 搜索返回陈旧/错位结果且无任何日志;语义索引是搜索功能的一部分,
1242                            // 静默失败不可接受。函数级隔离哲学不变——调用方(lib.rs Phase 5)
1243                            // 仍以 warn 包装,不中断主流程。
1244                            for file in changed_files {
1245                                semantic_engine.remove_by_file(&file.to_string_lossy())?;
1246                            }
1247                            semantic_engine.index_batch(&items)?;
1248                        }
1249                        // v32 10.1:增量语义更新成功(含维度重建路径)→ 清降级标记
1250                        clear_semantic_degraded(config);
1251                    }
1252                    Err(e) => {
1253                        tracing::warn!("语义索引打开失败,增量语义更新跳过(保留旧索引): {}", e);
1254                        // v32 10.1:降级标记(search/status 显式提示)
1255                        mark_semantic_degraded(config, &e);
1256                    }
1257                }
1258            }
1259            Err(e) => {
1260                tracing::warn!("Embedding 引擎初始化失败,增量语义更新跳过(保留旧索引): {}", e);
1261                // v32 10.1:降级标记
1262                mark_semantic_degraded(config, &e);
1263            }
1264        }
1265    }
1266
1267    tracing::info!("搜索索引增量更新: 删除 {} 条, 新增 {} 条", total_removed, indexed_count);
1268    Ok(())
1269}
1270
1271/// 收集全部可索引实体(项目/模块/文件级节点跳过),全量与增量路径共用
1272///
1273/// U04/D2 提取:增量路径的"变更文件过滤"是 collect 之后的选择,
1274/// 维度变化回退全量重建直接复用本函数,保证过滤规则单一来源。
1275fn collect_index_items(
1276    graph: &model::KnowledgeGraph,
1277    source_map: &std::collections::HashMap<String, String>,
1278) -> Vec<(model::CodeNode, String)> {
1279    graph
1280        .graph
1281        .node_indices()
1282        .filter_map(|idx| {
1283            let node = graph.graph.node_weight(idx)?;
1284            // 跳过项目/模块/文件级别的节点,只索引具体实体
1285            if matches!(
1286                node.kind,
1287                model::NodeKind::Project | model::NodeKind::Module | model::NodeKind::File
1288            ) {
1289                return None;
1290            }
1291            let source = extract_entity_source(node, source_map);
1292            Some((node.clone(), source))
1293        })
1294        .collect()
1295}
1296
1297/// 构建文件路径 → 文件源码的查找表(直接使用 FileInsight.source 避免重复 I/O)
1298fn build_source_map(insights: &[ingest::parser::FileInsight]) -> std::collections::HashMap<String, String> {
1299    insights.iter()
1300        .map(|i| (i.path.to_string_lossy().to_string(), i.source.clone()))
1301        .collect()
1302}
1303
1304/// 收集增量路径的待索引实体:全量 items 中只保留属于变更文件的实体
1305///
1306/// 与全量路径共用 collect_index_items(过滤规则单一来源),再按
1307/// 变更文件集过滤。路径比较前归一化分隔符(票 08):node.file_path
1308/// 可能是平台反斜杠路径,changed_files 来自 git diff/watch(正斜杠
1309/// 或相对路径),比较点必须同基准,否则增量删除/重索引在 Windows
1310/// 上永不命中。
1311fn incremental_index_items(
1312    graph: &model::KnowledgeGraph,
1313    file_insights: &[ingest::parser::FileInsight],
1314    changed_files: &std::collections::HashSet<std::path::PathBuf>,
1315) -> Vec<(model::CodeNode, String)> {
1316    let source_map = build_source_map(file_insights);
1317    collect_index_items(graph, &source_map)
1318        .into_iter()
1319        .filter(|(node, _)| {
1320            let Some(node_file) = node.file_path.as_deref() else {
1321                return false;
1322            };
1323            let node_file_norm = incremental::norm_sep(node_file);
1324            changed_files
1325                .iter()
1326                .any(|f| incremental::norm_sep(&f.to_string_lossy()) == node_file_norm)
1327        })
1328        .collect()
1329}
1330
1331/// 从源码中提取实体对应的代码片段
1332///
1333/// 根据实体的 line_range 从源文件中截取对应行。
1334fn extract_entity_source(
1335    node: &model::CodeNode,
1336    source_map: &std::collections::HashMap<String, String>,
1337) -> String {
1338    let file_path = match &node.file_path {
1339        Some(p) => p,
1340        None => return node.signature.clone().unwrap_or_default(),
1341    };
1342    let source = match source_map.get(file_path) {
1343        Some(s) => s,
1344        None => return node.signature.clone().unwrap_or_default(),
1345    };
1346    let (start, end) = match node.line_range {
1347        Some(r) => r,
1348        None => return node.signature.clone().unwrap_or_default(),
1349    };
1350    // 截取对应行(1-based 转 0-based)
1351    source.lines()
1352        .skip(start.saturating_sub(1))
1353        .take(end.saturating_sub(start) + 1)
1354        .collect::<Vec<_>>()
1355        .join("\n")
1356}
1357
1358/// 执行搜索查询(供 CLI search 子命令调用)
1359///
1360/// 加载持久化索引,根据引擎类型执行搜索,返回结果列表。
1361/// - Text: 仅 BM25 全文搜索
1362/// - Semantic: 仅向量语义搜索(需 embed.enabled)
1363/// - Hybrid: 两者结果经 RRF 合并
1364pub fn execute_search(
1365    config_path: Option<&Path>,
1366    root: &project::ProjectRoot,
1367    query: &str,
1368    top_k: usize,
1369    engine_type: &config::schema::SearchEngineType,
1370) -> anyhow::Result<Vec<search::hybrid::SearchHit>> {
1371    if query.trim().is_empty() {
1372        return Ok(Vec::new());
1373    }
1374    // v25:None 走默认配置链(项目级字段级合并覆盖用户级)
1375    let config = match config_path {
1376        Some(p) => config::load_config(p)?,
1377        None => config::load_default_config(root)?.1,
1378    };
1379    let index_dir = search_index_dir(&config);
1380    let text_path = index_dir.join("text_index.db");
1381    let semantic_path = index_dir.join("semantic_index.db");
1382
1383    match engine_type {
1384        config::schema::SearchEngineType::Text => {
1385            if !text_path.exists() {
1386                anyhow::bail!("搜索索引不存在,请先运行 `code-repo-wiki generate` 或 `code-repo-wiki update` 构建索引");
1387            }
1388            let (text_engine, _) = search::text::TextEngine::open(&text_path)?;
1389            let results = text_engine.search(query, top_k)?;
1390            Ok(search::hybrid::text_results_to_hits(results))
1391        }
1392        config::schema::SearchEngineType::Semantic => {
1393            // v30:embed 已硬编码恒启用——语义索引缺失即引导(无嵌入
1394            // key 时 generate 会告警跳过语义索引构建,见 build_search_index)
1395            if !semantic_path.exists() {
1396                anyhow::bail!("语义索引不存在——未配置嵌入 key(embed.api_key_env)或索引未构建,请配置后重新运行 `code-repo-wiki generate`");
1397            }
1398            let embedder = generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone())?;
1399            let embedder = std::sync::Arc::new(embedder);
1400            let semantic_engine = search::semantic::SemanticEngine::open(&semantic_path, embedder, get_global_runtime().clone())?;
1401            let results = semantic_engine.search(query, top_k)?;
1402            Ok(search::hybrid::semantic_results_to_hits(results))
1403        }
1404        config::schema::SearchEngineType::Hybrid => {
1405            // 与 Text/Semantic 分支一致:text 索引是混合检索的必需底座
1406            //(RRF 至少一路有效),缺失时明确报错而非打开空库。
1407            if !text_path.exists() {
1408                anyhow::bail!("搜索索引不存在,请先运行 `code-repo-wiki generate` 或 `code-repo-wiki update` 构建索引");
1409            }
1410            let (text_engine, _) = search::text::TextEngine::open(&text_path)?;
1411            // hybrid 语义一路:语义引擎构建失败(embedding 配置缺失/key
1412            // 无效/数据库损坏)显式告警并降级为纯 text——搜索结果少一路
1413            // 召回,但错误可见而非静默(v5 审计:全 .ok() 链把失败全吞掉,
1414            // 用户配置了 embed 却永远收不到语义结果且无任何提示)
1415            let semantic_engine: Option<Box<dyn search::semantic::SemanticSearch>> =
1416                if semantic_path.exists() {
1417                    match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
1418                        Ok(e) => match search::semantic::SemanticEngine::open(
1419                            &semantic_path,
1420                            Arc::new(e),
1421                            get_global_runtime().clone(),
1422                        ) {
1423                            Ok(engine) => Some(Box::new(engine) as Box<dyn search::semantic::SemanticSearch>),
1424                            Err(e) => {
1425                                tracing::warn!("语义索引打开失败,hybrid 降级为纯 text: {}", e);
1426                                None
1427                            }
1428                        },
1429                        Err(e) => {
1430                            tracing::warn!("embedding 引擎初始化失败,hybrid 降级为纯 text: {}", e);
1431                            None
1432                        }
1433                    }
1434                } else { None };
1435            let mut agent = search::agent::SearchAgent::new(text_engine, semantic_engine, config::schema::SEARCH_RRF_K);
1436            // 调用链补全:优先加载磁盘缓存(v36:hybrid 为默认引擎,
1437            // 缓存按源码指纹失效,命中时跳过整次图谱重建;重建成本仅
1438            // 在指纹变化后付出一次)。缓存与重建失败均静默降级为无补全
1439            //(索引缺失等,搜索主功能不受影响)。
1440            let index = match load_call_index_cache(&config) {
1441                Some(i) => i,
1442                None => {
1443                    if let Ok(scan) = ingest::scan_and_parse_at(root)
1444                        && let Ok(graph) = analysis::build_graph(&scan.insights)
1445                    {
1446                        let index = search::callgraph::CallGraph::new(&graph).build_call_index();
1447                        save_call_index_cache(&config, &index);
1448                        index
1449                    } else {
1450                        HashMap::new()
1451                    }
1452                }
1453            };
1454            agent = agent.with_call_index(index);
1455            // v36 起 hybrid = 双引擎召回 + RRF 融合 + 调用链补全
1456            // (v36 用户拍板:不使用 rerank 精排——召回质量已足够,
1457            // 精排增加延迟与外部依赖,收益不成比例)
1458            Ok(agent.search(query, top_k, true))
1459        }
1460    }
1461}
1462
1463/// 执行 AST 精确符号查找(供 CLI `ast-search` 子命令调用)
1464///
1465/// 扫描配置范围内全部源文件,对每个文件用 tree-sitter 解析 AST,
1466/// 定位与 `symbol` 同名的顶层定义节点(函数/结构体/trait/类等)。
1467/// 与索引搜索(text/semantic/hybrid,模糊匹配)互补:AST 查找返回
1468/// **精确的定义位置**(文件+行号+签名),不依赖搜索索引。
1469///
1470/// `language` 为源语言(rust/python/go/...),传入 None 时由文件扩展名自动推断。
1471pub fn execute_ast_search(
1472    config_path: Option<&Path>,
1473    root: &project::ProjectRoot,
1474    symbol: &str,
1475    language: Option<&str>,
1476) -> anyhow::Result<Vec<search::hybrid::SearchHit>> {
1477    if symbol.trim().is_empty() {
1478        return Ok(Vec::new());
1479    }
1480    let _config = match config_path {
1481        // 配置在此 fail-fast 校验(无效配置提前报错);AST 检索本身不依赖配置
1482        Some(p) => config::load_config(p)?,
1483        None => config::load_default_config(root)?.1,
1484    };
1485    let insights = ingest::scan_and_parse_at(root)?.insights;
1486
1487    let mut hits = Vec::new();
1488    for insight in &insights {
1489        // 语言:显式指定优先;否则按文件扩展名推断(与 parser 注册一致)
1490        let lang = match language {
1491            Some(l) => l.to_string(),
1492            None => match insight.path.extension().and_then(|e| e.to_str()) {
1493                Some("rs") => "rust".to_string(),
1494                Some("py") => "python".to_string(),
1495                Some("js") => "javascript".to_string(),
1496                Some("ts") => "typescript".to_string(),
1497                Some("go") => "go".to_string(),
1498                Some("cs") => "csharp".to_string(),
1499                _ => continue,
1500            },
1501        };
1502        // 直接用 AstQuery 解析查找(不经过 SearchAgent,搜索上下文不依赖索引)
1503        let mut q = match search::ast::AstQuery::new(&lang) {
1504            Ok(q) => q,
1505            Err(_) => continue,
1506        };
1507        let Ok(Some(m)) = q.find_definition(&insight.source, symbol) else {
1508            continue;
1509        };
1510        // 捕获节点文本作为签名(如整行函数定义);定位到文件+行号
1511        let signature = m
1512            .captures
1513            .get("name")
1514            .cloned()
1515            .unwrap_or_else(|| symbol.to_string());
1516        // 模块路径从文件父目录派生(与 chunk_by_file 同规则:Normal 组件 "::" 连接)
1517        let module_path: Vec<String> = insight
1518            .path
1519            .parent()
1520            .map(|p| {
1521                p.components()
1522                    .filter(|c| matches!(c, std::path::Component::Normal(_)))
1523                    .map(|c| c.as_os_str().to_string_lossy().to_string())
1524                    .collect()
1525            })
1526            .unwrap_or_default();
1527        hits.push(search::hybrid::SearchHit {
1528            node: model::CodeNode {
1529                id: model::NodeId::new(0),
1530                kind: model::NodeKind::Function,
1531                name: symbol.to_string(),
1532                file_path: Some(insight.path.to_string_lossy().to_string()),
1533                line_range: Some((m.start_line, m.end_line)),
1534                doc_comment: None,
1535                signature: Some(signature), visibility: None,
1536                module_path,
1537            },
1538            score: 100.0,
1539            source: "ast".into(),
1540            callers: vec![],
1541            callees: vec![],
1542        });
1543    }
1544    Ok(hits)
1545}
1546
1547#[cfg(test)]
1548mod tests {
1549    use super::*;
1550
1551    /// v33 生产审计 ②:embedding 模型标记写读往返 + 不匹配判定
1552    ///
1553    /// 模型版本化判定为纯文件比对(无网络),在此做单元级覆盖;
1554    /// 全链路(增量触发重建)依赖真实 embed key,留待真实环境验证。
1555    #[test]
1556    fn test_embed_model_marker_roundtrip_and_mismatch() {
1557        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_embed_marker_{}", std::process::id()));
1558        let _ = std::fs::remove_dir_all(&dir);
1559        std::fs::create_dir_all(dir.join(".search")).unwrap();
1560
1561        let mut config = config::schema::WikiConfig {
1562            output_dir: Some(dir.clone()),
1563            embed: config::schema::EmbedSection {
1564                model: "model-a".into(),
1565                ..Default::default()
1566            },
1567            ..Default::default()
1568        };
1569
1570        // 无标记(旧版构建的索引)→ 视为不匹配(保守触发重建)
1571        assert!(embed_model_mismatch(&config), "标记缺失应视为模型不匹配");
1572
1573        // 写入标记后匹配
1574        write_embed_model(&config);
1575        assert!(!embed_model_mismatch(&config), "标记与配置一致应匹配");
1576        assert_eq!(read_embed_model(&config).as_deref(), Some("model-a"));
1577
1578        // 模型升级(同维度)→ 不匹配
1579        config.embed.model = "model-b".into();
1580        assert!(embed_model_mismatch(&config), "同维度模型升级应判定不匹配");
1581
1582        // 重写标记自愈 → 匹配
1583        write_embed_model(&config);
1584        assert!(!embed_model_mismatch(&config));
1585        assert_eq!(read_embed_model(&config).as_deref(), Some("model-b"));
1586
1587        // 标记损坏 → 视为未知模型(不匹配)
1588        std::fs::write(dir.join(".search").join("embed_model.json"), "{broken").unwrap();
1589        assert!(embed_model_mismatch(&config), "损坏标记应视为不匹配");
1590
1591        let _ = std::fs::remove_dir_all(&dir);
1592    }
1593
1594    /// v32 8.1:分段计时序列化往返与写盘/读取(缺省字段补零、损坏文件不 panic)
1595    #[test]
1596    fn test_generation_timings_roundtrip() {
1597        let timings = GenerationTimings {
1598            scan_parse_ms: 1,
1599            graph_ms: 2,
1600            incremental_ms: 3,
1601            chunk_ms: 4,
1602            card_ms: 5,
1603            wiki_ms: 6,
1604            index_guide_ms: 7,
1605            render_ms: 8,
1606            index_ms: 9,
1607            state_ms: 10,
1608            total_ms: 55,
1609        };
1610        let text = serde_json::to_string_pretty(&timings).unwrap();
1611        let back: GenerationTimings = serde_json::from_str(&text).unwrap();
1612        assert_eq!(back.scan_parse_ms, 1);
1613        assert_eq!(back.total_ms, 55);
1614        // 损坏文件 → 解析失败(调用方按 None 处理,不 panic)
1615        assert!(serde_json::from_str::<GenerationTimings>("{broken").is_err());
1616        // 缺字段 → serde(default) 补零
1617        let partial: GenerationTimings =
1618            serde_json::from_str(r#"{"scan_parse_ms": 42}"#).unwrap();
1619        assert_eq!(partial.scan_parse_ms, 42);
1620        assert_eq!(partial.total_ms, 0);
1621    }
1622
1623    /// 产物集合 diff 清理(票 10):旧状态记录过、但本次渲染集合之外的
1624    /// 产物路径被删除(全语言目录),本次渲染集合内的路径(含受保护文档)
1625    /// 一律保留。
1626    #[test]
1627    fn test_cleanup_stale_outputs_removes_unrendered_across_languages() {
1628        let dir = std::env::temp_dir()
1629            .join(format!("code_repo_wiki_test_stale_{}", std::process::id()));
1630        let _ = std::fs::remove_dir_all(&dir);
1631
1632        // 旧状态记录两个产物:src.md(双语言)与 lib.md(双语言)
1633        let mut state = incremental::state::GenerationState {
1634            last_commit_hash: None,
1635            file_fingerprints: std::collections::HashMap::new(),
1636            doc_fingerprints: std::collections::HashMap::new(),
1637            doc_modules: std::collections::HashMap::new(),
1638            protected_docs: vec![],
1639            generated_at: String::new(),
1640            tool_version: None,
1641            failed_modules: vec![],
1642        };
1643        for lang in ["zh", "en"] {
1644            let stale = dir.join("wiki").join(lang).join("src.md");
1645            let keep = dir.join("wiki").join(lang).join("lib.md");
1646            std::fs::create_dir_all(stale.parent().unwrap()).unwrap();
1647            std::fs::create_dir_all(keep.parent().unwrap()).unwrap();
1648            std::fs::write(&stale, "旧页面").unwrap();
1649            std::fs::write(&keep, "保留页面").unwrap();
1650            state
1651                .doc_fingerprints
1652                .insert(stale.to_string_lossy().to_string(), "fp".into());
1653            state
1654                .doc_fingerprints
1655                .insert(keep.to_string_lossy().to_string(), "fp".into());
1656        }
1657
1658        // 本次渲染集合只含 lib.md(src.md 对应模块已消失,不在渲染集)
1659        let rendered: Vec<std::path::PathBuf> = ["zh", "en"]
1660            .iter()
1661            .map(|lang| dir.join("wiki").join(lang).join("lib.md"))
1662            .collect();
1663
1664        // preserved 为空:src.md 的模块 src::foo 不在保留集 → 按原语义清理
1665        cleanup_stale_outputs(Some(&state), &rendered, &std::collections::HashSet::new());
1666
1667        for lang in ["zh", "en"] {
1668            assert!(
1669                !dir.join("wiki").join(lang).join("src.md").exists(),
1670                "未渲染的旧产物应被清理({lang})"
1671            );
1672            assert!(
1673                dir.join("wiki").join(lang).join("lib.md").exists(),
1674                "本次渲染集合内的产物应保留({lang})"
1675            );
1676        }
1677
1678        let _ = std::fs::remove_dir_all(&dir);
1679    }
1680
1681    /// 受保护文档在渲染集合内(rendered_paths 含受保护路径),diff 后
1682    /// 不会被误删——人工编辑内容由保护语义而非清理语义保障。
1683    #[test]
1684    fn test_cleanup_stale_outputs_keeps_rendered_protected() {
1685        let dir = std::env::temp_dir()
1686            .join(format!("code_repo_wiki_test_stale_protected_{}", std::process::id()));
1687        let _ = std::fs::remove_dir_all(&dir);
1688
1689        let mut state = incremental::state::GenerationState {
1690            last_commit_hash: None,
1691            file_fingerprints: std::collections::HashMap::new(),
1692            doc_fingerprints: std::collections::HashMap::new(),
1693            doc_modules: std::collections::HashMap::new(),
1694            protected_docs: vec![],
1695            generated_at: String::new(),
1696            tool_version: None,
1697            failed_modules: vec![],
1698        };
1699        // 受保护页面被人工编辑过(指纹不匹配)——doc_fingerprints 仍记录其路径
1700        let manual = dir.join("wiki").join("zh").join("manual.md");
1701        std::fs::create_dir_all(manual.parent().unwrap()).unwrap();
1702        std::fs::write(&manual, "人工编辑内容").unwrap();
1703        state
1704            .doc_fingerprints
1705            .insert(manual.to_string_lossy().to_string(), "旧指纹".into());
1706        state
1707            .doc_modules
1708            .insert(manual.to_string_lossy().to_string(), "manual".into());
1709
1710        // 本次渲染集合包含该路径(受保护文档属于生成集)
1711        let rendered = vec![manual.clone()];
1712        cleanup_stale_outputs(Some(&state), &rendered, &std::collections::HashSet::new());
1713
1714        assert!(
1715            manual.exists(),
1716            "渲染集合内的人工编辑文档不应被清理"
1717        );
1718        let _ = std::fs::remove_dir_all(&dir);
1719    }
1720
1721    /// v17 F 组(t09 实测修复):增量模式下未受影响模块的旧页面必须保留
1722    /// ——模块仍在当前扫描(preserved 集合)中,即使本次未重新生成,
1723    /// 清理也须跳过(误删会制造断链)
1724    #[test]
1725    fn test_cleanup_stale_outputs_preserves_modules_still_in_scan() {
1726        let dir = std::env::temp_dir()
1727            .join(format!("code_repo_wiki_test_stale_preserve_{}", std::process::id()));
1728        let _ = std::fs::remove_dir_all(&dir);
1729
1730        let mut state = incremental::state::GenerationState {
1731            last_commit_hash: None,
1732            file_fingerprints: std::collections::HashMap::new(),
1733            doc_fingerprints: std::collections::HashMap::new(),
1734            doc_modules: std::collections::HashMap::new(),
1735            protected_docs: vec![],
1736            generated_at: String::new(),
1737            tool_version: None,
1738            failed_modules: vec![],
1739        };
1740        // 旧状态:src::fs 模块的页面(模拟增量前生成的产物)
1741        let fs_page = dir.join("wiki").join("zh").join("src_fs.md");
1742        std::fs::create_dir_all(fs_page.parent().unwrap()).unwrap();
1743        std::fs::write(&fs_page, "旧内容").unwrap();
1744        state
1745            .doc_fingerprints
1746            .insert(fs_page.to_string_lossy().to_string(), "fp".into());
1747        state
1748            .doc_modules
1749            .insert(fs_page.to_string_lossy().to_string(), "src::fs".into());
1750        // 旧状态:src::deleted 模块的页面(模拟源文件已删除的模块)
1751        let gone_page = dir.join("wiki").join("zh").join("src_deleted.md");
1752        std::fs::write(&gone_page, "旧内容").unwrap();
1753        state
1754            .doc_fingerprints
1755            .insert(gone_page.to_string_lossy().to_string(), "fp".into());
1756        state
1757            .doc_modules
1758            .insert(gone_page.to_string_lossy().to_string(), "src::deleted".into());
1759
1760        // 本次渲染集不含任何上述页面(增量只生成其他模块);
1761        // 保留集含 src::fs(模块仍在扫描)但不含 src::deleted(已删除)
1762        let preserved: std::collections::HashSet<String> =
1763            ["src::fs".to_string()].into_iter().collect();
1764        cleanup_stale_outputs(Some(&state), &[], &preserved);
1765
1766        assert!(fs_page.exists(), "仍在扫描的模块页面应保留");
1767        assert!(!gone_page.exists(), "已删除模块的页面应清理");
1768
1769        let _ = std::fs::remove_dir_all(&dir);
1770    }
1771
1772    /// 无旧状态(首次生成)时清理为空操作
1773    #[test]
1774    fn test_cleanup_stale_outputs_noop_without_state() {
1775        let dir = std::env::temp_dir()
1776            .join(format!("code_repo_wiki_test_stale_noop_{}", std::process::id()));
1777        let _ = std::fs::remove_dir_all(&dir);
1778        cleanup_stale_outputs(None, &[], &std::collections::HashSet::new());
1779        let _ = std::fs::remove_dir_all(&dir);
1780    }
1781
1782    /// A2:force=true 清空保护集(含旧 protected_docs 与人工修改检测),
1783    /// force=false 保留保护语义 —— 与 run_pipeline 的 --force 行为一致
1784    #[test]
1785    fn test_load_protection_force_clears_protection() {
1786        let dir = std::env::temp_dir()
1787            .join(format!("code_repo_wiki_test_force_{}", std::process::id()));
1788        let _ = std::fs::remove_dir_all(&dir);
1789
1790        let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
1791
1792        // 构造旧 state:一个"人工修改过"的文档(磁盘内容与指纹不匹配)
1793        let state_dir = dir.join(".state");
1794        std::fs::create_dir_all(&state_dir).unwrap();
1795        let doc_path = dir.join("wiki").join("zh").join("src.md");
1796        std::fs::create_dir_all(doc_path.parent().unwrap()).unwrap();
1797        std::fs::write(&doc_path, "人工修改后的内容").unwrap();
1798        let mut state = incremental::state::GenerationState {
1799            last_commit_hash: None,
1800            file_fingerprints: std::collections::HashMap::new(),
1801            doc_fingerprints: std::collections::HashMap::new(),
1802            doc_modules: std::collections::HashMap::new(),
1803            protected_docs: vec![],
1804            generated_at: String::new(),
1805            tool_version: None,
1806            failed_modules: vec![],
1807        };
1808        state.doc_fingerprints.insert(
1809            doc_path.to_string_lossy().to_string(),
1810            "与磁盘内容不同的指纹".into(),
1811        );
1812        state.doc_modules.insert(
1813            doc_path.to_string_lossy().to_string(),
1814            "src".into(),
1815        );
1816        state.save(&state_dir).unwrap();
1817
1818        // force=false:保护集包含检测出的人工修改(下次生成不覆盖)
1819        let (protected, _) = load_protection(&config, false).unwrap();
1820        assert!(
1821            protected.contains(&doc_path.to_string_lossy().to_string()),
1822            "force=false 应保护人工修改的文档"
1823        );
1824
1825        // force=true:保护集清空(render_all 将覆盖所有文档)
1826        let (protected, _) = load_protection(&config, true).unwrap();
1827        assert!(protected.is_empty(), "force=true 应清空保护集");
1828
1829        let _ = std::fs::remove_dir_all(&dir);
1830    }
1831
1832    /// 票 02:state.json 存在但损坏(非 JSON)时 load_protection 必须 fail-loud,
1833    /// 不得静默返回空保护集(空保护会让人工修改保护在后续 update 中失效)。
1834    /// 与 sync_from_git 对损坏状态的拒绝行为对偶(tests/test_git_sync.rs:109-121)。
1835    #[test]
1836    fn test_load_protection_corrupt_state_fails_loud() {
1837        let dir = std::env::temp_dir()
1838            .join(format!("code_repo_wiki_test_corrupt_state_{}", std::process::id()));
1839        let _ = std::fs::remove_dir_all(&dir);
1840
1841        let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
1842
1843        // 写入损坏的状态文件(半截 JSON)
1844        let state_dir = dir.join(".state");
1845        std::fs::create_dir_all(&state_dir).unwrap();
1846        std::fs::write(state_dir.join("generation_state.json"), "{ 半截").unwrap();
1847
1848        let err = load_protection(&config, false).unwrap_err();
1849        let msg = err.to_string();
1850        assert!(msg.contains("状态文件损坏"), "应明确报告损坏, 实际: {msg}");
1851
1852        // force=true 不受影响(清空保护是显式操作,不读状态)
1853        assert!(load_protection(&config, true).unwrap().0.is_empty());
1854
1855        let _ = std::fs::remove_dir_all(&dir);
1856    }
1857
1858    /// 票 02:状态文件不存在(首次运行)是合法场景,返回空保护不报错
1859    #[test]
1860    fn test_load_protection_missing_state_is_ok() {
1861        let dir = std::env::temp_dir()
1862            .join(format!("code_repo_wiki_test_missing_state_{}", std::process::id()));
1863        let _ = std::fs::remove_dir_all(&dir);
1864
1865        let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
1866
1867        let (protected, state) = load_protection(&config, false).unwrap();
1868        assert!(protected.is_empty());
1869        assert!(state.is_none());
1870
1871        let _ = std::fs::remove_dir_all(&dir);
1872    }
1873
1874    // ==================== 调用索引磁盘缓存(v36 C3)====================
1875
1876    /// 非 git 且无生成状态文件时指纹为 None(保守:不缓存)
1877    #[test]
1878    fn test_call_index_fingerprint_none_without_state() {
1879        let dir = std::env::temp_dir()
1880            .join(format!("code_repo_wiki_test_fp_none_{}", std::process::id()));
1881        let _ = std::fs::remove_dir_all(&dir);
1882        std::fs::create_dir_all(&dir).unwrap();
1883
1884        let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
1885        assert!(call_index_fingerprint(&config).is_none());
1886
1887        let _ = std::fs::remove_dir_all(&dir);
1888    }
1889
1890    /// 生成状态文件存在时指纹稳定(非 git 场景),内容变化后指纹变化
1891    #[test]
1892    fn test_call_index_fingerprint_state_stable() {
1893        let dir = std::env::temp_dir()
1894            .join(format!("code_repo_wiki_test_fp_state_{}", std::process::id()));
1895        let _ = std::fs::remove_dir_all(&dir);
1896        std::fs::create_dir_all(dir.join(".state")).unwrap();
1897        std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
1898
1899        let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
1900        let fp1 = call_index_fingerprint(&config).expect("有状态文件应有指纹");
1901        let fp2 = call_index_fingerprint(&config).expect("有状态文件应有指纹");
1902        assert_eq!(fp1, fp2, "指纹必须稳定(同状态两次调用相同)");
1903
1904        // 状态文件重写(generate/update 落盘)→ mtime 变化 → 指纹变化
1905        std::thread::sleep(std::time::Duration::from_millis(20));
1906        std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
1907        let fp3 = call_index_fingerprint(&config).expect("有状态文件应有指纹");
1908        assert_ne!(fp1, fp3, "状态文件重写后指纹必须变化");
1909
1910        let _ = std::fs::remove_dir_all(&dir);
1911    }
1912
1913    /// 缓存往返:保存后可加载且内容一致;指纹不匹配时视为未命中
1914    #[test]
1915    fn test_call_index_cache_round_trip_and_invalidation() {
1916        let dir = std::env::temp_dir()
1917            .join(format!("code_repo_wiki_test_call_cache_{}", std::process::id()));
1918        let _ = std::fs::remove_dir_all(&dir);
1919        std::fs::create_dir_all(dir.join(".state")).unwrap();
1920        std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
1921
1922        let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
1923        let mut index = std::collections::HashMap::new();
1924        index.insert("fn_a".to_string(), (vec!["fn_b".to_string()], vec!["fn_c".to_string()]));
1925
1926        // 保存前加载=未命中
1927        assert!(load_call_index_cache(&config).is_none());
1928
1929        save_call_index_cache(&config, &index);
1930        let loaded = load_call_index_cache(&config).expect("保存后应命中");
1931        assert_eq!(loaded, index, "缓存往返内容必须一致");
1932
1933        // 指纹失效(状态文件重写)→ 未命中
1934        std::thread::sleep(std::time::Duration::from_millis(20));
1935        std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
1936        assert!(load_call_index_cache(&config).is_none(), "指纹变化后必须失效");
1937
1938        let _ = std::fs::remove_dir_all(&dir);
1939    }
1940
1941    /// 损坏的缓存 JSON 视为未命中(走重建路径,不 panic)
1942    #[test]
1943    fn test_call_index_cache_corrupt_is_miss() {
1944        let dir = std::env::temp_dir()
1945            .join(format!("code_repo_wiki_test_call_cache_corrupt_{}", std::process::id()));
1946        let _ = std::fs::remove_dir_all(&dir);
1947        std::fs::create_dir_all(dir.join(".state")).unwrap();
1948        std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
1949
1950        let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
1951        // 指纹匹配但 JSON 损坏
1952        let fp = call_index_fingerprint(&config).unwrap();
1953        std::fs::write(dir.join(".state/call_index.fingerprint"), &fp).unwrap();
1954        std::fs::write(dir.join(".state/call_index.json"), "{ 半截").unwrap();
1955
1956        assert!(load_call_index_cache(&config).is_none(), "损坏缓存必须视为未命中");
1957
1958        let _ = std::fs::remove_dir_all(&dir);
1959    }
1960}