use crate::config::schema::WikiConfig;
use crate::model::{EntitySummary, KnowledgeCard, WikiDocument};
pub const LLMS_FULL_TOKEN_BUDGET: usize = 32_000;
const LLMS_STALE_DAYS: i64 = 7;
fn stale_by_age(modified: chrono::DateTime<chrono::Utc>, now: chrono::DateTime<chrono::Utc>) -> Option<String> {
if now.signed_duration_since(modified) > chrono::TimeDelta::days(LLMS_STALE_DAYS) {
Some(format!(
"生成于 {},距今超过 {LLMS_STALE_DAYS} 天",
modified.to_rfc3339()
))
} else {
None
}
}
fn warn_if_stale(path: &std::path::Path, label: &str) {
let Ok(meta) = std::fs::metadata(path) else {
return;
};
let Ok(modified) = meta.modified() else {
return;
};
let modified = chrono::DateTime::<chrono::Utc>::from(modified);
if let Some(reason) = stale_by_age(modified, chrono::Utc::now()) {
tracing::warn!(
"{} 已过期({});过期产物会降低 Agent 检索质量(Synscribe 实测 ρ=−0.54),请重新运行 generate",
label,
reason
);
}
}
pub fn render_llms_txt(
repo_name: &str,
documents: &[WikiDocument],
cards: &[KnowledgeCard],
languages: &[String],
) -> String {
let mut out = String::new();
out.push_str(&format!("# {repo_name} Wiki\n\n"));
out.push_str("> code-repo-wiki 生成的代码仓库 Wiki 文档索引。\n");
out.push_str(&format!(
"> 由 code-repo-wiki v{} 生成;发现索引与工具版本不匹配时,请重新运行 generate。\n\n",
env!("CARGO_PKG_VERSION")
));
let mut pages: Vec<(&str, &str)> = Vec::new();
for lang in languages {
for doc in documents {
if doc.kind != crate::model::DocumentKind::WikiPage {
continue;
}
pages.push((lang, doc.title.as_str()));
}
}
pages.sort_unstable();
if !pages.is_empty() {
out.push_str("## Modules\n\n");
for (lang, title) in &pages {
out.push_str(&format!(
"- [{title}](wiki/{lang}/{}.md)\n",
title.replace("::", "_")
));
}
out.push('\n');
}
let mut globals: Vec<String> = Vec::new();
for lang in languages {
for name in ["api.md", "overview.md", "architecture.md", "index.md"] {
globals.push(format!("wiki/{lang}/{name}"));
}
}
globals.sort();
out.push_str("## Global\n\n");
for path in &globals {
let label = path
.trim_end_matches(".md")
.rsplit('/')
.next()
.unwrap_or(path);
out.push_str(&format!("- [{label}]({path})\n"));
}
out.push_str("- [目录](_toc.md)\n\n");
if !cards.is_empty() && let Some(primary) = languages.first() {
out.push_str("## Cards\n\n");
let mut card_names: Vec<&str> = cards.iter().map(|c| c.module_name.as_str()).collect();
card_names.sort_unstable();
for name in &card_names {
out.push_str(&format!(
"- [{name}](cards/{primary}/{}.md)\n",
name.replace("::", "_")
));
}
}
out
}
pub fn llms_txt_path(output_dir: &std::path::Path) -> std::path::PathBuf {
output_dir.join("llms.txt")
}
pub fn write_llms_txt(
output_dir: &std::path::Path,
documents: &[WikiDocument],
cards: &[KnowledgeCard],
config: &WikiConfig,
) -> Result<(), anyhow::Error> {
let repo_name = output_dir
.parent()
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "repo".to_string());
let languages = crate::output::wiki_languages(config);
let path = llms_txt_path(output_dir);
warn_if_stale(&path, "llms.txt");
let content = render_llms_txt(&repo_name, documents, cards, &languages);
crate::fs::write_file_atomic(&path, &content)
}
#[derive(Clone)]
struct ModuleSection {
name: String,
summary: String,
entities: Vec<EntityEntry>,
}
#[derive(Clone)]
struct EntityEntry {
name: String,
kind: String,
visibility: String,
source: Option<String>,
doc: Option<String>,
}
impl From<&EntitySummary> for EntityEntry {
fn from(e: &EntitySummary) -> Self {
EntityEntry {
name: e.name.clone(),
kind: e.kind.clone(),
visibility: e.visibility.clone(),
source: e.source.clone(),
doc: e.doc.clone(),
}
}
}
fn build_sections(cards: &[KnowledgeCard]) -> Vec<ModuleSection> {
let mut cards: Vec<&KnowledgeCard> = cards.iter().collect();
cards.sort_unstable_by_key(|c| c.module_name.as_str());
cards
.into_iter()
.map(|c| ModuleSection {
name: c.module_name.clone(),
summary: c
.summary
.split('\n')
.next()
.unwrap_or_default()
.chars()
.take(200)
.collect(),
entities: {
let mut list: Vec<EntityEntry> = c.key_entities.iter().map(EntityEntry::from).collect();
list.sort_unstable_by(|a, b| a.name.cmp(&b.name));
list
},
})
.collect()
}
fn estimate_tokens(text: &str) -> usize {
text.chars().count() / 4
}
fn render_section(section: &ModuleSection, minimal: bool) -> String {
let mut out = format!("## {}\n\n{}\n\n", section.name, section.summary);
for e in §ion.entities {
if minimal {
out.push_str(&format!("- {} {}\n", e.name, e.kind));
} else {
let mut line = format!("- {} {} ({})", e.name, e.kind, e.visibility);
if let Some(src) = &e.source {
line.push_str(&format!(" — 定位: {src}"));
}
if let Some(doc) = &e.doc {
line.push_str(&format!(" — {doc}"));
}
out.push_str(&line);
out.push('\n');
}
}
out
}
pub fn render_llms_full_txt(
repo_name: &str,
cards: &[KnowledgeCard],
primary_lang: &str,
token_budget: usize,
) -> String {
let sections = build_sections(cards);
let mut out = format!("# {repo_name} Wiki — 完整内容索引\n\n");
out.push_str("> 模块职责与实体清单内联版(llms.txt 的超集,非官方规范,社区惯例格式)。\n");
out.push_str(&format!(
"> 模块卡片目录: cards/{primary_lang}/(实体详情以卡片为准)。\n"
));
out.push_str(&format!(
"> 由 code-repo-wiki v{} 生成;发现索引与工具版本不匹配时,请重新运行 generate。\n\n",
env!("CARGO_PKG_VERSION")
));
let mut content = sections
.iter()
.map(|s| render_section(s, false))
.collect::<String>();
if estimate_tokens(&out) + estimate_tokens(&content) <= token_budget {
return format!("{out}{content}");
}
let mut filtered: Vec<ModuleSection> = Vec::new();
for mut s in sections.clone() {
s.entities.retain(|e| e.kind != "constant");
filtered.push(s);
}
content = filtered
.iter()
.map(|s| render_section(s, false))
.collect::<String>();
if estimate_tokens(&out) + estimate_tokens(&content) <= token_budget {
return format!("{out}{content}");
}
let mut located: Vec<ModuleSection> = Vec::new();
for mut s in filtered {
s.entities.retain(|e| e.source.is_some());
located.push(s);
}
content = located
.iter()
.map(|s| render_section(s, false))
.collect::<String>();
if estimate_tokens(&out) + estimate_tokens(&content) <= token_budget {
return format!("{out}{content}");
}
let mut minimal: Vec<ModuleSection> = Vec::new();
for mut s in located {
s.entities.retain(|e| e.source.is_some() && e.kind != "constant");
minimal.push(s);
}
content = minimal
.iter()
.map(|s| render_section(s, true))
.collect::<String>();
if estimate_tokens(&out) + estimate_tokens(&content) <= token_budget {
return format!("{out}{content}");
}
let mut kept: Vec<ModuleSection> = minimal;
kept.sort_unstable_by(|a, b| {
render_section(b, true)
.len()
.cmp(&render_section(a, true).len())
.then_with(|| a.name.cmp(&b.name))
});
let mut omitted: Vec<String> = Vec::new();
let mut final_content = String::new();
let mut remaining = token_budget.saturating_sub(estimate_tokens(&out));
for s in kept {
let section = render_section(&s, true);
if estimate_tokens(§ion) <= remaining {
final_content.push_str(§ion);
remaining -= estimate_tokens(§ion);
} else {
omitted.push(s.name.clone());
}
}
let mut final_out = out;
if !omitted.is_empty() {
final_out.push_str(&format!("## 省略模块(预算 {token_budget} tokens 内未展开)\n"));
for name in &omitted {
final_out.push_str(&format!("- {name}\n"));
}
final_out.push('\n');
}
final_out.push_str(&final_content);
final_out
}
pub fn llms_full_txt_path(output_dir: &std::path::Path) -> std::path::PathBuf {
output_dir.join("llms-full.txt")
}
pub fn write_llms_full_txt(
output_dir: &std::path::Path,
cards: &[KnowledgeCard],
config: &WikiConfig,
) -> Result<(), anyhow::Error> {
let repo_name = output_dir
.parent()
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "repo".to_string());
let primary_lang = crate::output::wiki_languages(config)
.first()
.cloned()
.unwrap_or_else(|| "zh".to_string());
let path = llms_full_txt_path(output_dir);
warn_if_stale(&path, "llms-full.txt");
let content = render_llms_full_txt(
&repo_name,
cards,
&primary_lang,
LLMS_FULL_TOKEN_BUDGET,
);
crate::fs::write_file_atomic(&path, &content)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::DocumentKind;
fn make_doc(title: &str, kind: DocumentKind) -> WikiDocument {
WikiDocument {
title: title.into(),
kind,
content: String::new(),
language: "zh".into(),
module_path: Vec::new(),
references: Vec::new(),
last_updated: String::new(),
based_on_commit: None,
fingerprint: None,
}
}
fn make_card(name: &str) -> KnowledgeCard {
KnowledgeCard {
module_name: name.into(),
module_type: "module".into(),
summary: String::new(),
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_render_llms_txt_deterministic_and_complete() {
let docs = vec![
make_doc("src::zebra", DocumentKind::WikiPage),
make_doc("src::alpha", DocumentKind::WikiPage),
make_doc("API Reference", DocumentKind::ApiReference),
];
let cards = vec![make_card("src::alpha"), make_card("src::beta")];
let langs = vec!["zh".to_string(), "en".to_string()];
let first = render_llms_txt("demo", &docs, &cards, &langs, );
let second = render_llms_txt("demo", &docs, &cards, &langs, );
assert_eq!(first, second, "同输入两次渲染必须字节一致");
assert!(first.contains("# demo Wiki"), "应含仓库名标题");
assert!(
first.contains(&format!("code-repo-wiki v{}", env!("CARGO_PKG_VERSION"))),
"应含工具版本行(版本自检载体)"
);
assert!(first.contains("## Modules"), "应含模块页节");
assert!(first.contains("wiki/zh/src_alpha.md"), "zh 模块页链接");
assert!(first.contains("wiki/zh/src_zebra.md"), "zh 模块页排序稳定");
assert!(first.contains("wiki/en/src_alpha.md"), "en 模块页链接");
let alpha_pos = first.find("src_alpha.md").unwrap();
let zebra_pos = first.find("src_zebra.md").unwrap();
assert!(alpha_pos < zebra_pos, "模块页应按 title 字典序: {first}");
assert!(first.contains("## Global"), "应含全局文档节");
assert!(first.contains("wiki/zh/api.md"), "api 链接");
assert!(first.contains("wiki/en/overview.md"), "扩展语言全局链接");
assert!(first.contains("(_toc.md)"), "目录链接");
assert!(first.contains("## Cards"), "应含卡片节");
assert!(first.contains("cards/zh/src_alpha.md"), "卡片链接");
assert!(first.contains("cards/zh/src_beta.md"), "卡片链接排序稳定");
}
#[test]
fn test_render_llms_txt_empty_docs() {
let out = render_llms_txt("demo", &[], &[], &["zh".to_string()], );
assert!(out.contains("# demo Wiki"));
assert!(!out.contains("## Modules"), "无模块页不应出现 Modules 节");
}
fn make_card_entities(name: &str, entities: Vec<(&str, Option<&str>, Option<&str>)>) -> KnowledgeCard {
let mut card = make_card(name);
card.summary = format!("{name} 模块职责一句话");
card.key_entities = entities
.into_iter()
.map(|(n, src, doc)| crate::model::EntitySummary {
name: n.into(),
kind: "function".into(),
visibility: "pub".into(),
doc: doc.map(String::from),
source: src.map(String::from),
})
.collect();
card
}
#[test]
fn test_render_llms_full_txt_deterministic_and_complete() {
let cards = vec![
make_card_entities(
"src::beta",
vec![
("zulu", Some("src/beta.rs:1-5"), Some("说明")),
("alpha", Some("src/beta.rs:10-12"), None),
],
),
make_card_entities("src::alpha", vec![("server", Some("src/alpha.rs:1-3"), None)]),
];
let first = render_llms_full_txt("demo", &cards, "zh", 32_000, );
let second = render_llms_full_txt("demo", &cards, "zh", 32_000, );
assert_eq!(first, second, "同输入两次渲染必须字节一致");
assert!(first.contains("# demo Wiki"), "应含仓库名标题");
assert!(
first.contains(&format!("code-repo-wiki v{}", env!("CARGO_PKG_VERSION"))),
"应含工具版本行"
);
assert!(first.contains("## src::alpha"), "模块节标题");
assert!(first.contains("src::alpha 模块职责一句话"), "模块职责一句话");
assert!(first.contains("- server function (pub) — 定位: src/alpha.rs:1-3"), "完整签名行");
let a = first.find("## src::alpha").unwrap();
let b = first.find("## src::beta").unwrap();
assert!(a < b, "模块节应按 module_name 字典序: {first}");
}
#[test]
fn test_render_llms_full_txt_budget_trims() {
let mut card = make_card("src::alpha");
card.summary = "alpha 模块".into();
let mut entities = Vec::new();
for i in 0..2000 {
entities.push(crate::model::EntitySummary {
name: format!("fn{i}"),
kind: "function".into(),
visibility: "pub".into(),
doc: Some("说明".into()),
source: Some(format!("src/alpha.rs:{}-{}", i + 1, i + 2)),
});
}
entities.push(crate::model::EntitySummary {
name: "const_x".into(),
kind: "constant".into(),
visibility: "pub".into(),
doc: None,
source: Some("src/alpha.rs:999".into()),
});
entities.push(crate::model::EntitySummary {
name: "ghost".into(),
kind: "function".into(),
visibility: "pub".into(),
doc: None,
source: None,
});
card.key_entities = entities;
let cards = vec![card];
let tiny = render_llms_full_txt("demo", &cards, "zh", 80, );
assert!(
tiny.contains("## 省略模块") && tiny.contains("src::alpha"),
"整模块省略时模块名必须保留: {tiny}"
);
assert!(
estimate_tokens(&tiny) <= 80 + 1,
"输出应落在预算内: {} tokens",
estimate_tokens(&tiny)
);
let mid = render_llms_full_txt("demo", &cards, "zh", 20_000, );
assert!(
estimate_tokens(&mid) <= 20_000 + 1,
"输出应落在预算内: {} tokens",
estimate_tokens(&mid)
);
assert!(!mid.contains("定位:"), "③ 档后不应再有完整签名行");
assert!(!mid.contains("const_x"), "② 档应已丢常量级条目");
}
#[test]
fn test_render_llms_full_txt_empty_cards() {
let out = render_llms_full_txt("demo", &[], "zh", 32_000, );
assert!(out.contains("# demo Wiki"));
assert!(!out.contains("## "), "无卡片不应出现模块节");
}
#[test]
fn test_stale_by_age_older_than_seven_days() {
let now = chrono::Utc::now();
let old = now - chrono::TimeDelta::days(8);
let reason = stale_by_age(old, now).unwrap();
assert!(reason.contains("超过 7 天"), "应报时间过期: {reason}");
}
#[test]
fn test_stale_by_age_recent_is_fresh() {
let now = chrono::Utc::now();
assert!(stale_by_age(now - chrono::TimeDelta::days(1), now).is_none());
}
#[test]
fn test_stale_by_age_future_clock_is_fresh() {
let now = chrono::Utc::now();
assert!(
stale_by_age(now + chrono::TimeDelta::days(1), now).is_none(),
"未来时间戳不应误报过期"
);
}
#[test]
fn test_write_llms_txt_smoke_deterministic_content() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_llms_mtime_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let config = WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
write_llms_txt(&dir, &[], &[], &config).unwrap();
let content = std::fs::read_to_string(llms_txt_path(&dir)).unwrap();
assert!(
!content.contains("> 生成时间:"),
"内容禁止注入易变时间戳(确定性契约): {content}"
);
assert!(
content.starts_with("# ") && content.contains("Wiki"),
"应含仓库名标题: {content}"
);
let _ = std::fs::remove_dir_all(&dir);
}
}