Skip to main content

code_repo_wiki/output/
markdown.rs

1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::model::{DocumentKind, KnowledgeCard, KnowledgeGraph, WikiDocument};
6use crate::output::crossref::render_cite_link;
7
8/// 渲染 WikiDocument 为 Markdown 字符串
9pub fn render_wiki_page(doc: &WikiDocument) -> String {
10    let mut output = String::new();
11
12    // 标题
13    output.push_str(&format!("# {}\n\n", doc.title));
14
15    // 元信息
16    output.push_str(&format!("> 最后更新: {}\n\n", doc.last_updated));
17
18    // v32 10.2:git 基线行(仅 git 仓库有 HEAD 时输出;非 git 仓库省略,
19    // 页面仍只有「最后更新」时间戳)。HEAD 非易变信号——同一提交下多次
20    // 生成值不变,与 test_determinism 内容级哈希兼容(时间戳才需归一化)。
21    if let Some(commit) = &doc.based_on_commit {
22        output.push_str(&format!("> 基于提交: {}\n\n", commit));
23    }
24
25    // 内容(LLM 生成的主体部分)
26    output.push_str(&doc.content);
27
28    // 交叉引用
29    if !doc.references.is_empty() {
30        output.push_str("\n\n## 交叉引用\n\n");
31        for reference in &doc.references {
32            let rel = match reference.relation.as_str() {
33                "depends_on" => "依赖",
34                "used_by" => "被使用",
35                "related" => "相关",
36                _ => &reference.relation,
37            };
38            output.push_str(&format!(
39                "- {} — {}\n",
40                render_cite_link(&reference.target_title, &reference.target_path), rel
41            ));
42        }
43    }
44
45    output
46}
47
48/// 渲染 API 参考页(按模块分组,每实体一行)
49///
50/// 输出 `签名 — 文档注释 — 文件:行` 格式,供 api-ref 模板的页面使用。
51/// 只收录代码实体节点,跳过 project/module/file 容器节点。
52pub fn render_api_reference(graph: &KnowledgeGraph) -> WikiDocument {
53    let mut lines = vec!["# API 参考".to_string(), String::new()];
54
55    for module in &graph.modules {
56        lines.push(format!("## {}", module.name));
57        lines.push(String::new());
58        for nid in &module.node_ids {
59            let Some(node) = graph.graph.node_weight(*nid) else {
60                continue;
61            };
62            // 容器节点没有 API 形态,跳过
63            if matches!(
64                node.kind,
65                crate::model::NodeKind::Project
66                    | crate::model::NodeKind::Module
67                    | crate::model::NodeKind::File
68            ) {
69                continue;
70            }
71            // 签名优先,缺失时退回实体名
72            let signature = node.signature.as_deref().unwrap_or(node.name.as_str());
73            // 文档注释多行时只取首行,保持一行一实体
74            let doc = node
75                .doc_comment
76                .as_deref()
77                .map(|d| d.lines().next().unwrap_or(""))
78                .unwrap_or("");
79            // v21 G 组:实体行增强标注——追加类型中文名与可见性修饰符
80            // (如 `- \`pub fn load(...)\` (函数, pub) — ...`),使文档读者
81            // 一眼可知实体形态与可访问性。可见性由解析器按行级文本提取
82            // (Entity.visibility → CodeNode.visibility),缺失时省略标注。
83            let kind_zh = kind_label(&node.kind);
84            let vis_part = node
85                .visibility
86                .as_deref()
87                .map(|v| format!(", {v}"))
88                .unwrap_or_default();
89            let mut line = format!("- `{}` ({kind_zh}{vis_part}) — {}", signature, doc);
90            // 文件:行定位(无行号信息时省略)
91            if let Some(file) = node.file_path.as_deref() {
92                if let Some((start, _)) = node.line_range {
93                    line.push_str(&format!(" — {}:{}", file, start));
94                } else {
95                    line.push_str(&format!(" — {}", file));
96                }
97            }
98            lines.push(line);
99        }
100        lines.push(String::new());
101    }
102
103    WikiDocument {
104        title: "API 参考".into(),
105        kind: DocumentKind::ApiReference,
106        content: lines.join("\n"),
107        language: String::new(),
108        module_path: vec![],
109        references: vec![],
110        last_updated: chrono::Utc::now().to_rfc3339(),
111        // API 参考页由代码图渲染(非 LLM 页),不带 git 基线行
112        based_on_commit: None,
113        fingerprint: None,
114    }
115}
116
117/// 实体类型的中文标签(api.md 实体行增强标注用)
118fn kind_label(kind: &crate::model::NodeKind) -> &'static str {
119    match kind {
120        crate::model::NodeKind::Project => "项目",
121        crate::model::NodeKind::Module => "模块",
122        crate::model::NodeKind::File => "文件",
123        crate::model::NodeKind::Struct => "结构体",
124        crate::model::NodeKind::Enum => "枚举",
125        crate::model::NodeKind::Function => "函数",
126        crate::model::NodeKind::Trait => "Trait",
127        crate::model::NodeKind::Impl => "实现",
128        crate::model::NodeKind::Type => "类型别名",
129        crate::model::NodeKind::Constant => "常量",
130        crate::model::NodeKind::Variable => "变量",
131        crate::model::NodeKind::Interface => "接口",
132        crate::model::NodeKind::Class => "类",
133        crate::model::NodeKind::Macro => "宏",
134    }
135}
136
137/// 渲染 KnowledgeCard 为 Markdown(YAML frontmatter 格式)
138pub fn render_knowledge_card(card: &KnowledgeCard) -> String {
139    let mut output = String::new();
140
141    // YAML frontmatter
142    output.push_str("---\n");
143    output.push_str(&format!("module_name: {}\n", card.module_name));
144    output.push_str(&format!("module_type: {}\n", card.module_type));
145    if !card.dependencies.is_empty() {
146        output.push_str(&format!(
147            "dependencies: [{}]\n",
148            card.dependencies.join(", ")
149        ));
150    }
151    if !card.dependents.is_empty() {
152        output.push_str(&format!(
153            "dependents: [{}]\n",
154            card.dependents.join(", ")
155        ));
156    }
157    if !card.design_patterns.is_empty() {
158        output.push_str(&format!(
159            "design_patterns: [{}]\n",
160            card.design_patterns.join(", ")
161        ));
162    }
163    if !card.tech_stack.is_empty() {
164        output.push_str(&format!("tech_stack: [{}]\n", card.tech_stack.join(", ")));
165    }
166    output.push_str("---\n");
167
168    // 内容
169    output.push_str(&format!("# {}\n\n", card.module_name));
170    output.push_str(&format!("## 摘要\n\n{}\n\n", card.summary));
171
172    // 关键实体(source 为回填的源码定位反向链接,T3.3)
173    if !card.key_entities.is_empty() {
174        output.push_str("## 关键实体\n\n");
175        for entity in &card.key_entities {
176            let doc = entity.doc.as_deref().unwrap_or("");
177            if let Some(src) = &entity.source {
178                output.push_str(&format!(
179                    "- `{}` ({}) — {} [源码:{}]\n",
180                    entity.name, entity.kind, doc, src
181                ));
182            } else {
183                output.push_str(&format!(
184                    "- `{}` ({}) — {}\n",
185                    entity.name, entity.kind, doc
186                ));
187            }
188        }
189        output.push('\n');
190    }
191
192    // 相关文件(来自 chunk 源文件列表,非 LLM 输出)
193    if !card.related_files.is_empty() {
194        output.push_str("## 相关文件\n\n");
195        for f in &card.related_files {
196            output.push_str(&format!("- `{}`\n", f));
197        }
198        output.push('\n');
199    }
200
201    // 编码规范
202    if let Some(spec) = &card.coding_spec {
203        output.push_str(&format!("## 编码规范\n\n{}\n\n", spec));
204    }
205
206    // 架构说明
207    if let Some(arch) = &card.architecture {
208        output.push_str(&format!("## 架构说明\n\n{}\n\n", arch));
209    }
210
211    // 待办事项
212    if !card.todo_notes.is_empty() {
213        output.push_str("## 待办事项\n\n");
214        for note in &card.todo_notes {
215            output.push_str(&format!("- [ ] {}\n", note));
216        }
217        output.push('\n');
218    }
219
220    // 特征追溯(演进计划 T3.3:本模块参与的实体级特征,非空时渲染)
221    if !card.features.is_empty() {
222        output.push_str("## 特征追溯\n\n");
223        for f in &card.features {
224            output.push_str(&format!("- `{}`\n", f));
225        }
226        output.push('\n');
227    }
228
229    // 人工修改待同步(增量管道注入的记录,仅非空时渲染避免空节)
230    if !card.pending_manual_edits.is_empty() {
231        output.push_str("## 人工修改待同步\n\n");
232        for note in &card.pending_manual_edits {
233            output.push_str(&format!("- {}\n", note));
234        }
235        output.push('\n');
236    }
237
238    output
239}
240
241/// 渲染目录页 _toc.md
242///
243/// 按模块分组(Karpathy LLM Wiki 的"index 优先导航"最佳实践):
244/// 模块文档按 module_path 前缀分组展示,全局文档(架构/概览/API/目录)单列。
245/// 链接路径与写盘命名保持一致(wiki/{doc.language}/{file})。
246pub fn render_table_of_contents(documents: &[WikiDocument]) -> String {
247    let mut output = String::new();
248    output.push_str("# Wiki 文档目录\n\n");
249    output.push_str(&format!("> 共 {} 个页面\n\n", documents.len()));
250
251    // 按 module_path 前缀分组:模块文档归入各自模块,全局文档单列
252    let mut module_docs: std::collections::BTreeMap<String, Vec<&WikiDocument>> = Default::default();
253    let mut global_docs: Vec<&WikiDocument> = Vec::new();
254    for doc in documents {
255        if doc.module_path.is_empty() {
256            global_docs.push(doc);
257        } else {
258            module_docs
259                .entry(doc.module_path.join("::"))
260                .or_default()
261                .push(doc);
262        }
263    }
264
265    // 全局文档(架构/概览/API/目录)优先
266    if !global_docs.is_empty() {
267        output.push_str("## 全局文档\n\n");
268        for doc in &global_docs {
269            output.push_str(&render_toc_line(doc));
270        }
271        output.push('\n');
272    }
273
274    // 模块文档按模块分组
275    output.push_str("## 模块\n\n");
276    for (module, docs) in &module_docs {
277        output.push_str(&format!("### {module}\n\n"));
278        for doc in docs {
279            output.push_str(&render_toc_line(doc));
280        }
281        output.push('\n');
282    }
283
284    output
285}
286
287/// 渲染 TOC 单行:链接 + 类型标签 + 模块路径
288fn render_toc_line(doc: &WikiDocument) -> String {
289    let kind = match doc.kind {
290        DocumentKind::WikiPage => "模块文档",
291        DocumentKind::ArchitectureOverview => "架构概览",
292        DocumentKind::ProjectOverview => "项目概览",
293        DocumentKind::TableOfContents => "目录",
294        DocumentKind::KnowledgeCard => "知识卡片",
295        DocumentKind::ApiReference => "API 参考",
296        DocumentKind::DatabaseSchema => "数据库 Schema",
297    };
298    // 链接的文件名必须与 write_document 的落盘命名保持一致,否则 TOC 就是断链:
299    // 1. 所有文档都写在 wiki/{doc.language}/ 语言目录下,链接必须带语言前缀;
300    // 2. 架构概览固定写为 architecture.md(见 write_document),不能走 module_path 派生;
301    // 3. 项目概览固定写为 overview.md,与 wiki_page_path 特判保持一致;
302    // 4. 其余文档用 wiki_file_name(模块路径或标题派生,覆盖 Database Schema 等无模块路径文档)。
303    let file = match doc.kind {
304        DocumentKind::ArchitectureOverview => "architecture.md".to_string(),
305        DocumentKind::ProjectOverview => "overview.md".to_string(),
306        _ => wiki_file_name(doc),
307    };
308    let module_path = if doc.module_path.is_empty() {
309        "根".to_string()
310    } else {
311        doc.module_path.join(" > ")
312    };
313    format!(
314        "- [{}](wiki/{}/{}) `[{}]` — {}\n",
315        doc.title, doc.language, file, kind, module_path
316    )
317}
318
319/// 计算 Wiki 页面文件名
320///
321/// module_path 为空时用标题,标题中的路径分隔符与 Windows 非法字符(/ \ :)
322/// 替换为 '-',避免生成嵌套目录或写盘失败(如 Database Schema 文档标题含路径)。
323pub fn wiki_file_name(doc: &WikiDocument) -> String {
324    if doc.module_path.is_empty() {
325        format!("{}.md", doc.title.replace(['/', '\\', ':'], "-"))
326    } else {
327        format!("{}.md", doc.module_path.join("_"))
328    }
329}
330
331/// 写文件到磁盘
332///
333/// 将 WikiDocument 渲染后写入 `{output_dir}/wiki/{language}/{module_path}.md`。
334/// 路径统一由 output::wiki_page_path 产出,与 render_all 的保护判定路径
335/// 同一规则,保证命名不会漂移。
336///
337/// v22 修复(Unity 实测):Knowledge Card 写盘原先绑定在本函数(页面成功
338/// 才写卡片)——模块页面 LLM 生成失败时卡片也丢失,产出「快照/_index
339/// 有、磁盘无」的不一致。卡片写盘已移至 render_all 独立循环(页面失败
340/// 不影响卡片落盘),本函数只负责页面。
341pub fn write_document(doc: &WikiDocument, output_dir: &Path, language: &str) -> Result<()> {
342    let wiki_dir = output_dir.join("wiki").join(language);
343    std::fs::create_dir_all(&wiki_dir)?;
344
345    let wiki_path = crate::output::wiki_page_path(output_dir, language, doc);
346    let content = render_wiki_page(doc);
347    crate::fs::write_file_atomic(&wiki_path, &content)?;
348
349    Ok(())
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::model::EntitySummary;
356    use crate::model::Reference;
357
358    fn make_test_doc(title: &str) -> WikiDocument {
359        WikiDocument {
360            title: title.into(),
361            kind: DocumentKind::WikiPage,
362            content: format!("## 概述\n\n这是 {} 的文档。\n\n## 核心实体\n\n- `Foo` — 核心结构体", title),
363            language: "zh".into(),
364            module_path: vec!["crate".into(), title.to_lowercase()],
365            references: vec![Reference {
366                target_title: "bar".into(),
367                target_path: "wiki/bar.md".into(),
368                relation: "depends_on".into(),
369            }],
370            last_updated: "2025-01-01T00:00:00Z".into(),
371            based_on_commit: None,
372            fingerprint: None,
373        }
374    }
375
376    #[test]
377    fn test_render_wiki_page() {
378        let doc = make_test_doc("Config");
379        let output = render_wiki_page(&doc);
380
381        assert!(output.contains("# Config"));
382        assert!(output.contains("## 概述"));
383        assert!(output.contains("`Foo`"));
384        assert!(output.contains("交叉引用"));
385        assert!(output.contains("bar"));
386    }
387
388    #[test]
389    fn test_render_knowledge_card() {
390        let card = KnowledgeCard {
391            module_name: "crate::config".into(),
392            module_type: "module".into(),
393            summary: "配置管理模块".into(),
394            key_entities: vec![EntitySummary {
395                name: "Config".into(),
396                kind: "struct".into(),
397                visibility: "public".into(),
398                doc: Some("配置结构体".into()),
399                source: None,
400            }],
401            dependencies: vec!["serde".into()],
402            dependents: vec![],
403            design_patterns: vec!["Builder".into()],
404            todo_notes: vec!["增加环境变量支持".into()],
405            related_files: vec!["src/config.rs".into()],
406            coding_spec: Some("遵循 rustfmt".into()),
407            tech_stack: vec!["serde".into()],
408            architecture: Some("分层".into()),
409            pending_manual_edits: vec!["人工修改待同步: wiki/zh/src_config.md 内容摘要: 手动改".into()],
410            features: Vec::new(),
411        };
412
413        let output = render_knowledge_card(&card);
414        assert!(output.starts_with("---"));
415        assert!(output.contains("module_name: crate::config"));
416        assert!(output.contains("dependencies: [serde]"));
417        assert!(output.contains("design_patterns: [Builder]"));
418        assert!(output.contains("tech_stack: [serde]"));
419        assert!(output.contains("## 摘要"));
420        assert!(output.contains("配置管理模块"));
421        assert!(output.contains("`Config`"));
422        assert!(output.contains("增加环境变量支持"));
423        assert!(output.contains("## 相关文件"));
424        assert!(output.contains("src/config.rs"));
425        assert!(output.contains("## 编码规范"));
426        assert!(output.contains("遵循 rustfmt"));
427        assert!(output.contains("## 架构说明"));
428        assert!(output.contains("分层"));
429        assert!(output.contains("## 人工修改待同步"));
430        assert!(output.contains("内容摘要: 手动改"));
431    }
432
433    #[test]
434    fn test_render_knowledge_card_skips_empty_pending_edits() {
435        let card = KnowledgeCard {
436            module_name: "crate::config".into(),
437            module_type: "module".into(),
438            summary: "配置管理模块".into(),
439            key_entities: vec![],
440            dependencies: vec![],
441            dependents: vec![],
442            design_patterns: vec![],
443            todo_notes: vec![],
444            related_files: vec![],
445            coding_spec: None,
446            tech_stack: vec![],
447            architecture: None,
448            pending_manual_edits: vec![],
449            features: Vec::new(),
450        };
451
452        let output = render_knowledge_card(&card);
453        assert!(!output.contains("人工修改待同步"), "无记录时不应渲染空节");
454    }
455
456    #[test]
457    fn test_render_table_of_contents() {
458        let mut docs = vec![make_test_doc("Config"), make_test_doc("Server")];
459        // 架构概览无模块路径,链接固定指向 architecture.md(与 write_document 命名一致)
460        docs.push(WikiDocument {
461            title: "架构概览".into(),
462            kind: DocumentKind::ArchitectureOverview,
463            content: String::new(),
464            language: "zh".into(),
465            module_path: vec![],
466            references: vec![],
467            last_updated: "2025-01-01T00:00:00Z".into(),
468            based_on_commit: None,
469            fingerprint: None,
470        });
471        let output = render_table_of_contents(&docs);
472
473        assert!(output.contains("# Wiki 文档目录"));
474        assert!(output.contains("Config"));
475        assert!(output.contains("Server"));
476        assert!(output.contains("3 个页面"));
477        // 链接必须带语言目录前缀,与实际落盘路径 wiki/{lang}/{file}.md 一致
478        assert!(output.contains("](wiki/zh/crate_config.md)"));
479        assert!(output.contains("](wiki/zh/crate_server.md)"));
480        assert!(output.contains("](wiki/zh/architecture.md)"));
481    }
482
483    /// TOC 按模块分组(index 优先导航):全局文档与模块文档分节
484    #[test]
485    fn test_render_table_of_contents_groups_by_module() {
486        let mut docs = vec![make_test_doc("Config"), make_test_doc("Server")];
487        docs.push(WikiDocument {
488            title: "项目概览".into(),
489            kind: DocumentKind::ProjectOverview,
490            content: String::new(),
491            language: "zh".into(),
492            module_path: vec![],
493            references: vec![],
494            last_updated: "2025-01-01T00:00:00Z".into(),
495            based_on_commit: None,
496            fingerprint: None,
497        });
498        let output = render_table_of_contents(&docs);
499
500        // 全局文档节与模块节并存
501        assert!(output.contains("## 全局文档"), "应有全局文档节");
502        assert!(output.contains("## 模块"), "应有模块节");
503        // 模块文档归入 module_path 分组头
504        assert!(
505            output.contains("### crate::config"),
506            "模块文档应按 module_path 分组, 实际: {output}"
507        );
508        // 全局文档在全局节内(项目概览 title 出现), 且不在模块节内
509        let global_section = output.split("## 模块").next().unwrap_or("");
510        assert!(
511            global_section.contains("项目概览"),
512            "项目概览应在全局文档节"
513        );
514    }
515
516    #[test]
517    fn test_render_api_reference() {
518        // 构造含容器节点 + 实体的图
519        let mut g = petgraph::stable_graph::StableDiGraph::<
520            crate::model::CodeNode,
521            crate::model::CodeEdge,
522        >::new();
523        let file_id = g.add_node(crate::model::CodeNode {
524            id: petgraph::stable_graph::NodeIndex::new(0),
525            kind: crate::model::NodeKind::File,
526            name: "config.rs".into(),
527            file_path: Some("src/config.rs".into()),
528            line_range: None,
529            doc_comment: None,
530            signature: None, visibility: None,
531            module_path: vec!["crate".into(), "config".into()],
532        });
533        let fn_id = g.add_node(crate::model::CodeNode {
534            id: petgraph::stable_graph::NodeIndex::new(1),
535            kind: crate::model::NodeKind::Function,
536            name: "load".into(),
537            file_path: Some("src/config.rs".into()),
538            line_range: Some((12, 20)),
539            doc_comment: Some("加载配置\n\n多行注释".into()),
540            signature: Some("pub fn load(path: &str) -> Result<Config>".into()), visibility: Some("pub".into()),
541            module_path: vec!["crate".into(), "config".into()],
542        });
543        let graph = KnowledgeGraph {
544            graph: g,
545            modules: vec![crate::model::ModuleCluster {
546                name: "crate::config".into(),
547                node_ids: vec![file_id, fn_id],
548                cohesion: 0.8,
549                coupling: 0.2,
550                description: None,
551            }],
552            features: Vec::new(),
553        };
554
555        let doc = render_api_reference(&graph);
556        assert_eq!(doc.kind, DocumentKind::ApiReference);
557        // 容器节点被跳过,只输出函数实体
558        assert!(doc.content.contains("## crate::config"));
559        // v21 G 组:实体行带类型中文标注 + 可见性修饰符
560        assert!(doc.content.contains("- `pub fn load(path: &str) -> Result<Config>` (函数, pub) — 加载配置 — src/config.rs:12"));
561        assert!(!doc.content.contains("config.rs` —"));
562    }
563
564    #[test]
565    fn test_render_api_reference_omits_visibility_when_absent() {
566        // 无可见性信息(如 Python/Go 无修饰符语法)时标注省略可见性段
567        let mut g = petgraph::stable_graph::StableDiGraph::<
568            crate::model::CodeNode,
569            crate::model::CodeEdge,
570        >::new();
571        let fn_id = g.add_node(crate::model::CodeNode {
572            id: petgraph::stable_graph::NodeIndex::new(0),
573            kind: crate::model::NodeKind::Function,
574            name: "run".into(),
575            file_path: Some("src/main.py".into()),
576            line_range: Some((1, 3)),
577            doc_comment: None,
578            signature: Some("def run()".into()), visibility: None,
579            module_path: vec!["main".into()],
580        });
581        let graph = KnowledgeGraph {
582            graph: g,
583            modules: vec![crate::model::ModuleCluster {
584                name: "main".into(),
585                node_ids: vec![fn_id],
586                cohesion: 1.0,
587                coupling: 0.0,
588                description: None,
589            }],
590            features: Vec::new(),
591        };
592        let doc = render_api_reference(&graph);
593        assert!(doc.content.contains("- `def run()` (函数) —  — src/main.py:1"));
594        assert!(!doc.content.contains(", pub)"));
595    }
596
597    #[test]
598    fn test_write_document_roundtrip() {
599        let doc = make_test_doc("TestModule");
600
601        let dir = std::env::temp_dir().join("code-repo-wiki-test-markdown");
602        let _ = std::fs::remove_dir_all(&dir);
603
604        write_document(&doc, &dir, "zh").unwrap();
605
606        assert!(dir.join("wiki").join("zh").join("crate_testmodule.md").exists());
607
608        let _ = std::fs::remove_dir_all(&dir);
609    }
610}