1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::model::{DocumentKind, KnowledgeCard, KnowledgeGraph, WikiDocument};
6use crate::output::crossref::render_cite_link;
7
8pub fn render_wiki_page(doc: &WikiDocument) -> String {
10 let mut output = String::new();
11
12 output.push_str(&format!("# {}\n\n", doc.title));
14
15 output.push_str(&format!("> 最后更新: {}\n\n", doc.last_updated));
17
18 if let Some(commit) = &doc.based_on_commit {
22 output.push_str(&format!("> 基于提交: {}\n\n", commit));
23 }
24
25 output.push_str(&doc.content);
27
28 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
48pub 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 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 let signature = node.signature.as_deref().unwrap_or(node.name.as_str());
73 let doc = node
75 .doc_comment
76 .as_deref()
77 .map(|d| d.lines().next().unwrap_or(""))
78 .unwrap_or("");
79 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 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 based_on_commit: None,
113 fingerprint: None,
114 }
115}
116
117fn 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
137pub fn render_knowledge_card(card: &KnowledgeCard) -> String {
139 let mut output = String::new();
140
141 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 output.push_str(&format!("# {}\n\n", card.module_name));
170 output.push_str(&format!("## 摘要\n\n{}\n\n", card.summary));
171
172 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 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 if let Some(spec) = &card.coding_spec {
203 output.push_str(&format!("## 编码规范\n\n{}\n\n", spec));
204 }
205
206 if let Some(arch) = &card.architecture {
208 output.push_str(&format!("## 架构说明\n\n{}\n\n", arch));
209 }
210
211 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 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 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
241pub 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 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 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 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
287fn 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 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
319pub 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
331pub 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 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 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 #[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 assert!(output.contains("## 全局文档"), "应有全局文档节");
502 assert!(output.contains("## 模块"), "应有模块节");
503 assert!(
505 output.contains("### crate::config"),
506 "模块文档应按 module_path 分组, 实际: {output}"
507 );
508 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 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 assert!(doc.content.contains("## crate::config"));
559 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 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}