pub mod config;
pub mod model;
pub mod ingest;
pub mod analysis;
pub mod generate;
pub mod output;
pub mod incremental;
pub mod search;
pub mod commands;
pub mod fs;
pub mod mcp;
pub mod project;
pub mod bench;
pub mod doctor;
pub mod key;
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, OnceLock};
use tokio::runtime::Runtime;
use anyhow::{bail, Context};
pub struct AnalysisResult {
pub graph: model::KnowledgeGraph,
pub documents: Vec<model::WikiDocument>,
pub cards: Vec<model::KnowledgeCard>,
pub stats: AnalysisStats,
}
#[derive(Debug, Clone, Default)]
pub struct AnalysisStats {
pub files_scanned: usize,
pub files_parsed: usize,
pub files_failed: usize,
pub total_entities: usize,
pub total_edges: usize,
pub modules_detected: usize,
pub generation_time_ms: u64,
pub failed_modules: Vec<String>,
}
pub fn get_global_runtime() -> &'static Arc<Runtime> {
static RT: OnceLock<Arc<Runtime>> = OnceLock::new();
RT.get_or_init(|| Arc::new(Runtime::new().expect("创建 tokio Runtime 失败")))
}
fn load_config_with_output(
config_path: Option<&Path>,
output: Option<&Path>,
root: &project::ProjectRoot,
) -> anyhow::Result<config::schema::WikiConfig> {
let mut config = match config_path {
Some(p) => config::load_config(p)?,
None => config::load_default_config(root)?.1,
};
let output_dir = match output {
Some(out) => root.path().join(out),
None => root.path().join(crate::config::schema::OUTPUT_DIR),
};
config.output_dir = Some(output_dir);
Ok(config)
}
pub fn load_config_rooted(
config_path: Option<&Path>,
root: &project::ProjectRoot,
) -> anyhow::Result<config::schema::WikiConfig> {
load_config_with_output(config_path, None, root)
}
fn load_protection(
config: &config::schema::WikiConfig,
force: bool,
) -> anyhow::Result<(std::collections::HashSet<String>, Option<incremental::state::GenerationState>)> {
if force {
return Ok((std::collections::HashSet::new(), None));
}
let state_dir = config.output_dir().join(".state");
let state_path = state_dir.join("generation_state.json");
if !state_path.exists() {
return Ok((std::collections::HashSet::new(), None));
}
let state = incremental::state::GenerationState::load(&state_dir).with_context(|| {
format!(
"状态文件损坏或不可读: {}(删除该文件后重新运行 generate 可重建)",
state_path.display()
)
})?;
let mut protected: std::collections::HashSet<String> = state
.protected_docs
.iter()
.cloned()
.collect();
for p in state.detect_manually_modified() {
protected.insert(p);
}
Ok((protected, Some(state)))
}
#[allow(clippy::too_many_arguments)]
fn save_generation_state(
root: &project::ProjectRoot,
config: &config::schema::WikiConfig,
insights: &[ingest::parser::FileInsight],
documents: &[model::WikiDocument],
cards: &[model::KnowledgeCard],
protected: &std::collections::HashSet<String>,
commit_hash: &str,
failed_modules: &[String],
) {
let output_dir = config.output_dir();
let state_dir = output_dir.join(".state");
match incremental::state::GenerationState::from_insights(root, insights, commit_hash) {
Ok(mut state) => {
state.failed_modules = failed_modules.to_vec();
let mut protected_docs: Vec<String> = protected.iter().cloned().collect();
protected_docs.sort();
state.protected_docs = protected_docs;
match incremental::state::GenerationState::record_doc_fingerprints(
documents,
cards,
output_dir,
&output::wiki_languages(config),
) {
Ok((fps, modules)) => {
state.doc_fingerprints = fps;
state.doc_modules = modules;
}
Err(e) => tracing::warn!(
"产物指纹记录失败(下次 update 人工修改检测可能失效): {e}"
),
}
if let Err(e) = state.save(&state_dir) {
tracing::warn!("生成状态保存失败(下次 update 无指纹基线,人工修改保护失效): {e}");
}
}
Err(e) => tracing::warn!("生成状态构造失败(本次状态未落盘): {e}"),
}
}
#[derive(Debug, Clone, Copy)]
pub struct ProgressEvent {
pub stage: &'static str,
pub percent: u8,
}
#[derive(Debug, Clone)]
pub enum GenerationMode {
Full,
Incremental {
watch_paths: Vec<std::path::PathBuf>,
change_kind: Option<incremental::watch::ChangeKind>,
},
}
pub fn run_pipeline(
config_path: Option<&Path>,
output: Option<&Path>,
force: bool,
root: &project::ProjectRoot,
mode: &GenerationMode,
) -> anyhow::Result<AnalysisResult> {
run_pipeline_with_progress(config_path, output, force, root, mode, &|_| {})
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct GenerationTimings {
pub scan_parse_ms: u64,
pub graph_ms: u64,
pub incremental_ms: u64,
pub chunk_ms: u64,
pub card_ms: u64,
pub wiki_ms: u64,
pub index_guide_ms: u64,
pub render_ms: u64,
pub index_ms: u64,
pub state_ms: u64,
pub total_ms: u64,
}
pub(crate) fn write_last_timings(config: &config::schema::WikiConfig, timings: &GenerationTimings) {
let state_dir = config.output_dir().join(".state");
if let Err(e) = std::fs::create_dir_all(&state_dir) {
tracing::warn!("分段计时目录创建失败: {e}");
return;
}
let path = state_dir.join("last_timings.json");
match serde_json::to_string_pretty(timings) {
Ok(text) => {
if let Err(e) = std::fs::write(&path, text) {
tracing::warn!("分段计时写盘失败: {e}");
}
}
Err(e) => tracing::warn!("分段计时序列化失败: {e}"),
}
}
pub fn run_pipeline_with_progress(
config_path: Option<&Path>,
output: Option<&Path>,
force: bool,
root: &project::ProjectRoot,
mode: &GenerationMode,
on_progress: &dyn Fn(ProgressEvent),
) -> anyhow::Result<AnalysisResult> {
let config = load_config_with_output(config_path, output, root)?;
let _run_lock = crate::fs::acquire_run_lock(&config)?;
let _span = tracing::info_span!("pipeline", config = %config_path.map(|p| p.display().to_string()).unwrap_or_else(|| "默认链".into()));
let _enter = _span.enter();
let start = std::time::Instant::now();
let mut timings = GenerationTimings::default();
let mut is_incremental = matches!(mode, GenerationMode::Incremental { .. });
if force && is_incremental {
tracing::info!("--force 与增量模式同时使用:退化为全量重生成");
is_incremental = false;
}
let (protected, old_state) = load_protection(&config, force)?;
if is_incremental && incremental::should_skip_noop(root, &config)? {
tracing::info!("无文件变更,跳过更新(no-op 快速判定)");
if let Some(state) = &old_state {
let synced = sync_manual_edits_to_cards(&config, state)?;
if synced > 0 {
tracing::info!("人工修改已反向同步到 {} 张卡片", synced);
}
}
let stats = AnalysisStats {
files_scanned: 0,
generation_time_ms: start.elapsed().as_millis() as u64,
..Default::default()
};
return Ok(AnalysisResult {
graph: model::KnowledgeGraph::default(),
documents: Vec::new(),
cards: Vec::new(),
stats,
});
}
let watch_list: Vec<std::path::PathBuf> = match mode {
GenerationMode::Incremental { watch_paths, .. } => watch_paths.clone(),
GenerationMode::Full => Vec::new(),
};
let watch_paths: Vec<std::path::PathBuf> = watch_list
.iter()
.map(|p| p.strip_prefix(root.path()).map(|r| r.to_path_buf()).unwrap_or_else(|_| p.clone()))
.collect();
let watch_set: std::collections::HashSet<std::path::PathBuf> =
watch_paths.iter().cloned().collect();
let scan = if is_incremental {
let cache_path = config.output_dir().join(".state").join("insights_cache.json");
ingest::scan_and_parse_cached_at(root, &Some(cache_path), &watch_set)?
} else {
ingest::scan_and_parse_at(root)?
};
let file_insights = scan.insights;
let files_failed = scan.files_failed;
timings.scan_parse_ms = start.elapsed().as_millis() as u64;
on_progress(ProgressEvent { stage: "scanning", percent: 10 });
if file_insights.is_empty() {
bail!("未找到任何源文件");
}
let mut stats = AnalysisStats {
files_scanned: file_insights.len(),
files_parsed: file_insights.iter().filter(|f| !f.entities.is_empty()).count(),
files_failed,
..Default::default()
};
let mut graph = analysis::build_graph(&file_insights)?;
attach_features(&mut graph, &config);
timings.graph_ms = start.elapsed().as_millis() as u64 - timings.scan_parse_ms;
on_progress(ProgressEvent { stage: "analyzing", percent: 25 });
stats.total_entities = graph.graph.node_count();
stats.total_edges = graph.graph.edge_count();
stats.modules_detected = graph.modules.len();
let inc_result = if is_incremental {
Some(incremental::run_incremental_update_at(root, &file_insights, &graph, &config, &watch_paths)?)
} else {
None
};
timings.incremental_ms = start.elapsed().as_millis() as u64
- timings.scan_parse_ms
- timings.graph_ms;
if let Some(inc) = &inc_result
&& inc.changed_files.is_empty()
{
if let Some(state) = &old_state {
let synced = sync_manual_edits_to_cards(&config, state)?;
if synced > 0 {
tracing::info!("人工修改已反向同步到 {} 张卡片", synced);
}
}
tracing::info!("无变更,跳过生成");
let stats = AnalysisStats {
files_scanned: file_insights.len(),
generation_time_ms: start.elapsed().as_millis() as u64,
..Default::default()
};
return Ok(AnalysisResult {
graph,
documents: Vec::new(),
cards: Vec::new(),
stats,
});
}
on_progress(ProgressEvent { stage: "chunking", percent: 30 });
let rt = get_global_runtime();
let extra_edits = collect_manual_edits(old_state.as_ref());
let mut gen_output = if let Some(inc) = &inc_result {
rt.block_on(generate::run_generation_filtered(
&graph, &file_insights, &config, root, inc, &extra_edits,
))?
} else {
rt.block_on(generate::run_generation(&graph, &file_insights, &config, root, &extra_edits))?
};
on_progress(ProgressEvent { stage: "cards", percent: 60 });
timings.chunk_ms = gen_output.timings.chunk_ms;
timings.card_ms = gen_output.timings.card_ms;
timings.wiki_ms = gen_output.timings.wiki_ms;
let gated = if is_incremental
&& inc_result
.as_ref()
.is_some_and(|i| i.affected_modules.is_empty() && !i.has_deleted_files)
{
generate::backfill_global_docs(
&config,
&mut gen_output.documents,
&[crate::model::DocumentKind::TableOfContents],
)
} else {
false
};
if !gated {
let index_doc = match generate::create_provider(&config) {
Ok(provider) => rt.block_on(generate::index::generate_index_guide(
&provider,
&graph,
&gen_output.cards,
&config,
)),
Err(e) => {
tracing::warn!("阅读指南 LLM 不可用,降级为确定性骨架: {e}");
generate::index::fallback_index_guide(&graph, &config)
}
};
gen_output.documents.push(index_doc);
}
timings.index_guide_ms = start.elapsed().as_millis() as u64
- timings.scan_parse_ms
- timings.graph_ms
- timings.incremental_ms
- timings.chunk_ms
- timings.card_ms
- timings.wiki_ms;
if matches!(
config.llm.provider,
crate::config::schema::LlmProviderType::Mock
) {
tracing::warn!("使用 mock provider:产物为占位内容,非真实文档(仅供测试/CI 演示)");
for doc in &mut gen_output.documents {
if !doc.content.ends_with(crate::output::MOCK_FOOTER_MARK) {
doc.content.push_str(crate::output::MOCK_FOOTER_MARK);
}
}
}
on_progress(ProgressEvent { stage: "wiki", percent: 90 });
output::render_all(&gen_output.documents, &gen_output.cards, &graph, &config, &protected)?;
let preserved_modules: std::collections::HashSet<String> = graph
.modules
.iter()
.map(|m| m.name.clone())
.collect();
cleanup_stale_outputs(
old_state.as_ref(),
&output::rendered_paths(&gen_output.documents, &gen_output.cards, &config),
&preserved_modules,
);
timings.render_ms = start.elapsed().as_millis() as u64
- timings.scan_parse_ms
- timings.graph_ms
- timings.incremental_ms
- timings.chunk_ms
- timings.card_ms
- timings.wiki_ms
- timings.index_guide_ms;
on_progress(ProgressEvent { stage: "output", percent: 95 });
let index_result = if is_incremental {
let changed_set: std::collections::HashSet<std::path::PathBuf> = inc_result
.as_ref()
.map(|i| i.changed_files.iter().cloned().collect())
.unwrap_or_default();
update_search_index_incremental(&graph, &file_insights, &config, &changed_set)
} else {
build_search_index(&graph, &file_insights, &config)
};
if let Err(e) = index_result {
tracing::warn!("搜索索引构建失败(不影响主流程): {}", e);
}
timings.index_ms = start.elapsed().as_millis() as u64
- timings.scan_parse_ms
- timings.graph_ms
- timings.incremental_ms
- timings.chunk_ms
- timings.card_ms
- timings.wiki_ms
- timings.index_guide_ms
- timings.render_ms;
on_progress(ProgressEvent { stage: "index", percent: 98 });
let head_hash = match incremental::diff::get_head_commit_hash_at(root) {
Ok(h) => h,
Err(e) => {
if e.downcast_ref::<git2::Error>()
.map(|g| g.code() == git2::ErrorCode::NotFound)
.unwrap_or(false)
{
tracing::info!("非 git 仓库,无 git 基线(增量状态不推进): {}", e);
} else {
tracing::warn!("获取 git HEAD 失败(增量状态不推进): {}", e);
}
String::new()
}
};
save_generation_state(root, &config, &file_insights, &gen_output.documents, &gen_output.cards, &protected, &head_hash, &gen_output.generation_stats.failed_modules);
timings.state_ms = start.elapsed().as_millis() as u64
- timings.scan_parse_ms
- timings.graph_ms
- timings.incremental_ms
- timings.chunk_ms
- timings.card_ms
- timings.wiki_ms
- timings.index_guide_ms
- timings.render_ms
- timings.index_ms;
timings.total_ms = start.elapsed().as_millis() as u64;
write_last_timings(&config, &timings);
on_progress(ProgressEvent { stage: "done", percent: 100 });
stats.generation_time_ms = start.elapsed().as_millis() as u64;
stats.failed_modules = gen_output.generation_stats.failed_modules.clone();
tracing::info!("流水线完成: {} 个文件, {} 个实体, {} 条边, {} 个模块, 耗时 {}ms",
stats.files_scanned, stats.total_entities, stats.total_edges,
stats.modules_detected, stats.generation_time_ms);
Ok(AnalysisResult {
graph,
documents: gen_output.documents,
cards: gen_output.cards,
stats,
})
}
pub fn run_card_command(
config_path: Option<&Path>,
root: &project::ProjectRoot,
action: &generate::card::CardAction,
) -> anyhow::Result<()> {
let config = load_config_with_output(config_path, None, root)?;
match action {
generate::card::CardAction::Generate { .. } => {}
generate::card::CardAction::Modify { module, .. }
| generate::card::CardAction::Supplement { module, .. }
| generate::card::CardAction::Rewrite { module, .. } => {
if generate::card::read_card(&config, module)?.is_none() {
anyhow::bail!("模块 {module} 的卡片不存在,请先运行 `code-repo-wiki generate` 或 `code-repo-wiki card generate {module}` 生成");
}
}
}
let provider = generate::create_provider(&config)?;
let rt = get_global_runtime();
match action {
generate::card::CardAction::Generate { module } => {
rt.block_on(generate::card::generate_module_card(&provider, &config, root, module))
}
generate::card::CardAction::Modify { module, instruction, references } => {
rt.block_on(generate::card::edit_card(
&provider, &config, module, instruction, references,
generate::card::CardEditMode::Modify,
))
}
generate::card::CardAction::Supplement { module, instruction, references } => {
rt.block_on(generate::card::edit_card(
&provider, &config, module, instruction, references,
generate::card::CardEditMode::Supplement,
))
}
generate::card::CardAction::Rewrite { module, instruction, references } => {
rt.block_on(generate::card::edit_card(
&provider, &config, module, instruction, references,
generate::card::CardEditMode::Rewrite,
))
}
}
}
pub(crate) fn cleanup_stale_outputs(
old_state: Option<&incremental::state::GenerationState>,
rendered: &[std::path::PathBuf],
preserved_modules: &std::collections::HashSet<String>,
) {
let Some(state) = old_state else {
return; };
let mut stale: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
stale.extend(state.doc_fingerprints.keys().map(String::as_str));
stale.extend(state.doc_modules.keys().map(String::as_str));
let rendered_set: std::collections::BTreeSet<String> = rendered
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
let mut removed = 0usize;
for path in stale {
if rendered_set.contains(path) {
continue;
}
if Path::new(path).is_relative() {
continue;
}
if state
.doc_modules
.get(path)
.is_some_and(|m| preserved_modules.contains(m))
{
continue;
}
let p = Path::new(path);
if p.exists() {
match std::fs::remove_file(p) {
Ok(()) => removed += 1,
Err(e) => tracing::warn!("清理过期产物失败 {}: {}", p.display(), e),
}
}
}
if removed > 0 {
tracing::info!("清理过期产物 {} 个", removed);
}
}
pub fn collect_manual_edits(
state: Option<&incremental::state::GenerationState>,
) -> HashMap<String, Vec<String>> {
let mut out: HashMap<String, Vec<String>> = HashMap::new();
let Some(state) = state else { return out };
for path in state.detect_manually_modified() {
let Some(module) = state.doc_modules.get(&path) else {
continue;
};
let summary = std::fs::read_to_string(&path)
.map(|content| content.chars().take(200).collect::<String>())
.unwrap_or_default();
let note = format!("人工修改待同步: {path} 内容摘要: {summary}");
out.entry(module.clone()).or_default().push(note);
}
out
}
pub fn sync_manual_edits_to_cards(
config: &config::schema::WikiConfig,
state: &incremental::state::GenerationState,
) -> anyhow::Result<usize> {
let edits = collect_manual_edits(Some(state));
if edits.is_empty() {
return Ok(0);
}
let mut synced = 0usize;
for (module, notes) in &edits {
let card_path =
output::card_page_path(config.output_dir(), &config.wiki.language, module);
let mut content = match std::fs::read_to_string(&card_path) {
Ok(c) => c,
Err(e) => {
tracing::warn!("读取卡片失败,跳过人工修改反向同步 {}: {}", card_path.display(), e);
continue;
}
};
let mut changed = false;
for note in notes {
if content.contains(note.as_str()) {
continue;
}
changed = true;
if let Some(section) = content.find("## 人工修改待同步") {
let insert_at = content[section..]
.find("\n\n")
.map(|i| section + i + 2)
.unwrap_or(content.len());
content.insert_str(insert_at, &format!("- {note}\n"));
} else {
content.push_str(&format!("\n## 人工修改待同步\n\n- {note}\n"));
}
}
if changed {
crate::fs::write_file_atomic(&card_path, &content)?;
synced += 1;
}
}
Ok(synced)
}
pub fn run_watch(config_path: Option<&Path>, root: &project::ProjectRoot) -> anyhow::Result<()> {
let _config = match config_path {
Some(p) => config::load_config(p)?,
None => config::load_default_config(root)?.1,
};
tracing::info!("首次全量生成...");
run_pipeline(config_path, None, false, root, &GenerationMode::Full)?;
tracing::info!("全量生成完成,开始监听文件变化...");
let config_path = config_path.map(|p| p.to_path_buf());
let watch_root = root.path().to_path_buf();
let watch_root_for_loop = watch_root.clone();
let stop_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
{
let flag = stop_flag.clone();
let rt = get_global_runtime();
std::thread::spawn(move || {
rt.block_on(async {
let _ = tokio::signal::ctrl_c().await;
flag.store(true, std::sync::atomic::Ordering::Relaxed);
tracing::info!("收到 Ctrl-C,等待当前增量更新完成后退出...");
});
});
}
incremental::watch::run_watch_loop(
&watch_root_for_loop,
stop_flag,
move |events| {
for event in events {
tracing::info!(
"检测到 {:?} {} 个文件变更,触发增量更新...",
event.kind,
event.paths.len()
);
let change_kind = (event.kind == incremental::watch::ChangeKind::Deleted)
.then_some(event.kind);
let root = project::ProjectRoot::new(watch_root.clone());
let mode = GenerationMode::Incremental {
watch_paths: event.paths.clone(),
change_kind,
};
if let Err(e) = run_pipeline(config_path.as_deref(), None, false, &root, &mode) {
tracing::error!("增量更新失败: {}", e);
} else {
tracing::info!("增量更新完成");
}
}
},
)
}
fn search_index_dir(config: &config::schema::WikiConfig) -> std::path::PathBuf {
config.output_dir().join(config::schema::SEARCH_INDEX_DIR)
}
fn call_index_fingerprint(config: &config::schema::WikiConfig) -> Option<String> {
let root = config.output_dir().parent()?;
if let Ok(repo) = git2::Repository::discover(root)
&& let Ok(head) = repo.head()
&& let Some(target) = head.target()
{
return Some(format!("git:{}", target));
}
let state_path = config.output_dir().join(".state").join("generation_state.json");
let meta = std::fs::metadata(&state_path).ok()?;
let mtime = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
Some(format!("state:{}:{}", meta.len(), mtime.as_millis()))
}
fn load_call_index_cache(config: &config::schema::WikiConfig) -> Option<search::callgraph::CallIndex> {
let fp = call_index_fingerprint(config)?;
let state_dir = config.output_dir().join(".state");
let fp_file = std::fs::read_to_string(state_dir.join("call_index.fingerprint")).ok()?;
if fp_file.trim() != fp {
return None;
}
let data = std::fs::read_to_string(state_dir.join("call_index.json")).ok()?;
serde_json::from_str(&data).ok()
}
fn save_call_index_cache(config: &config::schema::WikiConfig, index: &search::callgraph::CallIndex) {
let Some(fp) = call_index_fingerprint(config) else {
return;
};
let state_dir = config.output_dir().join(".state");
if let Err(e) = std::fs::create_dir_all(&state_dir) {
tracing::warn!("调用索引缓存目录创建失败: {}", e);
return;
}
match serde_json::to_string(index) {
Ok(json) => {
if let Err(e) = std::fs::write(state_dir.join("call_index.json"), json) {
tracing::warn!("调用索引缓存写入失败: {}", e);
return;
}
if let Err(e) = std::fs::write(state_dir.join("call_index.fingerprint"), fp) {
tracing::warn!("调用索引指纹写入失败: {}", e);
}
}
Err(e) => tracing::warn!("调用索引序列化失败: {}", e),
}
}
fn semantic_degraded_marker(config: &config::schema::WikiConfig) -> std::path::PathBuf {
search_index_dir(config).join("semantic_degraded")
}
fn mark_semantic_degraded(config: &config::schema::WikiConfig, reason: &anyhow::Error) {
let marker = semantic_degraded_marker(config);
if let Err(e) = std::fs::write(&marker, reason.to_string()) {
tracing::warn!("写语义降级标记失败 {}: {}", marker.display(), e);
}
}
fn clear_semantic_degraded(config: &config::schema::WikiConfig) {
let _ = std::fs::remove_file(semantic_degraded_marker(config));
}
pub fn semantic_degraded_reason(config: &config::schema::WikiConfig) -> Option<String> {
let marker = semantic_degraded_marker(config);
std::fs::read_to_string(&marker).ok()
}
fn embed_model_marker(config: &config::schema::WikiConfig) -> std::path::PathBuf {
search_index_dir(config).join("embed_model.json")
}
fn read_embed_model(config: &config::schema::WikiConfig) -> Option<String> {
let path = embed_model_marker(config);
let text = std::fs::read_to_string(&path).ok()?;
serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| v.get("model")?.as_str().map(|s| s.to_string()))
}
fn write_embed_model(config: &config::schema::WikiConfig) {
let path = embed_model_marker(config);
let content = serde_json::json!({ "model": config.embed.model }).to_string();
if let Err(e) = crate::fs::write_file_atomic(&path, &content) {
tracing::warn!("embedding 模型标记写入失败(下次增量将回退全量重建): {}", e);
}
}
fn embed_model_mismatch(config: &config::schema::WikiConfig) -> bool {
read_embed_model(config).as_deref() != Some(config.embed.model.as_str())
}
fn attach_features(graph: &mut model::KnowledgeGraph, config: &config::schema::WikiConfig) {
let embedder: Option<std::sync::Arc<dyn analysis::feature::Embedder>> =
match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
Ok(e) => {
let engine: std::sync::Arc<dyn analysis::feature::Embedder> = std::sync::Arc::new(e);
Some(engine)
}
Err(e) => {
tracing::warn!("特征聚类 Embedding 初始化失败,降级为纯结构聚类: {e}");
None
}
};
match analysis::feature::detect_features(graph, embedder.as_deref()) {
Ok(features) => {
graph.features = features;
tracing::info!("特征聚类完成: {} 个特征", graph.features.len());
}
Err(e) => {
tracing::warn!("特征聚类失败(不影响主流程): {e}");
}
}
}
fn build_search_index(
graph: &model::KnowledgeGraph,
file_insights: &[ingest::parser::FileInsight],
config: &config::schema::WikiConfig,
) -> anyhow::Result<()> {
let index_dir = search_index_dir(config);
std::fs::create_dir_all(&index_dir)?;
let source_map = build_source_map(file_insights);
let items = collect_index_items(graph, &source_map);
let text_path = index_dir.join("text_index.db");
let _ = std::fs::remove_file(&text_path); let (mut text_engine, _) = search::text::TextEngine::open(&text_path)?;
text_engine.index_batch(&items)?;
let semantic_path = index_dir.join("semantic_index.db");
match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
Ok(embedder) => {
let _ = std::fs::remove_file(&semantic_path);
let embedder = std::sync::Arc::new(embedder);
match search::semantic::SemanticEngine::open(&semantic_path, embedder, get_global_runtime().clone()) {
Ok(mut semantic_engine) => match semantic_engine.index_batch(&items) {
Ok(()) => {
tracing::info!("语义索引构建完成: {} 个实体已向量化", items.len());
clear_semantic_degraded(config);
write_embed_model(config);
}
Err(e) => {
tracing::warn!("语义索引构建失败(保留旧索引,搜索回退纯文本): {}", e);
let _ = std::fs::remove_file(&semantic_path);
mark_semantic_degraded(config, &e);
}
},
Err(e) => {
tracing::warn!("语义索引构建失败(保留旧索引,搜索回退纯文本): {}", e);
mark_semantic_degraded(config, &e);
}
}
}
Err(e) => {
tracing::warn!("语义索引构建跳过(Embedding 引擎初始化失败,保留旧索引): {}", e);
mark_semantic_degraded(config, &e);
}
}
tracing::info!("搜索索引构建完成: {} 个实体已索引", items.len());
Ok(())
}
fn update_search_index_incremental(
graph: &model::KnowledgeGraph,
file_insights: &[ingest::parser::FileInsight],
config: &config::schema::WikiConfig,
changed_files: &std::collections::HashSet<std::path::PathBuf>,
) -> anyhow::Result<()> {
let index_dir = search_index_dir(config);
let text_path = index_dir.join("text_index.db");
if !text_path.exists() {
return build_search_index(graph, file_insights, config);
}
let (mut text_engine, need_reindex) = search::text::TextEngine::open(&text_path)?;
let source_map = build_source_map(file_insights);
let mut total_removed = 0;
let indexed_count;
let items: Vec<(model::CodeNode, String)>;
if need_reindex {
tracing::warn!("文本索引 schema 已升级(CJK tokens 列),重建全量文本索引");
items = collect_index_items(graph, &source_map);
indexed_count = items.len();
text_engine.index_batch(&items)?;
} else {
for file in changed_files {
let file_str = file.to_string_lossy();
total_removed += text_engine.remove_by_file(&file_str)?;
}
items = incremental_index_items(graph, file_insights, changed_files);
indexed_count = items.len();
text_engine.index_batch(&items)?;
}
let semantic_path = index_dir.join("semantic_index.db");
if semantic_path.exists() {
match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
Ok(embedder) => {
let embedder = std::sync::Arc::new(embedder);
match search::semantic::SemanticEngine::open(&semantic_path, embedder.clone(), get_global_runtime().clone()) {
Ok(mut semantic_engine) => {
let stored_model = read_embed_model(config);
let model_mismatch = embed_model_mismatch(config);
let dim_changed = if model_mismatch {
false
} else {
let probe_dim = if items.is_empty() {
None
} else {
match get_global_runtime().block_on(embedder.embed(&items[0].1)) {
Ok(v) => Some(v.len()),
Err(e) => {
tracing::warn!("embedding 维度探测失败,跳过维度重建检查: {}", e);
None
}
}
};
match semantic_engine.table_dimension() {
Ok(existing_dim) => probe_dim
.zip(existing_dim)
.is_some_and(|(new_dim, existing)| new_dim != existing),
Err(e) => {
tracing::warn!("读取语义索引维度失败,跳过维度重建检查: {}", e);
false
}
}
};
if model_mismatch {
tracing::warn!(
"embedding 模型变化(标记 {:?} → 配置 {}),回退全量重建语义索引(新旧模型向量空间不兼容)",
stored_model,
config.embed.model
);
let all_items = collect_index_items(graph, &source_map);
semantic_engine.clear()?;
semantic_engine.index_batch(&all_items)?;
write_embed_model(config);
} else if dim_changed {
tracing::warn!(
"embedding 维度变化,回退全量重建语义索引(增量删除+回填会丢全部既有向量)"
);
let all_items = collect_index_items(graph, &source_map);
semantic_engine.clear()?;
semantic_engine.index_batch(&all_items)?;
} else {
for file in changed_files {
semantic_engine.remove_by_file(&file.to_string_lossy())?;
}
semantic_engine.index_batch(&items)?;
}
clear_semantic_degraded(config);
}
Err(e) => {
tracing::warn!("语义索引打开失败,增量语义更新跳过(保留旧索引): {}", e);
mark_semantic_degraded(config, &e);
}
}
}
Err(e) => {
tracing::warn!("Embedding 引擎初始化失败,增量语义更新跳过(保留旧索引): {}", e);
mark_semantic_degraded(config, &e);
}
}
}
tracing::info!("搜索索引增量更新: 删除 {} 条, 新增 {} 条", total_removed, indexed_count);
Ok(())
}
fn collect_index_items(
graph: &model::KnowledgeGraph,
source_map: &std::collections::HashMap<String, String>,
) -> Vec<(model::CodeNode, String)> {
graph
.graph
.node_indices()
.filter_map(|idx| {
let node = graph.graph.node_weight(idx)?;
if matches!(
node.kind,
model::NodeKind::Project | model::NodeKind::Module | model::NodeKind::File
) {
return None;
}
let source = extract_entity_source(node, source_map);
Some((node.clone(), source))
})
.collect()
}
fn build_source_map(insights: &[ingest::parser::FileInsight]) -> std::collections::HashMap<String, String> {
insights.iter()
.map(|i| (i.path.to_string_lossy().to_string(), i.source.clone()))
.collect()
}
fn incremental_index_items(
graph: &model::KnowledgeGraph,
file_insights: &[ingest::parser::FileInsight],
changed_files: &std::collections::HashSet<std::path::PathBuf>,
) -> Vec<(model::CodeNode, String)> {
let source_map = build_source_map(file_insights);
collect_index_items(graph, &source_map)
.into_iter()
.filter(|(node, _)| {
let Some(node_file) = node.file_path.as_deref() else {
return false;
};
let node_file_norm = incremental::norm_sep(node_file);
changed_files
.iter()
.any(|f| incremental::norm_sep(&f.to_string_lossy()) == node_file_norm)
})
.collect()
}
fn extract_entity_source(
node: &model::CodeNode,
source_map: &std::collections::HashMap<String, String>,
) -> String {
let file_path = match &node.file_path {
Some(p) => p,
None => return node.signature.clone().unwrap_or_default(),
};
let source = match source_map.get(file_path) {
Some(s) => s,
None => return node.signature.clone().unwrap_or_default(),
};
let (start, end) = match node.line_range {
Some(r) => r,
None => return node.signature.clone().unwrap_or_default(),
};
source.lines()
.skip(start.saturating_sub(1))
.take(end.saturating_sub(start) + 1)
.collect::<Vec<_>>()
.join("\n")
}
pub fn execute_search(
config_path: Option<&Path>,
root: &project::ProjectRoot,
query: &str,
top_k: usize,
engine_type: &config::schema::SearchEngineType,
) -> anyhow::Result<Vec<search::hybrid::SearchHit>> {
if query.trim().is_empty() {
return Ok(Vec::new());
}
let config = match config_path {
Some(p) => config::load_config(p)?,
None => config::load_default_config(root)?.1,
};
let index_dir = search_index_dir(&config);
let text_path = index_dir.join("text_index.db");
let semantic_path = index_dir.join("semantic_index.db");
match engine_type {
config::schema::SearchEngineType::Text => {
if !text_path.exists() {
anyhow::bail!("搜索索引不存在,请先运行 `code-repo-wiki generate` 或 `code-repo-wiki update` 构建索引");
}
let (text_engine, _) = search::text::TextEngine::open(&text_path)?;
let results = text_engine.search(query, top_k)?;
Ok(search::hybrid::text_results_to_hits(results))
}
config::schema::SearchEngineType::Semantic => {
if !semantic_path.exists() {
anyhow::bail!("语义索引不存在——未配置嵌入 key(embed.api_key_env)或索引未构建,请配置后重新运行 `code-repo-wiki generate`");
}
let embedder = generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone())?;
let embedder = std::sync::Arc::new(embedder);
let semantic_engine = search::semantic::SemanticEngine::open(&semantic_path, embedder, get_global_runtime().clone())?;
let results = semantic_engine.search(query, top_k)?;
Ok(search::hybrid::semantic_results_to_hits(results))
}
config::schema::SearchEngineType::Hybrid => {
if !text_path.exists() {
anyhow::bail!("搜索索引不存在,请先运行 `code-repo-wiki generate` 或 `code-repo-wiki update` 构建索引");
}
let (text_engine, _) = search::text::TextEngine::open(&text_path)?;
let semantic_engine: Option<Box<dyn search::semantic::SemanticSearch>> =
if semantic_path.exists() {
match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
Ok(e) => match search::semantic::SemanticEngine::open(
&semantic_path,
Arc::new(e),
get_global_runtime().clone(),
) {
Ok(engine) => Some(Box::new(engine) as Box<dyn search::semantic::SemanticSearch>),
Err(e) => {
tracing::warn!("语义索引打开失败,hybrid 降级为纯 text: {}", e);
None
}
},
Err(e) => {
tracing::warn!("embedding 引擎初始化失败,hybrid 降级为纯 text: {}", e);
None
}
}
} else { None };
let mut agent = search::agent::SearchAgent::new(text_engine, semantic_engine, config::schema::SEARCH_RRF_K);
let index = match load_call_index_cache(&config) {
Some(i) => i,
None => {
if let Ok(scan) = ingest::scan_and_parse_at(root)
&& let Ok(graph) = analysis::build_graph(&scan.insights)
{
let index = search::callgraph::CallGraph::new(&graph).build_call_index();
save_call_index_cache(&config, &index);
index
} else {
HashMap::new()
}
}
};
agent = agent.with_call_index(index);
Ok(agent.search(query, top_k, true))
}
}
}
pub fn execute_ast_search(
config_path: Option<&Path>,
root: &project::ProjectRoot,
symbol: &str,
language: Option<&str>,
) -> anyhow::Result<Vec<search::hybrid::SearchHit>> {
if symbol.trim().is_empty() {
return Ok(Vec::new());
}
let _config = match config_path {
Some(p) => config::load_config(p)?,
None => config::load_default_config(root)?.1,
};
let insights = ingest::scan_and_parse_at(root)?.insights;
let mut hits = Vec::new();
for insight in &insights {
let lang = match language {
Some(l) => l.to_string(),
None => match insight.path.extension().and_then(|e| e.to_str()) {
Some("rs") => "rust".to_string(),
Some("py") => "python".to_string(),
Some("js") => "javascript".to_string(),
Some("ts") => "typescript".to_string(),
Some("go") => "go".to_string(),
Some("cs") => "csharp".to_string(),
_ => continue,
},
};
let mut q = match search::ast::AstQuery::new(&lang) {
Ok(q) => q,
Err(_) => continue,
};
let Ok(Some(m)) = q.find_definition(&insight.source, symbol) else {
continue;
};
let signature = m
.captures
.get("name")
.cloned()
.unwrap_or_else(|| symbol.to_string());
let module_path: Vec<String> = insight
.path
.parent()
.map(|p| {
p.components()
.filter(|c| matches!(c, std::path::Component::Normal(_)))
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect()
})
.unwrap_or_default();
hits.push(search::hybrid::SearchHit {
node: model::CodeNode {
id: model::NodeId::new(0),
kind: model::NodeKind::Function,
name: symbol.to_string(),
file_path: Some(insight.path.to_string_lossy().to_string()),
line_range: Some((m.start_line, m.end_line)),
doc_comment: None,
signature: Some(signature), visibility: None,
module_path,
},
score: 100.0,
source: "ast".into(),
callers: vec![],
callees: vec![],
});
}
Ok(hits)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_embed_model_marker_roundtrip_and_mismatch() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_embed_marker_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".search")).unwrap();
let mut config = config::schema::WikiConfig {
output_dir: Some(dir.clone()),
embed: config::schema::EmbedSection {
model: "model-a".into(),
..Default::default()
},
..Default::default()
};
assert!(embed_model_mismatch(&config), "标记缺失应视为模型不匹配");
write_embed_model(&config);
assert!(!embed_model_mismatch(&config), "标记与配置一致应匹配");
assert_eq!(read_embed_model(&config).as_deref(), Some("model-a"));
config.embed.model = "model-b".into();
assert!(embed_model_mismatch(&config), "同维度模型升级应判定不匹配");
write_embed_model(&config);
assert!(!embed_model_mismatch(&config));
assert_eq!(read_embed_model(&config).as_deref(), Some("model-b"));
std::fs::write(dir.join(".search").join("embed_model.json"), "{broken").unwrap();
assert!(embed_model_mismatch(&config), "损坏标记应视为不匹配");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_generation_timings_roundtrip() {
let timings = GenerationTimings {
scan_parse_ms: 1,
graph_ms: 2,
incremental_ms: 3,
chunk_ms: 4,
card_ms: 5,
wiki_ms: 6,
index_guide_ms: 7,
render_ms: 8,
index_ms: 9,
state_ms: 10,
total_ms: 55,
};
let text = serde_json::to_string_pretty(&timings).unwrap();
let back: GenerationTimings = serde_json::from_str(&text).unwrap();
assert_eq!(back.scan_parse_ms, 1);
assert_eq!(back.total_ms, 55);
assert!(serde_json::from_str::<GenerationTimings>("{broken").is_err());
let partial: GenerationTimings =
serde_json::from_str(r#"{"scan_parse_ms": 42}"#).unwrap();
assert_eq!(partial.scan_parse_ms, 42);
assert_eq!(partial.total_ms, 0);
}
#[test]
fn test_cleanup_stale_outputs_removes_unrendered_across_languages() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_stale_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut state = incremental::state::GenerationState {
last_commit_hash: None,
file_fingerprints: std::collections::HashMap::new(),
doc_fingerprints: std::collections::HashMap::new(),
doc_modules: std::collections::HashMap::new(),
protected_docs: vec![],
generated_at: String::new(),
tool_version: None,
failed_modules: vec![],
};
for lang in ["zh", "en"] {
let stale = dir.join("wiki").join(lang).join("src.md");
let keep = dir.join("wiki").join(lang).join("lib.md");
std::fs::create_dir_all(stale.parent().unwrap()).unwrap();
std::fs::create_dir_all(keep.parent().unwrap()).unwrap();
std::fs::write(&stale, "旧页面").unwrap();
std::fs::write(&keep, "保留页面").unwrap();
state
.doc_fingerprints
.insert(stale.to_string_lossy().to_string(), "fp".into());
state
.doc_fingerprints
.insert(keep.to_string_lossy().to_string(), "fp".into());
}
let rendered: Vec<std::path::PathBuf> = ["zh", "en"]
.iter()
.map(|lang| dir.join("wiki").join(lang).join("lib.md"))
.collect();
cleanup_stale_outputs(Some(&state), &rendered, &std::collections::HashSet::new());
for lang in ["zh", "en"] {
assert!(
!dir.join("wiki").join(lang).join("src.md").exists(),
"未渲染的旧产物应被清理({lang})"
);
assert!(
dir.join("wiki").join(lang).join("lib.md").exists(),
"本次渲染集合内的产物应保留({lang})"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_cleanup_stale_outputs_keeps_rendered_protected() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_stale_protected_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut state = incremental::state::GenerationState {
last_commit_hash: None,
file_fingerprints: std::collections::HashMap::new(),
doc_fingerprints: std::collections::HashMap::new(),
doc_modules: std::collections::HashMap::new(),
protected_docs: vec![],
generated_at: String::new(),
tool_version: None,
failed_modules: vec![],
};
let manual = dir.join("wiki").join("zh").join("manual.md");
std::fs::create_dir_all(manual.parent().unwrap()).unwrap();
std::fs::write(&manual, "人工编辑内容").unwrap();
state
.doc_fingerprints
.insert(manual.to_string_lossy().to_string(), "旧指纹".into());
state
.doc_modules
.insert(manual.to_string_lossy().to_string(), "manual".into());
let rendered = vec![manual.clone()];
cleanup_stale_outputs(Some(&state), &rendered, &std::collections::HashSet::new());
assert!(
manual.exists(),
"渲染集合内的人工编辑文档不应被清理"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_cleanup_stale_outputs_preserves_modules_still_in_scan() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_stale_preserve_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut state = incremental::state::GenerationState {
last_commit_hash: None,
file_fingerprints: std::collections::HashMap::new(),
doc_fingerprints: std::collections::HashMap::new(),
doc_modules: std::collections::HashMap::new(),
protected_docs: vec![],
generated_at: String::new(),
tool_version: None,
failed_modules: vec![],
};
let fs_page = dir.join("wiki").join("zh").join("src_fs.md");
std::fs::create_dir_all(fs_page.parent().unwrap()).unwrap();
std::fs::write(&fs_page, "旧内容").unwrap();
state
.doc_fingerprints
.insert(fs_page.to_string_lossy().to_string(), "fp".into());
state
.doc_modules
.insert(fs_page.to_string_lossy().to_string(), "src::fs".into());
let gone_page = dir.join("wiki").join("zh").join("src_deleted.md");
std::fs::write(&gone_page, "旧内容").unwrap();
state
.doc_fingerprints
.insert(gone_page.to_string_lossy().to_string(), "fp".into());
state
.doc_modules
.insert(gone_page.to_string_lossy().to_string(), "src::deleted".into());
let preserved: std::collections::HashSet<String> =
["src::fs".to_string()].into_iter().collect();
cleanup_stale_outputs(Some(&state), &[], &preserved);
assert!(fs_page.exists(), "仍在扫描的模块页面应保留");
assert!(!gone_page.exists(), "已删除模块的页面应清理");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_cleanup_stale_outputs_noop_without_state() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_stale_noop_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
cleanup_stale_outputs(None, &[], &std::collections::HashSet::new());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_load_protection_force_clears_protection() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_force_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
let state_dir = dir.join(".state");
std::fs::create_dir_all(&state_dir).unwrap();
let doc_path = dir.join("wiki").join("zh").join("src.md");
std::fs::create_dir_all(doc_path.parent().unwrap()).unwrap();
std::fs::write(&doc_path, "人工修改后的内容").unwrap();
let mut state = incremental::state::GenerationState {
last_commit_hash: None,
file_fingerprints: std::collections::HashMap::new(),
doc_fingerprints: std::collections::HashMap::new(),
doc_modules: std::collections::HashMap::new(),
protected_docs: vec![],
generated_at: String::new(),
tool_version: None,
failed_modules: vec![],
};
state.doc_fingerprints.insert(
doc_path.to_string_lossy().to_string(),
"与磁盘内容不同的指纹".into(),
);
state.doc_modules.insert(
doc_path.to_string_lossy().to_string(),
"src".into(),
);
state.save(&state_dir).unwrap();
let (protected, _) = load_protection(&config, false).unwrap();
assert!(
protected.contains(&doc_path.to_string_lossy().to_string()),
"force=false 应保护人工修改的文档"
);
let (protected, _) = load_protection(&config, true).unwrap();
assert!(protected.is_empty(), "force=true 应清空保护集");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_load_protection_corrupt_state_fails_loud() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_corrupt_state_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
let state_dir = dir.join(".state");
std::fs::create_dir_all(&state_dir).unwrap();
std::fs::write(state_dir.join("generation_state.json"), "{ 半截").unwrap();
let err = load_protection(&config, false).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("状态文件损坏"), "应明确报告损坏, 实际: {msg}");
assert!(load_protection(&config, true).unwrap().0.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_load_protection_missing_state_is_ok() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_missing_state_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
let (protected, state) = load_protection(&config, false).unwrap();
assert!(protected.is_empty());
assert!(state.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_call_index_fingerprint_none_without_state() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_fp_none_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
assert!(call_index_fingerprint(&config).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_call_index_fingerprint_state_stable() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_fp_state_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".state")).unwrap();
std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
let fp1 = call_index_fingerprint(&config).expect("有状态文件应有指纹");
let fp2 = call_index_fingerprint(&config).expect("有状态文件应有指纹");
assert_eq!(fp1, fp2, "指纹必须稳定(同状态两次调用相同)");
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
let fp3 = call_index_fingerprint(&config).expect("有状态文件应有指纹");
assert_ne!(fp1, fp3, "状态文件重写后指纹必须变化");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_call_index_cache_round_trip_and_invalidation() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_call_cache_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".state")).unwrap();
std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
let mut index = std::collections::HashMap::new();
index.insert("fn_a".to_string(), (vec!["fn_b".to_string()], vec!["fn_c".to_string()]));
assert!(load_call_index_cache(&config).is_none());
save_call_index_cache(&config, &index);
let loaded = load_call_index_cache(&config).expect("保存后应命中");
assert_eq!(loaded, index, "缓存往返内容必须一致");
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
assert!(load_call_index_cache(&config).is_none(), "指纹变化后必须失效");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_call_index_cache_corrupt_is_miss() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_call_cache_corrupt_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".state")).unwrap();
std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
let fp = call_index_fingerprint(&config).unwrap();
std::fs::write(dir.join(".state/call_index.fingerprint"), &fp).unwrap();
std::fs::write(dir.join(".state/call_index.json"), "{ 半截").unwrap();
assert!(load_call_index_cache(&config).is_none(), "损坏缓存必须视为未命中");
let _ = std::fs::remove_dir_all(&dir);
}
}