pub mod crossref;
pub mod citation;
pub mod lint;
pub mod llms_txt;
pub mod semantic_lint;
pub mod markdown;
pub mod mermaid;
pub mod mermaid_check;
pub mod html;
use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::config::schema::WikiConfig;
use crate::model::{KnowledgeCard, KnowledgeGraph, WikiDocument};
use self::markdown::write_document;
pub fn wiki_languages(config: &WikiConfig) -> Vec<String> {
let languages = vec![config.wiki.language.clone()];
languages
}
pub fn api_doc_path(output_dir: &Path, lang: &str) -> PathBuf {
output_dir.join("wiki").join(lang).join("api.md")
}
pub fn overview_doc_path(output_dir: &Path, lang: &str) -> PathBuf {
output_dir.join("wiki").join(lang).join("overview.md")
}
pub fn toc_doc_path(output_dir: &Path) -> PathBuf {
output_dir.join("_toc.md")
}
pub(crate) fn card_file_stem(module: &str) -> String {
module.replace("::", "_")
}
pub(crate) fn card_page_path(output_dir: &Path, lang: &str, module: &str) -> PathBuf {
output_dir
.join("cards")
.join(lang)
.join(format!("{}.md", card_file_stem(module)))
}
pub(crate) fn wiki_page_path(output_dir: &Path, lang: &str, doc: &WikiDocument) -> PathBuf {
if doc.kind == crate::model::DocumentKind::ArchitectureOverview {
output_dir.join("wiki").join(lang).join("architecture.md")
} else if doc.kind == crate::model::DocumentKind::ProjectOverview {
output_dir.join("wiki").join(lang).join("overview.md")
} else {
output_dir.join("wiki").join(lang).join(markdown::wiki_file_name(doc))
}
}
pub fn wiki_page_html_path(output_dir: &Path, doc: &WikiDocument) -> PathBuf {
wiki_page_path(output_dir, &doc.language, doc).with_extension("html")
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ExportModuleSnapshot {
pub name: String,
pub files: Vec<String>,
pub cohesion: f64,
pub coupling: f64,
pub features: Vec<String>,
#[serde(default)]
pub dependencies: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ExportSnapshot {
pub version: u32,
pub documents: Vec<WikiDocument>,
pub cards: Vec<KnowledgeCard>,
pub modules: Vec<ExportModuleSnapshot>,
}
pub fn export_snapshot_path(output_dir: &Path) -> PathBuf {
output_dir.join(".state").join("export_snapshot.json")
}
pub fn latest_wiki_page_mtime(output_dir: &Path) -> Option<std::time::SystemTime> {
let wiki_root = output_dir.join("wiki");
let mut latest: Option<std::time::SystemTime> = None;
let Ok(entries) = std::fs::read_dir(&wiki_root) else {
return None;
};
for lang in entries.flatten() {
if !lang.path().is_dir() {
continue;
}
let Ok(pages) = std::fs::read_dir(lang.path()) else {
continue;
};
for page in pages.flatten() {
let path = page.path();
if path.extension().is_some_and(|e| e == "md")
&& let Ok(meta) = std::fs::metadata(&path)
&& let Ok(mtime) = meta.modified()
{
latest = Some(match latest {
Some(prev) => prev.max(mtime),
None => mtime,
});
}
}
}
latest
}
pub fn export_modules(graph: &KnowledgeGraph, cards: &[KnowledgeCard]) -> Vec<ExportModuleSnapshot> {
use petgraph::visit::{EdgeRef, IntoEdgeReferences};
use std::collections::{BTreeMap, BTreeSet};
let mut node_module: std::collections::HashMap<crate::model::NodeId, String> =
std::collections::HashMap::new();
for module in &graph.modules {
for nid in &module.node_ids {
node_module
.entry(*nid)
.or_insert_with(|| module.name.clone());
}
}
let mut deps: BTreeMap<String, BTreeSet<String>> = Default::default();
for edge in graph.graph.edge_references() {
if matches!(
graph.graph[edge.id()].kind,
crate::model::EdgeKind::Calls | crate::model::EdgeKind::Imports
) {
let (Some(src), Some(tgt)) = (
node_module.get(&edge.source()),
node_module.get(&edge.target()),
) else {
continue;
};
if src != tgt {
deps.entry(src.clone()).or_default().insert(tgt.clone());
}
}
}
let mut modules: Vec<ExportModuleSnapshot> = graph
.modules
.iter()
.map(|m| {
let mut files: Vec<String> = m
.node_ids
.iter()
.filter_map(|nid| graph.graph.node_weight(*nid).and_then(|n| n.file_path.clone()))
.collect();
files.sort();
files.dedup();
let features = cards
.iter()
.find(|c| c.module_name == m.name)
.map(|c| c.features.clone())
.unwrap_or_default();
let mut dependencies: Vec<String> = deps
.get(&m.name)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default();
dependencies.sort();
ExportModuleSnapshot {
name: m.name.clone(),
files,
cohesion: m.cohesion,
coupling: m.coupling,
features,
dependencies,
}
})
.collect();
modules.sort_by(|a, b| a.name.cmp(&b.name));
modules
}
fn write_export_snapshot(
output_dir: &Path,
documents: &[WikiDocument],
cards: &[KnowledgeCard],
graph: &KnowledgeGraph,
) -> Result<()> {
let snapshot = ExportSnapshot {
version: 1,
documents: documents.to_vec(),
cards: cards.to_vec(),
modules: export_modules(graph, cards),
};
let path = export_snapshot_path(output_dir);
crate::fs::write_file_atomic(&path, &serde_json::to_string_pretty(&snapshot)?)?;
Ok(())
}
pub fn rendered_paths(
documents: &[WikiDocument],
cards: &[KnowledgeCard],
config: &WikiConfig,
) -> Vec<PathBuf> {
let output_dir = config.output_dir();
let mut paths: std::collections::BTreeSet<PathBuf> = Default::default();
for doc in documents {
paths.insert(wiki_page_path(output_dir, &doc.language, doc));
let doc_module = doc.module_path.join("::");
for card in cards {
if card.module_name == doc_module {
paths.insert(card_page_path(output_dir, &doc.language, &card.module_name));
}
}
}
paths.insert(api_doc_path(output_dir, &config.wiki.language));
paths.insert(toc_doc_path(output_dir));
paths.into_iter().collect()
}
pub const MOCK_FOOTER_MARK: &str = "\n\n<!-- 本页由 mock provider 生成,非真实内容 -->\n";
fn is_mock_provider(config: &WikiConfig) -> bool {
matches!(
config.llm.provider,
crate::config::schema::LlmProviderType::Mock
)
}
pub fn render_all(
documents: &[WikiDocument],
cards: &[KnowledgeCard],
graph: &KnowledgeGraph,
config: &WikiConfig,
protected: &std::collections::HashSet<String>,
) -> Result<()> {
let output_dir = config.output_dir();
let assets_dir = output_dir.join("assets");
let languages = wiki_languages(config);
for lang in &languages {
std::fs::create_dir_all(output_dir.join("wiki").join(lang))?;
std::fs::create_dir_all(output_dir.join("cards").join(lang))?;
}
std::fs::create_dir_all(&assets_dir)?;
for doc in documents {
let wiki_path = wiki_page_path(output_dir, &doc.language, doc);
if protected.contains(&wiki_path.to_string_lossy().to_string()) {
continue;
}
write_document(doc, output_dir, &doc.language)?;
}
for lang in &languages {
for card in cards {
let card_path = card_page_path(output_dir, lang, &card.module_name);
if protected.contains(&card_path.to_string_lossy().to_string()) {
continue;
}
crate::fs::write_file_atomic(
&card_path,
&markdown::render_knowledge_card(card),
)?;
}
}
let primary_lang = &config.wiki.language;
for lang in &languages {
if lang != primary_lang {
continue;
}
let api_path = api_doc_path(output_dir, lang);
if protected.contains(&api_path.to_string_lossy().to_string()) {
continue;
}
let api_doc = markdown::render_api_reference(graph);
let content = if is_mock_provider(config) {
format!("{}{}", api_doc.content, MOCK_FOOTER_MARK)
} else {
api_doc.content
};
crate::fs::write_file_atomic(&api_path, &content)?;
}
let primary_lang = &languages[0];
let cards_index_json = serde_json::json!({
"version": "1.0",
"generated_at": chrono::Utc::now().to_rfc3339(),
"cards": cards.iter().map(|c| {
serde_json::json!({
"name": card_file_stem(&c.module_name),
"title": c.module_name,
"path": format!("cards/{}/{}.md", primary_lang, card_file_stem(&c.module_name)),
})
}).collect::<Vec<_>>(),
});
let cards_index = output_dir.join("cards").join(primary_lang).join("_index.json");
crate::fs::write_file_atomic(&cards_index, &serde_json::to_string_pretty(&cards_index_json)?)?;
let toc_path = toc_doc_path(output_dir);
if !protected.contains(&toc_path.to_string_lossy().to_string()) {
let toc = markdown::render_table_of_contents(documents);
crate::fs::write_file_atomic(&toc_path, &toc)?;
}
if let Err(e) = llms_txt::write_llms_txt(output_dir, documents, cards, config) {
tracing::warn!("llms.txt 写入失败(Agent 入口文件缺失,搜索类 Agent 将无法发现本 Wiki): {}", e);
}
if let Err(e) = llms_txt::write_llms_full_txt(output_dir, cards, config) {
tracing::warn!("llms-full.txt 写入失败: {}", e);
}
let diagrams_dir = assets_dir.join("diagrams");
std::fs::create_dir_all(&diagrams_dir)?;
let mermaid_content = mermaid::render_module_dependency_graph(graph);
crate::fs::write_file_atomic(&diagrams_dir.join("module-deps.mermaid"), &mermaid_content)?;
let call_graph_content = mermaid::render_module_call_graph(graph);
crate::fs::write_file_atomic(&diagrams_dir.join("call-graph.mermaid"), &call_graph_content)?;
tracing::info!(
"输出完成: {} 个页面, {} 个卡片, {} 个模块, 目录: {}",
documents.len(),
cards.len(),
graph.modules.len(),
config.output_dir().display()
);
if let Err(e) = generate_agents_md(output_dir) {
tracing::warn!("AGENTS.md 引导文件生成失败: {}", e);
}
if let Err(e) = write_export_snapshot(output_dir, documents, cards, graph) {
tracing::warn!("导出快照写入失败: {}", e);
}
Ok(())
}
pub fn generate_agents_md(output_dir: &Path) -> Result<bool> {
let Some(root) = output_dir.parent() else {
return Ok(false);
};
let agents_path = root.join("AGENTS.md");
if agents_path.exists() {
tracing::warn!(
"仓库已存在 AGENTS.md({}),跳过注入以保护人工维护内容;如需 code-repo-wiki 指引可运行 `code-repo-wiki install`",
agents_path.display()
);
return Ok(false);
}
let content = format!(
r#"# AGENTS.md — AI 代理导航(由 code-repo-wiki 生成,可人工编辑)
本仓库使用 code-repo-wiki 维护可持续进化的项目 Wiki,产物位于 `{output_dir}/`。
## 产物布局
- `{output_dir}/llms.txt` — Agent 站点地图(llmstxt.org 规范,首选入口;头部含生成时间戳与 git 源码基线,可据此核对新鲜度)
- `{output_dir}/llms-full.txt` — 完整内容索引(32K token 预算内联实体清单,超预算模块页内注明省略)
- `{output_dir}/wiki/{{lang}}/` — 模块页(每模块一份,含职责/实体/依赖/使用示例)
- `{output_dir}/wiki/{{lang}}/api.md` — API 参考(按模块分组)
- `{output_dir}/wiki/{{lang}}/architecture.md` — 架构概览
- `{output_dir}/wiki/{{lang}}/overview.md` — 项目概览(自底向上合成)
- `{output_dir}/cards/{{lang}}/` — Knowledge Card(AI 代理的结构化摘要,JSON 元数据+Markdown)
- `{output_dir}/assets/diagrams/` — Mermaid 调用图/依赖图
## 常用命令
- 查找实体(函数/结构体/类):`code-repo-wiki search -q "<关键词>"`(text/semantic/hybrid 三引擎,hybrid 含调用链补全)。何时做:需要某实体的签名/定位/说明时;何时不做:不知道关键词时先读 llms.txt 定位页面,不要盲目搜索。
- 更新产物:代码修改后运行 `code-repo-wiki update` 增量更新;`code-repo-wiki sync` 以 Git 内容合入;`code-repo-wiki lint` 检查产物健康(孤儿页/断链/过时)。何时做:每次代码变更后、以及发现产物与代码不一致时;何时不做:未改代码时不运行(no-op 无收益)。
- 知识沉淀:`code-repo-wiki note "<记录>"` 追加到 `{output_dir}/wiki/{{lang}}/_log.md`。何时做:需要给后续会话留下可检索的决策或教训时。
## 开发规范
- 开始任务时:先读 `{output_dir}/wiki/{{lang}}/overview.md` 与 `{output_dir}/wiki/{{lang}}/architecture.md` 建立全局认知,再按需深入模块页;上下文预算充足时用 `llms-full.txt` 一次获得完整实体骨架。
- 判断新鲜度:核对 `{output_dir}/llms.txt` 头部的生成时间戳与 git 基线——基线落后当前 HEAD 或时间戳距今超过 7 天时,先运行 `code-repo-wiki update` 再消费(过期产物会降低检索质量)。
- 人工修改保护:产物页面被人工编辑后不会被自动覆盖(保护机制),修改会反向同步到卡片(pending_manual_edits 节)。
- 何时不做:不直接编辑 `llms.txt` / `llms-full.txt`(确定性重生成会覆盖);不在产物目录手工放置页面(`code-repo-wiki lint` 会判为孤儿页)。
"#,
output_dir = output_dir.display(),
);
crate::fs::write_file_atomic(&agents_path, &content)?;
tracing::info!("AGENTS.md 已生成: {}", agents_path.display());
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{DocumentKind, KnowledgeCard, WikiDocument};
fn make_doc(language: &str) -> WikiDocument {
WikiDocument {
title: "TestModule".into(),
kind: DocumentKind::WikiPage,
content: "## 概述\n\n内容".into(),
language: language.into(),
module_path: vec!["src".into(), "testmodule".into()],
references: vec![],
last_updated: "2025-01-01T00:00:00Z".into(),
based_on_commit: None,
fingerprint: None,
}
}
fn make_card() -> KnowledgeCard {
KnowledgeCard {
module_name: "src::testmodule".into(),
module_type: "module".into(),
summary: "摘要".into(),
key_entities: vec![],
dependencies: vec![],
dependents: vec![],
design_patterns: vec![],
todo_notes: vec![],
related_files: vec![],
coding_spec: None,
tech_stack: vec![],
architecture: None,
pending_manual_edits: vec![],
features: Vec::new(),
}
}
#[test]
fn test_collect_languages_default_single() {
let config = WikiConfig::default();
assert_eq!(wiki_languages(&config), vec!["zh"]);
}
#[test]
fn test_collect_languages_single_main() {
let config = WikiConfig {
wiki: crate::config::schema::WikiSection {
language: "zh".into(),
guide: Default::default(),
},
..Default::default()
};
assert_eq!(wiki_languages(&config), vec!["zh"]);
}
#[test]
fn test_wiki_and_card_path_rules() {
let doc = make_doc("zh");
assert_eq!(
wiki_page_path(Path::new("out"), "zh", &doc),
Path::new("out").join("wiki").join("zh").join("src_testmodule.md")
);
let arch = WikiDocument {
kind: DocumentKind::ArchitectureOverview,
..make_doc("zh")
};
assert_eq!(
wiki_page_path(Path::new("out"), "zh", &arch),
Path::new("out").join("wiki").join("zh").join("architecture.md")
);
let overview = WikiDocument {
kind: DocumentKind::ProjectOverview,
..make_doc("zh")
};
assert_eq!(
wiki_page_path(Path::new("out"), "zh", &overview),
Path::new("out").join("wiki").join("zh").join("overview.md")
);
assert_eq!(
card_page_path(Path::new("out"), "zh", "src::testmodule"),
Path::new("out").join("cards").join("zh").join("src_testmodule.md")
);
}
#[test]
fn test_render_all_skips_protected_card() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_protected_card_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let config = WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
let card = make_card();
let doc = make_doc("zh");
let graph = KnowledgeGraph::default();
let card_file = dir.join("cards").join("zh").join("src_testmodule.md");
std::fs::create_dir_all(card_file.parent().unwrap()).unwrap();
std::fs::write(&card_file, "人工编辑的内容").unwrap();
let protected: std::collections::HashSet<String> =
[card_file.to_string_lossy().to_string()].into_iter().collect();
render_all(std::slice::from_ref(&doc), std::slice::from_ref(&card), &graph, &config, &protected).unwrap();
let kept = std::fs::read_to_string(&card_file).unwrap();
assert_eq!(kept, "人工编辑的内容", "被保护的卡片不应被全量 generate 覆盖");
let _ = std::fs::remove_file(&card_file);
let empty = std::collections::HashSet::new();
render_all(&[doc], &[card], &graph, &config, &empty).unwrap();
assert!(card_file.exists(), "未保护的卡片应正常写盘");
assert!(
dir.join("wiki").join("zh").join("src_testmodule.md").exists(),
"wiki 页应正常写盘"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_render_all_writes_cards_without_documents() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_cards_no_docs_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let config = WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
let card = make_card();
let graph = KnowledgeGraph::default();
let empty_docs: [WikiDocument; 0] = [];
let empty_protected = std::collections::HashSet::new();
render_all(&empty_docs, std::slice::from_ref(&card), &graph, &config, &empty_protected)
.unwrap();
assert!(
dir.join("cards").join("zh").join("src_testmodule.md").exists(),
"页面全部失败时卡片必须独立落盘"
);
assert!(
!dir.join("wiki").join("zh").join("src_testmodule.md").exists(),
"无文档时不应产出页面"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_generate_agents_md_template_aligned() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_agents_md_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let output_dir = dir.join("out");
std::fs::create_dir_all(&output_dir).unwrap();
let generated = generate_agents_md(&output_dir).unwrap();
assert!(generated, "首次生成应返回 true");
let content = std::fs::read_to_string(dir.join("AGENTS.md")).unwrap();
assert!(
content.lines().count() < 200,
"模板须精简(<200 行): {} 行",
content.lines().count()
);
assert!(content.contains("## 产物布局"), "应含产物布局节: {content}");
assert!(content.contains("## 常用命令"), "应含常用命令节: {content}");
assert!(content.contains("## 开发规范"), "应含开发规范节: {content}");
assert!(content.contains("llms.txt"), "应保留 llms.txt 指引: {content}");
assert!(content.contains("llms-full.txt"), "应保留 llms-full.txt 指引: {content}");
assert!(content.contains("code-repo-wiki search"), "应保留搜索建议: {content}");
assert!(content.contains("何时"), "指令须可证伪(含何时): {content}");
assert!(
!dir.join("CLAUDE.md").exists(),
"只生成 AGENTS.md 不生成 CLAUDE.md"
);
let second = generate_agents_md(&output_dir).unwrap();
assert!(!second, "已存在时应跳过注入");
let kept = std::fs::read_to_string(dir.join("AGENTS.md")).unwrap();
assert_eq!(kept, content, "已存在时内容不得被改动");
let _ = std::fs::remove_dir_all(&dir);
}
}