1use std::path::Path;
2use std::sync::atomic::{AtomicUsize, Ordering};
3
4use anyhow::Result;
5
6use crate::config::schema::WikiConfig;
7use crate::generate::chunk::Chunk;
8use crate::generate::llm::{LlmProvider, Message};
9use crate::generate::prompt;
10use crate::generate::GenerationOutput;
11use crate::model::{DocumentKind, EdgeKind, KnowledgeGraph, NodeId, Reference, WikiDocument};
12
13pub struct WikiGenerator<'a, P: LlmProvider> {
18 provider: &'a P,
19 call_count: AtomicUsize,
20 failed: std::sync::Mutex<Vec<String>>,
22 semaphore: std::sync::Arc<tokio::sync::Semaphore>,
25 desc_cache: std::sync::Mutex<Option<ModuleDescCache>>,
32 head_short: std::sync::OnceLock<Option<String>>,
35}
36
37impl<P: LlmProvider> WikiGenerator<'_, P> {
38 fn head_short_for(&self, root: &crate::project::ProjectRoot) -> Option<String> {
40 self.head_short
41 .get_or_init(|| git_head_short(root))
42 .clone()
43 }
44}
45
46#[derive(serde::Serialize, serde::Deserialize)]
51struct CacheEntry {
52 fingerprint: String,
53 description: String,
54}
55
56 struct ModuleDescCache {
62 entries: std::collections::HashMap<String, CacheEntry>,
63 }
64
65 fn git_head_short(root: &crate::project::ProjectRoot) -> Option<String> {
71 let repo = git2::Repository::open(root.path()).ok()?;
72 let head = repo.head().ok()?;
73 let commit = head.peel_to_commit().ok()?;
74 let id = commit.id().to_string();
75 Some(id[..id.len().min(8)].to_string())
76 }
77
78impl ModuleDescCache {
79 fn new() -> Self {
80 Self {
81 entries: std::collections::HashMap::new(),
82 }
83 }
84
85 fn load(path: &Path) -> Self {
87 let Ok(content) = std::fs::read_to_string(path) else {
88 return Self::new();
89 };
90 match serde_json::from_str::<std::collections::HashMap<String, CacheEntry>>(&content) {
91 Ok(entries) => Self { entries },
92 Err(e) => {
93 tracing::warn!(
94 "模块描述缓存解析失败(回退空缓存,按需重新生成): {} {}",
95 path.display(),
96 e
97 );
98 Self::new()
99 }
100 }
101 }
102
103 fn save(&self, path: &Path) {
105 let Ok(content) = serde_json::to_string(&self.entries) else {
106 return;
107 };
108 let Some(parent) = path.parent() else {
109 return;
110 };
111 let _ = std::fs::create_dir_all(parent);
112 let tmp = path.with_extension("json.tmp");
113 if std::fs::write(&tmp, content).is_ok() {
114 let _ = std::fs::rename(&tmp, path);
115 }
116 }
117}
118
119fn module_files_fingerprint(
125 module: &crate::model::ModuleCluster,
126 graph: &KnowledgeGraph,
127 root: &crate::project::ProjectRoot,
128) -> String {
129 let mut files: Vec<&str> = module
130 .node_ids
131 .iter()
132 .filter_map(|nid| graph.graph.node_weight(*nid))
133 .filter_map(|n| n.file_path.as_deref())
134 .collect();
135 files.sort_unstable();
136 files.dedup();
137 files
138 .iter()
139 .map(|f| {
140 crate::incremental::state::GenerationState::compute_file_fingerprint(
144 &root.path().join(f),
145 )
146 .unwrap_or_else(|_| "missing".to_string())
147 })
148 .collect::<Vec<_>>()
149 .join("|")
150}
151
152pub const CITATION_RETRY_MAX: usize = 2;
156
157pub const MERMAID_RETRY_MAX: usize = crate::output::mermaid_check::MERMAID_RETRY_MAX;
162
163const CALL_RETRY_MAX: usize = 3;
169
170async fn complete_with_retry<P: LlmProvider>(
176 provider: &P,
177 messages: &[Message],
178 module: &str,
179) -> Result<String> {
180 let mut last_err = None;
181 for attempt in 0..CALL_RETRY_MAX {
182 match provider.complete(messages).await {
183 Ok(content) => return Ok(content),
184 Err(e) => {
185 tracing::warn!(
186 "LLM 调用失败(第 {} 次重试): {} {}",
187 attempt + 1,
188 module,
189 e
190 );
191 last_err = Some(e);
192 tokio::time::sleep(std::time::Duration::from_millis(500 << attempt)).await;
193 }
194 }
195 }
196 Err(last_err.expect("CALL_RETRY_MAX 至少为 1"))
197}
198
199impl<'a, P: LlmProvider> WikiGenerator<'a, P> {
200 pub fn new(provider: &'a P, max_concurrent: usize) -> Self {
204 let max = if max_concurrent == 0 { 1_000_000_000 } else { max_concurrent };
207 Self {
208 provider,
209 call_count: AtomicUsize::new(0),
210 failed: std::sync::Mutex::new(Vec::new()),
211 semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(max)),
212 desc_cache: std::sync::Mutex::new(None),
214 head_short: std::sync::OnceLock::new(),
216 }
217 }
218
219 pub fn llm_call_count(&self) -> usize {
221 self.call_count.load(Ordering::Relaxed)
222 }
223
224 pub fn failed_modules(&self) -> Vec<String> {
226 self.failed.lock().map(|g| g.clone()).unwrap_or_default()
227 }
228
229 pub(crate) fn record_failure(&self, module: String) {
231 if let Ok(mut failed) = self.failed.lock() {
232 failed.push(module);
233 }
234 }
235
236 pub async fn generate_wiki_page(
250 &self,
251 chunk: &Chunk,
252 card_summary: &str,
253 config: &WikiConfig,
254 root: &crate::project::ProjectRoot,
255 entity_ranges: Option<&crate::output::citation::EntityRanges>,
256 ) -> Result<WikiDocument> {
257 if chunk.is_empty() {
258 anyhow::bail!("空块,跳过 Wiki 页面生成");
259 }
260
261 let language = &config.wiki.language;
262 let mut messages =
263 prompt::wiki_page_prompt(chunk, card_summary, language, &config.wiki.guide.notes);
264 let mut content = String::new();
265 let mut last_invalid = Vec::new();
266 let mut last_mermaid = Vec::new();
267
268 let retry_max = CITATION_RETRY_MAX.max(MERMAID_RETRY_MAX);
272 for attempt in 0..=retry_max {
273 self.call_count.fetch_add(1, Ordering::Relaxed);
274 content = complete_with_retry(self.provider, &messages, &chunk.module_path.join("::"))
279 .await?;
280 last_invalid = if content.trim().is_empty() {
282 vec![crate::output::citation::InvalidCitation {
283 citation: crate::output::citation::Citation {
284 path: String::new(),
285 start: 0,
286 end: 0,
287 },
288 reason: "输出为空(未生成任何内容)".into(),
289 }]
290 } else {
291 match entity_ranges {
294 Some(ranges) => {
295 crate::output::citation::validate_citations_against_entities(
296 root.path(),
297 &content,
298 ranges,
299 )
300 }
301 None => crate::output::citation::validate_citations(root.path(), &content),
302 }
303 };
304 last_mermaid = crate::output::mermaid_check::validate_mermaid_blocks(&content);
305 if last_invalid.is_empty() && last_mermaid.is_empty() {
306 break;
307 }
308 if !last_invalid.is_empty() {
309 tracing::warn!(
310 "Wiki 页面引用校验失败(第 {} 次,无效 {} 条): {}",
311 attempt + 1,
312 last_invalid.len(),
313 chunk.module_path.join("::")
314 );
315 }
316 if !last_mermaid.is_empty() {
317 tracing::warn!(
318 "Wiki 页面 Mermaid 校验失败(第 {} 次,坏块 {} 个): {}",
319 attempt + 1,
320 last_mermaid.len(),
321 chunk.module_path.join("::")
322 );
323 }
324 if attempt == retry_max {
326 break;
327 }
328 if !last_invalid.is_empty() {
329 messages.push(Message::user(
330 crate::output::citation::retry_feedback(&last_invalid),
331 ));
332 }
333 if !last_mermaid.is_empty() {
334 messages.push(Message::user(
335 crate::output::mermaid_check::mermaid_retry_feedback(&last_mermaid),
336 ));
337 }
338 }
339
340 if !last_invalid.is_empty() {
342 anyhow::bail!(
343 "Wiki 页面引用校验失败(重试 {} 次仍无效,共 {} 条无效引用): {}",
344 retry_max,
345 last_invalid.len(),
346 chunk.module_path.join("::")
347 );
348 }
349 if !last_mermaid.is_empty() {
352 tracing::warn!(
353 "Wiki 页面 Mermaid 重试耗尽({} 个坏块),降级为 text 块: {}",
354 last_mermaid.len(),
355 chunk.module_path.join("::")
356 );
357 content = crate::output::mermaid_check::degrade_mermaid_blocks(&content, &last_mermaid);
358 }
359
360 let now = chrono::Utc::now().to_rfc3339();
361 let based_on_commit = self.head_short_for(root);
364
365 Ok(WikiDocument {
366 title: chunk.module_path.join("::"),
370 kind: DocumentKind::WikiPage,
371 content,
372 language: config.wiki.language.clone(),
373 module_path: chunk.module_path.clone(),
374 references: build_references(chunk, &config.wiki.language),
375 last_updated: now,
376 based_on_commit,
377 fingerprint: None,
378 })
379 }
380
381 async fn complete_with_mermaid_guard(
391 &self,
392 messages: Vec<Message>,
393 label: &str,
394 ) -> Result<String> {
395 complete_with_mermaid_guard_free(self.provider, messages, label, Some(&self.call_count))
396 .await
397 }
398
399 pub async fn generate_architecture(
407 &self,
408 output: &GenerationOutput,
409 graph: &KnowledgeGraph,
410 config: &WikiConfig,
411 root: &crate::project::ProjectRoot,
412 ) -> Result<WikiDocument> {
413 let language = &config.wiki.language;
414 let modules = self.describe_modules(graph, language, config, root).await;
415 let messages =
416 prompt::architecture_overview_prompt(&modules, graph, language);
417 let content = self.complete_with_mermaid_guard(messages, "架构概览").await?;
419 let now = chrono::Utc::now().to_rfc3339();
420 let based_on_commit = self.head_short_for(root);
422
423 Ok(WikiDocument {
424 title: "架构概览".into(),
425 kind: DocumentKind::ArchitectureOverview,
426 content,
427 language: config.wiki.language.clone(),
428 module_path: vec![],
429 references: output
430 .cards
431 .iter()
432 .map(|c| Reference {
433 target_title: c.module_name.clone(),
434 target_path: format!(
435 "wiki/{}/{}.md",
436 config.wiki.language,
437 c.module_name.replace("::", "_")
440 ),
441 relation: "module".into(),
442 })
443 .collect(),
444 last_updated: now,
445 based_on_commit,
446 fingerprint: None,
447 })
448 }
449
450 async fn describe_modules(
463 &self,
464 graph: &KnowledgeGraph,
465 language: &str,
466 config: &WikiConfig,
467 root: &crate::project::ProjectRoot,
468 ) -> Vec<crate::model::ModuleCluster> {
469 let cache_path = config.output_dir().join(".state").join("module_descriptions.json");
472 {
474 let mut guard = self.desc_cache.lock().unwrap_or_else(|e| e.into_inner());
475 if guard.is_none() {
476 *guard = Some(ModuleDescCache::load(&cache_path));
477 }
478 }
479 let semaphore = self.semaphore.clone();
482 let futures: Vec<_> = graph
483 .modules
484 .iter()
485 .map(|module| {
486 let semaphore = semaphore.clone();
487 let cache_key = format!("{}@{}", module.name, language);
488 let fingerprint = module_files_fingerprint(module, graph, root);
489 async move {
490 if module.name == "src" || module.node_ids.is_empty() {
492 return module.clone();
493 }
494 {
496 let guard = self.desc_cache.lock().unwrap_or_else(|e| e.into_inner());
497 if let Some(cache) = guard.as_ref()
498 && let Some(entry) = cache.entries.get(&cache_key)
499 && entry.fingerprint == fingerprint
500 {
501 let mut enriched = module.clone();
502 enriched.description = Some(entry.description.clone());
503 return enriched;
504 }
505 }
506 let _permit = match semaphore.acquire().await {
507 Ok(p) => p,
508 Err(_) => return module.clone(),
509 };
510 let mut enriched = module.clone();
511 if let Ok(text) = self.describe_module(module, graph, language).await
512 && !text.trim().is_empty()
513 {
514 let description = text.trim().to_string();
515 {
518 let mut guard = self.desc_cache.lock().unwrap_or_else(|e| e.into_inner());
519 if let Some(cache) = guard.as_mut() {
520 cache.entries.insert(
521 cache_key,
522 CacheEntry {
523 fingerprint,
524 description: description.clone(),
525 },
526 );
527 }
528 }
529 enriched.description = Some(description);
530 }
531 enriched
532 }
533 })
534 .collect();
535 let modules = futures::future::join_all(futures).await;
536 {
538 let guard = self.desc_cache.lock().unwrap_or_else(|e| e.into_inner());
539 if let Some(cache) = guard.as_ref() {
540 cache.save(&cache_path);
541 }
542 }
543 modules
544 }
545
546 async fn describe_module(
548 &self,
549 module: &crate::model::ModuleCluster,
550 graph: &KnowledgeGraph,
551 language: &str,
552 ) -> Result<String> {
553 self.call_count.fetch_add(1, Ordering::Relaxed);
554
555 let entity_names = collect_module_entity_names(module, graph);
557
558 let messages = prompt::module_description_prompt(
559 &module.name,
560 &entity_names,
561 language,
562 );
563 self.provider.complete(&messages).await
564 }
565
566 pub async fn generate_overview(
571 &self,
572 output: &GenerationOutput,
573 graph: &KnowledgeGraph,
574 config: &WikiConfig,
575 root: &crate::project::ProjectRoot,
576 ) -> Result<WikiDocument> {
577 let modules = self.describe_modules(graph, &config.wiki.language, config, root).await;
582 let messages = vec![Message::user(overview_prompt(&modules, &output.cards, graph, config))];
583 let content = self.complete_with_mermaid_guard(messages, "项目概览").await?;
584 let now = chrono::Utc::now().to_rfc3339();
585 let based_on_commit = self.head_short_for(root);
587
588 Ok(WikiDocument {
589 title: "项目概览".into(),
590 kind: DocumentKind::ProjectOverview,
591 content,
592 language: config.wiki.language.clone(),
593 module_path: vec![],
594 references: output
595 .cards
596 .iter()
597 .map(|c| Reference {
598 target_title: c.module_name.clone(),
599 target_path: format!(
600 "wiki/{}/{}.md",
601 config.wiki.language,
602 c.module_name.replace("::", "_")
605 ),
606 relation: "module".into(),
607 })
608 .collect(),
609 last_updated: now,
610 based_on_commit,
611 fingerprint: None,
612 })
613 }
614}
615
616pub(crate) const DESCRIBE_ENTITY_CAP: usize = 30;
626
627fn entity_priority(kind: &crate::model::NodeKind) -> u8 {
629 match kind {
630 crate::model::NodeKind::Function
632 | crate::model::NodeKind::Trait
633 | crate::model::NodeKind::Impl
634 | crate::model::NodeKind::Interface
635 | crate::model::NodeKind::Class
636 | crate::model::NodeKind::Macro => 0,
637 crate::model::NodeKind::Struct
639 | crate::model::NodeKind::Enum
640 | crate::model::NodeKind::Type => 1,
641 crate::model::NodeKind::Constant => 2,
643 _ => 3,
645 }
646}
647
648pub(crate) fn collect_module_entity_names(
650 module: &crate::model::ModuleCluster,
651 graph: &KnowledgeGraph,
652) -> Vec<String> {
653 let mut names: Vec<(u8, String)> = module
654 .node_ids
655 .iter()
656 .filter_map(|nid| graph.graph.node_weight(*nid))
657 .filter(|n| {
658 !matches!(
659 n.kind,
660 crate::model::NodeKind::Project
661 | crate::model::NodeKind::Module
662 | crate::model::NodeKind::File
663 | crate::model::NodeKind::Variable
664 )
665 })
666 .map(|n| (entity_priority(&n.kind), n.name.clone()))
667 .collect();
668 names.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
670 names
671 .into_iter()
672 .map(|(_, name)| name)
673 .take(DESCRIBE_ENTITY_CAP)
674 .collect()
675}
676
677pub async fn complete_with_mermaid_guard_free<P: LlmProvider>(
684 provider: &P,
685 mut messages: Vec<Message>,
686 label: &str,
687 call_count: Option<&std::sync::atomic::AtomicUsize>,
688) -> Result<String> {
689 use std::sync::atomic::Ordering;
690 let mut content = String::new();
691 let mut last_mermaid = Vec::new();
692
693 for attempt in 0..=MERMAID_RETRY_MAX {
694 if let Some(c) = call_count {
695 c.fetch_add(1, Ordering::Relaxed);
696 }
697 content = provider.complete(&messages).await?;
698 last_mermaid = crate::output::mermaid_check::validate_mermaid_blocks(&content);
699 if last_mermaid.is_empty() {
700 return Ok(content);
701 }
702 tracing::warn!(
703 "{label} Mermaid 校验失败(第 {} 次,坏块 {} 个)",
704 attempt + 1,
705 last_mermaid.len()
706 );
707 if attempt == MERMAID_RETRY_MAX {
708 break;
709 }
710 messages.push(Message::user(
711 crate::output::mermaid_check::mermaid_retry_feedback(&last_mermaid),
712 ));
713 }
714
715 tracing::warn!("{label} Mermaid 重试耗尽({} 个坏块),降级为 text 块", last_mermaid.len());
716 Ok(crate::output::mermaid_check::degrade_mermaid_blocks(
717 &content,
718 &last_mermaid,
719 ))
720}
721
722fn overview_prompt(
727 modules: &[crate::model::ModuleCluster],
728 cards: &[crate::model::KnowledgeCard],
729 graph: &KnowledgeGraph,
730 config: &WikiConfig,
731) -> String {
732 let mut parts = Vec::new();
733
734 parts.push(format!(
735 "你是一个资深软件架构师,负责为整个项目生成人类可读的项目概览文档。\n\n\
736 请基于下面的模块聚类信息、各模块卡片摘要和模块间依赖摘要,输出以下结构:\n\n\
737 # 项目概览\n\n\
738 ## 技术栈\n根据模块名称与依赖关系推断项目使用的技术栈。\n\n\
739 ## 目录结构\n根据模块划分描述仓库的目录结构。\n\n\
740 ## 核心模块\n列出核心模块及其职责。\n\n\
741 请用 {} 语言输出。保留 Markdown 格式。",
742 config.wiki.language
743 ));
744
745 parts.push("## 模块列表".to_string());
746 for module in modules {
747 let desc = module.description.as_deref().unwrap_or("");
748 parts.push(format!(
749 "- {} (节点数: {}{})",
750 module.name,
751 module.node_ids.len(),
752 if desc.is_empty() {
753 String::new()
754 } else {
755 format!(", 职责: {}", desc)
756 }
757 ));
758 }
759
760 if !cards.is_empty() {
763 parts.push("## 模块卡片摘要".to_string());
764 for card in cards {
765 let entities: Vec<&str> = card
766 .key_entities
767 .iter()
768 .map(|e| e.name.as_str())
769 .take(8)
770 .collect();
771 parts.push(format!(
772 "- {}: {}(关键实体: {})",
773 card.module_name,
774 card.summary,
775 if entities.is_empty() {
776 "无".to_string()
777 } else {
778 entities.join(", ")
779 }
780 ));
781 }
782 }
783
784 let mut module_of: std::collections::HashMap<NodeId, &str> = Default::default();
786 for module in modules {
787 for nid in &module.node_ids {
788 module_of.insert(*nid, module.name.as_str());
789 }
790 }
791 let mut deps: std::collections::BTreeMap<(String, String), usize> = Default::default();
792 for edge in graph.graph.edge_weights() {
793 if edge.kind == EdgeKind::Contains {
794 continue;
795 }
796 let (Some(src), Some(dst)) = (module_of.get(&edge.source), module_of.get(&edge.target))
797 else {
798 continue;
799 };
800 *deps.entry((src.to_string(), dst.to_string())).or_default() += 1;
801 }
802 if deps.is_empty() {
803 parts.push("\n## 模块间依赖\n(图中未检测到模块间依赖边)".to_string());
804 } else {
805 parts.push("\n## 模块间依赖".to_string());
806 for ((src, dst), count) in deps {
807 parts.push(format!("- {} → {} ({} 条边)", src, dst, count));
808 }
809 }
810
811 parts.join("\n")
812}
813
814fn build_references(chunk: &Chunk, language: &str) -> Vec<Reference> {
818 chunk
819 .dependencies
820 .iter()
821 .map(|dep| Reference {
822 target_title: dep.clone(),
823 target_path: format!(
824 "wiki/{language}/{}.md",
825 dep.replace("::", "_")
827 ),
828 relation: "depends_on".into(),
829 })
830 .collect()
831}
832
833pub fn fallback_architecture_doc(
840 graph: &KnowledgeGraph,
841 config: &WikiConfig,
842 kind: DocumentKind,
843 title: &str,
844) -> WikiDocument {
845 use petgraph::visit::{EdgeRef, IntoEdgeReferences};
846 use std::collections::{BTreeMap, BTreeSet, HashMap};
847
848 let mut node_module: HashMap<NodeId, String> = HashMap::new();
850 for module in &graph.modules {
851 for nid in &module.node_ids {
852 node_module
853 .entry(*nid)
854 .or_insert_with(|| module.name.clone());
855 }
856 }
857 let mut deps: BTreeMap<String, BTreeSet<String>> = Default::default();
859 for edge in graph.graph.edge_references() {
860 if matches!(
861 graph.graph[edge.id()].kind,
862 EdgeKind::Calls | EdgeKind::Imports
863 ) {
864 let (Some(src), Some(tgt)) = (
865 node_module.get(&edge.source()),
866 node_module.get(&edge.target()),
867 ) else {
868 continue;
869 };
870 if src != tgt {
871 deps.entry(src.clone()).or_default().insert(tgt.clone());
872 }
873 }
874 }
875
876 let mut body = format!(
877 "# {title}\n\n> LLM 生成不可用,本页为确定性骨架:模块与依赖关系由知识图谱自动生成(无 LLM 摘要)。\n\n## 模块\n\n"
878 );
879 for module in &graph.modules {
880 body.push_str(&format!("- `{}`({} 个实体)", module.name, module.node_ids.len()));
881 if let Some(dl) = deps.get(&module.name)
882 && !dl.is_empty()
883 {
884 body.push_str(&format!(" — 依赖 {}", dl.iter().cloned().collect::<Vec<_>>().join(", ")));
885 }
886 body.push('\n');
887 }
888
889 let mut refs: Vec<Reference> = graph
890 .modules
891 .iter()
892 .map(|m| Reference {
893 target_title: m.name.clone(),
894 target_path: format!(
895 "wiki/{}/{}.md",
896 config.wiki.language,
897 m.name.replace("::", "_")
898 ),
899 relation: "module".into(),
900 })
901 .collect();
902 refs.sort_by(|a, b| a.target_title.cmp(&b.target_title));
904
905 WikiDocument {
906 title: title.to_string(),
907 kind,
908 content: body,
909 language: config.wiki.language.clone(),
910 module_path: vec![],
911 references: refs,
912 last_updated: chrono::Utc::now().to_rfc3339(),
913 based_on_commit: None,
915 fingerprint: None,
916 }
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922 use crate::generate::chunk::chunk_by_file;
923 use crate::generate::llm::MockProvider;
924 use crate::ingest::parser::{Entity, FileInsight, ImportStmt};
925
926 use std::path::PathBuf;
927
928 struct ScriptedProvider {
930 responses: std::sync::Mutex<std::vec::IntoIter<String>>,
931 calls: std::sync::atomic::AtomicUsize,
932 }
933
934 impl ScriptedProvider {
935 fn new(responses: Vec<String>) -> Self {
936 Self {
937 responses: std::sync::Mutex::new(responses.into_iter()),
938 calls: std::sync::atomic::AtomicUsize::new(0),
939 }
940 }
941 }
942
943 impl LlmProvider for ScriptedProvider {
944 async fn complete(&self, _messages: &[Message]) -> Result<String> {
945 self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
946 self.responses
947 .lock()
948 .unwrap()
949 .next()
950 .ok_or_else(|| anyhow::anyhow!("预设响应耗尽"))
951 }
952 }
953
954 struct FlakyProvider {
956 fail_times: std::sync::atomic::AtomicUsize,
957 calls: std::sync::atomic::AtomicUsize,
958 }
959
960 impl FlakyProvider {
961 fn new(fail_times: usize) -> Self {
962 Self {
963 fail_times: std::sync::atomic::AtomicUsize::new(fail_times),
964 calls: std::sync::atomic::AtomicUsize::new(0),
965 }
966 }
967 }
968
969 impl LlmProvider for FlakyProvider {
970 async fn complete(&self, _messages: &[Message]) -> Result<String> {
971 self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
972 let remaining = self.fail_times.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
974 if remaining > 0 {
975 Err(anyhow::anyhow!("模拟瞬时网络错误"))
976 } else {
977 Ok("重试成功".to_string())
978 }
979 }
980 }
981
982 #[tokio::test]
983 async fn test_complete_with_retry_recovers_after_transient_failure() {
984 let provider = FlakyProvider::new(1);
986 let content = complete_with_retry(&provider, &[], "src::test").await.unwrap();
987 assert_eq!(content, "重试成功");
988 assert_eq!(provider.calls.load(std::sync::atomic::Ordering::Relaxed), 2);
989 }
990
991 #[tokio::test]
992 async fn test_complete_with_retry_gives_up_after_max_attempts() {
993 let provider = FlakyProvider::new(10);
995 let err = complete_with_retry(&provider, &[], "src::test")
996 .await
997 .unwrap_err();
998 assert!(err.to_string().contains("模拟瞬时网络错误"));
999 assert_eq!(
1000 provider.calls.load(std::sync::atomic::Ordering::Relaxed),
1001 CALL_RETRY_MAX
1002 );
1003 }
1004
1005 fn make_test_chunk() -> Chunk {
1006 let entity = Entity {
1007 name: "Server".into(),
1008 kind: "struct".into(),
1009 line_start: 1,
1010 line_end: 50,
1011 doc_comment: Some("HTTP 服务".into()),
1012 signature: None, visibility: None,
1013 };
1014 let insight = FileInsight {
1015 path: PathBuf::from("src/server.rs"),
1016 language: "rust".into(),
1017 entities: vec![entity],
1018 imports: vec![ImportStmt {
1019 source: "tokio".into(),
1020 alias: None,
1021 line: 1,
1022 }],
1023 doc_comments: vec![],
1024 source: String::new(),
1025 };
1026 chunk_by_file(&insight)
1027 }
1028
1029 #[tokio::test]
1030 async fn test_skip_empty_chunk() {
1031 let provider = MockProvider::new();
1032 let generator = WikiGenerator::new(&provider, 0);
1033 let config = WikiConfig::default();
1034 let root = crate::project::ProjectRoot::new(std::env::temp_dir());
1035 let empty_chunk = Chunk {
1036 module_path: vec![],
1037 entities: vec![],
1038 imports: vec![],
1039 dependencies: vec![],
1040 file_paths: vec![],
1041 entity_sources: vec![],
1042 };
1043
1044 let result = generator.generate_wiki_page(&empty_chunk, "", &config, &root, None).await;
1045 assert!(result.is_err());
1046 }
1047
1048 #[tokio::test]
1050 async fn test_wiki_page_retries_on_invalid_citation() {
1051 let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_retry_{}", std::process::id()));
1053 let _ = std::fs::remove_dir_all(&dir);
1054 let src = dir.join("src");
1055 std::fs::create_dir_all(&src).unwrap();
1056 std::fs::write(src.join("server.rs"), "pub struct Server;\n// comment\n").unwrap();
1057 let root = crate::project::ProjectRoot::new(dir.clone());
1058
1059 let provider = ScriptedProvider::new(vec![
1061 "模块职责是管理连接。核心实体 `Server` 定义见 nonexistent.rs:99。".to_string(),
1062 "模块职责是管理连接。核心实体 `Server` 定义见 src/server.rs:1。".to_string(),
1063 ]);
1064 let generator = WikiGenerator::new(&provider, 0);
1065 let config = WikiConfig::default();
1066 let chunk = make_test_chunk();
1067
1068 let doc = generator.generate_wiki_page(&chunk, "摘要", &config, &root, None).await.unwrap();
1069 assert!(doc.content.contains("src/server.rs:1"), "重试后应使用有效引用");
1070 assert_eq!(provider.calls.load(std::sync::atomic::Ordering::Relaxed), 2, "应调用 2 次(1 次失败 + 1 次重试)");
1071
1072 let _ = std::fs::remove_dir_all(&dir);
1073 }
1074
1075 #[tokio::test]
1077 async fn test_wiki_page_bails_when_citations_never_valid() {
1078 let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_fail_{}", std::process::id()));
1079 let _ = std::fs::remove_dir_all(&dir);
1080 let root = crate::project::ProjectRoot::new(dir.clone());
1081
1082 let provider = ScriptedProvider::new(vec![
1084 "引用 nonexistent.rs:99".to_string(),
1085 "引用 nonexistent.rs:99".to_string(),
1086 "引用 nonexistent.rs:99".to_string(),
1087 ]);
1088 let generator = WikiGenerator::new(&provider, 0);
1089 let config = WikiConfig::default();
1090 let chunk = make_test_chunk();
1091
1092 let result = generator.generate_wiki_page(&chunk, "摘要", &config, &root, None).await;
1093 assert!(result.is_err(), "重试耗尽后应报错");
1094 let err = result.unwrap_err().to_string();
1095 assert!(err.contains("引用校验失败"), "错误信息应说明引用校验失败: {err}");
1096 assert_eq!(
1097 provider.calls.load(std::sync::atomic::Ordering::Relaxed),
1098 CITATION_RETRY_MAX + 1,
1099 "应调用 CITATION_RETRY_MAX+1 次后放弃"
1100 );
1101
1102 let _ = std::fs::remove_dir_all(&dir);
1103 }
1104
1105 #[tokio::test]
1107 async fn test_wiki_page_without_citations_passes() {
1108 let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_ok_{}", std::process::id()));
1109 let _ = std::fs::remove_dir_all(&dir);
1110 let root = crate::project::ProjectRoot::new(dir.clone());
1111
1112 let provider = ScriptedProvider::new(vec!["模块职责是管理连接。".to_string()]);
1113 let generator = WikiGenerator::new(&provider, 0);
1114 let config = WikiConfig::default();
1115 let chunk = make_test_chunk();
1116
1117 let doc = generator.generate_wiki_page(&chunk, "摘要", &config, &root, None).await.unwrap();
1118 assert_eq!(doc.content, "模块职责是管理连接。");
1119 assert_eq!(provider.calls.load(std::sync::atomic::Ordering::Relaxed), 1, "无引用无需重试");
1120
1121 let _ = std::fs::remove_dir_all(&dir);
1122 }
1123
1124 #[tokio::test]
1126 async fn test_wiki_page_retries_on_bad_mermaid() {
1127 let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_mermaid_retry_{}", std::process::id()));
1128 let _ = std::fs::remove_dir_all(&dir);
1129 let root = crate::project::ProjectRoot::new(dir.clone());
1130
1131 let provider = ScriptedProvider::new(vec![
1133 "```mermaid\nflowchart LR\nA[hello world\nB --> C\n```\n".to_string(),
1134 "```mermaid\nflowchart LR\nA[Start] --> B[End]\n```\n".to_string(),
1135 ]);
1136 let generator = WikiGenerator::new(&provider, 0);
1137 let config = WikiConfig::default();
1138 let chunk = make_test_chunk();
1139
1140 let doc = generator.generate_wiki_page(&chunk, "摘要", &config, &root, None).await.unwrap();
1141 assert!(doc.content.contains("A[Start] --> B[End]"), "重试后应保留好图");
1142 assert_eq!(provider.calls.load(std::sync::atomic::Ordering::Relaxed), 2, "应调用 2 次(1 次坏图 + 1 次重试)");
1143
1144 let _ = std::fs::remove_dir_all(&dir);
1145 }
1146
1147 #[tokio::test]
1150 async fn test_wiki_page_retries_on_overlap_citation() {
1151 let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_overlap_{}", std::process::id()));
1152 let _ = std::fs::remove_dir_all(&dir);
1153 let src = dir.join("src");
1154 std::fs::create_dir_all(&src).unwrap();
1155 let content: String = (1..=10).map(|i| format!("line{i}\n")).collect();
1157 std::fs::write(src.join("server.rs"), content).unwrap();
1158 let root = crate::project::ProjectRoot::new(dir.clone());
1159
1160 let mut ranges: crate::output::citation::EntityRanges =
1161 crate::output::citation::EntityRanges::new();
1162 ranges.insert("src/server.rs".to_string(), vec![(2, 4)]);
1163
1164 let provider = ScriptedProvider::new(vec![
1166 "模块职责是管理连接。核心实体 `Server` 定义见 src/server.rs:8。".to_string(),
1167 "模块职责是管理连接。核心实体 `Server` 定义见 src/server.rs:2。".to_string(),
1168 ]);
1169 let generator = WikiGenerator::new(&provider, 0);
1170 let config = WikiConfig::default();
1171 let chunk = make_test_chunk();
1172
1173 let doc = generator
1174 .generate_wiki_page(&chunk, "摘要", &config, &root, Some(&ranges))
1175 .await
1176 .unwrap();
1177 assert!(doc.content.contains("src/server.rs:2"), "重试后应使用覆盖实体的引用");
1178 assert_eq!(
1179 provider.calls.load(std::sync::atomic::Ordering::Relaxed),
1180 2,
1181 "区间外引用应触发一次重试"
1182 );
1183
1184 let _ = std::fs::remove_dir_all(&dir);
1185 }
1186
1187 #[tokio::test]
1190 async fn test_wiki_page_bails_when_overlap_never_valid() {
1191 let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_overlap_fail_{}", std::process::id()));
1192 let _ = std::fs::remove_dir_all(&dir);
1193 let src = dir.join("src");
1194 std::fs::create_dir_all(&src).unwrap();
1195 let content: String = (1..=10).map(|i| format!("line{i}\n")).collect();
1196 std::fs::write(src.join("server.rs"), content).unwrap();
1197 let root = crate::project::ProjectRoot::new(dir.clone());
1198
1199 let mut ranges: crate::output::citation::EntityRanges =
1200 crate::output::citation::EntityRanges::new();
1201 ranges.insert("src/server.rs".to_string(), vec![(2, 4)]);
1202
1203 let provider = ScriptedProvider::new(vec![
1205 "核心实体 `Server` 见 src/server.rs:8。".to_string(),
1206 "核心实体 `Server` 见 src/server.rs:8。".to_string(),
1207 "核心实体 `Server` 见 src/server.rs:8。".to_string(),
1208 ]);
1209 let generator = WikiGenerator::new(&provider, 0);
1210 let config = WikiConfig::default();
1211 let chunk = make_test_chunk();
1212
1213 let result = generator
1214 .generate_wiki_page(&chunk, "摘要", &config, &root, Some(&ranges))
1215 .await;
1216 assert!(result.is_err(), "区间重叠校验重试耗尽应报错");
1217 let err = result.unwrap_err().to_string();
1218 assert!(err.contains("引用校验失败"), "错误信息应说明引用校验失败: {err}");
1219
1220 let _ = std::fs::remove_dir_all(&dir);
1221 }
1222
1223 #[tokio::test]
1226 async fn test_wiki_page_passes_non_code_file_citation() {
1227 let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_noncode_{}", std::process::id()));
1228 let _ = std::fs::remove_dir_all(&dir);
1229 std::fs::create_dir_all(&dir).unwrap();
1230 std::fs::write(dir.join("README.md"), "docs\n").unwrap();
1231 let root = crate::project::ProjectRoot::new(dir.clone());
1232
1233 let ranges: crate::output::citation::EntityRanges =
1235 crate::output::citation::EntityRanges::new();
1236 let provider = ScriptedProvider::new(vec![
1237 "模块说明见 README.md:1。".to_string(),
1238 ]);
1239 let generator = WikiGenerator::new(&provider, 0);
1240 let config = WikiConfig::default();
1241 let chunk = make_test_chunk();
1242
1243 let doc = generator
1244 .generate_wiki_page(&chunk, "摘要", &config, &root, Some(&ranges))
1245 .await
1246 .unwrap();
1247 assert!(doc.content.contains("README.md:1"), "无实体文件引用应放行");
1248 assert_eq!(provider.calls.load(std::sync::atomic::Ordering::Relaxed), 1, "无需重试");
1249
1250 let _ = std::fs::remove_dir_all(&dir);
1251 }
1252
1253 #[tokio::test]
1256 async fn test_wiki_page_degrades_when_mermaid_never_valid() {
1257 let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_mermaid_degrade_{}", std::process::id()));
1258 let _ = std::fs::remove_dir_all(&dir);
1259 let root = crate::project::ProjectRoot::new(dir.clone());
1260
1261 let provider = ScriptedProvider::new(vec![
1263 "```mermaid\nflowchart LR\nA[hello world\nB --> C\n```\n".to_string(),
1264 "```mermaid\nflowchart LR\nA[hello world\nB --> C\n```\n".to_string(),
1265 "```mermaid\nflowchart LR\nA[hello world\nB --> C\n```\n".to_string(),
1266 ]);
1267 let generator = WikiGenerator::new(&provider, 0);
1268 let config = WikiConfig::default();
1269 let chunk = make_test_chunk();
1270
1271 let doc = generator.generate_wiki_page(&chunk, "摘要", &config, &root, None).await.unwrap();
1272 assert!(!doc.content.contains("```mermaid"), "坏图不应再以 mermaid 块出现");
1273 assert!(doc.content.contains("```text"), "坏块应降级为 text fence");
1274 assert!(doc.content.contains("code-repo-wiki: mermaid parse failed"), "应含降级标记注释");
1275 assert_eq!(
1276 provider.calls.load(std::sync::atomic::Ordering::Relaxed),
1277 MERMAID_RETRY_MAX + 1,
1278 "应调用 MERMAID_RETRY_MAX+1 次后降级"
1279 );
1280
1281 let _ = std::fs::remove_dir_all(&dir);
1282 }
1283
1284 #[tokio::test]
1286 async fn test_architecture_degrades_on_bad_mermaid() {
1287 let provider = ScriptedProvider::new(vec![
1288 "```mermaid\nflowchart LR\nA[hello world\n```\n".to_string(),
1289 "```mermaid\nflowchart LR\nA[hello world\n```\n".to_string(),
1290 "```mermaid\nflowchart LR\nA[hello world\n```\n".to_string(),
1291 ]);
1292 let generator = WikiGenerator::new(&provider, 0);
1293 let config = WikiConfig::default();
1294 let graph = crate::model::KnowledgeGraph::default();
1295 let output = crate::generate::GenerationOutput {
1296 cards: vec![],
1297 documents: vec![],
1298 generation_stats: crate::generate::GenerationStats::default(),
1299 timings: crate::GenerationTimings::default(),
1300 };
1301 let root = crate::project::ProjectRoot::new(
1303 std::env::temp_dir().join(format!("rw_arch_mermaid_{}", std::process::id())),
1304 );
1305
1306 let doc = generator
1307 .generate_architecture(&output, &graph, &config, &root)
1308 .await
1309 .unwrap();
1310 assert!(!doc.content.contains("```mermaid"), "坏图不应再以 mermaid 块出现");
1311 assert!(doc.content.contains("code-repo-wiki: mermaid parse failed"), "应含降级标记注释");
1312 let _ = std::fs::remove_dir_all(root.path());
1313 }
1314
1315 #[test]
1316 fn test_build_references() {
1317 let chunk = Chunk {
1318 module_path: vec!["crate".into(), "net".into()],
1319 entities: vec![],
1320 imports: vec![],
1321 dependencies: vec!["tokio".into(), "serde".into()],
1322 file_paths: vec![],
1323 entity_sources: vec![],
1324 };
1325
1326 let refs = build_references(&chunk, "zh");
1327 assert_eq!(refs.len(), 2);
1328 assert_eq!(refs[0].target_title, "tokio");
1329 assert_eq!(refs[0].target_path, "wiki/zh/tokio.md");
1330 }
1331
1332 #[test]
1336 fn test_build_references_uses_underscore_like_write_path() {
1337 let chunk = Chunk {
1338 module_path: vec!["src".into(), "generate".into()],
1339 entities: vec![],
1340 imports: vec![],
1341 dependencies: vec!["src::analysis".into(), "src::output".into()],
1342 file_paths: vec![],
1343 entity_sources: vec![],
1344 };
1345
1346 let refs = build_references(&chunk, "zh");
1347 assert_eq!(refs[0].target_path, "wiki/zh/src_analysis.md");
1348 assert_eq!(refs[1].target_path, "wiki/zh/src_output.md");
1349 }
1350
1351 #[test]
1353 fn test_module_description_prompt_shape() {
1354 let messages = prompt::module_description_prompt(
1355 "src::net",
1356 &["connect".into(), "listen".into()],
1357 "zh",
1358 );
1359 let user = &messages[1].content;
1360 assert!(user.contains("src::net"), "应含模块名");
1361 assert!(user.contains("connect"), "应含实体名");
1362 assert!(user.contains("listen"), "应含实体名");
1363 assert!(messages[0].content.contains("30"), "zh 应约束 30 字内");
1364 }
1365
1366 #[tokio::test]
1368 async fn test_describe_modules_enriches_description() {
1369 use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
1370 use petgraph::stable_graph::StableDiGraph;
1371
1372 let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
1373 let f = g.add_node(CodeNode {
1374 id: NodeId::new(0),
1375 kind: NodeKind::File,
1376 name: "net.rs".into(),
1377 file_path: Some("src/net.rs".into()),
1378 line_range: None,
1379 doc_comment: None,
1380 signature: None, visibility: None,
1381 module_path: vec!["src".into(), "net".into()],
1382 });
1383 let e = g.add_node(CodeNode {
1384 id: NodeId::new(1),
1385 kind: NodeKind::Function,
1386 name: "connect".into(),
1387 file_path: Some("src/net.rs".into()),
1388 line_range: None,
1389 doc_comment: None,
1390 signature: None, visibility: None,
1391 module_path: vec!["src".into(), "net".into()],
1392 });
1393 let kg = KnowledgeGraph {
1394 graph: g,
1395 modules: vec![
1396 ModuleCluster {
1397 name: "src::net".into(),
1398 node_ids: vec![f, e],
1399 cohesion: 0.5,
1400 coupling: 0.5,
1401 description: None,
1402 },
1403 ModuleCluster {
1404 name: "src".into(),
1405 node_ids: vec![],
1406 cohesion: 1.0,
1407 coupling: 0.0,
1408 description: None,
1409 },
1410 ],
1411 features: Vec::new(),
1412 };
1413 let provider = MockProvider::new();
1414 let generator = WikiGenerator::new(&provider, 0);
1415 let config = crate::config::schema::WikiConfig {
1417 output_dir: Some(
1418 std::env::temp_dir()
1419 .join(format!("rw_desc_cache_test_{}", std::process::id())),
1420 ),
1421 ..Default::default()
1422 };
1423 let root = crate::project::ProjectRoot::new(
1424 std::env::temp_dir().join(format!("rw_desc_root_test_{}", std::process::id())),
1425 );
1426 let enriched = generator.describe_modules(&kg, "zh", &config, &root).await;
1427 assert_eq!(enriched.len(), 2);
1428 assert!(enriched[0].description.is_some(), "带实体的模块应获得描述");
1429 assert_eq!(enriched[1].name, "src");
1430 assert!(enriched[1].description.is_none(), "src 兜底模块不描述");
1431 }
1432
1433 #[tokio::test]
1436 async fn test_describe_modules_cache_hit_skips_llm() {
1437 use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
1438 use petgraph::stable_graph::StableDiGraph;
1439
1440 let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
1441 g.add_node(CodeNode {
1442 id: NodeId::new(0),
1443 kind: NodeKind::File,
1444 name: "net.rs".into(),
1445 file_path: Some("src/net.rs".into()),
1446 line_range: None,
1447 doc_comment: None,
1448 signature: None,
1449 visibility: None,
1450 module_path: vec!["src".into(), "net".into()],
1451 });
1452 let kg = KnowledgeGraph {
1453 graph: g,
1454 modules: vec![
1455 ModuleCluster {
1456 name: "src::net".into(),
1457 node_ids: vec![NodeId::new(0)],
1458 cohesion: 1.0,
1459 coupling: 0.0,
1460 description: None,
1461 },
1462 ],
1463 features: Vec::new(),
1464 };
1465 let provider = MockProvider::new();
1466 let generator = WikiGenerator::new(&provider, 0);
1467 let out_dir = std::env::temp_dir().join(format!("rw_desc_cache_hit_{}", std::process::id()));
1471 let _ = std::fs::remove_dir_all(&out_dir);
1472 let root_dir = std::env::temp_dir().join(format!("rw_desc_root_hit_{}", std::process::id()));
1473 let _ = std::fs::remove_dir_all(&root_dir);
1474 let config = crate::config::schema::WikiConfig {
1475 output_dir: Some(out_dir),
1476 ..Default::default()
1477 };
1478 let root = crate::project::ProjectRoot::new(root_dir);
1479 let first = generator.describe_modules(&kg, "zh", &config, &root).await;
1480 assert!(first[0].description.is_some(), "首次应走 LLM 获得描述");
1481 let calls_after_first = generator.llm_call_count();
1482 assert!(calls_after_first > 0, "首次必须真实调用 LLM");
1483 let second = generator.describe_modules(&kg, "zh", &config, &root).await;
1484 assert_eq!(second[0].description, first[0].description, "缓存应返回相同描述");
1485 assert_eq!(
1486 generator.llm_call_count(),
1487 calls_after_first,
1488 "第二次调用必须命中缓存、不触发 LLM"
1489 );
1490 }
1491
1492 #[tokio::test]
1494 async fn test_describe_modules_cache_invalidated_by_file_change() {
1495 use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
1496 use petgraph::stable_graph::StableDiGraph;
1497
1498 let dir = std::env::temp_dir().join(format!("rw_desc_cache_chg_{}", std::process::id()));
1500 let _ = std::fs::remove_dir_all(&dir);
1501 std::fs::create_dir_all(dir.join("src")).unwrap();
1502 std::fs::write(dir.join("src/net.rs"), "pub fn connect() {}\n").unwrap();
1503
1504 let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
1505 g.add_node(CodeNode {
1506 id: NodeId::new(0),
1507 kind: NodeKind::File,
1508 name: "net.rs".into(),
1509 file_path: Some("src/net.rs".into()),
1510 line_range: None,
1511 doc_comment: None,
1512 signature: None,
1513 visibility: None,
1514 module_path: vec!["src".into(), "net".into()],
1515 });
1516 let kg = KnowledgeGraph {
1517 graph: g,
1518 modules: vec![ModuleCluster {
1519 name: "src::net".into(),
1520 node_ids: vec![NodeId::new(0)],
1521 cohesion: 1.0,
1522 coupling: 0.0,
1523 description: None,
1524 }],
1525 features: Vec::new(),
1526 };
1527 let provider = MockProvider::new();
1528 let generator = WikiGenerator::new(&provider, 0);
1529 let config = crate::config::schema::WikiConfig {
1531 output_dir: Some(dir.join(".code-repo-wiki")),
1532 ..Default::default()
1533 };
1534 let root = crate::project::ProjectRoot::new(dir.clone());
1535 generator.describe_modules(&kg, "zh", &config, &root).await;
1536 let calls_after_first = generator.llm_call_count();
1537
1538 std::fs::write(dir.join("src/net.rs"), "pub fn connect() {}\npub fn listen() {}\n").unwrap();
1540 generator.describe_modules(&kg, "zh", &config, &root).await;
1541 assert!(
1542 generator.llm_call_count() > calls_after_first,
1543 "文件内容变化后必须重新调用 LLM"
1544 );
1545 let _ = std::fs::remove_dir_all(&dir);
1546 }
1547
1548 #[tokio::test]
1551 async fn test_describe_modules_recovers_from_corrupt_cache() {
1552 use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
1553 use petgraph::stable_graph::StableDiGraph;
1554
1555 let dir = std::env::temp_dir().join(format!("rw_desc_cache_corrupt_{}", std::process::id()));
1556 let _ = std::fs::remove_dir_all(&dir);
1557 std::fs::create_dir_all(dir.join(".code-repo-wiki/.state")).unwrap();
1558 std::fs::write(dir.join(".code-repo-wiki/.state/module_descriptions.json"), "{not-json").unwrap();
1560
1561 let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
1562 g.add_node(CodeNode {
1563 id: NodeId::new(0),
1564 kind: NodeKind::File,
1565 name: "net.rs".into(),
1566 file_path: Some("src/net.rs".into()),
1567 line_range: None,
1568 doc_comment: None,
1569 signature: None,
1570 visibility: None,
1571 module_path: vec!["src".into(), "net".into()],
1572 });
1573 let kg = KnowledgeGraph {
1574 graph: g,
1575 modules: vec![ModuleCluster {
1576 name: "src::net".into(),
1577 node_ids: vec![NodeId::new(0)],
1578 cohesion: 1.0,
1579 coupling: 0.0,
1580 description: None,
1581 }],
1582 features: Vec::new(),
1583 };
1584 let provider = MockProvider::new();
1585 let generator = WikiGenerator::new(&provider, 0);
1586 let config = crate::config::schema::WikiConfig {
1587 output_dir: Some(dir.join(".code-repo-wiki")),
1588 ..Default::default()
1589 };
1590 let root = crate::project::ProjectRoot::new(dir.clone());
1591 let modules = generator.describe_modules(&kg, "zh", &config, &root).await;
1593 assert!(modules[0].description.is_some(), "损坏缓存回退后应重新生成描述");
1594 assert!(generator.llm_call_count() > 0, "损坏缓存必须触发 LLM 调用");
1595 let _ = std::fs::remove_dir_all(&dir);
1596 }
1597
1598 #[test]
1600 fn test_collect_module_entity_names_prioritizes_behavior_over_fields() {
1601 use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
1602 use petgraph::stable_graph::StableDiGraph;
1603
1604 let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
1605 let mut ids = Vec::new();
1606 for i in 0..8 {
1608 ids.push(g.add_node(CodeNode {
1609 id: NodeId::new(i as usize),
1610 kind: NodeKind::Variable,
1611 name: format!("field_{i}"),
1612 file_path: None,
1613 line_range: None,
1614 doc_comment: None,
1615 signature: None,
1616 visibility: None,
1617 module_path: vec!["src".into(), "net".into()],
1618 }));
1619 }
1620 for i in 0..5 {
1621 ids.push(g.add_node(CodeNode {
1622 id: NodeId::new(100 + i as usize),
1623 kind: NodeKind::Constant,
1624 name: format!("const_{i}"),
1625 file_path: None,
1626 line_range: None,
1627 doc_comment: None,
1628 signature: None,
1629 visibility: None,
1630 module_path: vec!["src".into(), "net".into()],
1631 }));
1632 }
1633 for i in 0..10 {
1635 ids.push(g.add_node(CodeNode {
1636 id: NodeId::new(200 + i as usize),
1637 kind: NodeKind::Function,
1638 name: format!("fn_{i:02}"),
1639 file_path: None,
1640 line_range: None,
1641 doc_comment: None,
1642 signature: None,
1643 visibility: None,
1644 module_path: vec!["src".into(), "net".into()],
1645 }));
1646 }
1647 for i in 0..5 {
1648 ids.push(g.add_node(CodeNode {
1649 id: NodeId::new(300 + i as usize),
1650 kind: NodeKind::Struct,
1651 name: format!("struct_{i}"),
1652 file_path: None,
1653 line_range: None,
1654 doc_comment: None,
1655 signature: None,
1656 visibility: None,
1657 module_path: vec!["src".into(), "net".into()],
1658 }));
1659 }
1660 ids.push(g.add_node(CodeNode {
1661 id: NodeId::new(999),
1662 kind: NodeKind::File,
1663 name: "net.rs".into(),
1664 file_path: None,
1665 line_range: None,
1666 doc_comment: None,
1667 signature: None,
1668 visibility: None,
1669 module_path: vec!["src".into(), "net".into()],
1670 }));
1671 let module = ModuleCluster {
1672 name: "src::net".into(),
1673 node_ids: ids,
1674 cohesion: 0.5,
1675 coupling: 0.5,
1676 description: None,
1677 };
1678 let graph = KnowledgeGraph {
1679 graph: g,
1680 modules: vec![module.clone()],
1681 features: Vec::new(),
1682 };
1683
1684 let names = collect_module_entity_names(&module, &graph);
1685 assert_eq!(names.len(), 20, "变量与容器不进入名额");
1687 assert!(
1688 !names.iter().any(|n| n.starts_with("field_")),
1689 "字段级实体(variable)应被排除"
1690 );
1691 assert!(
1692 !names.iter().any(|n| n == "net.rs"),
1693 "容器节点(File)应被排除"
1694 );
1695 let fn_pos = names.iter().position(|n| n == "fn_00").unwrap();
1697 let struct_pos = names.iter().position(|n| n == "struct_0").unwrap();
1698 let const_pos = names.iter().position(|n| n == "const_0").unwrap();
1699 assert!(fn_pos < struct_pos && struct_pos < const_pos, "优先级序: 函数 < 结构体 < 常量");
1700 assert!(names.iter().position(|n| n == "fn_00").unwrap() < names.iter().position(|n| n == "fn_01").unwrap());
1702 }
1703
1704 #[test]
1706 fn test_collect_module_entity_names_caps_at_30() {
1707 use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
1708 use petgraph::stable_graph::StableDiGraph;
1709
1710 let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
1711 let mut ids = Vec::new();
1712 for i in 0..40 {
1713 ids.push(g.add_node(CodeNode {
1714 id: NodeId::new(i as usize),
1715 kind: NodeKind::Function,
1716 name: format!("fn_{i:02}"),
1717 file_path: None,
1718 line_range: None,
1719 doc_comment: None,
1720 signature: None,
1721 visibility: None,
1722 module_path: vec!["src".into()],
1723 }));
1724 }
1725 let module = ModuleCluster {
1726 name: "src".into(),
1727 node_ids: ids,
1728 cohesion: 1.0,
1729 coupling: 0.0,
1730 description: None,
1731 };
1732 let graph = KnowledgeGraph {
1733 graph: g,
1734 modules: vec![module.clone()],
1735 features: Vec::new(),
1736 };
1737 let names = collect_module_entity_names(&module, &graph);
1738 assert_eq!(names.len(), DESCRIBE_ENTITY_CAP);
1739 assert_eq!(names[0], "fn_00", "字典序稳定");
1740 assert_eq!(names[29], "fn_29", "截断取前 30");
1741 }
1742
1743 #[test]
1745 fn test_overview_prompt_includes_card_summaries() {
1746 use crate::model::KnowledgeCard;
1747
1748 let graph = KnowledgeGraph::default();
1749 let config = WikiConfig::default();
1750 let card = KnowledgeCard {
1751 module_name: "src::net".into(),
1752 module_type: "module".into(),
1753 summary: "网络模块:连接管理与监听".into(),
1754 key_entities: vec![crate::model::EntitySummary {
1755 name: "connect".into(),
1756 kind: "function".into(),
1757 visibility: "public".into(),
1758 doc: None,
1759 source: None,
1760 }],
1761 dependencies: vec![],
1762 dependents: vec![],
1763 design_patterns: vec![],
1764 todo_notes: vec![],
1765 related_files: vec![],
1766 coding_spec: None,
1767 tech_stack: vec![],
1768 architecture: None,
1769 pending_manual_edits: vec![],
1770 features: Vec::new(),
1771 };
1772 let prompt = overview_prompt(&[], &[card], &graph, &config);
1773 assert!(prompt.contains("## 模块卡片摘要"), "应含卡片摘要节");
1774 assert!(prompt.contains("src::net"), "应含模块名");
1775 assert!(prompt.contains("网络模块"), "应含卡片摘要");
1776 assert!(prompt.contains("connect"), "应含关键实体");
1777 }
1778}