use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use code_repo_wiki::config::schema::{LlmProviderType, LlmSection, WikiConfig, WikiSection};
use code_repo_wiki::ingest::parser::{Entity, FileInsight};
use code_repo_wiki::incremental::state::GenerationState;
fn wait_until(mut cond: impl FnMut() -> bool, interval: Duration, what: &str) {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if cond() {
return;
}
std::thread::sleep(interval);
}
panic!("等待超时(30s): {what}");
}
fn watch_config(repo: &Path) -> WikiConfig {
WikiConfig {
output_dir: Some((repo.join(".code-repo-wiki").to_string_lossy().into_owned()).into()),
wiki: WikiSection { language: "zh".into(), guide: Default::default() },
llm: LlmSection { provider: LlmProviderType::Mock, ..Default::default() },
embed: code_repo_wiki::config::schema::EmbedSection {
api_key_env: String::new(),
..Default::default()
},
}
}
fn read_opt(path: &Path) -> Option<String> {
std::fs::read_to_string(path).ok()
}
#[test]
fn watch_e2e_file_change_triggers_incremental() {
let repo = std::env::temp_dir().join(format!("code_repo_wiki_watch_e2e_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&repo);
std::fs::create_dir_all(repo.join("src")).expect("创建临时仓库失败");
std::fs::write(repo.join("src").join("alpha.rs"), "pub fn alpha_fn(x: u32) -> u32 { x + 1 }\n")
.expect("写入 alpha.rs 失败");
std::fs::write(repo.join("src").join("beta.rs"), "pub fn beta_fn(x: u32) -> u32 { x + 2 }\n")
.expect("写入 beta.rs 失败");
let config = watch_config(&repo);
std::fs::write(repo.join("config.toml"), toml::to_string_pretty(&config).expect("序列化配置失败"))
.expect("写入 config.toml 失败");
let config_path = repo.join("config.toml");
let root = code_repo_wiki::project::ProjectRoot::new(repo.clone());
let thread_root = root.clone();
let thread_config_path = config_path.clone();
let handle = std::thread::spawn(move || {
code_repo_wiki::run_watch(Some(&thread_config_path), &thread_root).expect("run_watch 启动失败");
});
drop(handle);
let api_path = repo.join(".code-repo-wiki").join("wiki").join("zh").join("api.md");
wait_until(
|| read_opt(&api_path).is_some_and(|s| s.contains("alpha_fn")),
Duration::from_millis(250),
"初始全量生成产物(.code-repo-wiki/wiki/zh/api.md 含 alpha_fn)",
);
std::thread::sleep(std::time::Duration::from_millis(500));
std::fs::write(
repo.join("src").join("alpha.rs"),
"pub fn alpha_fn(x: u32) -> u32 { x + 1 }\npub fn alpha_fn_v2(x: u32) -> u32 { x + 100 }\n",
)
.expect("修改 alpha.rs 失败");
wait_until(
|| read_opt(&api_path).is_some_and(|s| s.contains("alpha_fn_v2")),
Duration::from_millis(500),
"增量更新产物(api.md 出现 alpha_fn_v2)",
);
if let Err(e) = std::fs::remove_dir_all(&repo) {
eprintln!("临时目录清理失败(watch 句柄可能未释放,进程退出即回收): {e}");
}
}
#[test]
fn insights_cache_size_reports() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_cache_size_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("创建临时目录失败");
for i in 0..10 {
let content = format!("pub fn m{i}(x: u32) -> u32 {{ x + {i} }}\npub struct S{i} {{ pub v: u32 }}\n");
std::fs::write(dir.join(format!("m{i}.rs")), content).expect("写入源文件失败");
}
let root = code_repo_wiki::project::ProjectRoot::new(dir.clone());
let cache_path = dir.join(".code-repo-wiki").join(".state").join("insights_cache.json");
let empty_changed = std::collections::HashSet::new();
let first = code_repo_wiki::ingest::scan_and_parse_cached_at(
&root,
&Some(cache_path.clone()),
&empty_changed,
)
.expect("首次扫描解析失败")
.insights;
assert_eq!(first.len(), 10, "应解析出 10 个文件");
let second = code_repo_wiki::ingest::scan_and_parse_cached_at(
&root,
&Some(cache_path.clone()),
&empty_changed,
)
.expect("缓存命中扫描失败")
.insights;
assert_eq!(second.len(), 10);
let bytes = std::fs::metadata(&cache_path).expect("缓存文件应存在").len();
assert!(bytes > 0, "缓存文件不应为空");
println!(
"insights_cache 实测: 10 文件 -> {} bytes(真实 60 文件仓库线性外推 ≈ {} bytes)",
bytes,
bytes * 6
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn watch_path_dot_slash_prefix_boundary() {
let repo = std::env::temp_dir().join(format!("code_repo_wiki_dot_prefix_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&repo);
std::fs::create_dir_all(repo.join("src")).expect("创建临时仓库失败");
let src_file = repo.join("src").join("foo.rs");
std::fs::write(&src_file, "pub fn foo_fn(x: u32) -> u32 { x + 1 }\n").expect("写入 foo.rs 失败");
let root_path = repo.clone();
let dot_slash = PathBuf::from("./src/foo.rs");
let relativized = dot_slash
.strip_prefix(&root_path)
.map(|r| r.to_path_buf())
.unwrap_or_else(|_| dot_slash.clone());
assert_eq!(
relativized,
PathBuf::from("./src/foo.rs"),
"当前行为确认:./ 前缀路径未被相对化(strip_prefix 组件比较失败,原样保留)"
);
let abs = src_file.clone();
let relativized_abs = abs
.strip_prefix(&root_path)
.map(|r| r.to_path_buf())
.unwrap_or_else(|_| abs.clone());
assert_eq!(relativized_abs, PathBuf::from("src/foo.rs"), "绝对路径应被相对化");
let insight = FileInsight {
path: src_file.clone(),
language: "rust".into(),
entities: vec![Entity {
name: "foo_fn".into(),
kind: "function".into(),
line_start: 1,
line_end: 1,
doc_comment: None,
signature: Some("pub fn foo_fn(x: u32) -> u32 { x + 1 }".into()), visibility: None,
}],
imports: Vec::new(),
doc_comments: Vec::new(),
source: std::fs::read_to_string(&src_file).unwrap(),
};
let graph = code_repo_wiki::analysis::build_graph(std::slice::from_ref(&insight)).expect("构建 graph 失败");
let config = watch_config(&repo);
let state_dir = config.output_dir().join(".state");
let fp = GenerationState::compute_file_fingerprint(&src_file).expect("计算文件指纹失败");
let state = GenerationState {
last_commit_hash: Some("test".into()),
file_fingerprints: HashMap::from([(src_file.to_string_lossy().to_string(), fp)]),
doc_fingerprints: HashMap::new(),
doc_modules: HashMap::new(),
protected_docs: Vec::new(),
generated_at: "test".into(),
tool_version: None,
failed_modules: vec![],
};
state.save(&state_dir).expect("保存状态失败");
let root = code_repo_wiki::project::ProjectRoot::new(repo.clone());
let dot_result = code_repo_wiki::incremental::run_incremental_update_at(
&root,
std::slice::from_ref(&insight),
&graph,
&config,
&[PathBuf::from("./src/foo.rs")],
)
.expect("增量分析失败");
assert_eq!(
dot_result.changed_files,
vec![PathBuf::from("./src/foo.rs")],
"当前行为:./ 前缀路径未相对化、未归一化,原样透传到 changed_files"
);
assert!(
dot_result.affected_modules.is_empty(),
"当前行为:未相对化路径在 find_start_nodes 子串匹配中不命中 → 不参与影响传播: {:?}",
dot_result.affected_modules
);
let ok_result = code_repo_wiki::incremental::run_incremental_update_at(
&root,
&[insight],
&graph,
&config,
&[PathBuf::from("src/foo.rs")],
)
.expect("增量分析失败");
assert!(
!ok_result.affected_modules.is_empty(),
"相对化成功形态应命中影响传播(对照,证明 ./ 前缀是传播不命中的原因)"
);
let _ = std::fs::remove_dir_all(&repo);
}