1pub mod config;
2pub mod model;
3pub mod ingest;
4pub mod analysis;
5pub mod generate;
6pub mod output;
7pub mod incremental;
8pub mod search;
9pub mod commands;
10pub mod fs;
11pub mod mcp;
12pub mod project;
13pub mod bench;
14pub mod doctor;
15pub mod key;
16
17use std::collections::HashMap;
18use std::path::Path;
19
20use std::sync::{Arc, OnceLock};
21use tokio::runtime::Runtime;
22
23use anyhow::{bail, Context};
24
25pub struct AnalysisResult {
27 pub graph: model::KnowledgeGraph,
28 pub documents: Vec<model::WikiDocument>,
29 pub cards: Vec<model::KnowledgeCard>,
30 pub stats: AnalysisStats,
31}
32
33#[derive(Debug, Clone, Default)]
35pub struct AnalysisStats {
36 pub files_scanned: usize,
37 pub files_parsed: usize,
38 pub files_failed: usize,
41 pub total_entities: usize,
42 pub total_edges: usize,
43 pub modules_detected: usize,
44 pub generation_time_ms: u64,
45 pub failed_modules: Vec<String>,
48}
49
50pub fn get_global_runtime() -> &'static Arc<Runtime> {
52 static RT: OnceLock<Arc<Runtime>> = OnceLock::new();
53 RT.get_or_init(|| Arc::new(Runtime::new().expect("创建 tokio Runtime 失败")))
54}
55
56fn load_config_with_output(
63 config_path: Option<&Path>,
64 output: Option<&Path>,
65 root: &project::ProjectRoot,
66) -> anyhow::Result<config::schema::WikiConfig> {
67 let mut config = match config_path {
71 Some(p) => config::load_config(p)?,
72 None => config::load_default_config(root)?.1,
73 };
74 let output_dir = match output {
79 Some(out) => root.path().join(out),
80 None => root.path().join(crate::config::schema::OUTPUT_DIR),
81 };
82 config.output_dir = Some(output_dir);
83 Ok(config)
84}
85
86pub fn load_config_rooted(
90 config_path: Option<&Path>,
91 root: &project::ProjectRoot,
92) -> anyhow::Result<config::schema::WikiConfig> {
93 load_config_with_output(config_path, None, root)
94}
95
96fn load_protection(
104 config: &config::schema::WikiConfig,
105 force: bool,
106) -> anyhow::Result<(std::collections::HashSet<String>, Option<incremental::state::GenerationState>)> {
107 if force {
108 return Ok((std::collections::HashSet::new(), None));
109 }
110 let state_dir = config.output_dir().join(".state");
111 let state_path = state_dir.join("generation_state.json");
112 if !state_path.exists() {
113 return Ok((std::collections::HashSet::new(), None));
115 }
116 let state = incremental::state::GenerationState::load(&state_dir).with_context(|| {
117 format!(
118 "状态文件损坏或不可读: {}(删除该文件后重新运行 generate 可重建)",
119 state_path.display()
120 )
121 })?;
122 let mut protected: std::collections::HashSet<String> = state
123 .protected_docs
124 .iter()
125 .cloned()
126 .collect();
127 for p in state.detect_manually_modified() {
128 protected.insert(p);
129 }
130 Ok((protected, Some(state)))
131}
132
133#[allow(clippy::too_many_arguments)]
140fn save_generation_state(
141 root: &project::ProjectRoot,
142 config: &config::schema::WikiConfig,
143 insights: &[ingest::parser::FileInsight],
144 documents: &[model::WikiDocument],
145 cards: &[model::KnowledgeCard],
146 protected: &std::collections::HashSet<String>,
147 commit_hash: &str,
148 failed_modules: &[String],
149) {
150 let output_dir = config.output_dir();
151 let state_dir = output_dir.join(".state");
152 match incremental::state::GenerationState::from_insights(root, insights, commit_hash) {
156 Ok(mut state) => {
157 state.failed_modules = failed_modules.to_vec();
158 let mut protected_docs: Vec<String> = protected.iter().cloned().collect();
159 protected_docs.sort();
160 state.protected_docs = protected_docs;
161 match incremental::state::GenerationState::record_doc_fingerprints(
162 documents,
163 cards,
164 output_dir,
165 &output::wiki_languages(config),
166 ) {
167 Ok((fps, modules)) => {
168 state.doc_fingerprints = fps;
173 state.doc_modules = modules;
174 }
175 Err(e) => tracing::warn!(
176 "产物指纹记录失败(下次 update 人工修改检测可能失效): {e}"
177 ),
178 }
179 if let Err(e) = state.save(&state_dir) {
180 tracing::warn!("生成状态保存失败(下次 update 无指纹基线,人工修改保护失效): {e}");
181 }
182 }
183 Err(e) => tracing::warn!("生成状态构造失败(本次状态未落盘): {e}"),
184 }
185}
186
187#[derive(Debug, Clone, Copy)]
189pub struct ProgressEvent {
190 pub stage: &'static str,
192 pub percent: u8,
194}
195
196#[derive(Debug, Clone)]
201pub enum GenerationMode {
202 Full,
203 Incremental {
204 watch_paths: Vec<std::path::PathBuf>,
206 change_kind: Option<incremental::watch::ChangeKind>,
208 },
209}
210
211pub fn run_pipeline(
220 config_path: Option<&Path>,
221 output: Option<&Path>,
222 force: bool,
223 root: &project::ProjectRoot,
224 mode: &GenerationMode,
225) -> anyhow::Result<AnalysisResult> {
226 run_pipeline_with_progress(config_path, output, force, root, mode, &|_| {})
227}
228
229#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
238#[serde(default)]
239pub struct GenerationTimings {
240 pub scan_parse_ms: u64,
241 pub graph_ms: u64,
242 pub incremental_ms: u64,
243 pub chunk_ms: u64,
244 pub card_ms: u64,
245 pub wiki_ms: u64,
246 pub index_guide_ms: u64,
247 pub render_ms: u64,
248 pub index_ms: u64,
249 pub state_ms: u64,
250 pub total_ms: u64,
251}
252
253pub(crate) fn write_last_timings(config: &config::schema::WikiConfig, timings: &GenerationTimings) {
258 let state_dir = config.output_dir().join(".state");
259 if let Err(e) = std::fs::create_dir_all(&state_dir) {
260 tracing::warn!("分段计时目录创建失败: {e}");
261 return;
262 }
263 let path = state_dir.join("last_timings.json");
264 match serde_json::to_string_pretty(timings) {
265 Ok(text) => {
266 if let Err(e) = std::fs::write(&path, text) {
267 tracing::warn!("分段计时写盘失败: {e}");
268 }
269 }
270 Err(e) => tracing::warn!("分段计时序列化失败: {e}"),
271 }
272}
273
274pub fn run_pipeline_with_progress(
279 config_path: Option<&Path>,
280 output: Option<&Path>,
281 force: bool,
282 root: &project::ProjectRoot,
283 mode: &GenerationMode,
284 on_progress: &dyn Fn(ProgressEvent),
285) -> anyhow::Result<AnalysisResult> {
286 let config = load_config_with_output(config_path, output, root)?;
287 let _run_lock = crate::fs::acquire_run_lock(&config)?;
291 let _span = tracing::info_span!("pipeline", config = %config_path.map(|p| p.display().to_string()).unwrap_or_else(|| "默认链".into()));
292 let _enter = _span.enter();
293 let start = std::time::Instant::now();
294 let mut timings = GenerationTimings::default();
297 let mut is_incremental = matches!(mode, GenerationMode::Incremental { .. });
298 if force && is_incremental {
302 tracing::info!("--force 与增量模式同时使用:退化为全量重生成");
303 is_incremental = false;
304 }
305
306 let (protected, old_state) = load_protection(&config, force)?;
309
310 if is_incremental && incremental::should_skip_noop(root, &config)? {
316 tracing::info!("无文件变更,跳过更新(no-op 快速判定)");
317 if let Some(state) = &old_state {
318 let synced = sync_manual_edits_to_cards(&config, state)?;
319 if synced > 0 {
320 tracing::info!("人工修改已反向同步到 {} 张卡片", synced);
321 }
322 }
323 let stats = AnalysisStats {
324 files_scanned: 0,
325 generation_time_ms: start.elapsed().as_millis() as u64,
326 ..Default::default()
327 };
328 return Ok(AnalysisResult {
329 graph: model::KnowledgeGraph::default(),
330 documents: Vec::new(),
331 cards: Vec::new(),
332 stats,
333 });
334 }
335
336 let watch_list: Vec<std::path::PathBuf> = match mode {
339 GenerationMode::Incremental { watch_paths, .. } => watch_paths.clone(),
340 GenerationMode::Full => Vec::new(),
341 };
342 let watch_paths: Vec<std::path::PathBuf> = watch_list
346 .iter()
347 .map(|p| p.strip_prefix(root.path()).map(|r| r.to_path_buf()).unwrap_or_else(|_| p.clone()))
348 .collect();
349 let watch_set: std::collections::HashSet<std::path::PathBuf> =
350 watch_paths.iter().cloned().collect();
351 let scan = if is_incremental {
352 let cache_path = config.output_dir().join(".state").join("insights_cache.json");
353 ingest::scan_and_parse_cached_at(root, &Some(cache_path), &watch_set)?
354 } else {
355 ingest::scan_and_parse_at(root)?
356 };
357 let file_insights = scan.insights;
358 let files_failed = scan.files_failed;
359 timings.scan_parse_ms = start.elapsed().as_millis() as u64;
360 on_progress(ProgressEvent { stage: "scanning", percent: 10 });
361 if file_insights.is_empty() {
362 bail!("未找到任何源文件");
363 }
364 let mut stats = AnalysisStats {
365 files_scanned: file_insights.len(),
366 files_parsed: file_insights.iter().filter(|f| !f.entities.is_empty()).count(),
367 files_failed,
368 ..Default::default()
369 };
370
371 let mut graph = analysis::build_graph(&file_insights)?;
374 attach_features(&mut graph, &config);
375 timings.graph_ms = start.elapsed().as_millis() as u64 - timings.scan_parse_ms;
376 on_progress(ProgressEvent { stage: "analyzing", percent: 25 });
377 stats.total_entities = graph.graph.node_count();
378 stats.total_edges = graph.graph.edge_count();
379 stats.modules_detected = graph.modules.len();
380
381 let inc_result = if is_incremental {
384 Some(incremental::run_incremental_update_at(root, &file_insights, &graph, &config, &watch_paths)?)
385 } else {
386 None
387 };
388 timings.incremental_ms = start.elapsed().as_millis() as u64
389 - timings.scan_parse_ms
390 - timings.graph_ms;
391
392 if let Some(inc) = &inc_result
396 && inc.changed_files.is_empty()
397 {
398 if let Some(state) = &old_state {
399 let synced = sync_manual_edits_to_cards(&config, state)?;
400 if synced > 0 {
401 tracing::info!("人工修改已反向同步到 {} 张卡片", synced);
402 }
403 }
404 tracing::info!("无变更,跳过生成");
405 let stats = AnalysisStats {
406 files_scanned: file_insights.len(),
407 generation_time_ms: start.elapsed().as_millis() as u64,
408 ..Default::default()
409 };
410 return Ok(AnalysisResult {
411 graph,
412 documents: Vec::new(),
413 cards: Vec::new(),
414 stats,
415 });
416 }
417
418 on_progress(ProgressEvent { stage: "chunking", percent: 30 });
423 let rt = get_global_runtime();
424 let extra_edits = collect_manual_edits(old_state.as_ref());
425 let mut gen_output = if let Some(inc) = &inc_result {
426 rt.block_on(generate::run_generation_filtered(
427 &graph, &file_insights, &config, root, inc, &extra_edits,
428 ))?
429 } else {
430 rt.block_on(generate::run_generation(&graph, &file_insights, &config, root, &extra_edits))?
431 };
432 on_progress(ProgressEvent { stage: "cards", percent: 60 });
433 timings.chunk_ms = gen_output.timings.chunk_ms;
435 timings.card_ms = gen_output.timings.card_ms;
436 timings.wiki_ms = gen_output.timings.wiki_ms;
437
438 let gated = if is_incremental
450 && inc_result
451 .as_ref()
452 .is_some_and(|i| i.affected_modules.is_empty() && !i.has_deleted_files)
453 {
454 generate::backfill_global_docs(
455 &config,
456 &mut gen_output.documents,
457 &[crate::model::DocumentKind::TableOfContents],
458 )
459 } else {
460 false
461 };
462 if !gated {
463 let index_doc = match generate::create_provider(&config) {
464 Ok(provider) => rt.block_on(generate::index::generate_index_guide(
465 &provider,
466 &graph,
467 &gen_output.cards,
468 &config,
469 )),
470 Err(e) => {
471 tracing::warn!("阅读指南 LLM 不可用,降级为确定性骨架: {e}");
472 generate::index::fallback_index_guide(&graph, &config)
473 }
474 };
475 gen_output.documents.push(index_doc);
476 }
477 timings.index_guide_ms = start.elapsed().as_millis() as u64
478 - timings.scan_parse_ms
479 - timings.graph_ms
480 - timings.incremental_ms
481 - timings.chunk_ms
482 - timings.card_ms
483 - timings.wiki_ms;
484
485 if matches!(
490 config.llm.provider,
491 crate::config::schema::LlmProviderType::Mock
492 ) {
493 tracing::warn!("使用 mock provider:产物为占位内容,非真实文档(仅供测试/CI 演示)");
494 for doc in &mut gen_output.documents {
495 if !doc.content.ends_with(crate::output::MOCK_FOOTER_MARK) {
498 doc.content.push_str(crate::output::MOCK_FOOTER_MARK);
499 }
500 }
501 }
502
503 on_progress(ProgressEvent { stage: "wiki", percent: 90 });
507 output::render_all(&gen_output.documents, &gen_output.cards, &graph, &config, &protected)?;
508 let preserved_modules: std::collections::HashSet<String> = graph
512 .modules
513 .iter()
514 .map(|m| m.name.clone())
515 .collect();
516 cleanup_stale_outputs(
517 old_state.as_ref(),
518 &output::rendered_paths(&gen_output.documents, &gen_output.cards, &config),
519 &preserved_modules,
520 );
521 timings.render_ms = start.elapsed().as_millis() as u64
522 - timings.scan_parse_ms
523 - timings.graph_ms
524 - timings.incremental_ms
525 - timings.chunk_ms
526 - timings.card_ms
527 - timings.wiki_ms
528 - timings.index_guide_ms;
529 on_progress(ProgressEvent { stage: "output", percent: 95 });
530
531 let index_result = if is_incremental {
533 let changed_set: std::collections::HashSet<std::path::PathBuf> = inc_result
534 .as_ref()
535 .map(|i| i.changed_files.iter().cloned().collect())
536 .unwrap_or_default();
537 update_search_index_incremental(&graph, &file_insights, &config, &changed_set)
538 } else {
539 build_search_index(&graph, &file_insights, &config)
540 };
541 if let Err(e) = index_result {
542 tracing::warn!("搜索索引构建失败(不影响主流程): {}", e);
543 }
544 timings.index_ms = start.elapsed().as_millis() as u64
545 - timings.scan_parse_ms
546 - timings.graph_ms
547 - timings.incremental_ms
548 - timings.chunk_ms
549 - timings.card_ms
550 - timings.wiki_ms
551 - timings.index_guide_ms
552 - timings.render_ms;
553 on_progress(ProgressEvent { stage: "index", percent: 98 });
554
555 let head_hash = match incremental::diff::get_head_commit_hash_at(root) {
561 Ok(h) => h,
562 Err(e) => {
563 if e.downcast_ref::<git2::Error>()
564 .map(|g| g.code() == git2::ErrorCode::NotFound)
565 .unwrap_or(false)
566 {
567 tracing::info!("非 git 仓库,无 git 基线(增量状态不推进): {}", e);
568 } else {
569 tracing::warn!("获取 git HEAD 失败(增量状态不推进): {}", e);
570 }
571 String::new()
572 }
573 };
574 save_generation_state(root, &config, &file_insights, &gen_output.documents, &gen_output.cards, &protected, &head_hash, &gen_output.generation_stats.failed_modules);
575
576 timings.state_ms = start.elapsed().as_millis() as u64
577 - timings.scan_parse_ms
578 - timings.graph_ms
579 - timings.incremental_ms
580 - timings.chunk_ms
581 - timings.card_ms
582 - timings.wiki_ms
583 - timings.index_guide_ms
584 - timings.render_ms
585 - timings.index_ms;
586 timings.total_ms = start.elapsed().as_millis() as u64;
587 write_last_timings(&config, &timings);
588
589 on_progress(ProgressEvent { stage: "done", percent: 100 });
590 stats.generation_time_ms = start.elapsed().as_millis() as u64;
591 stats.failed_modules = gen_output.generation_stats.failed_modules.clone();
596 tracing::info!("流水线完成: {} 个文件, {} 个实体, {} 条边, {} 个模块, 耗时 {}ms",
597 stats.files_scanned, stats.total_entities, stats.total_edges,
598 stats.modules_detected, stats.generation_time_ms);
599
600 Ok(AnalysisResult {
601 graph,
602 documents: gen_output.documents,
603 cards: gen_output.cards,
604 stats,
605 })
606}
607
608pub fn run_card_command(
610 config_path: Option<&Path>,
611 root: &project::ProjectRoot,
612 action: &generate::card::CardAction,
613) -> anyhow::Result<()> {
614 let config = load_config_with_output(config_path, None, root)?;
615 match action {
617 generate::card::CardAction::Generate { .. } => {}
618 generate::card::CardAction::Modify { module, .. }
619 | generate::card::CardAction::Supplement { module, .. }
620 | generate::card::CardAction::Rewrite { module, .. } => {
621 if generate::card::read_card(&config, module)?.is_none() {
622 anyhow::bail!("模块 {module} 的卡片不存在,请先运行 `code-repo-wiki generate` 或 `code-repo-wiki card generate {module}` 生成");
623 }
624 }
625 }
626 let provider = generate::create_provider(&config)?;
627 let rt = get_global_runtime();
628 match action {
629 generate::card::CardAction::Generate { module } => {
630 rt.block_on(generate::card::generate_module_card(&provider, &config, root, module))
631 }
632 generate::card::CardAction::Modify { module, instruction, references } => {
633 rt.block_on(generate::card::edit_card(
634 &provider, &config, module, instruction, references,
635 generate::card::CardEditMode::Modify,
636 ))
637 }
638 generate::card::CardAction::Supplement { module, instruction, references } => {
639 rt.block_on(generate::card::edit_card(
640 &provider, &config, module, instruction, references,
641 generate::card::CardEditMode::Supplement,
642 ))
643 }
644 generate::card::CardAction::Rewrite { module, instruction, references } => {
645 rt.block_on(generate::card::edit_card(
646 &provider, &config, module, instruction, references,
647 generate::card::CardEditMode::Rewrite,
648 ))
649 }
650 }
651}
652
653
654pub(crate) fn cleanup_stale_outputs(
668 old_state: Option<&incremental::state::GenerationState>,
669 rendered: &[std::path::PathBuf],
670 preserved_modules: &std::collections::HashSet<String>,
671) {
672 let Some(state) = old_state else {
673 return; };
675 let mut stale: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
676 stale.extend(state.doc_fingerprints.keys().map(String::as_str));
677 stale.extend(state.doc_modules.keys().map(String::as_str));
678 let rendered_set: std::collections::BTreeSet<String> = rendered
679 .iter()
680 .map(|p| p.to_string_lossy().to_string())
681 .collect();
682 let mut removed = 0usize;
683 for path in stale {
684 if rendered_set.contains(path) {
685 continue;
686 }
687 if Path::new(path).is_relative() {
694 continue;
695 }
696 if state
703 .doc_modules
704 .get(path)
705 .is_some_and(|m| preserved_modules.contains(m))
706 {
707 continue;
708 }
709 let p = Path::new(path);
710 if p.exists() {
711 match std::fs::remove_file(p) {
712 Ok(()) => removed += 1,
713 Err(e) => tracing::warn!("清理过期产物失败 {}: {}", p.display(), e),
714 }
715 }
716 }
717 if removed > 0 {
718 tracing::info!("清理过期产物 {} 个", removed);
719 }
720}
721
722pub fn collect_manual_edits(
733 state: Option<&incremental::state::GenerationState>,
734) -> HashMap<String, Vec<String>> {
735 let mut out: HashMap<String, Vec<String>> = HashMap::new();
736 let Some(state) = state else { return out };
737 for path in state.detect_manually_modified() {
738 let Some(module) = state.doc_modules.get(&path) else {
739 continue;
740 };
741 let summary = std::fs::read_to_string(&path)
742 .map(|content| content.chars().take(200).collect::<String>())
743 .unwrap_or_default();
744 let note = format!("人工修改待同步: {path} 内容摘要: {summary}");
745 out.entry(module.clone()).or_default().push(note);
746 }
747 out
748}
749
750pub fn sync_manual_edits_to_cards(
760 config: &config::schema::WikiConfig,
761 state: &incremental::state::GenerationState,
762) -> anyhow::Result<usize> {
763 let edits = collect_manual_edits(Some(state));
764 if edits.is_empty() {
765 return Ok(0);
766 }
767 let mut synced = 0usize;
768 for (module, notes) in &edits {
769 let card_path =
770 output::card_page_path(config.output_dir(), &config.wiki.language, module);
771 let mut content = match std::fs::read_to_string(&card_path) {
775 Ok(c) => c,
776 Err(e) => {
777 tracing::warn!("读取卡片失败,跳过人工修改反向同步 {}: {}", card_path.display(), e);
778 continue;
779 }
780 };
781 let mut changed = false;
782 for note in notes {
783 if content.contains(note.as_str()) {
784 continue;
785 }
786 changed = true;
787 if let Some(section) = content.find("## 人工修改待同步") {
788 let insert_at = content[section..]
790 .find("\n\n")
791 .map(|i| section + i + 2)
792 .unwrap_or(content.len());
793 content.insert_str(insert_at, &format!("- {note}\n"));
794 } else {
795 content.push_str(&format!("\n## 人工修改待同步\n\n- {note}\n"));
796 }
797 }
798 if changed {
799 crate::fs::write_file_atomic(&card_path, &content)?;
800 synced += 1;
801 }
802 }
803 Ok(synced)
804}
805
806pub fn run_watch(config_path: Option<&Path>, root: &project::ProjectRoot) -> anyhow::Result<()> {
811 let _config = match config_path {
813 Some(p) => config::load_config(p)?,
814 None => config::load_default_config(root)?.1,
815 };
816 tracing::info!("首次全量生成...");
817 run_pipeline(config_path, None, false, root, &GenerationMode::Full)?;
818 tracing::info!("全量生成完成,开始监听文件变化...");
819
820 let config_path = config_path.map(|p| p.to_path_buf());
821 let watch_root = root.path().to_path_buf();
823 let watch_root_for_loop = watch_root.clone();
824 let stop_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
828 {
829 let flag = stop_flag.clone();
830 let rt = get_global_runtime();
831 std::thread::spawn(move || {
832 rt.block_on(async {
833 let _ = tokio::signal::ctrl_c().await;
834 flag.store(true, std::sync::atomic::Ordering::Relaxed);
835 tracing::info!("收到 Ctrl-C,等待当前增量更新完成后退出...");
836 });
837 });
838 }
839 incremental::watch::run_watch_loop(
840 &watch_root_for_loop,
841 stop_flag,
842 move |events| {
843 for event in events {
844 tracing::info!(
845 "检测到 {:?} {} 个文件变更,触发增量更新...",
846 event.kind,
847 event.paths.len()
848 );
849 let change_kind = (event.kind == incremental::watch::ChangeKind::Deleted)
852 .then_some(event.kind);
853 let root = project::ProjectRoot::new(watch_root.clone());
854 let mode = GenerationMode::Incremental {
855 watch_paths: event.paths.clone(),
856 change_kind,
857 };
858 if let Err(e) = run_pipeline(config_path.as_deref(), None, false, &root, &mode) {
859 tracing::error!("增量更新失败: {}", e);
860 } else {
861 tracing::info!("增量更新完成");
862 }
863 }
864 },
865 )
866}
867
868fn search_index_dir(config: &config::schema::WikiConfig) -> std::path::PathBuf {
872 config.output_dir().join(config::schema::SEARCH_INDEX_DIR)
873}
874
875fn call_index_fingerprint(config: &config::schema::WikiConfig) -> Option<String> {
887 let root = config.output_dir().parent()?;
888 if let Ok(repo) = git2::Repository::discover(root)
890 && let Ok(head) = repo.head()
891 && let Some(target) = head.target()
892 {
893 return Some(format!("git:{}", target));
894 }
895 let state_path = config.output_dir().join(".state").join("generation_state.json");
897 let meta = std::fs::metadata(&state_path).ok()?;
898 let mtime = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
899 Some(format!("state:{}:{}", meta.len(), mtime.as_millis()))
902}
903
904fn load_call_index_cache(config: &config::schema::WikiConfig) -> Option<search::callgraph::CallIndex> {
907 let fp = call_index_fingerprint(config)?;
908 let state_dir = config.output_dir().join(".state");
909 let fp_file = std::fs::read_to_string(state_dir.join("call_index.fingerprint")).ok()?;
910 if fp_file.trim() != fp {
911 return None;
912 }
913 let data = std::fs::read_to_string(state_dir.join("call_index.json")).ok()?;
914 serde_json::from_str(&data).ok()
915}
916
917fn save_call_index_cache(config: &config::schema::WikiConfig, index: &search::callgraph::CallIndex) {
920 let Some(fp) = call_index_fingerprint(config) else {
921 return;
922 };
923 let state_dir = config.output_dir().join(".state");
924 if let Err(e) = std::fs::create_dir_all(&state_dir) {
925 tracing::warn!("调用索引缓存目录创建失败: {}", e);
926 return;
927 }
928 match serde_json::to_string(index) {
929 Ok(json) => {
930 if let Err(e) = std::fs::write(state_dir.join("call_index.json"), json) {
931 tracing::warn!("调用索引缓存写入失败: {}", e);
932 return;
933 }
934 if let Err(e) = std::fs::write(state_dir.join("call_index.fingerprint"), fp) {
935 tracing::warn!("调用索引指纹写入失败: {}", e);
936 }
937 }
938 Err(e) => tracing::warn!("调用索引序列化失败: {}", e),
939 }
940}
941
942fn semantic_degraded_marker(config: &config::schema::WikiConfig) -> std::path::PathBuf {
952 search_index_dir(config).join("semantic_degraded")
953}
954
955fn mark_semantic_degraded(config: &config::schema::WikiConfig, reason: &anyhow::Error) {
957 let marker = semantic_degraded_marker(config);
958 if let Err(e) = std::fs::write(&marker, reason.to_string()) {
959 tracing::warn!("写语义降级标记失败 {}: {}", marker.display(), e);
960 }
961}
962
963fn clear_semantic_degraded(config: &config::schema::WikiConfig) {
965 let _ = std::fs::remove_file(semantic_degraded_marker(config));
966}
967
968pub fn semantic_degraded_reason(config: &config::schema::WikiConfig) -> Option<String> {
970 let marker = semantic_degraded_marker(config);
971 std::fs::read_to_string(&marker).ok()
972}
973
974fn embed_model_marker(config: &config::schema::WikiConfig) -> std::path::PathBuf {
982 search_index_dir(config).join("embed_model.json")
983}
984
985fn read_embed_model(config: &config::schema::WikiConfig) -> Option<String> {
993 let path = embed_model_marker(config);
994 let text = std::fs::read_to_string(&path).ok()?;
995 serde_json::from_str::<serde_json::Value>(&text)
996 .ok()
997 .and_then(|v| v.get("model")?.as_str().map(|s| s.to_string()))
998}
999
1000fn write_embed_model(config: &config::schema::WikiConfig) {
1005 let path = embed_model_marker(config);
1006 let content = serde_json::json!({ "model": config.embed.model }).to_string();
1007 if let Err(e) = crate::fs::write_file_atomic(&path, &content) {
1008 tracing::warn!("embedding 模型标记写入失败(下次增量将回退全量重建): {}", e);
1009 }
1010}
1011
1012fn embed_model_mismatch(config: &config::schema::WikiConfig) -> bool {
1017 read_embed_model(config).as_deref() != Some(config.embed.model.as_str())
1018}
1019
1020fn attach_features(graph: &mut model::KnowledgeGraph, config: &config::schema::WikiConfig) {
1026 let embedder: Option<std::sync::Arc<dyn analysis::feature::Embedder>> =
1027 match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
1028 Ok(e) => {
1029 let engine: std::sync::Arc<dyn analysis::feature::Embedder> = std::sync::Arc::new(e);
1031 Some(engine)
1032 }
1033 Err(e) => {
1034 tracing::warn!("特征聚类 Embedding 初始化失败,降级为纯结构聚类: {e}");
1035 None
1036 }
1037 };
1038 match analysis::feature::detect_features(graph, embedder.as_deref()) {
1039 Ok(features) => {
1040 graph.features = features;
1041 tracing::info!("特征聚类完成: {} 个特征", graph.features.len());
1042 }
1043 Err(e) => {
1044 tracing::warn!("特征聚类失败(不影响主流程): {e}");
1045 }
1046 }
1047}
1048
1049fn build_search_index(
1054 graph: &model::KnowledgeGraph,
1055 file_insights: &[ingest::parser::FileInsight],
1056 config: &config::schema::WikiConfig,
1057) -> anyhow::Result<()> {
1058 let index_dir = search_index_dir(config);
1059 std::fs::create_dir_all(&index_dir)?;
1060
1061 let source_map = build_source_map(file_insights);
1063
1064 let items = collect_index_items(graph, &source_map);
1067
1068 let text_path = index_dir.join("text_index.db");
1070 let _ = std::fs::remove_file(&text_path); let (mut text_engine, _) = search::text::TextEngine::open(&text_path)?;
1072 text_engine.index_batch(&items)?;
1073
1074 let semantic_path = index_dir.join("semantic_index.db");
1076 match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
1081 Ok(embedder) => {
1082 let _ = std::fs::remove_file(&semantic_path);
1083 let embedder = std::sync::Arc::new(embedder);
1084 match search::semantic::SemanticEngine::open(&semantic_path, embedder, get_global_runtime().clone()) {
1091 Ok(mut semantic_engine) => match semantic_engine.index_batch(&items) {
1092 Ok(()) => {
1093 tracing::info!("语义索引构建完成: {} 个实体已向量化", items.len());
1094 clear_semantic_degraded(config);
1095 write_embed_model(config);
1097 }
1098 Err(e) => {
1099 tracing::warn!("语义索引构建失败(保留旧索引,搜索回退纯文本): {}", e);
1100 let _ = std::fs::remove_file(&semantic_path);
1101 mark_semantic_degraded(config, &e);
1102 }
1103 },
1104 Err(e) => {
1105 tracing::warn!("语义索引构建失败(保留旧索引,搜索回退纯文本): {}", e);
1106 mark_semantic_degraded(config, &e);
1107 }
1108 }
1109 }
1110 Err(e) => {
1111 tracing::warn!("语义索引构建跳过(Embedding 引擎初始化失败,保留旧索引): {}", e);
1112 mark_semantic_degraded(config, &e);
1113 }
1114 }
1115
1116 tracing::info!("搜索索引构建完成: {} 个实体已索引", items.len());
1117 Ok(())
1118}
1119
1120fn update_search_index_incremental(
1125 graph: &model::KnowledgeGraph,
1126 file_insights: &[ingest::parser::FileInsight],
1127 config: &config::schema::WikiConfig,
1128 changed_files: &std::collections::HashSet<std::path::PathBuf>,
1129) -> anyhow::Result<()> {
1130 let index_dir = search_index_dir(config);
1131 let text_path = index_dir.join("text_index.db");
1132
1133 if !text_path.exists() {
1135 return build_search_index(graph, file_insights, config);
1136 }
1137
1138 let (mut text_engine, need_reindex) = search::text::TextEngine::open(&text_path)?;
1139
1140 let source_map = build_source_map(file_insights);
1145 let mut total_removed = 0;
1146 let indexed_count;
1147 let items: Vec<(model::CodeNode, String)>;
1148
1149 if need_reindex {
1150 tracing::warn!("文本索引 schema 已升级(CJK tokens 列),重建全量文本索引");
1155 items = collect_index_items(graph, &source_map);
1156 indexed_count = items.len();
1157 text_engine.index_batch(&items)?;
1158 } else {
1159 for file in changed_files {
1161 let file_str = file.to_string_lossy();
1162 total_removed += text_engine.remove_by_file(&file_str)?;
1163 }
1164
1165 items = incremental_index_items(graph, file_insights, changed_files);
1168 indexed_count = items.len();
1169 text_engine.index_batch(&items)?;
1170 }
1171
1172 let semantic_path = index_dir.join("semantic_index.db");
1174 if semantic_path.exists() {
1179 match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
1180 Ok(embedder) => {
1181 let embedder = std::sync::Arc::new(embedder);
1182 match search::semantic::SemanticEngine::open(&semantic_path, embedder.clone(), get_global_runtime().clone()) {
1183 Ok(mut semantic_engine) => {
1184 let stored_model = read_embed_model(config);
1189 let model_mismatch = embed_model_mismatch(config);
1190 let dim_changed = if model_mismatch {
1192 false
1193 } else {
1194 let probe_dim = if items.is_empty() {
1199 None
1200 } else {
1201 match get_global_runtime().block_on(embedder.embed(&items[0].1)) {
1202 Ok(v) => Some(v.len()),
1203 Err(e) => {
1204 tracing::warn!("embedding 维度探测失败,跳过维度重建检查: {}", e);
1205 None
1206 }
1207 }
1208 };
1209 match semantic_engine.table_dimension() {
1212 Ok(existing_dim) => probe_dim
1213 .zip(existing_dim)
1214 .is_some_and(|(new_dim, existing)| new_dim != existing),
1215 Err(e) => {
1216 tracing::warn!("读取语义索引维度失败,跳过维度重建检查: {}", e);
1217 false
1218 }
1219 }
1220 };
1221 if model_mismatch {
1222 tracing::warn!(
1223 "embedding 模型变化(标记 {:?} → 配置 {}),回退全量重建语义索引(新旧模型向量空间不兼容)",
1224 stored_model,
1225 config.embed.model
1226 );
1227 let all_items = collect_index_items(graph, &source_map);
1228 semantic_engine.clear()?;
1229 semantic_engine.index_batch(&all_items)?;
1230 write_embed_model(config);
1231 } else if dim_changed {
1232 tracing::warn!(
1233 "embedding 维度变化,回退全量重建语义索引(增量删除+回填会丢全部既有向量)"
1234 );
1235 let all_items = collect_index_items(graph, &source_map);
1236 semantic_engine.clear()?;
1237 semantic_engine.index_batch(&all_items)?;
1238 } else {
1239 for file in changed_files {
1245 semantic_engine.remove_by_file(&file.to_string_lossy())?;
1246 }
1247 semantic_engine.index_batch(&items)?;
1248 }
1249 clear_semantic_degraded(config);
1251 }
1252 Err(e) => {
1253 tracing::warn!("语义索引打开失败,增量语义更新跳过(保留旧索引): {}", e);
1254 mark_semantic_degraded(config, &e);
1256 }
1257 }
1258 }
1259 Err(e) => {
1260 tracing::warn!("Embedding 引擎初始化失败,增量语义更新跳过(保留旧索引): {}", e);
1261 mark_semantic_degraded(config, &e);
1263 }
1264 }
1265 }
1266
1267 tracing::info!("搜索索引增量更新: 删除 {} 条, 新增 {} 条", total_removed, indexed_count);
1268 Ok(())
1269}
1270
1271fn collect_index_items(
1276 graph: &model::KnowledgeGraph,
1277 source_map: &std::collections::HashMap<String, String>,
1278) -> Vec<(model::CodeNode, String)> {
1279 graph
1280 .graph
1281 .node_indices()
1282 .filter_map(|idx| {
1283 let node = graph.graph.node_weight(idx)?;
1284 if matches!(
1286 node.kind,
1287 model::NodeKind::Project | model::NodeKind::Module | model::NodeKind::File
1288 ) {
1289 return None;
1290 }
1291 let source = extract_entity_source(node, source_map);
1292 Some((node.clone(), source))
1293 })
1294 .collect()
1295}
1296
1297fn build_source_map(insights: &[ingest::parser::FileInsight]) -> std::collections::HashMap<String, String> {
1299 insights.iter()
1300 .map(|i| (i.path.to_string_lossy().to_string(), i.source.clone()))
1301 .collect()
1302}
1303
1304fn incremental_index_items(
1312 graph: &model::KnowledgeGraph,
1313 file_insights: &[ingest::parser::FileInsight],
1314 changed_files: &std::collections::HashSet<std::path::PathBuf>,
1315) -> Vec<(model::CodeNode, String)> {
1316 let source_map = build_source_map(file_insights);
1317 collect_index_items(graph, &source_map)
1318 .into_iter()
1319 .filter(|(node, _)| {
1320 let Some(node_file) = node.file_path.as_deref() else {
1321 return false;
1322 };
1323 let node_file_norm = incremental::norm_sep(node_file);
1324 changed_files
1325 .iter()
1326 .any(|f| incremental::norm_sep(&f.to_string_lossy()) == node_file_norm)
1327 })
1328 .collect()
1329}
1330
1331fn extract_entity_source(
1335 node: &model::CodeNode,
1336 source_map: &std::collections::HashMap<String, String>,
1337) -> String {
1338 let file_path = match &node.file_path {
1339 Some(p) => p,
1340 None => return node.signature.clone().unwrap_or_default(),
1341 };
1342 let source = match source_map.get(file_path) {
1343 Some(s) => s,
1344 None => return node.signature.clone().unwrap_or_default(),
1345 };
1346 let (start, end) = match node.line_range {
1347 Some(r) => r,
1348 None => return node.signature.clone().unwrap_or_default(),
1349 };
1350 source.lines()
1352 .skip(start.saturating_sub(1))
1353 .take(end.saturating_sub(start) + 1)
1354 .collect::<Vec<_>>()
1355 .join("\n")
1356}
1357
1358pub fn execute_search(
1365 config_path: Option<&Path>,
1366 root: &project::ProjectRoot,
1367 query: &str,
1368 top_k: usize,
1369 engine_type: &config::schema::SearchEngineType,
1370) -> anyhow::Result<Vec<search::hybrid::SearchHit>> {
1371 if query.trim().is_empty() {
1372 return Ok(Vec::new());
1373 }
1374 let config = match config_path {
1376 Some(p) => config::load_config(p)?,
1377 None => config::load_default_config(root)?.1,
1378 };
1379 let index_dir = search_index_dir(&config);
1380 let text_path = index_dir.join("text_index.db");
1381 let semantic_path = index_dir.join("semantic_index.db");
1382
1383 match engine_type {
1384 config::schema::SearchEngineType::Text => {
1385 if !text_path.exists() {
1386 anyhow::bail!("搜索索引不存在,请先运行 `code-repo-wiki generate` 或 `code-repo-wiki update` 构建索引");
1387 }
1388 let (text_engine, _) = search::text::TextEngine::open(&text_path)?;
1389 let results = text_engine.search(query, top_k)?;
1390 Ok(search::hybrid::text_results_to_hits(results))
1391 }
1392 config::schema::SearchEngineType::Semantic => {
1393 if !semantic_path.exists() {
1396 anyhow::bail!("语义索引不存在——未配置嵌入 key(embed.api_key_env)或索引未构建,请配置后重新运行 `code-repo-wiki generate`");
1397 }
1398 let embedder = generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone())?;
1399 let embedder = std::sync::Arc::new(embedder);
1400 let semantic_engine = search::semantic::SemanticEngine::open(&semantic_path, embedder, get_global_runtime().clone())?;
1401 let results = semantic_engine.search(query, top_k)?;
1402 Ok(search::hybrid::semantic_results_to_hits(results))
1403 }
1404 config::schema::SearchEngineType::Hybrid => {
1405 if !text_path.exists() {
1408 anyhow::bail!("搜索索引不存在,请先运行 `code-repo-wiki generate` 或 `code-repo-wiki update` 构建索引");
1409 }
1410 let (text_engine, _) = search::text::TextEngine::open(&text_path)?;
1411 let semantic_engine: Option<Box<dyn search::semantic::SemanticSearch>> =
1416 if semantic_path.exists() {
1417 match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
1418 Ok(e) => match search::semantic::SemanticEngine::open(
1419 &semantic_path,
1420 Arc::new(e),
1421 get_global_runtime().clone(),
1422 ) {
1423 Ok(engine) => Some(Box::new(engine) as Box<dyn search::semantic::SemanticSearch>),
1424 Err(e) => {
1425 tracing::warn!("语义索引打开失败,hybrid 降级为纯 text: {}", e);
1426 None
1427 }
1428 },
1429 Err(e) => {
1430 tracing::warn!("embedding 引擎初始化失败,hybrid 降级为纯 text: {}", e);
1431 None
1432 }
1433 }
1434 } else { None };
1435 let mut agent = search::agent::SearchAgent::new(text_engine, semantic_engine, config::schema::SEARCH_RRF_K);
1436 let index = match load_call_index_cache(&config) {
1441 Some(i) => i,
1442 None => {
1443 if let Ok(scan) = ingest::scan_and_parse_at(root)
1444 && let Ok(graph) = analysis::build_graph(&scan.insights)
1445 {
1446 let index = search::callgraph::CallGraph::new(&graph).build_call_index();
1447 save_call_index_cache(&config, &index);
1448 index
1449 } else {
1450 HashMap::new()
1451 }
1452 }
1453 };
1454 agent = agent.with_call_index(index);
1455 Ok(agent.search(query, top_k, true))
1459 }
1460 }
1461}
1462
1463pub fn execute_ast_search(
1472 config_path: Option<&Path>,
1473 root: &project::ProjectRoot,
1474 symbol: &str,
1475 language: Option<&str>,
1476) -> anyhow::Result<Vec<search::hybrid::SearchHit>> {
1477 if symbol.trim().is_empty() {
1478 return Ok(Vec::new());
1479 }
1480 let _config = match config_path {
1481 Some(p) => config::load_config(p)?,
1483 None => config::load_default_config(root)?.1,
1484 };
1485 let insights = ingest::scan_and_parse_at(root)?.insights;
1486
1487 let mut hits = Vec::new();
1488 for insight in &insights {
1489 let lang = match language {
1491 Some(l) => l.to_string(),
1492 None => match insight.path.extension().and_then(|e| e.to_str()) {
1493 Some("rs") => "rust".to_string(),
1494 Some("py") => "python".to_string(),
1495 Some("js") => "javascript".to_string(),
1496 Some("ts") => "typescript".to_string(),
1497 Some("go") => "go".to_string(),
1498 Some("cs") => "csharp".to_string(),
1499 _ => continue,
1500 },
1501 };
1502 let mut q = match search::ast::AstQuery::new(&lang) {
1504 Ok(q) => q,
1505 Err(_) => continue,
1506 };
1507 let Ok(Some(m)) = q.find_definition(&insight.source, symbol) else {
1508 continue;
1509 };
1510 let signature = m
1512 .captures
1513 .get("name")
1514 .cloned()
1515 .unwrap_or_else(|| symbol.to_string());
1516 let module_path: Vec<String> = insight
1518 .path
1519 .parent()
1520 .map(|p| {
1521 p.components()
1522 .filter(|c| matches!(c, std::path::Component::Normal(_)))
1523 .map(|c| c.as_os_str().to_string_lossy().to_string())
1524 .collect()
1525 })
1526 .unwrap_or_default();
1527 hits.push(search::hybrid::SearchHit {
1528 node: model::CodeNode {
1529 id: model::NodeId::new(0),
1530 kind: model::NodeKind::Function,
1531 name: symbol.to_string(),
1532 file_path: Some(insight.path.to_string_lossy().to_string()),
1533 line_range: Some((m.start_line, m.end_line)),
1534 doc_comment: None,
1535 signature: Some(signature), visibility: None,
1536 module_path,
1537 },
1538 score: 100.0,
1539 source: "ast".into(),
1540 callers: vec![],
1541 callees: vec![],
1542 });
1543 }
1544 Ok(hits)
1545}
1546
1547#[cfg(test)]
1548mod tests {
1549 use super::*;
1550
1551 #[test]
1556 fn test_embed_model_marker_roundtrip_and_mismatch() {
1557 let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_embed_marker_{}", std::process::id()));
1558 let _ = std::fs::remove_dir_all(&dir);
1559 std::fs::create_dir_all(dir.join(".search")).unwrap();
1560
1561 let mut config = config::schema::WikiConfig {
1562 output_dir: Some(dir.clone()),
1563 embed: config::schema::EmbedSection {
1564 model: "model-a".into(),
1565 ..Default::default()
1566 },
1567 ..Default::default()
1568 };
1569
1570 assert!(embed_model_mismatch(&config), "标记缺失应视为模型不匹配");
1572
1573 write_embed_model(&config);
1575 assert!(!embed_model_mismatch(&config), "标记与配置一致应匹配");
1576 assert_eq!(read_embed_model(&config).as_deref(), Some("model-a"));
1577
1578 config.embed.model = "model-b".into();
1580 assert!(embed_model_mismatch(&config), "同维度模型升级应判定不匹配");
1581
1582 write_embed_model(&config);
1584 assert!(!embed_model_mismatch(&config));
1585 assert_eq!(read_embed_model(&config).as_deref(), Some("model-b"));
1586
1587 std::fs::write(dir.join(".search").join("embed_model.json"), "{broken").unwrap();
1589 assert!(embed_model_mismatch(&config), "损坏标记应视为不匹配");
1590
1591 let _ = std::fs::remove_dir_all(&dir);
1592 }
1593
1594 #[test]
1596 fn test_generation_timings_roundtrip() {
1597 let timings = GenerationTimings {
1598 scan_parse_ms: 1,
1599 graph_ms: 2,
1600 incremental_ms: 3,
1601 chunk_ms: 4,
1602 card_ms: 5,
1603 wiki_ms: 6,
1604 index_guide_ms: 7,
1605 render_ms: 8,
1606 index_ms: 9,
1607 state_ms: 10,
1608 total_ms: 55,
1609 };
1610 let text = serde_json::to_string_pretty(&timings).unwrap();
1611 let back: GenerationTimings = serde_json::from_str(&text).unwrap();
1612 assert_eq!(back.scan_parse_ms, 1);
1613 assert_eq!(back.total_ms, 55);
1614 assert!(serde_json::from_str::<GenerationTimings>("{broken").is_err());
1616 let partial: GenerationTimings =
1618 serde_json::from_str(r#"{"scan_parse_ms": 42}"#).unwrap();
1619 assert_eq!(partial.scan_parse_ms, 42);
1620 assert_eq!(partial.total_ms, 0);
1621 }
1622
1623 #[test]
1627 fn test_cleanup_stale_outputs_removes_unrendered_across_languages() {
1628 let dir = std::env::temp_dir()
1629 .join(format!("code_repo_wiki_test_stale_{}", std::process::id()));
1630 let _ = std::fs::remove_dir_all(&dir);
1631
1632 let mut state = incremental::state::GenerationState {
1634 last_commit_hash: None,
1635 file_fingerprints: std::collections::HashMap::new(),
1636 doc_fingerprints: std::collections::HashMap::new(),
1637 doc_modules: std::collections::HashMap::new(),
1638 protected_docs: vec![],
1639 generated_at: String::new(),
1640 tool_version: None,
1641 failed_modules: vec![],
1642 };
1643 for lang in ["zh", "en"] {
1644 let stale = dir.join("wiki").join(lang).join("src.md");
1645 let keep = dir.join("wiki").join(lang).join("lib.md");
1646 std::fs::create_dir_all(stale.parent().unwrap()).unwrap();
1647 std::fs::create_dir_all(keep.parent().unwrap()).unwrap();
1648 std::fs::write(&stale, "旧页面").unwrap();
1649 std::fs::write(&keep, "保留页面").unwrap();
1650 state
1651 .doc_fingerprints
1652 .insert(stale.to_string_lossy().to_string(), "fp".into());
1653 state
1654 .doc_fingerprints
1655 .insert(keep.to_string_lossy().to_string(), "fp".into());
1656 }
1657
1658 let rendered: Vec<std::path::PathBuf> = ["zh", "en"]
1660 .iter()
1661 .map(|lang| dir.join("wiki").join(lang).join("lib.md"))
1662 .collect();
1663
1664 cleanup_stale_outputs(Some(&state), &rendered, &std::collections::HashSet::new());
1666
1667 for lang in ["zh", "en"] {
1668 assert!(
1669 !dir.join("wiki").join(lang).join("src.md").exists(),
1670 "未渲染的旧产物应被清理({lang})"
1671 );
1672 assert!(
1673 dir.join("wiki").join(lang).join("lib.md").exists(),
1674 "本次渲染集合内的产物应保留({lang})"
1675 );
1676 }
1677
1678 let _ = std::fs::remove_dir_all(&dir);
1679 }
1680
1681 #[test]
1684 fn test_cleanup_stale_outputs_keeps_rendered_protected() {
1685 let dir = std::env::temp_dir()
1686 .join(format!("code_repo_wiki_test_stale_protected_{}", std::process::id()));
1687 let _ = std::fs::remove_dir_all(&dir);
1688
1689 let mut state = incremental::state::GenerationState {
1690 last_commit_hash: None,
1691 file_fingerprints: std::collections::HashMap::new(),
1692 doc_fingerprints: std::collections::HashMap::new(),
1693 doc_modules: std::collections::HashMap::new(),
1694 protected_docs: vec![],
1695 generated_at: String::new(),
1696 tool_version: None,
1697 failed_modules: vec![],
1698 };
1699 let manual = dir.join("wiki").join("zh").join("manual.md");
1701 std::fs::create_dir_all(manual.parent().unwrap()).unwrap();
1702 std::fs::write(&manual, "人工编辑内容").unwrap();
1703 state
1704 .doc_fingerprints
1705 .insert(manual.to_string_lossy().to_string(), "旧指纹".into());
1706 state
1707 .doc_modules
1708 .insert(manual.to_string_lossy().to_string(), "manual".into());
1709
1710 let rendered = vec![manual.clone()];
1712 cleanup_stale_outputs(Some(&state), &rendered, &std::collections::HashSet::new());
1713
1714 assert!(
1715 manual.exists(),
1716 "渲染集合内的人工编辑文档不应被清理"
1717 );
1718 let _ = std::fs::remove_dir_all(&dir);
1719 }
1720
1721 #[test]
1725 fn test_cleanup_stale_outputs_preserves_modules_still_in_scan() {
1726 let dir = std::env::temp_dir()
1727 .join(format!("code_repo_wiki_test_stale_preserve_{}", std::process::id()));
1728 let _ = std::fs::remove_dir_all(&dir);
1729
1730 let mut state = incremental::state::GenerationState {
1731 last_commit_hash: None,
1732 file_fingerprints: std::collections::HashMap::new(),
1733 doc_fingerprints: std::collections::HashMap::new(),
1734 doc_modules: std::collections::HashMap::new(),
1735 protected_docs: vec![],
1736 generated_at: String::new(),
1737 tool_version: None,
1738 failed_modules: vec![],
1739 };
1740 let fs_page = dir.join("wiki").join("zh").join("src_fs.md");
1742 std::fs::create_dir_all(fs_page.parent().unwrap()).unwrap();
1743 std::fs::write(&fs_page, "旧内容").unwrap();
1744 state
1745 .doc_fingerprints
1746 .insert(fs_page.to_string_lossy().to_string(), "fp".into());
1747 state
1748 .doc_modules
1749 .insert(fs_page.to_string_lossy().to_string(), "src::fs".into());
1750 let gone_page = dir.join("wiki").join("zh").join("src_deleted.md");
1752 std::fs::write(&gone_page, "旧内容").unwrap();
1753 state
1754 .doc_fingerprints
1755 .insert(gone_page.to_string_lossy().to_string(), "fp".into());
1756 state
1757 .doc_modules
1758 .insert(gone_page.to_string_lossy().to_string(), "src::deleted".into());
1759
1760 let preserved: std::collections::HashSet<String> =
1763 ["src::fs".to_string()].into_iter().collect();
1764 cleanup_stale_outputs(Some(&state), &[], &preserved);
1765
1766 assert!(fs_page.exists(), "仍在扫描的模块页面应保留");
1767 assert!(!gone_page.exists(), "已删除模块的页面应清理");
1768
1769 let _ = std::fs::remove_dir_all(&dir);
1770 }
1771
1772 #[test]
1774 fn test_cleanup_stale_outputs_noop_without_state() {
1775 let dir = std::env::temp_dir()
1776 .join(format!("code_repo_wiki_test_stale_noop_{}", std::process::id()));
1777 let _ = std::fs::remove_dir_all(&dir);
1778 cleanup_stale_outputs(None, &[], &std::collections::HashSet::new());
1779 let _ = std::fs::remove_dir_all(&dir);
1780 }
1781
1782 #[test]
1785 fn test_load_protection_force_clears_protection() {
1786 let dir = std::env::temp_dir()
1787 .join(format!("code_repo_wiki_test_force_{}", std::process::id()));
1788 let _ = std::fs::remove_dir_all(&dir);
1789
1790 let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
1791
1792 let state_dir = dir.join(".state");
1794 std::fs::create_dir_all(&state_dir).unwrap();
1795 let doc_path = dir.join("wiki").join("zh").join("src.md");
1796 std::fs::create_dir_all(doc_path.parent().unwrap()).unwrap();
1797 std::fs::write(&doc_path, "人工修改后的内容").unwrap();
1798 let mut state = incremental::state::GenerationState {
1799 last_commit_hash: None,
1800 file_fingerprints: std::collections::HashMap::new(),
1801 doc_fingerprints: std::collections::HashMap::new(),
1802 doc_modules: std::collections::HashMap::new(),
1803 protected_docs: vec![],
1804 generated_at: String::new(),
1805 tool_version: None,
1806 failed_modules: vec![],
1807 };
1808 state.doc_fingerprints.insert(
1809 doc_path.to_string_lossy().to_string(),
1810 "与磁盘内容不同的指纹".into(),
1811 );
1812 state.doc_modules.insert(
1813 doc_path.to_string_lossy().to_string(),
1814 "src".into(),
1815 );
1816 state.save(&state_dir).unwrap();
1817
1818 let (protected, _) = load_protection(&config, false).unwrap();
1820 assert!(
1821 protected.contains(&doc_path.to_string_lossy().to_string()),
1822 "force=false 应保护人工修改的文档"
1823 );
1824
1825 let (protected, _) = load_protection(&config, true).unwrap();
1827 assert!(protected.is_empty(), "force=true 应清空保护集");
1828
1829 let _ = std::fs::remove_dir_all(&dir);
1830 }
1831
1832 #[test]
1836 fn test_load_protection_corrupt_state_fails_loud() {
1837 let dir = std::env::temp_dir()
1838 .join(format!("code_repo_wiki_test_corrupt_state_{}", std::process::id()));
1839 let _ = std::fs::remove_dir_all(&dir);
1840
1841 let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
1842
1843 let state_dir = dir.join(".state");
1845 std::fs::create_dir_all(&state_dir).unwrap();
1846 std::fs::write(state_dir.join("generation_state.json"), "{ 半截").unwrap();
1847
1848 let err = load_protection(&config, false).unwrap_err();
1849 let msg = err.to_string();
1850 assert!(msg.contains("状态文件损坏"), "应明确报告损坏, 实际: {msg}");
1851
1852 assert!(load_protection(&config, true).unwrap().0.is_empty());
1854
1855 let _ = std::fs::remove_dir_all(&dir);
1856 }
1857
1858 #[test]
1860 fn test_load_protection_missing_state_is_ok() {
1861 let dir = std::env::temp_dir()
1862 .join(format!("code_repo_wiki_test_missing_state_{}", std::process::id()));
1863 let _ = std::fs::remove_dir_all(&dir);
1864
1865 let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
1866
1867 let (protected, state) = load_protection(&config, false).unwrap();
1868 assert!(protected.is_empty());
1869 assert!(state.is_none());
1870
1871 let _ = std::fs::remove_dir_all(&dir);
1872 }
1873
1874 #[test]
1878 fn test_call_index_fingerprint_none_without_state() {
1879 let dir = std::env::temp_dir()
1880 .join(format!("code_repo_wiki_test_fp_none_{}", std::process::id()));
1881 let _ = std::fs::remove_dir_all(&dir);
1882 std::fs::create_dir_all(&dir).unwrap();
1883
1884 let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
1885 assert!(call_index_fingerprint(&config).is_none());
1886
1887 let _ = std::fs::remove_dir_all(&dir);
1888 }
1889
1890 #[test]
1892 fn test_call_index_fingerprint_state_stable() {
1893 let dir = std::env::temp_dir()
1894 .join(format!("code_repo_wiki_test_fp_state_{}", std::process::id()));
1895 let _ = std::fs::remove_dir_all(&dir);
1896 std::fs::create_dir_all(dir.join(".state")).unwrap();
1897 std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
1898
1899 let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
1900 let fp1 = call_index_fingerprint(&config).expect("有状态文件应有指纹");
1901 let fp2 = call_index_fingerprint(&config).expect("有状态文件应有指纹");
1902 assert_eq!(fp1, fp2, "指纹必须稳定(同状态两次调用相同)");
1903
1904 std::thread::sleep(std::time::Duration::from_millis(20));
1906 std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
1907 let fp3 = call_index_fingerprint(&config).expect("有状态文件应有指纹");
1908 assert_ne!(fp1, fp3, "状态文件重写后指纹必须变化");
1909
1910 let _ = std::fs::remove_dir_all(&dir);
1911 }
1912
1913 #[test]
1915 fn test_call_index_cache_round_trip_and_invalidation() {
1916 let dir = std::env::temp_dir()
1917 .join(format!("code_repo_wiki_test_call_cache_{}", std::process::id()));
1918 let _ = std::fs::remove_dir_all(&dir);
1919 std::fs::create_dir_all(dir.join(".state")).unwrap();
1920 std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
1921
1922 let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
1923 let mut index = std::collections::HashMap::new();
1924 index.insert("fn_a".to_string(), (vec!["fn_b".to_string()], vec!["fn_c".to_string()]));
1925
1926 assert!(load_call_index_cache(&config).is_none());
1928
1929 save_call_index_cache(&config, &index);
1930 let loaded = load_call_index_cache(&config).expect("保存后应命中");
1931 assert_eq!(loaded, index, "缓存往返内容必须一致");
1932
1933 std::thread::sleep(std::time::Duration::from_millis(20));
1935 std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
1936 assert!(load_call_index_cache(&config).is_none(), "指纹变化后必须失效");
1937
1938 let _ = std::fs::remove_dir_all(&dir);
1939 }
1940
1941 #[test]
1943 fn test_call_index_cache_corrupt_is_miss() {
1944 let dir = std::env::temp_dir()
1945 .join(format!("code_repo_wiki_test_call_cache_corrupt_{}", std::process::id()));
1946 let _ = std::fs::remove_dir_all(&dir);
1947 std::fs::create_dir_all(dir.join(".state")).unwrap();
1948 std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
1949
1950 let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
1951 let fp = call_index_fingerprint(&config).unwrap();
1953 std::fs::write(dir.join(".state/call_index.fingerprint"), &fp).unwrap();
1954 std::fs::write(dir.join(".state/call_index.json"), "{ 半截").unwrap();
1955
1956 assert!(load_call_index_cache(&config).is_none(), "损坏缓存必须视为未命中");
1957
1958 let _ = std::fs::remove_dir_all(&dir);
1959 }
1960}