1pub mod card;
2pub mod chunk;
3pub mod embed;
4pub mod index;
5pub mod llm;
6pub mod prompt;
7pub mod schema;
8pub mod wiki;
9
10use std::collections::HashMap;
11use std::path::Path;
12use std::time::Instant;
13
14use anyhow::Result;
15
16use crate::config::schema::WikiConfig;
17use crate::ingest::parser::FileInsight;
18use crate::model::{KnowledgeCard, KnowledgeGraph, WikiDocument};
19
20use self::card::CardGenerator;
21use self::chunk::Chunk;
22use self::llm::{AnthropicProvider, LlmProvider, OpenAiProvider, Provider};
23use self::wiki::WikiGenerator;
24
25pub struct GenerationOutput {
27 pub cards: Vec<KnowledgeCard>,
28 pub documents: Vec<WikiDocument>,
29 pub generation_stats: GenerationStats,
30 pub timings: crate::GenerationTimings,
33}
34
35#[derive(Debug, Clone, Default)]
37pub struct GenerationStats {
38 pub total_tokens_used: usize,
39 pub llm_calls: usize,
40 pub generation_time_ms: u64,
41 pub failed_modules: Vec<String>,
43}
44
45pub fn create_provider(config: &WikiConfig) -> Result<Provider> {
47 match config.llm.provider {
48 crate::config::schema::LlmProviderType::OpenAI => {
50 Ok(Provider::OpenAi(OpenAiProvider::new(&config.llm, crate::generate::llm::OpenAiProtocol::Responses)?))
51 }
52 crate::config::schema::LlmProviderType::Anthropic => {
53 Ok(Provider::Anthropic(AnthropicProvider::new(&config.llm)?))
54 }
55 crate::config::schema::LlmProviderType::OpenAiCompatible => {
57 Ok(Provider::OpenAi(OpenAiProvider::new(&config.llm, crate::generate::llm::OpenAiProtocol::Chat)?))
58 }
59 crate::config::schema::LlmProviderType::Mock => {
60 Ok(Provider::Mock(crate::generate::llm::MockProvider::new()))
62 }
63 }
64}
65
66fn filter_chunks_by_guide(
89 chunks: Vec<Chunk>,
90 guide: &crate::config::schema::WikiGuideSection,
91 strict_empty: bool,
92) -> Result<Vec<Chunk>> {
93 if guide.pages.is_empty() {
94 return Ok(chunks);
95 }
96 let original_len = chunks.len();
97 let mut filtered: Vec<Chunk> = chunks
98 .into_iter()
99 .filter(|c| guide.pages.iter().any(|p| guide_prefix_match(&c.module_path, p)))
100 .collect();
101 if filtered.is_empty() && original_len > 0 {
102 if strict_empty {
103 anyhow::bail!(
104 "[wiki.guide].pages 未匹配任何模块(共 {} 个模块),请检查 pages 配置",
105 original_len
106 );
107 }
108 tracing::info!("增量生成: 受影响模块均不在 [wiki.guide].pages 白名单,跳过生成");
109 }
110 if !guide.priority.is_empty() {
111 filtered.sort_by_key(|c| {
112 guide
113 .priority
114 .iter()
115 .position(|p| guide_prefix_match(&c.module_path, p))
116 .unwrap_or(usize::MAX)
117 });
118 }
119 Ok(filtered)
120}
121
122fn guide_prefix_match(module_path: &[String], pattern: &str) -> bool {
125 let pat: Vec<&str> = pattern
126 .split(['/', ':', '\\'])
127 .filter(|s| !s.is_empty())
128 .collect();
129 if pat.is_empty() {
130 return false;
131 }
132 module_path
133 .iter()
134 .take(pat.len())
135 .map(|s| s.as_str())
136 .eq(pat.iter().copied())
137}
138
139pub async fn run_generation(
140 graph: &KnowledgeGraph,
141 insights: &[FileInsight],
142 config: &WikiConfig,
143 root: &crate::project::ProjectRoot,
144 extra_edits: &HashMap<String, Vec<String>>,
145) -> Result<GenerationOutput> {
146 let start = Instant::now();
147 let chunk_start = Instant::now();
149
150 let chunks = if graph.modules.is_empty() {
152 tracing::warn!("未检测到模块聚类,回退到文件级分块");
153 insights
154 .iter()
155 .map(chunk::chunk_by_file)
156 .collect::<Vec<_>>()
157 } else {
158 chunk::chunk_by_module(insights, &graph.modules, graph)
159 };
160 let chunks: Vec<_> = chunks
166 .into_iter()
167 .filter(|c| !c.is_empty())
168 .collect();
169 let chunks = filter_chunks_by_guide(chunks, &config.wiki.guide, true)?;
171 tracing::info!("生成进度: 30% - 分块完成,共 {} 个块", chunks.len());
172 let chunk_ms = chunk_start.elapsed().as_millis() as u64;
173
174 let provider = create_provider(config)?;
176
177 let card_start = Instant::now();
179 let card_gen = CardGenerator::new(
180 &provider,
181 config.clone(),
182 crate::config::schema::LLM_MAX_CONCURRENT,
183 config.wiki.language.clone(),
184 );
185 let mut cards = card_gen
186 .generate_all_cards(&chunks, extra_edits)
187 .await?;
188 backfill_features(&mut cards, &chunks, graph);
190 tracing::info!("生成进度: 60% - 知识卡片生成完成,共 {} 个卡片", cards.len());
191 let card_ms = card_start.elapsed().as_millis() as u64;
192
193 let wiki_start = Instant::now();
196 let wiki_gen = WikiGenerator::new(&provider, crate::config::schema::LLM_MAX_CONCURRENT);
197 let mut documents =
198 generate_wiki_pages(&wiki_gen, &chunks, &cards, config, crate::config::schema::LLM_MAX_CONCURRENT, root, &build_entity_ranges(insights)).await;
199 tracing::info!("生成进度: 90% - Wiki 页面生成完成,共 {} 个页面", documents.len());
200 let wiki_ms = wiki_start.elapsed().as_millis() as u64;
201
202 generate_global_documents(&wiki_gen, &provider, graph, config, root, &cards, &mut documents, &GlobalDocAffected::all(), false).await?;
204
205 let elapsed = start.elapsed();
206 let stats = GenerationStats {
207 llm_calls: card_gen.llm_call_count() + wiki_gen.llm_call_count(),
208 generation_time_ms: elapsed.as_millis() as u64,
209 failed_modules: {
211 let mut f = card_gen.failed_modules();
212 f.extend(wiki_gen.failed_modules());
213 f
214 },
215 ..Default::default()
216 };
217
218 Ok(GenerationOutput {
219 cards,
220 documents,
221 generation_stats: stats,
222 timings: crate::GenerationTimings {
223 chunk_ms,
224 card_ms,
225 wiki_ms,
226 ..Default::default()
227 },
228 })
229}
230
231pub async fn run_generation_filtered(
238 graph: &KnowledgeGraph,
239 insights: &[FileInsight],
240 config: &WikiConfig,
241 root: &crate::project::ProjectRoot,
242 inc: &crate::incremental::IncrementalResult,
243 extra_edits: &HashMap<String, Vec<String>>,
244) -> Result<GenerationOutput> {
245 let start = Instant::now();
246 let changed_files = &inc.changed_files;
247 let entity_changes = &inc.entity_changes;
248 let affected_modules = &inc.affected_modules;
249 let chunk_start = Instant::now();
251
252 let affected_files = crate::incremental::impact::module_files(affected_modules, graph);
257 let no_entity_change_files = crate::incremental::change::no_entity_change_files(
265 changed_files,
266 entity_changes,
267 root,
268 );
269 let mut changed_insights: Vec<FileInsight> = insights
270 .iter()
271 .filter(|f| {
272 (changed_files.contains(&f.path) || affected_files.contains(&f.path))
273 && !no_entity_change_files.contains(&f.path)
274 })
275 .cloned()
276 .collect();
277
278 let deleted_files: std::collections::HashSet<std::path::PathBuf> = changed_files
291 .iter()
292 .filter(|f| !root.path().join(f).exists())
293 .cloned()
294 .collect();
295 let surviving_files: std::collections::HashSet<std::path::PathBuf> = if deleted_files.is_empty() {
296 std::collections::HashSet::new()
297 } else if let Ok(content) =
298 std::fs::read_to_string(crate::output::export_snapshot_path(config.output_dir()))
299 && let Ok(snapshot) = serde_json::from_str::<crate::output::ExportSnapshot>(&content)
300 {
301 snapshot
302 .cards
303 .iter()
304 .filter(|c| {
305 !c.related_files.is_empty()
306 && c.related_files.iter().any(|f| deleted_files.contains(Path::new(f)))
307 && c.related_files.iter().any(|f| root.path().join(f).exists())
308 })
309 .flat_map(|c| c.related_files.iter().map(std::path::PathBuf::from))
310 .collect()
311 } else {
312 std::collections::HashSet::new()
313 };
314 if !surviving_files.is_empty() {
315 let mut present: std::collections::HashSet<std::path::PathBuf> =
316 changed_insights.iter().map(|i| i.path.clone()).collect();
317 let mut merged = 0usize;
318 for insight in insights {
319 if surviving_files.contains(&insight.path) && present.insert(insight.path.clone()) {
320 changed_insights.push(insight.clone());
321 merged += 1;
322 }
323 }
324 if merged > 0 {
325 tracing::info!(
326 "增量生成: 删除文件所属模块的 {} 个存活文件并入变更集,重生成清除被删实体残留",
327 merged
328 );
329 }
330 }
331
332 if changed_insights.is_empty() {
333 if let Ok(content) = std::fs::read_to_string(crate::output::export_snapshot_path(config.output_dir()))
341 && let Ok(snapshot) = serde_json::from_str::<crate::output::ExportSnapshot>(&content)
342 {
343 let deleted_modules: std::collections::HashSet<String> = snapshot
347 .cards
348 .iter()
349 .filter(|c| {
350 !c.related_files.is_empty()
351 && c.related_files.iter().all(|f| !root.path().join(f).exists())
352 })
353 .map(|c| c.module_name.clone())
354 .collect();
355 let cards: Vec<KnowledgeCard> = snapshot
356 .cards
357 .into_iter()
358 .filter(|c| !deleted_modules.contains(&c.module_name))
359 .collect();
360 let documents: Vec<WikiDocument> = snapshot
361 .documents
362 .into_iter()
363 .filter(|d| !deleted_modules.contains(&d.title))
364 .collect();
365 tracing::info!(
366 "增量生成: 空集场景({} 个变更文件),从快照回填 {} 文档 {} 卡片(跳过已删模块 {} 个)",
367 changed_files.len(),
368 documents.len(),
369 cards.len(),
370 deleted_modules.len()
371 );
372 return Ok(GenerationOutput {
373 cards,
374 documents,
375 generation_stats: GenerationStats::default(),
376 timings: crate::GenerationTimings::default(),
377 });
378 } else {
379 tracing::warn!("增量生成: 纯删除场景但导出快照缺失,回退全量生成防止产物误清");
380 changed_insights = insights.to_vec();
382 }
383 } else {
384 tracing::info!("增量生成: {} 个文件变更", changed_insights.len());
385 }
386
387 let chunks: Vec<_> = if graph.modules.is_empty() {
389 changed_insights
390 .iter()
391 .map(chunk::chunk_by_file)
392 .collect()
393 } else {
394 chunk::chunk_by_module(&changed_insights, &graph.modules, graph)
396 };
397 let chunks: Vec<_> = chunks
401 .into_iter()
402 .filter(|c| !c.is_empty())
403 .collect();
404 let chunks = filter_chunks_by_guide(chunks, &config.wiki.guide, false)?;
407 tracing::info!("增量分块完成: {} 个块", chunks.len());
408 let chunk_ms = chunk_start.elapsed().as_millis() as u64;
409
410 let provider = create_provider(config)?;
412
413 let card_start = Instant::now();
415 let card_gen = CardGenerator::new(
416 &provider,
417 config.clone(),
418 crate::config::schema::LLM_MAX_CONCURRENT,
419 config.wiki.language.clone(),
420 );
421 let mut cards = card_gen
422 .generate_all_cards(&chunks, extra_edits)
423 .await?;
424 backfill_features(&mut cards, &chunks, graph);
426 let card_ms = card_start.elapsed().as_millis() as u64;
427
428 let wiki_start = Instant::now();
431 let wiki_gen = WikiGenerator::new(&provider, crate::config::schema::LLM_MAX_CONCURRENT);
432 let mut documents =
433 generate_wiki_pages(&wiki_gen, &chunks, &cards, config, crate::config::schema::LLM_MAX_CONCURRENT, root, &build_entity_ranges(insights)).await;
434 let wiki_ms = wiki_start.elapsed().as_millis() as u64;
435
436 let global_affected = GlobalDocAffected {
443 architecture: entity_changes.has_interface_change(),
444 schema: changed_files
445 .iter()
446 .any(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sql"))),
447 };
448 generate_global_documents(&wiki_gen, &provider, graph, config, root, &cards, &mut documents, &global_affected, inc.has_deleted_files).await?;
449
450 let elapsed = start.elapsed();
451 let stats = GenerationStats {
452 llm_calls: card_gen.llm_call_count() + wiki_gen.llm_call_count(),
453 generation_time_ms: elapsed.as_millis() as u64,
454 failed_modules: {
456 let mut f = card_gen.failed_modules();
457 f.extend(wiki_gen.failed_modules());
458 f
459 },
460 ..Default::default()
461 };
462
463 Ok(GenerationOutput {
464 cards,
465 documents,
466 generation_stats: stats,
467 timings: crate::GenerationTimings {
468 chunk_ms,
469 card_ms,
470 wiki_ms,
471 ..Default::default()
472 },
473 })
474}
475
476fn build_entity_ranges(insights: &[FileInsight]) -> crate::output::citation::EntityRanges {
486 insights
487 .iter()
488 .map(|insight| {
489 let key = crate::incremental::norm_sep(&insight.path.to_string_lossy());
490 let ranges: Vec<(usize, usize)> = insight
491 .entities
492 .iter()
493 .map(|e| (e.line_start, e.line_end))
494 .collect();
495 (key, ranges)
496 })
497 .collect()
498}
499
500fn backfill_features(cards: &mut [KnowledgeCard], chunks: &[Chunk], graph: &KnowledgeGraph) {
507 if graph.features.is_empty() || cards.is_empty() {
508 return;
509 }
510 let feature_entities: Vec<(String, std::collections::HashSet<String>)> = graph
512 .features
513 .iter()
514 .map(|f| {
515 let names: std::collections::HashSet<String> = f
516 .node_ids
517 .iter()
518 .filter_map(|nid| graph.graph.node_weight(*nid).map(|n| n.name.clone()))
519 .collect();
520 (f.name.clone(), names)
521 })
522 .collect();
523 for (card, chunk) in cards.iter_mut().zip(chunks) {
524 let entity_names: std::collections::HashSet<&str> =
525 chunk.entities.iter().map(|e| e.name.as_str()).collect();
526 let mut matched: Vec<String> = feature_entities
527 .iter()
528 .filter(|(_, names)| names.iter().any(|n| entity_names.contains(n.as_str())))
529 .map(|(name, _)| name.clone())
530 .collect();
531 matched.sort();
532 card.features = matched;
533 }
534}
535
536async fn generate_wiki_pages<P: LlmProvider>(
547 wiki_gen: &WikiGenerator<'_, P>,
548 chunks: &[Chunk],
549 cards: &[KnowledgeCard],
550 config: &WikiConfig,
551 max_concurrent: usize,
552 root: &crate::project::ProjectRoot,
553 entity_ranges: &crate::output::citation::EntityRanges,
554) -> Vec<WikiDocument> {
555 let languages = crate::output::wiki_languages(config);
556 let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1)));
557 let mut handles = Vec::with_capacity(chunks.len() * languages.len());
558 let mut task_modules = Vec::with_capacity(chunks.len() * languages.len());
560 for lang in &languages {
561 let mut lang_cfg = config.clone();
562 lang_cfg.wiki.language = lang.clone();
563 for (i, chunk) in chunks.iter().enumerate() {
564 let card_summary = cards.get(i).map(|c| c.summary.clone()).unwrap_or_default();
565 let semaphore = semaphore.clone();
566 let lang_cfg = lang_cfg.clone();
567 task_modules.push(chunk.module_path.join("::"));
568 handles.push(async move {
569 let _permit = semaphore
570 .acquire()
571 .await
572 .map_err(|_| anyhow::anyhow!("信号量已关闭"))?;
573 wiki_gen
574 .generate_wiki_page(chunk, &card_summary, &lang_cfg, root, Some(entity_ranges))
575 .await
576 });
577 }
578 }
579
580 let results = futures::future::join_all(handles).await;
581 task_modules
582 .into_iter()
583 .zip(results)
584 .filter_map(|(module, r)| match r {
585 Ok(doc) => Some(doc),
586 Err(e) => {
587 tracing::warn!("跳过 Wiki 页面生成 {}: {}", module, e);
589 wiki_gen.record_failure(module);
590 None
591 }
592 })
593 .collect()
594}
595
596#[derive(Debug, Clone, Default)]
601pub struct GlobalDocAffected {
602 pub architecture: bool,
604 pub schema: bool,
606}
607
608impl GlobalDocAffected {
609 pub fn all() -> Self {
611 Self { architecture: true, schema: true }
612 }
613}
614
615#[allow(clippy::too_many_arguments)]
629async fn generate_global_documents(
630 wiki_gen: &WikiGenerator<'_, Provider>,
631 provider: &Provider,
632 graph: &KnowledgeGraph,
633 config: &WikiConfig,
634 root: &crate::project::ProjectRoot,
635 cards: &[KnowledgeCard],
636 documents: &mut Vec<WikiDocument>,
637 affected: &GlobalDocAffected,
638 has_deleted_files: bool,
639) -> Result<()> {
640 if affected.architecture {
645 if !cards.is_empty() || has_deleted_files {
650 let output_snapshot = GenerationOutput {
652 cards: cards.to_vec(),
653 documents: documents.clone(),
654 generation_stats: GenerationStats::default(),
655 timings: crate::GenerationTimings::default(),
656 };
657 match wiki_gen
658 .generate_architecture(&output_snapshot, graph, config, root)
659 .await
660 {
661 Ok(arch) => documents.push(arch),
662 Err(e) => {
665 tracing::warn!("架构概览生成失败,降级为确定性骨架: {e}");
666 documents.push(crate::generate::wiki::fallback_architecture_doc(
667 graph,
668 config,
669 crate::model::DocumentKind::ArchitectureOverview,
670 "架构概览",
671 ));
672 }
673 }
674 match wiki_gen
675 .generate_overview(&output_snapshot, graph, config, root)
676 .await
677 {
678 Ok(overview) => documents.push(overview),
679 Err(e) => {
680 tracing::warn!("项目概览生成失败,降级为确定性骨架: {e}");
681 documents.push(crate::generate::wiki::fallback_architecture_doc(
682 graph,
683 config,
684 crate::model::DocumentKind::ProjectOverview,
685 "项目概览",
686 ));
687 }
688 }
689 }
690 } else if !backfill_global_docs(config, documents, &[
691 crate::model::DocumentKind::ArchitectureOverview,
692 crate::model::DocumentKind::ProjectOverview,
693 ]) {
694 tracing::info!("全局文档快照回填不可用,回退重新生成");
696 let output_snapshot = GenerationOutput {
697 cards: cards.to_vec(),
698 documents: documents.clone(),
699 generation_stats: GenerationStats::default(),
700 timings: crate::GenerationTimings::default(),
701 };
702 match wiki_gen
703 .generate_architecture(&output_snapshot, graph, config, root)
704 .await
705 {
706 Ok(arch) => documents.push(arch),
707 Err(e) => {
709 tracing::warn!("架构概览生成失败,降级为确定性骨架: {e}");
710 documents.push(crate::generate::wiki::fallback_architecture_doc(
711 graph,
712 config,
713 crate::model::DocumentKind::ArchitectureOverview,
714 "架构概览",
715 ));
716 }
717 }
718 match wiki_gen
719 .generate_overview(&output_snapshot, graph, config, root)
720 .await
721 {
722 Ok(overview) => documents.push(overview),
723 Err(e) => {
724 tracing::warn!("项目概览生成失败,降级为确定性骨架: {e}");
725 documents.push(crate::generate::wiki::fallback_architecture_doc(
726 graph,
727 config,
728 crate::model::DocumentKind::ProjectOverview,
729 "项目概览",
730 ));
731 }
732 }
733 }
734
735 if affected.schema {
737 match schema::generate_schema_documents_at(root, provider, config).await {
738 Ok(mut schema_docs) => documents.append(&mut schema_docs),
739 Err(e) => tracing::warn!("数据库 Schema 文档生成跳过: {}", e),
740 }
741 } else if !backfill_global_docs(config, documents, &[crate::model::DocumentKind::DatabaseSchema]) {
742 tracing::info!("Schema 快照回填不可用,回退重新生成");
743 match schema::generate_schema_documents_at(root, provider, config).await {
744 Ok(mut schema_docs) => documents.append(&mut schema_docs),
745 Err(e) => tracing::warn!("数据库 Schema 文档生成跳过: {}", e),
746 }
747 }
748
749 Ok(())
750}
751
752pub(crate) fn backfill_global_docs(
765 config: &WikiConfig,
766 documents: &mut Vec<WikiDocument>,
767 kinds: &[crate::model::DocumentKind],
768) -> bool {
769 let snapshot_path = crate::output::export_snapshot_path(config.output_dir());
770 let Ok(content) = std::fs::read_to_string(&snapshot_path) else {
771 return false;
772 };
773 let Ok(snapshot) = serde_json::from_str::<crate::output::ExportSnapshot>(&content) else {
774 tracing::warn!("导出快照解析失败(将回退重新生成全局文档): {}", snapshot_path.display());
775 return false;
776 };
777 let mut filled = false;
778 for doc in snapshot.documents {
779 if kinds.contains(&doc.kind)
780 && doc.language == config.wiki.language
783 && !documents.iter().any(|d| d.title == doc.title && d.language == doc.language)
787 {
788 documents.push(doc);
789 filled = true;
790 }
791 }
792 filled
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798 use crate::model::DocumentKind;
799
800 fn make_document(title: &str) -> WikiDocument {
802 WikiDocument {
803 title: title.into(),
804 kind: DocumentKind::WikiPage,
805 content: String::new(),
806 language: "zh".into(),
807 module_path: vec![],
808 references: vec![],
809 last_updated: String::new(),
810 based_on_commit: None,
811 fingerprint: None,
812 }
813 }
814
815 #[test]
818 fn test_backfill_global_docs_from_snapshot() {
819 let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_{}", std::process::id()));
820 let _ = std::fs::remove_dir_all(&dir);
821 std::fs::create_dir_all(dir.join(".state")).unwrap();
822
823 let arch = WikiDocument {
824 title: "架构概览".into(),
825 kind: DocumentKind::ArchitectureOverview,
826 content: "架构内容".into(),
827 language: "zh".into(),
828 module_path: vec![],
829 references: vec![],
830 last_updated: "2025-01-01T00:00:00Z".into(),
831 based_on_commit: None,
832 fingerprint: None,
833 };
834 let overview = WikiDocument {
835 title: "项目概览".into(),
836 kind: DocumentKind::ProjectOverview,
837 content: "概览内容".into(),
838 language: "zh".into(),
839 module_path: vec![],
840 references: vec![],
841 last_updated: "2025-01-01T00:00:00Z".into(),
842 based_on_commit: None,
843 fingerprint: None,
844 };
845 let snapshot = crate::output::ExportSnapshot {
846 version: 1,
847 documents: vec![arch.clone(), overview.clone()],
848 cards: vec![],
849 modules: vec![],
850 };
851 crate::fs::write_file_atomic(
852 &dir.join(".state").join("export_snapshot.json"),
853 &serde_json::to_string(&snapshot).unwrap(),
854 )
855 .unwrap();
856
857 let config = WikiConfig { output_dir: Some(dir.clone()), ..Default::default() };
858
859 let mut documents = vec![overview.clone()];
861 let filled = backfill_global_docs(
862 &config,
863 &mut documents,
864 &[DocumentKind::ArchitectureOverview, DocumentKind::ProjectOverview],
865 );
866 assert!(filled, "快照存在时应回填");
867 assert_eq!(documents.len(), 2, "回填架构(概览已存在不重复)");
868 assert_eq!(documents[1].kind, DocumentKind::ArchitectureOverview);
869 assert_eq!(documents[1].content, "架构内容");
870 let _ = std::fs::remove_dir_all(&dir);
871 }
872
873 #[test]
875 fn test_backfill_global_docs_missing_snapshot() {
876 let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_miss_{}", std::process::id()));
877 let _ = std::fs::remove_dir_all(&dir);
878 std::fs::create_dir_all(&dir).unwrap();
879
880 let config = WikiConfig { output_dir: Some(dir.clone()), ..Default::default() };
881
882 let mut documents = Vec::new();
883 let filled = backfill_global_docs(&config, &mut documents, &[DocumentKind::ArchitectureOverview]);
884 assert!(!filled, "快照缺失时回填失败(回退生成)");
885 assert!(documents.is_empty());
886 let _ = std::fs::remove_dir_all(&dir);
887 }
888
889 #[test]
892 fn test_backfill_global_docs_skips_on_language_mismatch() {
893 let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_lang_{}", std::process::id()));
894 let _ = std::fs::remove_dir_all(&dir);
895 std::fs::create_dir_all(&dir).unwrap();
896
897 let mut arch = make_document("架构概览");
898 arch.kind = DocumentKind::ArchitectureOverview;
899 arch.language = "zh".into(); let snapshot = crate::output::ExportSnapshot {
901 version: 1,
902 documents: vec![arch],
903 cards: vec![],
904 modules: vec![],
905 };
906 crate::fs::write_file_atomic(
907 &dir.join(".state").join("export_snapshot.json"),
908 &serde_json::to_string(&snapshot).unwrap(),
909 )
910 .unwrap();
911
912 let config = WikiConfig {
913 output_dir: Some(dir.clone()),
914 wiki: crate::config::schema::WikiSection { language: "en".into(), guide: Default::default() },
915 ..Default::default()
916 };
917
918 let mut documents = Vec::new();
919 let filled = backfill_global_docs(&config, &mut documents, &[DocumentKind::ArchitectureOverview]);
920 assert!(!filled, "语言不匹配时不得回填(回退生成新语言内容)");
921 assert!(documents.is_empty());
922 let _ = std::fs::remove_dir_all(&dir);
923 }
924
925 #[test]
927 fn test_global_affected_signal() {
928 use crate::incremental::change::{EntityChange, EntityChangeKind};
929
930 let mut changes = Vec::new();
931 changes.push(EntityChange {
932 file: std::path::PathBuf::from("src/a.rs"),
933 entity_name: "foo".into(),
934 kind: EntityChangeKind::BodyChanged,
935 old_range: None,
936 new_range: None,
937 });
938 let affected = GlobalDocAffected {
939 architecture: crate::incremental::change::EntityChangeSet { changes: changes.clone() }.has_interface_change(),
940 schema: false,
941 };
942 assert!(!affected.architecture, "纯实现级变化不应触发架构重生成");
943
944 changes.push(EntityChange {
945 file: std::path::PathBuf::from("src/a.rs"),
946 entity_name: "bar".into(),
947 kind: EntityChangeKind::Added,
948 old_range: None,
949 new_range: None,
950 });
951 let affected2 = GlobalDocAffected {
952 architecture: crate::incremental::change::EntityChangeSet { changes }.has_interface_change(),
953 schema: false,
954 };
955 assert!(affected2.architecture, "接口级变化应触发架构重生成");
956 }
957
958 #[test]
962 fn test_backfill_global_docs_dedup_by_title_not_kind() {
963 let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_schema_{}", std::process::id()));
964 let _ = std::fs::remove_dir_all(&dir);
965 std::fs::create_dir_all(&dir).unwrap();
966
967 let schema_a = WikiDocument {
968 title: "Database Schema: db/a.sql".into(),
969 kind: DocumentKind::DatabaseSchema,
970 content: "A 表结构".into(),
971 language: "zh".into(),
972 module_path: vec![],
973 references: vec![],
974 last_updated: "2025-01-01T00:00:00Z".into(),
975 based_on_commit: None,
976 fingerprint: None,
977 };
978 let schema_b = WikiDocument {
979 title: "Database Schema: db/b.sql".into(),
980 kind: DocumentKind::DatabaseSchema,
981 content: "B 表结构".into(),
982 language: "zh".into(),
983 module_path: vec![],
984 references: vec![],
985 last_updated: "2025-01-01T00:00:00Z".into(),
986 based_on_commit: None,
987 fingerprint: None,
988 };
989 let snapshot = crate::output::ExportSnapshot {
990 version: 1,
991 documents: vec![schema_a.clone(), schema_b.clone()],
992 cards: vec![],
993 modules: vec![],
994 };
995 crate::fs::write_file_atomic(
996 &dir.join(".state").join("export_snapshot.json"),
997 &serde_json::to_string(&snapshot).unwrap(),
998 )
999 .unwrap();
1000
1001 let config = WikiConfig { output_dir: Some(dir.clone()), ..Default::default() };
1002
1003 let mut documents = Vec::new();
1004 let filled = backfill_global_docs(&config, &mut documents, &[DocumentKind::DatabaseSchema]);
1005 assert!(filled, "快照存在时应回填");
1006 assert_eq!(
1007 documents.len(),
1008 2,
1009 "两份 schema 文档都应回填(按 title 去重,非按 kind)"
1010 );
1011 let _ = std::fs::remove_dir_all(&dir);
1012 }
1013
1014 #[test]
1017 fn test_entity_name_from_signature() {
1018 use crate::output::lint::entity_name_from_signature;
1019 assert_eq!(entity_name_from_signature("pub fn foo(x: i32) -> u32").as_deref(), Some("foo"));
1020 assert_eq!(entity_name_from_signature("fn main()").as_deref(), Some("main"));
1021 assert_eq!(entity_name_from_signature("def bar()").as_deref(), Some("bar"));
1022 assert_eq!(entity_name_from_signature("func Baz()").as_deref(), Some("Baz"));
1023 assert_eq!(entity_name_from_signature("Foo").as_deref(), Some("Foo"));
1024 assert_eq!(entity_name_from_signature("pub struct Alpha").as_deref(), Some("Alpha"));
1025 assert_eq!(entity_name_from_signature(""), None);
1026 assert_eq!(entity_name_from_signature(" "), None);
1027 }
1028}
1029
1030 #[test]
1033 fn test_fallback_architecture_doc_skeleton() {
1034 use crate::model::{CodeNode, EdgeKind, NodeKind};
1035 use petgraph::stable_graph::StableDiGraph;
1036
1037 let mut g = StableDiGraph::<CodeNode, crate::model::CodeEdge>::new();
1038 let a = g.add_node(CodeNode {
1039 id: crate::model::NodeId::new(0),
1040 kind: NodeKind::Function,
1041 name: "a_fn".into(),
1042 file_path: Some("src/a.rs".into()),
1043 line_range: None,
1044 doc_comment: None,
1045 signature: None, visibility: None,
1046 module_path: vec!["net".into()],
1047 });
1048 let b = g.add_node(CodeNode {
1049 id: crate::model::NodeId::new(1),
1050 kind: NodeKind::Function,
1051 name: "b_fn".into(),
1052 file_path: Some("src/b.rs".into()),
1053 line_range: None,
1054 doc_comment: None,
1055 signature: None, visibility: None,
1056 module_path: vec!["http".into()],
1057 });
1058 g.add_edge(a, b, crate::model::CodeEdge {
1059 id: petgraph::stable_graph::EdgeIndex::new(0),
1060 kind: EdgeKind::Calls,
1061 source: a,
1062 target: b,
1063 weight: 1.0,
1064 location: None,
1065 });
1066 let graph = crate::model::KnowledgeGraph {
1067 graph: g,
1068 modules: vec![
1069 crate::model::ModuleCluster {
1070 name: "net".into(),
1071 node_ids: vec![a],
1072 cohesion: 1.0,
1073 coupling: 0.0,
1074 description: None,
1075 },
1076 crate::model::ModuleCluster {
1077 name: "http".into(),
1078 node_ids: vec![b],
1079 cohesion: 1.0,
1080 coupling: 0.0,
1081 description: None,
1082 },
1083 ],
1084 features: Vec::new(),
1085 };
1086
1087 let config = WikiConfig::default();
1088 let doc = crate::generate::wiki::fallback_architecture_doc(
1089 &graph,
1090 &config,
1091 crate::model::DocumentKind::ArchitectureOverview,
1092 "架构概览",
1093 );
1094 assert!(doc.content.contains("架构概览"), "应含标题: {}", doc.content);
1095 assert!(doc.content.contains("net`(1 个实体)"), "应含模块与实体数");
1096 assert!(doc.content.contains("http`(1 个实体)"), "应含模块与实体数");
1097 assert!(doc.content.contains("依赖 http"), "net 应列出依赖 http");
1098 assert_eq!(doc.kind, crate::model::DocumentKind::ArchitectureOverview);
1099 let titles: Vec<&str> = doc.references.iter().map(|r| r.target_title.as_str()).collect();
1101 assert_eq!(titles, vec!["http", "net"], "references 应按标题字典序: {titles:?}");
1102 assert!(doc.references.iter().all(|r| r.target_path.starts_with("wiki/zh/")), "references 应指向主语言模块页");
1103 }