1pub 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
22pub fn wiki_languages(config: &WikiConfig) -> Vec<String> {
27 let languages = vec![config.wiki.language.clone()];
28 languages
29}
30
31pub fn api_doc_path(output_dir: &Path, lang: &str) -> PathBuf {
36 output_dir.join("wiki").join(lang).join("api.md")
37}
38
39pub fn overview_doc_path(output_dir: &Path, lang: &str) -> PathBuf {
41 output_dir.join("wiki").join(lang).join("overview.md")
42}
43
44pub fn toc_doc_path(output_dir: &Path) -> PathBuf {
46 output_dir.join("_toc.md")
47}
48
49pub(crate) fn card_file_stem(module: &str) -> String {
53 module.replace("::", "_")
54}
55
56pub(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
67pub(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
82pub 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#[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 #[serde(default)]
105 pub dependencies: Vec<String>,
106}
107
108#[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
121pub fn export_snapshot_path(output_dir: &Path) -> PathBuf {
124 output_dir.join(".state").join("export_snapshot.json")
125}
126
127pub 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
160pub fn export_modules(graph: &KnowledgeGraph, cards: &[KnowledgeCard]) -> Vec<ExportModuleSnapshot> {
162 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
229fn 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 crate::fs::write_file_atomic(&path, &serde_json::to_string_pretty(&snapshot)?)?;
246 Ok(())
247}
248
249pub 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
277pub const MOCK_FOOTER_MARK: &str = "\n\n<!-- 本页由 mock provider 生成,非真实内容 -->\n";
291
292fn 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 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 for doc in documents {
320 let wiki_path = wiki_page_path(output_dir, &doc.language, doc);
322 if protected.contains(&wiki_path.to_string_lossy().to_string()) {
323 continue;
326 }
327 write_document(doc, output_dir, &doc.language)?;
328 }
329
330 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 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 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 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 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 if let Err(e) = llms_txt::write_llms_txt(output_dir, documents, cards, config) {
400 tracing::warn!("llms.txt 写入失败(Agent 入口文件缺失,搜索类 Agent 将无法发现本 Wiki): {}", e);
403 }
404
405 if let Err(e) = llms_txt::write_llms_full_txt(output_dir, cards, config) {
410 tracing::warn!("llms-full.txt 写入失败: {}", e);
411 }
412
413 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 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 if let Err(e) = generate_agents_md(output_dir) {
437 tracing::warn!("AGENTS.md 引导文件生成失败: {}", e);
438 }
439
440 if let Err(e) = write_export_snapshot(output_dir, documents, cards, graph) {
444 tracing::warn!("导出快照写入失败: {}", e);
445 }
446 Ok(())
447}
448
449pub fn generate_agents_md(output_dir: &Path) -> Result<bool> {
467 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 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 assert_eq!(wiki_languages(&config), vec!["zh"]);
575 }
576
577 #[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 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 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 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 #[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 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 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 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 #[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 #[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 assert!(content.contains("## 产物布局"), "应含产物布局节: {content}");
701 assert!(content.contains("## 常用命令"), "应含常用命令节: {content}");
702 assert!(content.contains("## 开发规范"), "应含开发规范节: {content}");
703 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 assert!(content.contains("何时"), "指令须可证伪(含何时): {content}");
709 assert!(
711 !dir.join("CLAUDE.md").exists(),
712 "只生成 AGENTS.md 不生成 CLAUDE.md"
713 );
714
715 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}