pub mod card;
pub mod chunk;
pub mod embed;
pub mod index;
pub mod llm;
pub mod prompt;
pub mod schema;
pub mod wiki;
use std::collections::HashMap;
use std::path::Path;
use std::time::Instant;
use anyhow::Result;
use crate::config::schema::WikiConfig;
use crate::ingest::parser::FileInsight;
use crate::model::{KnowledgeCard, KnowledgeGraph, WikiDocument};
use self::card::CardGenerator;
use self::chunk::Chunk;
use self::llm::{AnthropicProvider, LlmProvider, OpenAiProvider, Provider};
use self::wiki::WikiGenerator;
pub struct GenerationOutput {
pub cards: Vec<KnowledgeCard>,
pub documents: Vec<WikiDocument>,
pub generation_stats: GenerationStats,
pub timings: crate::GenerationTimings,
}
#[derive(Debug, Clone, Default)]
pub struct GenerationStats {
pub total_tokens_used: usize,
pub llm_calls: usize,
pub generation_time_ms: u64,
pub failed_modules: Vec<String>,
}
pub fn create_provider(config: &WikiConfig) -> Result<Provider> {
match config.llm.provider {
crate::config::schema::LlmProviderType::OpenAI => {
Ok(Provider::OpenAi(OpenAiProvider::new(&config.llm, crate::generate::llm::OpenAiProtocol::Responses)?))
}
crate::config::schema::LlmProviderType::Anthropic => {
Ok(Provider::Anthropic(AnthropicProvider::new(&config.llm)?))
}
crate::config::schema::LlmProviderType::OpenAiCompatible => {
Ok(Provider::OpenAi(OpenAiProvider::new(&config.llm, crate::generate::llm::OpenAiProtocol::Chat)?))
}
crate::config::schema::LlmProviderType::Mock => {
Ok(Provider::Mock(crate::generate::llm::MockProvider::new()))
}
}
}
fn filter_chunks_by_guide(
chunks: Vec<Chunk>,
guide: &crate::config::schema::WikiGuideSection,
strict_empty: bool,
) -> Result<Vec<Chunk>> {
if guide.pages.is_empty() {
return Ok(chunks);
}
let original_len = chunks.len();
let mut filtered: Vec<Chunk> = chunks
.into_iter()
.filter(|c| guide.pages.iter().any(|p| guide_prefix_match(&c.module_path, p)))
.collect();
if filtered.is_empty() && original_len > 0 {
if strict_empty {
anyhow::bail!(
"[wiki.guide].pages 未匹配任何模块(共 {} 个模块),请检查 pages 配置",
original_len
);
}
tracing::info!("增量生成: 受影响模块均不在 [wiki.guide].pages 白名单,跳过生成");
}
if !guide.priority.is_empty() {
filtered.sort_by_key(|c| {
guide
.priority
.iter()
.position(|p| guide_prefix_match(&c.module_path, p))
.unwrap_or(usize::MAX)
});
}
Ok(filtered)
}
fn guide_prefix_match(module_path: &[String], pattern: &str) -> bool {
let pat: Vec<&str> = pattern
.split(['/', ':', '\\'])
.filter(|s| !s.is_empty())
.collect();
if pat.is_empty() {
return false;
}
module_path
.iter()
.take(pat.len())
.map(|s| s.as_str())
.eq(pat.iter().copied())
}
pub async fn run_generation(
graph: &KnowledgeGraph,
insights: &[FileInsight],
config: &WikiConfig,
root: &crate::project::ProjectRoot,
extra_edits: &HashMap<String, Vec<String>>,
) -> Result<GenerationOutput> {
let start = Instant::now();
let chunk_start = Instant::now();
let chunks = if graph.modules.is_empty() {
tracing::warn!("未检测到模块聚类,回退到文件级分块");
insights
.iter()
.map(chunk::chunk_by_file)
.collect::<Vec<_>>()
} else {
chunk::chunk_by_module(insights, &graph.modules, graph)
};
let chunks: Vec<_> = chunks
.into_iter()
.filter(|c| !c.is_empty())
.collect();
let chunks = filter_chunks_by_guide(chunks, &config.wiki.guide, true)?;
tracing::info!("生成进度: 30% - 分块完成,共 {} 个块", chunks.len());
let chunk_ms = chunk_start.elapsed().as_millis() as u64;
let provider = create_provider(config)?;
let card_start = Instant::now();
let card_gen = CardGenerator::new(
&provider,
config.clone(),
crate::config::schema::LLM_MAX_CONCURRENT,
config.wiki.language.clone(),
);
let mut cards = card_gen
.generate_all_cards(&chunks, extra_edits)
.await?;
backfill_features(&mut cards, &chunks, graph);
tracing::info!("生成进度: 60% - 知识卡片生成完成,共 {} 个卡片", cards.len());
let card_ms = card_start.elapsed().as_millis() as u64;
let wiki_start = Instant::now();
let wiki_gen = WikiGenerator::new(&provider, crate::config::schema::LLM_MAX_CONCURRENT);
let mut documents =
generate_wiki_pages(&wiki_gen, &chunks, &cards, config, crate::config::schema::LLM_MAX_CONCURRENT, root, &build_entity_ranges(insights)).await;
tracing::info!("生成进度: 90% - Wiki 页面生成完成,共 {} 个页面", documents.len());
let wiki_ms = wiki_start.elapsed().as_millis() as u64;
generate_global_documents(&wiki_gen, &provider, graph, config, root, &cards, &mut documents, &GlobalDocAffected::all(), false).await?;
let elapsed = start.elapsed();
let stats = GenerationStats {
llm_calls: card_gen.llm_call_count() + wiki_gen.llm_call_count(),
generation_time_ms: elapsed.as_millis() as u64,
failed_modules: {
let mut f = card_gen.failed_modules();
f.extend(wiki_gen.failed_modules());
f
},
..Default::default()
};
Ok(GenerationOutput {
cards,
documents,
generation_stats: stats,
timings: crate::GenerationTimings {
chunk_ms,
card_ms,
wiki_ms,
..Default::default()
},
})
}
pub async fn run_generation_filtered(
graph: &KnowledgeGraph,
insights: &[FileInsight],
config: &WikiConfig,
root: &crate::project::ProjectRoot,
inc: &crate::incremental::IncrementalResult,
extra_edits: &HashMap<String, Vec<String>>,
) -> Result<GenerationOutput> {
let start = Instant::now();
let changed_files = &inc.changed_files;
let entity_changes = &inc.entity_changes;
let affected_modules = &inc.affected_modules;
let chunk_start = Instant::now();
let affected_files = crate::incremental::impact::module_files(affected_modules, graph);
let no_entity_change_files = crate::incremental::change::no_entity_change_files(
changed_files,
entity_changes,
root,
);
let mut changed_insights: Vec<FileInsight> = insights
.iter()
.filter(|f| {
(changed_files.contains(&f.path) || affected_files.contains(&f.path))
&& !no_entity_change_files.contains(&f.path)
})
.cloned()
.collect();
let deleted_files: std::collections::HashSet<std::path::PathBuf> = changed_files
.iter()
.filter(|f| !root.path().join(f).exists())
.cloned()
.collect();
let surviving_files: std::collections::HashSet<std::path::PathBuf> = if deleted_files.is_empty() {
std::collections::HashSet::new()
} else if let Ok(content) =
std::fs::read_to_string(crate::output::export_snapshot_path(config.output_dir()))
&& let Ok(snapshot) = serde_json::from_str::<crate::output::ExportSnapshot>(&content)
{
snapshot
.cards
.iter()
.filter(|c| {
!c.related_files.is_empty()
&& c.related_files.iter().any(|f| deleted_files.contains(Path::new(f)))
&& c.related_files.iter().any(|f| root.path().join(f).exists())
})
.flat_map(|c| c.related_files.iter().map(std::path::PathBuf::from))
.collect()
} else {
std::collections::HashSet::new()
};
if !surviving_files.is_empty() {
let mut present: std::collections::HashSet<std::path::PathBuf> =
changed_insights.iter().map(|i| i.path.clone()).collect();
let mut merged = 0usize;
for insight in insights {
if surviving_files.contains(&insight.path) && present.insert(insight.path.clone()) {
changed_insights.push(insight.clone());
merged += 1;
}
}
if merged > 0 {
tracing::info!(
"增量生成: 删除文件所属模块的 {} 个存活文件并入变更集,重生成清除被删实体残留",
merged
);
}
}
if changed_insights.is_empty() {
if let Ok(content) = std::fs::read_to_string(crate::output::export_snapshot_path(config.output_dir()))
&& let Ok(snapshot) = serde_json::from_str::<crate::output::ExportSnapshot>(&content)
{
let deleted_modules: std::collections::HashSet<String> = snapshot
.cards
.iter()
.filter(|c| {
!c.related_files.is_empty()
&& c.related_files.iter().all(|f| !root.path().join(f).exists())
})
.map(|c| c.module_name.clone())
.collect();
let cards: Vec<KnowledgeCard> = snapshot
.cards
.into_iter()
.filter(|c| !deleted_modules.contains(&c.module_name))
.collect();
let documents: Vec<WikiDocument> = snapshot
.documents
.into_iter()
.filter(|d| !deleted_modules.contains(&d.title))
.collect();
tracing::info!(
"增量生成: 空集场景({} 个变更文件),从快照回填 {} 文档 {} 卡片(跳过已删模块 {} 个)",
changed_files.len(),
documents.len(),
cards.len(),
deleted_modules.len()
);
return Ok(GenerationOutput {
cards,
documents,
generation_stats: GenerationStats::default(),
timings: crate::GenerationTimings::default(),
});
} else {
tracing::warn!("增量生成: 纯删除场景但导出快照缺失,回退全量生成防止产物误清");
changed_insights = insights.to_vec();
}
} else {
tracing::info!("增量生成: {} 个文件变更", changed_insights.len());
}
let chunks: Vec<_> = if graph.modules.is_empty() {
changed_insights
.iter()
.map(chunk::chunk_by_file)
.collect()
} else {
chunk::chunk_by_module(&changed_insights, &graph.modules, graph)
};
let chunks: Vec<_> = chunks
.into_iter()
.filter(|c| !c.is_empty())
.collect();
let chunks = filter_chunks_by_guide(chunks, &config.wiki.guide, false)?;
tracing::info!("增量分块完成: {} 个块", chunks.len());
let chunk_ms = chunk_start.elapsed().as_millis() as u64;
let provider = create_provider(config)?;
let card_start = Instant::now();
let card_gen = CardGenerator::new(
&provider,
config.clone(),
crate::config::schema::LLM_MAX_CONCURRENT,
config.wiki.language.clone(),
);
let mut cards = card_gen
.generate_all_cards(&chunks, extra_edits)
.await?;
backfill_features(&mut cards, &chunks, graph);
let card_ms = card_start.elapsed().as_millis() as u64;
let wiki_start = Instant::now();
let wiki_gen = WikiGenerator::new(&provider, crate::config::schema::LLM_MAX_CONCURRENT);
let mut documents =
generate_wiki_pages(&wiki_gen, &chunks, &cards, config, crate::config::schema::LLM_MAX_CONCURRENT, root, &build_entity_ranges(insights)).await;
let wiki_ms = wiki_start.elapsed().as_millis() as u64;
let global_affected = GlobalDocAffected {
architecture: entity_changes.has_interface_change(),
schema: changed_files
.iter()
.any(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sql"))),
};
generate_global_documents(&wiki_gen, &provider, graph, config, root, &cards, &mut documents, &global_affected, inc.has_deleted_files).await?;
let elapsed = start.elapsed();
let stats = GenerationStats {
llm_calls: card_gen.llm_call_count() + wiki_gen.llm_call_count(),
generation_time_ms: elapsed.as_millis() as u64,
failed_modules: {
let mut f = card_gen.failed_modules();
f.extend(wiki_gen.failed_modules());
f
},
..Default::default()
};
Ok(GenerationOutput {
cards,
documents,
generation_stats: stats,
timings: crate::GenerationTimings {
chunk_ms,
card_ms,
wiki_ms,
..Default::default()
},
})
}
fn build_entity_ranges(insights: &[FileInsight]) -> crate::output::citation::EntityRanges {
insights
.iter()
.map(|insight| {
let key = crate::incremental::norm_sep(&insight.path.to_string_lossy());
let ranges: Vec<(usize, usize)> = insight
.entities
.iter()
.map(|e| (e.line_start, e.line_end))
.collect();
(key, ranges)
})
.collect()
}
fn backfill_features(cards: &mut [KnowledgeCard], chunks: &[Chunk], graph: &KnowledgeGraph) {
if graph.features.is_empty() || cards.is_empty() {
return;
}
let feature_entities: Vec<(String, std::collections::HashSet<String>)> = graph
.features
.iter()
.map(|f| {
let names: std::collections::HashSet<String> = f
.node_ids
.iter()
.filter_map(|nid| graph.graph.node_weight(*nid).map(|n| n.name.clone()))
.collect();
(f.name.clone(), names)
})
.collect();
for (card, chunk) in cards.iter_mut().zip(chunks) {
let entity_names: std::collections::HashSet<&str> =
chunk.entities.iter().map(|e| e.name.as_str()).collect();
let mut matched: Vec<String> = feature_entities
.iter()
.filter(|(_, names)| names.iter().any(|n| entity_names.contains(n.as_str())))
.map(|(name, _)| name.clone())
.collect();
matched.sort();
card.features = matched;
}
}
async fn generate_wiki_pages<P: LlmProvider>(
wiki_gen: &WikiGenerator<'_, P>,
chunks: &[Chunk],
cards: &[KnowledgeCard],
config: &WikiConfig,
max_concurrent: usize,
root: &crate::project::ProjectRoot,
entity_ranges: &crate::output::citation::EntityRanges,
) -> Vec<WikiDocument> {
let languages = crate::output::wiki_languages(config);
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1)));
let mut handles = Vec::with_capacity(chunks.len() * languages.len());
let mut task_modules = Vec::with_capacity(chunks.len() * languages.len());
for lang in &languages {
let mut lang_cfg = config.clone();
lang_cfg.wiki.language = lang.clone();
for (i, chunk) in chunks.iter().enumerate() {
let card_summary = cards.get(i).map(|c| c.summary.clone()).unwrap_or_default();
let semaphore = semaphore.clone();
let lang_cfg = lang_cfg.clone();
task_modules.push(chunk.module_path.join("::"));
handles.push(async move {
let _permit = semaphore
.acquire()
.await
.map_err(|_| anyhow::anyhow!("信号量已关闭"))?;
wiki_gen
.generate_wiki_page(chunk, &card_summary, &lang_cfg, root, Some(entity_ranges))
.await
});
}
}
let results = futures::future::join_all(handles).await;
task_modules
.into_iter()
.zip(results)
.filter_map(|(module, r)| match r {
Ok(doc) => Some(doc),
Err(e) => {
tracing::warn!("跳过 Wiki 页面生成 {}: {}", module, e);
wiki_gen.record_failure(module);
None
}
})
.collect()
}
#[derive(Debug, Clone, Default)]
pub struct GlobalDocAffected {
pub architecture: bool,
pub schema: bool,
}
impl GlobalDocAffected {
pub fn all() -> Self {
Self { architecture: true, schema: true }
}
}
#[allow(clippy::too_many_arguments)]
async fn generate_global_documents(
wiki_gen: &WikiGenerator<'_, Provider>,
provider: &Provider,
graph: &KnowledgeGraph,
config: &WikiConfig,
root: &crate::project::ProjectRoot,
cards: &[KnowledgeCard],
documents: &mut Vec<WikiDocument>,
affected: &GlobalDocAffected,
has_deleted_files: bool,
) -> Result<()> {
if affected.architecture {
if !cards.is_empty() || has_deleted_files {
let output_snapshot = GenerationOutput {
cards: cards.to_vec(),
documents: documents.clone(),
generation_stats: GenerationStats::default(),
timings: crate::GenerationTimings::default(),
};
match wiki_gen
.generate_architecture(&output_snapshot, graph, config, root)
.await
{
Ok(arch) => documents.push(arch),
Err(e) => {
tracing::warn!("架构概览生成失败,降级为确定性骨架: {e}");
documents.push(crate::generate::wiki::fallback_architecture_doc(
graph,
config,
crate::model::DocumentKind::ArchitectureOverview,
"架构概览",
));
}
}
match wiki_gen
.generate_overview(&output_snapshot, graph, config, root)
.await
{
Ok(overview) => documents.push(overview),
Err(e) => {
tracing::warn!("项目概览生成失败,降级为确定性骨架: {e}");
documents.push(crate::generate::wiki::fallback_architecture_doc(
graph,
config,
crate::model::DocumentKind::ProjectOverview,
"项目概览",
));
}
}
}
} else if !backfill_global_docs(config, documents, &[
crate::model::DocumentKind::ArchitectureOverview,
crate::model::DocumentKind::ProjectOverview,
]) {
tracing::info!("全局文档快照回填不可用,回退重新生成");
let output_snapshot = GenerationOutput {
cards: cards.to_vec(),
documents: documents.clone(),
generation_stats: GenerationStats::default(),
timings: crate::GenerationTimings::default(),
};
match wiki_gen
.generate_architecture(&output_snapshot, graph, config, root)
.await
{
Ok(arch) => documents.push(arch),
Err(e) => {
tracing::warn!("架构概览生成失败,降级为确定性骨架: {e}");
documents.push(crate::generate::wiki::fallback_architecture_doc(
graph,
config,
crate::model::DocumentKind::ArchitectureOverview,
"架构概览",
));
}
}
match wiki_gen
.generate_overview(&output_snapshot, graph, config, root)
.await
{
Ok(overview) => documents.push(overview),
Err(e) => {
tracing::warn!("项目概览生成失败,降级为确定性骨架: {e}");
documents.push(crate::generate::wiki::fallback_architecture_doc(
graph,
config,
crate::model::DocumentKind::ProjectOverview,
"项目概览",
));
}
}
}
if affected.schema {
match schema::generate_schema_documents_at(root, provider, config).await {
Ok(mut schema_docs) => documents.append(&mut schema_docs),
Err(e) => tracing::warn!("数据库 Schema 文档生成跳过: {}", e),
}
} else if !backfill_global_docs(config, documents, &[crate::model::DocumentKind::DatabaseSchema]) {
tracing::info!("Schema 快照回填不可用,回退重新生成");
match schema::generate_schema_documents_at(root, provider, config).await {
Ok(mut schema_docs) => documents.append(&mut schema_docs),
Err(e) => tracing::warn!("数据库 Schema 文档生成跳过: {}", e),
}
}
Ok(())
}
pub(crate) fn backfill_global_docs(
config: &WikiConfig,
documents: &mut Vec<WikiDocument>,
kinds: &[crate::model::DocumentKind],
) -> bool {
let snapshot_path = crate::output::export_snapshot_path(config.output_dir());
let Ok(content) = std::fs::read_to_string(&snapshot_path) else {
return false;
};
let Ok(snapshot) = serde_json::from_str::<crate::output::ExportSnapshot>(&content) else {
tracing::warn!("导出快照解析失败(将回退重新生成全局文档): {}", snapshot_path.display());
return false;
};
let mut filled = false;
for doc in snapshot.documents {
if kinds.contains(&doc.kind)
&& doc.language == config.wiki.language
&& !documents.iter().any(|d| d.title == doc.title && d.language == doc.language)
{
documents.push(doc);
filled = true;
}
}
filled
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::DocumentKind;
fn make_document(title: &str) -> WikiDocument {
WikiDocument {
title: title.into(),
kind: DocumentKind::WikiPage,
content: String::new(),
language: "zh".into(),
module_path: vec![],
references: vec![],
last_updated: String::new(),
based_on_commit: None,
fingerprint: None,
}
}
#[test]
fn test_backfill_global_docs_from_snapshot() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".state")).unwrap();
let arch = WikiDocument {
title: "架构概览".into(),
kind: DocumentKind::ArchitectureOverview,
content: "架构内容".into(),
language: "zh".into(),
module_path: vec![],
references: vec![],
last_updated: "2025-01-01T00:00:00Z".into(),
based_on_commit: None,
fingerprint: None,
};
let overview = WikiDocument {
title: "项目概览".into(),
kind: DocumentKind::ProjectOverview,
content: "概览内容".into(),
language: "zh".into(),
module_path: vec![],
references: vec![],
last_updated: "2025-01-01T00:00:00Z".into(),
based_on_commit: None,
fingerprint: None,
};
let snapshot = crate::output::ExportSnapshot {
version: 1,
documents: vec![arch.clone(), overview.clone()],
cards: vec![],
modules: vec![],
};
crate::fs::write_file_atomic(
&dir.join(".state").join("export_snapshot.json"),
&serde_json::to_string(&snapshot).unwrap(),
)
.unwrap();
let config = WikiConfig { output_dir: Some(dir.clone()), ..Default::default() };
let mut documents = vec![overview.clone()];
let filled = backfill_global_docs(
&config,
&mut documents,
&[DocumentKind::ArchitectureOverview, DocumentKind::ProjectOverview],
);
assert!(filled, "快照存在时应回填");
assert_eq!(documents.len(), 2, "回填架构(概览已存在不重复)");
assert_eq!(documents[1].kind, DocumentKind::ArchitectureOverview);
assert_eq!(documents[1].content, "架构内容");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_backfill_global_docs_missing_snapshot() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_miss_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let config = WikiConfig { output_dir: Some(dir.clone()), ..Default::default() };
let mut documents = Vec::new();
let filled = backfill_global_docs(&config, &mut documents, &[DocumentKind::ArchitectureOverview]);
assert!(!filled, "快照缺失时回填失败(回退生成)");
assert!(documents.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_backfill_global_docs_skips_on_language_mismatch() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_lang_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut arch = make_document("架构概览");
arch.kind = DocumentKind::ArchitectureOverview;
arch.language = "zh".into(); let snapshot = crate::output::ExportSnapshot {
version: 1,
documents: vec![arch],
cards: vec![],
modules: vec![],
};
crate::fs::write_file_atomic(
&dir.join(".state").join("export_snapshot.json"),
&serde_json::to_string(&snapshot).unwrap(),
)
.unwrap();
let config = WikiConfig {
output_dir: Some(dir.clone()),
wiki: crate::config::schema::WikiSection { language: "en".into(), guide: Default::default() },
..Default::default()
};
let mut documents = Vec::new();
let filled = backfill_global_docs(&config, &mut documents, &[DocumentKind::ArchitectureOverview]);
assert!(!filled, "语言不匹配时不得回填(回退生成新语言内容)");
assert!(documents.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_global_affected_signal() {
use crate::incremental::change::{EntityChange, EntityChangeKind};
let mut changes = Vec::new();
changes.push(EntityChange {
file: std::path::PathBuf::from("src/a.rs"),
entity_name: "foo".into(),
kind: EntityChangeKind::BodyChanged,
old_range: None,
new_range: None,
});
let affected = GlobalDocAffected {
architecture: crate::incremental::change::EntityChangeSet { changes: changes.clone() }.has_interface_change(),
schema: false,
};
assert!(!affected.architecture, "纯实现级变化不应触发架构重生成");
changes.push(EntityChange {
file: std::path::PathBuf::from("src/a.rs"),
entity_name: "bar".into(),
kind: EntityChangeKind::Added,
old_range: None,
new_range: None,
});
let affected2 = GlobalDocAffected {
architecture: crate::incremental::change::EntityChangeSet { changes }.has_interface_change(),
schema: false,
};
assert!(affected2.architecture, "接口级变化应触发架构重生成");
}
#[test]
fn test_backfill_global_docs_dedup_by_title_not_kind() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_schema_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let schema_a = WikiDocument {
title: "Database Schema: db/a.sql".into(),
kind: DocumentKind::DatabaseSchema,
content: "A 表结构".into(),
language: "zh".into(),
module_path: vec![],
references: vec![],
last_updated: "2025-01-01T00:00:00Z".into(),
based_on_commit: None,
fingerprint: None,
};
let schema_b = WikiDocument {
title: "Database Schema: db/b.sql".into(),
kind: DocumentKind::DatabaseSchema,
content: "B 表结构".into(),
language: "zh".into(),
module_path: vec![],
references: vec![],
last_updated: "2025-01-01T00:00:00Z".into(),
based_on_commit: None,
fingerprint: None,
};
let snapshot = crate::output::ExportSnapshot {
version: 1,
documents: vec![schema_a.clone(), schema_b.clone()],
cards: vec![],
modules: vec![],
};
crate::fs::write_file_atomic(
&dir.join(".state").join("export_snapshot.json"),
&serde_json::to_string(&snapshot).unwrap(),
)
.unwrap();
let config = WikiConfig { output_dir: Some(dir.clone()), ..Default::default() };
let mut documents = Vec::new();
let filled = backfill_global_docs(&config, &mut documents, &[DocumentKind::DatabaseSchema]);
assert!(filled, "快照存在时应回填");
assert_eq!(
documents.len(),
2,
"两份 schema 文档都应回填(按 title 去重,非按 kind)"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_entity_name_from_signature() {
use crate::output::lint::entity_name_from_signature;
assert_eq!(entity_name_from_signature("pub fn foo(x: i32) -> u32").as_deref(), Some("foo"));
assert_eq!(entity_name_from_signature("fn main()").as_deref(), Some("main"));
assert_eq!(entity_name_from_signature("def bar()").as_deref(), Some("bar"));
assert_eq!(entity_name_from_signature("func Baz()").as_deref(), Some("Baz"));
assert_eq!(entity_name_from_signature("Foo").as_deref(), Some("Foo"));
assert_eq!(entity_name_from_signature("pub struct Alpha").as_deref(), Some("Alpha"));
assert_eq!(entity_name_from_signature(""), None);
assert_eq!(entity_name_from_signature(" "), None);
}
}
#[test]
fn test_fallback_architecture_doc_skeleton() {
use crate::model::{CodeNode, EdgeKind, NodeKind};
use petgraph::stable_graph::StableDiGraph;
let mut g = StableDiGraph::<CodeNode, crate::model::CodeEdge>::new();
let a = g.add_node(CodeNode {
id: crate::model::NodeId::new(0),
kind: NodeKind::Function,
name: "a_fn".into(),
file_path: Some("src/a.rs".into()),
line_range: None,
doc_comment: None,
signature: None, visibility: None,
module_path: vec!["net".into()],
});
let b = g.add_node(CodeNode {
id: crate::model::NodeId::new(1),
kind: NodeKind::Function,
name: "b_fn".into(),
file_path: Some("src/b.rs".into()),
line_range: None,
doc_comment: None,
signature: None, visibility: None,
module_path: vec!["http".into()],
});
g.add_edge(a, b, crate::model::CodeEdge {
id: petgraph::stable_graph::EdgeIndex::new(0),
kind: EdgeKind::Calls,
source: a,
target: b,
weight: 1.0,
location: None,
});
let graph = crate::model::KnowledgeGraph {
graph: g,
modules: vec![
crate::model::ModuleCluster {
name: "net".into(),
node_ids: vec![a],
cohesion: 1.0,
coupling: 0.0,
description: None,
},
crate::model::ModuleCluster {
name: "http".into(),
node_ids: vec![b],
cohesion: 1.0,
coupling: 0.0,
description: None,
},
],
features: Vec::new(),
};
let config = WikiConfig::default();
let doc = crate::generate::wiki::fallback_architecture_doc(
&graph,
&config,
crate::model::DocumentKind::ArchitectureOverview,
"架构概览",
);
assert!(doc.content.contains("架构概览"), "应含标题: {}", doc.content);
assert!(doc.content.contains("net`(1 个实体)"), "应含模块与实体数");
assert!(doc.content.contains("http`(1 个实体)"), "应含模块与实体数");
assert!(doc.content.contains("依赖 http"), "net 应列出依赖 http");
assert_eq!(doc.kind, crate::model::DocumentKind::ArchitectureOverview);
let titles: Vec<&str> = doc.references.iter().map(|r| r.target_title.as_str()).collect();
assert_eq!(titles, vec!["http", "net"], "references 应按标题字典序: {titles:?}");
assert!(doc.references.iter().all(|r| r.target_path.starts_with("wiki/zh/")), "references 应指向主语言模块页");
}