Skip to main content

code_repo_wiki/output/
mod.rs

1//! 渲染与导出层(单进程契约:export_snapshot.json 等状态文件无锁,
2//! 同一输出目录并发运行不被支持,见 README 限制项)
3pub mod crossref;
4pub mod citation;
5pub mod lint;
6pub mod llms_txt;
7pub mod semantic_lint;
8pub mod markdown;
9pub mod mermaid;
10pub mod mermaid_check;
11pub mod html;
12
13use std::path::{Path, PathBuf};
14
15use anyhow::Result;
16
17use crate::config::schema::WikiConfig;
18use crate::model::{KnowledgeCard, KnowledgeGraph, WikiDocument};
19
20use self::markdown::write_document;
21
22/// 生效的 wiki 语言列表(主语言 + 扩展语言)
23///
24/// 由 generate::collect_languages 移入(消除 output→generate 反向依赖;
25/// generate 侧调用点改向本函数——generate→output 依赖本就存在,card.rs 已用)。
26pub fn wiki_languages(config: &WikiConfig) -> Vec<String> {
27    let languages = vec![config.wiki.language.clone()];
28    languages
29}
30
31/// API 参考页写盘路径:`{}/wiki/{lang}/api.md`(每种语言独立一份)
32///
33/// render_all 写盘与状态层指纹记录共用本函数产出路径,
34/// 保证人工修改保护的判定路径与指纹记录路径完全一致(同一规则,防止两处漂移)。
35pub fn api_doc_path(output_dir: &Path, lang: &str) -> PathBuf {
36    output_dir.join("wiki").join(lang).join("api.md")
37}
38
39/// 概览页写盘路径:`{}/wiki/{lang}/overview.md`(仅主语言一份)
40pub fn overview_doc_path(output_dir: &Path, lang: &str) -> PathBuf {
41    output_dir.join("wiki").join(lang).join("overview.md")
42}
43
44/// 目录页写盘路径:`{}/_toc.md`(输出目录根一份)
45pub fn toc_doc_path(output_dir: &Path) -> PathBuf {
46    output_dir.join("_toc.md")
47}
48
49/// 卡片文件名主体(不含 .md 后缀):`module.replace("::", "_")`
50///
51/// 卡片写盘、卡片索引与删除清理共用本函数,保证卡片命名单一来源。
52pub(crate) fn card_file_stem(module: &str) -> String {
53    module.replace("::", "_")
54}
55
56/// 卡片写盘路径:`{}/cards/{lang}/{module.replace("::","_")}.md`
57///
58/// render_all 写盘、卡片指纹记录与删除清理共用本函数产出路径,
59/// 保证人工修改保护的判定路径与指纹记录路径完全一致(防止两处漂移)。
60pub(crate) fn card_page_path(output_dir: &Path, lang: &str, module: &str) -> PathBuf {
61    output_dir
62        .join("cards")
63        .join(lang)
64        .join(format!("{}.md", card_file_stem(module)))
65}
66
67/// Wiki 页面写盘路径:`{}/wiki/{lang}/{file}.md`
68///
69/// 文件名复用 markdown::wiki_file_name(ArchitectureOverview 特判写 architecture.md)。
70/// render_all 写盘、write_document 落盘与状态层指纹记录共用本函数,
71/// 保证人工修改保护的判定路径与指纹记录路径完全一致。
72pub(crate) fn wiki_page_path(output_dir: &Path, lang: &str, doc: &WikiDocument) -> PathBuf {
73    if doc.kind == crate::model::DocumentKind::ArchitectureOverview {
74        output_dir.join("wiki").join(lang).join("architecture.md")
75    } else if doc.kind == crate::model::DocumentKind::ProjectOverview {
76        output_dir.join("wiki").join(lang).join("overview.md")
77    } else {
78        output_dir.join("wiki").join(lang).join(markdown::wiki_file_name(doc))
79    }
80}
81
82/// Wiki 页面 HTML 写盘路径:`{}/wiki/{lang}/{file}.html`
83///
84/// 与 wiki_page_path 同构(命名规则完全一致,仅扩展名 .md → .html),
85/// 保证 HTML 导出与 markdown 产物一一对应(多语言同名标题不冲突)。
86pub fn wiki_page_html_path(output_dir: &Path, doc: &WikiDocument) -> PathBuf {
87    wiki_page_path(output_dir, &doc.language, doc).with_extension("html")
88}
89
90/// 导出快照中的模块摘要(快照 JSON 的 modules 项)
91///
92/// name/cohesion/coupling 直接来自 graph.modules;files 由模块节点反查
93/// file_path 派生(去重排序);features 取同模块卡片的特征追溯列表
94/// (生成层 backfill_features 已按模块回填,无需重算交集)。
95#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
96pub struct ExportModuleSnapshot {
97    pub name: String,
98    pub files: Vec<String>,
99    pub cohesion: f64,
100    pub coupling: f64,
101    pub features: Vec<String>,
102    /// 依赖的模块名列表(U05/D9:module-deps.html 此前只有节点零边——
103    /// 快照无依赖字段,HTML 侧画不出依赖边;serde default 兼容旧快照)
104    #[serde(default)]
105    pub dependencies: Vec<String>,
106}
107
108/// 导出快照(`{output_dir}/.state/export_snapshot.json`)
109///
110/// 对外契约:main.rs 的 export --skip-generate 直接消费本文件,
111/// 不再重跑生成流水线。documents/cards 为本次完整生成集(含受保护
112/// 跳过写盘的文档——磁盘保留人工版,快照记录的是生成意图集)。
113#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
114pub struct ExportSnapshot {
115    pub version: u32,
116    pub documents: Vec<WikiDocument>,
117    pub cards: Vec<KnowledgeCard>,
118    pub modules: Vec<ExportModuleSnapshot>,
119}
120
121/// 导出快照写盘路径:`{output_dir}/.state/export_snapshot.json`
122/// (与 generation_state.json 同目录,沿用既有状态目录约定)
123pub fn export_snapshot_path(output_dir: &Path) -> PathBuf {
124    output_dir.join(".state").join("export_snapshot.json")
125}
126
127/// 全部语言目录下 wiki 页的最新修改时间(票 04 陈旧检测用)
128///
129/// 遍历 `wiki/{lang}/*.md`(主语言 + 扩展语言),返回最新 mtime;
130/// 无任何页面时返回 None(此时快照也必然不存在,陈旧检测不适用)。
131pub fn latest_wiki_page_mtime(output_dir: &Path) -> Option<std::time::SystemTime> {
132    let wiki_root = output_dir.join("wiki");
133    let mut latest: Option<std::time::SystemTime> = None;
134    let Ok(entries) = std::fs::read_dir(&wiki_root) else {
135        return None;
136    };
137    for lang in entries.flatten() {
138        if !lang.path().is_dir() {
139            continue;
140        }
141        let Ok(pages) = std::fs::read_dir(lang.path()) else {
142            continue;
143        };
144        for page in pages.flatten() {
145            let path = page.path();
146            if path.extension().is_some_and(|e| e == "md")
147                && let Ok(meta) = std::fs::metadata(&path)
148                && let Ok(mtime) = meta.modified()
149            {
150                latest = Some(match latest {
151                    Some(prev) => prev.max(mtime),
152                    None => mtime,
153                });
154            }
155        }
156    }
157    latest
158}
159
160/// 从图与卡片提取快照模块列表(按模块名排序保证确定性)
161pub fn export_modules(graph: &KnowledgeGraph, cards: &[KnowledgeCard]) -> Vec<ExportModuleSnapshot> {
162    // U05/D9:实体节点 → 所属模块映射(先到先得,与 index.rs/community
163    // 的同规则),用于聚合跨模块依赖边(Calls + Imports,排除 Contains)
164    use petgraph::visit::{EdgeRef, IntoEdgeReferences};
165    use std::collections::{BTreeMap, BTreeSet};
166
167    let mut node_module: std::collections::HashMap<crate::model::NodeId, String> =
168        std::collections::HashMap::new();
169    for module in &graph.modules {
170        for nid in &module.node_ids {
171            node_module
172                .entry(*nid)
173                .or_insert_with(|| module.name.clone());
174        }
175    }
176    let mut deps: BTreeMap<String, BTreeSet<String>> = Default::default();
177    for edge in graph.graph.edge_references() {
178        if matches!(
179            graph.graph[edge.id()].kind,
180            crate::model::EdgeKind::Calls | crate::model::EdgeKind::Imports
181        ) {
182            let (Some(src), Some(tgt)) = (
183                node_module.get(&edge.source()),
184                node_module.get(&edge.target()),
185            ) else {
186                continue;
187            };
188            if src != tgt {
189                deps.entry(src.clone()).or_default().insert(tgt.clone());
190            }
191        }
192    }
193
194    let mut modules: Vec<ExportModuleSnapshot> = graph
195        .modules
196        .iter()
197        .map(|m| {
198            let mut files: Vec<String> = m
199                .node_ids
200                .iter()
201                .filter_map(|nid| graph.graph.node_weight(*nid).and_then(|n| n.file_path.clone()))
202                .collect();
203            files.sort();
204            files.dedup();
205            let features = cards
206                .iter()
207                .find(|c| c.module_name == m.name)
208                .map(|c| c.features.clone())
209                .unwrap_or_default();
210            let mut dependencies: Vec<String> = deps
211                .get(&m.name)
212                .map(|s| s.iter().cloned().collect())
213                .unwrap_or_default();
214            dependencies.sort();
215            ExportModuleSnapshot {
216                name: m.name.clone(),
217                files,
218                cohesion: m.cohesion,
219                coupling: m.coupling,
220                features,
221                dependencies,
222            }
223        })
224        .collect();
225    modules.sort_by(|a, b| a.name.cmp(&b.name));
226    modules
227}
228
229/// 写导出快照到 `.state/export_snapshot.json`
230fn write_export_snapshot(
231    output_dir: &Path,
232    documents: &[WikiDocument],
233    cards: &[KnowledgeCard],
234    graph: &KnowledgeGraph,
235) -> Result<()> {
236    let snapshot = ExportSnapshot {
237        version: 1,
238        documents: documents.to_vec(),
239        cards: cards.to_vec(),
240        modules: export_modules(graph, cards),
241    };
242    let path = export_snapshot_path(output_dir);
243    // 原子写(fs::write_file_atomic):写入失败不残留半截快照;
244    // 陈旧检测(mtime 比对)在 export --skip-generate 侧,见票 04
245    crate::fs::write_file_atomic(&path, &serde_json::to_string_pretty(&snapshot)?)?;
246    Ok(())
247}
248
249/// 本次生成的产物路径集合(供增量清理 diff 使用)
250///
251/// 语义:render_all 本次生成意图写入的全部文件路径,**含受保护跳过写盘
252/// 的文档**(保护文档属于生成集,磁盘上是人工版;清理 diff 以本集合
253/// 为准时,保护路径天然被排除在待删集合外,不会误删人工编辑内容)。
254/// 路径与 render_all 写盘规则逐一对应:wiki 页按 doc.language 落盘、
255/// 关联卡片同语言落盘(精确关联)、api.md 只写主语言、_toc.md 写产物根。
256pub fn rendered_paths(
257    documents: &[WikiDocument],
258    cards: &[KnowledgeCard],
259    config: &WikiConfig,
260) -> Vec<PathBuf> {
261    let output_dir = config.output_dir();
262    let mut paths: std::collections::BTreeSet<PathBuf> = Default::default();
263    for doc in documents {
264        paths.insert(wiki_page_path(output_dir, &doc.language, doc));
265        let doc_module = doc.module_path.join("::");
266        for card in cards {
267            if card.module_name == doc_module {
268                paths.insert(card_page_path(output_dir, &doc.language, &card.module_name));
269            }
270        }
271    }
272    paths.insert(api_doc_path(output_dir, &config.wiki.language));
273    paths.insert(toc_doc_path(output_dir));
274    paths.into_iter().collect()
275}
276
277/// 渲染所有文档到输出目录
278///
279/// 1. 创建输出目录结构(主语言 + 扩展语言)
280/// 2. 按文档自身语言渲染并写入 Wiki 页面(多语言独立生成,不再按语言循环复制;
281///    项目概览与架构概览由生成层产出,经 wiki_page_path 特判写 overview.md / architecture.md)
282/// 3. 渲染并写入 Knowledge Card
283/// 4. 生成 API 参考页(只写主语言)与目录页
284/// 5. 生成 Mermaid 关系图
285///
286/// `protected` 为人工修改保护集(路径字符串),命中路径跳过写盘,
287/// 覆盖 Wiki 页面与三个全局文档(api.md / overview.md / _toc.md)。
288/// v17 t06:mock provider 占位页脚标注(产物可辨识,防误读为真实文档)。
289/// 单一来源:lib.rs(LLM 文档)与 render_all(合成页 api.md)共用。
290pub const MOCK_FOOTER_MARK: &str = "\n\n<!-- 本页由 mock provider 生成,非真实内容 -->\n";
291
292/// 当前配置是否为 mock provider(占位页脚注入判定)
293fn is_mock_provider(config: &WikiConfig) -> bool {
294    matches!(
295        config.llm.provider,
296        crate::config::schema::LlmProviderType::Mock
297    )
298}
299
300pub fn render_all(
301    documents: &[WikiDocument],
302    cards: &[KnowledgeCard],
303    graph: &KnowledgeGraph,
304    config: &WikiConfig,
305    protected: &std::collections::HashSet<String>,
306) -> Result<()> {
307    let output_dir = config.output_dir();
308    let assets_dir = output_dir.join("assets");
309    let languages = wiki_languages(config);
310
311    // 按语言创建目录(扩展语言无文档时也保留空目录,保持目录结构稳定)
312    for lang in &languages {
313        std::fs::create_dir_all(output_dir.join("wiki").join(lang))?;
314        std::fs::create_dir_all(output_dir.join("cards").join(lang))?;
315    }
316    std::fs::create_dir_all(&assets_dir)?;
317
318    // 1. 写入 Wiki 页面(按文档自身语言分组写入对应目录)
319    for doc in documents {
320        // 路径计算与 write_document 落盘共用 wiki_page_path(人工修改保护判定依据)
321        let wiki_path = wiki_page_path(output_dir, &doc.language, doc);
322        if protected.contains(&wiki_path.to_string_lossy().to_string()) {
323            // 页面受人工修改保护:跳过页面写盘(保留人工版)。卡片写盘
324            // 已移至下方独立循环(v22 修复),不在此处处理。
325            continue;
326        }
327        write_document(doc, output_dir, &doc.language)?;
328    }
329
330    // 1.3 独立写入 Knowledge Card(v22 修复:原卡片写盘绑定在 write_document
331    // 内,模块页面 LLM 生成失败时卡片一并丢失,产出「快照/_index 有、磁盘
332    // 无」的不一致(Unity 实测:52 卡仅 42 页对应卡落盘)。卡片与页面解耦:
333    // 无论页面是否生成成功,卡片都按语言目录全量落盘;受人工修改保护的
334    // 卡片跳过(保留人工版)。卡片仅主语言生成一次(generate_all_cards 以
335    // 主语言调用),各语言目录写同一份内容——与旧实现语义一致。
336    for lang in &languages {
337        for card in cards {
338            let card_path = card_page_path(output_dir, lang, &card.module_name);
339            if protected.contains(&card_path.to_string_lossy().to_string()) {
340                continue;
341            }
342            crate::fs::write_file_atomic(
343                &card_path,
344                &markdown::render_knowledge_card(card),
345            )?;
346        }
347    }
348
349    // 1.5 写入 API 参考页(按模块分组的实体清单;内容与语言无关,只写主语言一份;
350    // 命中保护集跳过写盘。指纹记录按同一规则:state.rs 对未落盘的 en/api.md 不记指纹)
351    let primary_lang = &config.wiki.language;
352    for lang in &languages {
353        if lang != primary_lang {
354            continue;
355        }
356        let api_path = api_doc_path(output_dir, lang);
357        if protected.contains(&api_path.to_string_lossy().to_string()) {
358            continue;
359        }
360        let api_doc = markdown::render_api_reference(graph);
361        // v17 t06:mock 模式下合成页(api.md 非 LLM 文档,不走 lib.rs 的
362        // documents 注入路径)同样追加占位页脚,标注点保持单一来源
363        // MOCK_FOOTER_MARK,与 lib.rs 注入一致
364        let content = if is_mock_provider(config) {
365            format!("{}{}", api_doc.content, MOCK_FOOTER_MARK)
366        } else {
367            api_doc.content
368        };
369        crate::fs::write_file_atomic(&api_path, &content)?;
370    }
371
372    // 3. 写入 Knowledge Card 索引(JSON 格式,写入主语言目录)
373    let primary_lang = &languages[0];
374    let cards_index_json = serde_json::json!({
375        "version": "1.0",
376        "generated_at": chrono::Utc::now().to_rfc3339(),
377        "cards": cards.iter().map(|c| {
378            serde_json::json!({
379                "name": card_file_stem(&c.module_name),
380                "title": c.module_name,
381                "path": format!("cards/{}/{}.md", primary_lang, card_file_stem(&c.module_name)),
382            })
383        }).collect::<Vec<_>>(),
384    });
385    let cards_index = output_dir.join("cards").join(primary_lang).join("_index.json");
386    crate::fs::write_file_atomic(&cards_index, &serde_json::to_string_pretty(&cards_index_json)?)?;
387
388    // 4. 生成目录页(命中保护集跳过写盘)
389    let toc_path = toc_doc_path(output_dir);
390    if !protected.contains(&toc_path.to_string_lossy().to_string()) {
391        let toc = markdown::render_table_of_contents(documents);
392        crate::fs::write_file_atomic(&toc_path, &toc)?;
393    }
394
395    // 4.1 llms.txt(v14 E 组,t07 拍板):Agent 站点地图(llmstxt.org
396    // 规范),列出全部模块页/全局文档/卡片路径。确定性重生成产物,
397    // 不参与人工修改保护(与 _toc.md 的人工编辑语义不同);写失败
398    // 仅告警——机器消费索引是辅助产物,缺了不破坏 Wiki 主体。
399    if let Err(e) = llms_txt::write_llms_txt(output_dir, documents, cards, config) {
400        // t05(v21):llms.txt 是外部 Agent 的入口文件(站点地图),缺失
401        // 会静默削弱 Agent 的发现路径——失败必须显式说明影响面。
402        tracing::warn!("llms.txt 写入失败(Agent 入口文件缺失,搜索类 Agent 将无法发现本 Wiki): {}", e);
403    }
404
405    // 4.2 llms-full.txt(v19 t05):模块职责 + 实体清单内联索引
406    // (llms.txt 的超集,单次读取即获完整骨架;32K token 预算裁剪)。
407    // 与 llms.txt 同生命周期语义:确定性重生成、不参与人工修改保护、
408    // 写失败仅告警。
409    if let Err(e) = llms_txt::write_llms_full_txt(output_dir, cards, config) {
410        tracing::warn!("llms-full.txt 写入失败: {}", e);
411    }
412
413    // 5. 生成 Mermaid 依赖图
414    let diagrams_dir = assets_dir.join("diagrams");
415    std::fs::create_dir_all(&diagrams_dir)?;
416    let mermaid_content = mermaid::render_module_dependency_graph(graph);
417    crate::fs::write_file_atomic(&diagrams_dir.join("module-deps.mermaid"), &mermaid_content)?;
418
419    // 5.1 模块级调用关系图(Calls 边按模块聚合)
420    let call_graph_content = mermaid::render_module_call_graph(graph);
421    crate::fs::write_file_atomic(&diagrams_dir.join("call-graph.mermaid"), &call_graph_content)?;
422
423
424    tracing::info!(
425        "输出完成: {} 个页面, {} 个卡片, {} 个模块, 目录: {}",
426        documents.len(),
427        cards.len(),
428        graph.modules.len(),
429        config.output_dir().display()
430    );
431
432    // AGENTS.md 引导文件:不存在才生成(幂等),人工已有 AGENTS.md 时跳过。
433    // A2(v14):写失败显式告警——此前 `let _` 静默吞掉(与下方导出快照
434    // 写失败的 warn 语义对齐;引导文件是辅助产物,失败不中断渲染,但
435    // 必须可观测,否则用户以为生成了导航入口实际没有)。
436    if let Err(e) = generate_agents_md(output_dir) {
437        tracing::warn!("AGENTS.md 引导文件生成失败: {}", e);
438    }
439
440    // 6. 写盘完成后同步导出快照(export --skip-generate 消费的对外契约)。
441    //    辅助产物:写入失败仅告警不中断——快照缺失时 export --skip-generate
442    //    会明确报错,属可观测性契约内,非兜底。
443    if let Err(e) = write_export_snapshot(output_dir, documents, cards, graph) {
444        tracing::warn!("导出快照写入失败: {}", e);
445    }
446    Ok(())
447}
448
449/// 生成 AGENTS.md 引导文件(AI 代理导航入口,repositories-wiki 最佳实践)
450///
451/// 写入当前工作目录(与 scan_and_parse 的扫描根一致):若已存在则跳过
452/// (尊重人工维护的 AGENTS.md,不覆盖);内容指向 wiki 产物目录并说明
453/// AI 代理如何消费(搜索命令、卡片格式、更新流程、lint 门禁)。
454/// 返回是否生成了文件(false = 已存在跳过)。
455///
456/// v28 t08 模板对齐(t12 生态核证):
457/// - 结构用 agents.md 官网推荐节(产物布局/常用命令/开发规范),纯
458///   Markdown 不发明 schema、无必填字段(官网 FAQ 明示 "Are there
459///   required fields? No. AGENTS.md is just standard Markdown");
460/// - 指令可证伪:每节写明「做什么/何时做/何时不做」(KyenAI 2026-07
461///   实测 53% 样本缺验证/完成标准);
462/// - 保持精简(<200 行;TomeVault 实测 AGENTS.md 中位数 29 行,>200 行
463///   进入指令过载区,占 2.2%);
464/// - 单一基线不双发:只生成 AGENTS.md,不生成 CLAUDE.md(TomeVault 实测
465///   双发仓 88.9% 两文件互不连接,"第二份文件几乎从未被读")。
466pub fn generate_agents_md(output_dir: &Path) -> Result<bool> {
467    // AGENTS.md 写到产物目录的上级(项目根):output_dir 通常是 .code-repo-wiki/ 或 wiki/,
468    // 其上级即仓库根。不用 cwd——测试的 cwd 是项目根而 output_dir 是临时目录,
469    // 用 cwd 会把 AGENTS.md 写进被测仓库,污染工作树。
470    let Some(root) = output_dir.parent() else {
471        return Ok(false);
472    };
473    let agents_path = root.join("AGENTS.md");
474    if agents_path.exists() {
475        // t04a(v21):已存在时跳过注入是保护行为,但必须让用户/外部 Agent
476        // 知道产物没被指引(静默跳过会让 AI 代理找不到 wiki 入口)——
477        // 提示补救路径(v33:install 命令默认注入 wiki 引用块,可把
478        // 当前工具的指引合并进既有文件)。
479        tracing::warn!(
480            "仓库已存在 AGENTS.md({}),跳过注入以保护人工维护内容;如需 code-repo-wiki 指引可运行 `code-repo-wiki install`",
481            agents_path.display()
482        );
483        return Ok(false);
484    }
485    let content = format!(
486        r#"# AGENTS.md — AI 代理导航(由 code-repo-wiki 生成,可人工编辑)
487
488本仓库使用 code-repo-wiki 维护可持续进化的项目 Wiki,产物位于 `{output_dir}/`。
489
490## 产物布局
491
492- `{output_dir}/llms.txt` — Agent 站点地图(llmstxt.org 规范,首选入口;头部含生成时间戳与 git 源码基线,可据此核对新鲜度)
493- `{output_dir}/llms-full.txt` — 完整内容索引(32K token 预算内联实体清单,超预算模块页内注明省略)
494- `{output_dir}/wiki/{{lang}}/` — 模块页(每模块一份,含职责/实体/依赖/使用示例)
495- `{output_dir}/wiki/{{lang}}/api.md` — API 参考(按模块分组)
496- `{output_dir}/wiki/{{lang}}/architecture.md` — 架构概览
497- `{output_dir}/wiki/{{lang}}/overview.md` — 项目概览(自底向上合成)
498- `{output_dir}/cards/{{lang}}/` — Knowledge Card(AI 代理的结构化摘要,JSON 元数据+Markdown)
499- `{output_dir}/assets/diagrams/` — Mermaid 调用图/依赖图
500
501## 常用命令
502
503- 查找实体(函数/结构体/类):`code-repo-wiki search -q "<关键词>"`(text/semantic/hybrid 三引擎,hybrid 含调用链补全)。何时做:需要某实体的签名/定位/说明时;何时不做:不知道关键词时先读 llms.txt 定位页面,不要盲目搜索。
504- 更新产物:代码修改后运行 `code-repo-wiki update` 增量更新;`code-repo-wiki sync` 以 Git 内容合入;`code-repo-wiki lint` 检查产物健康(孤儿页/断链/过时)。何时做:每次代码变更后、以及发现产物与代码不一致时;何时不做:未改代码时不运行(no-op 无收益)。
505- 知识沉淀:`code-repo-wiki note "<记录>"` 追加到 `{output_dir}/wiki/{{lang}}/_log.md`。何时做:需要给后续会话留下可检索的决策或教训时。
506
507## 开发规范
508
509- 开始任务时:先读 `{output_dir}/wiki/{{lang}}/overview.md` 与 `{output_dir}/wiki/{{lang}}/architecture.md` 建立全局认知,再按需深入模块页;上下文预算充足时用 `llms-full.txt` 一次获得完整实体骨架。
510- 判断新鲜度:核对 `{output_dir}/llms.txt` 头部的生成时间戳与 git 基线——基线落后当前 HEAD 或时间戳距今超过 7 天时,先运行 `code-repo-wiki update` 再消费(过期产物会降低检索质量)。
511- 人工修改保护:产物页面被人工编辑后不会被自动覆盖(保护机制),修改会反向同步到卡片(pending_manual_edits 节)。
512- 何时不做:不直接编辑 `llms.txt` / `llms-full.txt`(确定性重生成会覆盖);不在产物目录手工放置页面(`code-repo-wiki lint` 会判为孤儿页)。
513"#,
514        output_dir = output_dir.display(),
515    );
516    crate::fs::write_file_atomic(&agents_path, &content)?;
517    tracing::info!("AGENTS.md 已生成: {}", agents_path.display());
518    Ok(true)
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use crate::model::{DocumentKind, KnowledgeCard, WikiDocument};
525
526    fn make_doc(language: &str) -> WikiDocument {
527        WikiDocument {
528            title: "TestModule".into(),
529            kind: DocumentKind::WikiPage,
530            content: "## 概述\n\n内容".into(),
531            language: language.into(),
532            module_path: vec!["src".into(), "testmodule".into()],
533            references: vec![],
534            last_updated: "2025-01-01T00:00:00Z".into(),
535            based_on_commit: None,
536            fingerprint: None,
537        }
538    }
539
540    fn make_card() -> KnowledgeCard {
541        KnowledgeCard {
542            module_name: "src::testmodule".into(),
543            module_type: "module".into(),
544            summary: "摘要".into(),
545            key_entities: vec![],
546            dependencies: vec![],
547            dependents: vec![],
548            design_patterns: vec![],
549            todo_notes: vec![],
550            related_files: vec![],
551            coding_spec: None,
552            tech_stack: vec![],
553            architecture: None,
554            pending_manual_edits: vec![],
555            features: Vec::new(),
556        }
557    }
558
559    #[test]
560    fn test_collect_languages_default_single() {
561        let config = WikiConfig::default();
562        assert_eq!(wiki_languages(&config), vec!["zh"]);
563    }
564    #[test]
565    fn test_collect_languages_single_main() {
566        let config = WikiConfig {
567            wiki: crate::config::schema::WikiSection {
568                language: "zh".into(),
569                guide: Default::default(),
570            },
571            ..Default::default()
572        };
573        // v30:expand_languages 已删除,恒只生成主语言
574        assert_eq!(wiki_languages(&config), vec!["zh"]);
575    }
576
577    /// A4:wiki 页与卡片的路径规则收敛后,路径计算必须与
578    /// render_all/write_document 的落盘命名完全一致(单测锁死规则,防止漂移)
579    #[test]
580    fn test_wiki_and_card_path_rules() {
581        let doc = make_doc("zh");
582        assert_eq!(
583            wiki_page_path(Path::new("out"), "zh", &doc),
584            Path::new("out").join("wiki").join("zh").join("src_testmodule.md")
585        );
586        // ArchitectureOverview 特判写 architecture.md
587        let arch = WikiDocument {
588            kind: DocumentKind::ArchitectureOverview,
589            ..make_doc("zh")
590        };
591        assert_eq!(
592            wiki_page_path(Path::new("out"), "zh", &arch),
593            Path::new("out").join("wiki").join("zh").join("architecture.md")
594        );
595        // ProjectOverview 特判写 overview.md
596        let overview = WikiDocument {
597            kind: DocumentKind::ProjectOverview,
598            ..make_doc("zh")
599        };
600        assert_eq!(
601            wiki_page_path(Path::new("out"), "zh", &overview),
602            Path::new("out").join("wiki").join("zh").join("overview.md")
603        );
604        // 卡片命名:module.replace("::","_"),与 card.rs 的 card_path 一致
605        assert_eq!(
606            card_page_path(Path::new("out"), "zh", "src::testmodule"),
607            Path::new("out").join("cards").join("zh").join("src_testmodule.md")
608        );
609    }
610
611    /// A3:人工编辑过的卡片进入保护集后,全量 generate 不覆盖(保留人工编辑版)
612    #[test]
613    fn test_render_all_skips_protected_card() {
614        let dir = std::env::temp_dir()
615            .join(format!("code_repo_wiki_test_protected_card_{}", std::process::id()));
616        let _ = std::fs::remove_dir_all(&dir);
617        let config = WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
618
619        let card = make_card();
620        let doc = make_doc("zh");
621        let graph = KnowledgeGraph::default();
622
623        // 预写"人工编辑版"卡片(与 render_all 落盘路径一致)
624        let card_file = dir.join("cards").join("zh").join("src_testmodule.md");
625        std::fs::create_dir_all(card_file.parent().unwrap()).unwrap();
626        std::fs::write(&card_file, "人工编辑的内容").unwrap();
627
628        // 保护集命中卡片路径 → 写盘跳过,人工编辑版保留
629        let protected: std::collections::HashSet<String> =
630            [card_file.to_string_lossy().to_string()].into_iter().collect();
631        render_all(std::slice::from_ref(&doc), std::slice::from_ref(&card), &graph, &config, &protected).unwrap();
632        let kept = std::fs::read_to_string(&card_file).unwrap();
633        assert_eq!(kept, "人工编辑的内容", "被保护的卡片不应被全量 generate 覆盖");
634
635        // 无保护时卡片正常写盘(保护语义开关验证)
636        let _ = std::fs::remove_file(&card_file);
637        let empty = std::collections::HashSet::new();
638        render_all(&[doc], &[card], &graph, &config, &empty).unwrap();
639        assert!(card_file.exists(), "未保护的卡片应正常写盘");
640        assert!(
641            dir.join("wiki").join("zh").join("src_testmodule.md").exists(),
642            "wiki 页应正常写盘"
643        );
644
645        let _ = std::fs::remove_dir_all(&dir);
646    }
647
648    /// v22 修复:卡片写盘与页面生成解耦——页面全部失败(documents 为空)
649    /// 时卡片仍全量落盘,杜绝「快照/_index 有、磁盘无」的不一致
650    /// (Unity 实测:52 卡仅 42 页对应卡落盘的根因是卡片写盘绑定
651    /// write_document,页面 LLM 失败即连带丢卡)。
652    #[test]
653    fn test_render_all_writes_cards_without_documents() {
654        let dir = std::env::temp_dir()
655            .join(format!("code_repo_wiki_test_cards_no_docs_{}", std::process::id()));
656        let _ = std::fs::remove_dir_all(&dir);
657        let config = WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
658
659        let card = make_card();
660        let graph = KnowledgeGraph::default();
661        let empty_docs: [WikiDocument; 0] = [];
662        let empty_protected = std::collections::HashSet::new();
663
664        render_all(&empty_docs, std::slice::from_ref(&card), &graph, &config, &empty_protected)
665            .unwrap();
666
667        assert!(
668            dir.join("cards").join("zh").join("src_testmodule.md").exists(),
669            "页面全部失败时卡片必须独立落盘"
670        );
671        assert!(
672            !dir.join("wiki").join("zh").join("src_testmodule.md").exists(),
673            "无文档时不应产出页面"
674        );
675
676        let _ = std::fs::remove_dir_all(&dir);
677    }
678
679    /// v28 t08:AGENTS.md 模板对齐——精简(<200 行)、可证伪(每节含
680    /// 「何时」= 做什么/何时做/何时不做)、保留 llms.txt/llms-full.txt
681    /// 指引与搜索建议;只生成 AGENTS.md 不生成 CLAUDE.md(单一基线不双发)
682    #[test]
683    fn test_generate_agents_md_template_aligned() {
684        let dir = std::env::temp_dir()
685            .join(format!("code_repo_wiki_test_agents_md_{}", std::process::id()));
686        let _ = std::fs::remove_dir_all(&dir);
687        let output_dir = dir.join("out");
688        std::fs::create_dir_all(&output_dir).unwrap();
689
690        let generated = generate_agents_md(&output_dir).unwrap();
691        assert!(generated, "首次生成应返回 true");
692
693        let content = std::fs::read_to_string(dir.join("AGENTS.md")).unwrap();
694        assert!(
695            content.lines().count() < 200,
696            "模板须精简(<200 行): {} 行",
697            content.lines().count()
698        );
699        // 官网推荐节(项目概述/常用命令/开发规范)
700        assert!(content.contains("## 产物布局"), "应含产物布局节: {content}");
701        assert!(content.contains("## 常用命令"), "应含常用命令节: {content}");
702        assert!(content.contains("## 开发规范"), "应含开发规范节: {content}");
703        // 保留既有功能:llms.txt / llms-full.txt 指引 + 搜索建议
704        assert!(content.contains("llms.txt"), "应保留 llms.txt 指引: {content}");
705        assert!(content.contains("llms-full.txt"), "应保留 llms-full.txt 指引: {content}");
706        assert!(content.contains("code-repo-wiki search"), "应保留搜索建议: {content}");
707        // 可证伪措辞:每节明确「何时做/何时不做」
708        assert!(content.contains("何时"), "指令须可证伪(含何时): {content}");
709        // 单一基线不双发:不生成 CLAUDE.md
710        assert!(
711            !dir.join("CLAUDE.md").exists(),
712            "只生成 AGENTS.md 不生成 CLAUDE.md"
713        );
714
715        // 幂等:已存在时跳过(返回 false,人工内容不被覆盖)
716        let second = generate_agents_md(&output_dir).unwrap();
717        assert!(!second, "已存在时应跳过注入");
718        let kept = std::fs::read_to_string(dir.join("AGENTS.md")).unwrap();
719        assert_eq!(kept, content, "已存在时内容不得被改动");
720
721        let _ = std::fs::remove_dir_all(&dir);
722    }
723}