#![cfg(test)]
use std::path::Path;
use code_repo_wiki::config::schema::{LlmProviderType, LlmSection, WikiConfig};
use code_repo_wiki::config::schema::{WikiSection};
fn build_fixture_repo(repo: &Path) -> anyhow::Result<()> {
std::fs::create_dir_all(repo.join("src").join("a"))?;
std::fs::create_dir_all(repo.join("src").join("b"))?;
std::fs::write(repo.join("src").join("a").join("mod.rs"), "pub struct Alpha;\n\nimpl Alpha {\n pub fn run(&self) -> u32 { 42 }\n}\n")?;
std::fs::write(repo.join("src").join("b").join("mod.rs"), "pub fn beta() -> &'static str { \"beta\" }\n")?;
let config = 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()
},
..Default::default()
};
std::fs::write(repo.join("config.toml"), toml::to_string_pretty(&config)?)?;
Ok(())
}
fn artifact_paths(root: &Path) -> Vec<String> {
let mut out = Vec::new();
fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
if p.file_name().map(|n| n == ".state" || n == ".search").unwrap_or(false) {
continue;
}
walk(&p, root, out);
} else {
let rel = p.strip_prefix(root).unwrap_or(&p).to_string_lossy().to_string();
if rel.ends_with("_index.json") {
continue;
}
out.push(rel);
}
}
}
walk(root, root, &mut out);
out.sort();
out
}
fn content_hash(path: &Path, is_wiki_page: bool) -> String {
use sha2::{Digest, Sha256};
let raw = std::fs::read(path).unwrap_or_default();
let text = String::from_utf8_lossy(&raw);
let normalized = if is_wiki_page {
text.lines()
.map(|l| {
if l.starts_with("> 最后更新") {
"> 最后更新: <timestamp>".to_string()
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
} else {
text.to_string()
};
let mut hasher = Sha256::new();
hasher.update(normalized.as_bytes());
format!("{:x}", hasher.finalize())
}
#[test]
fn test_full_generate_artifact_set_deterministic() {
let repo = std::env::temp_dir().join(format!("code_repo_wiki_determinism_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&repo);
std::fs::create_dir_all(&repo).expect("构造临时仓库失败");
build_fixture_repo(&repo).expect("构造 fixture 失败");
let root = code_repo_wiki::project::ProjectRoot::new(repo.clone());
let config_path = repo.join("config.toml");
let first = code_repo_wiki::run_pipeline(Some(&config_path), None, false, &root, &code_repo_wiki::GenerationMode::Full).expect("第一次生成失败");
let out_dir = repo.join(".code-repo-wiki");
let paths_a = artifact_paths(&out_dir);
assert!(!paths_a.is_empty(), "产物集合不应为空");
let hash_all = |paths: &[String]| -> std::collections::HashMap<String, String> {
paths
.iter()
.map(|rel| {
let p = out_dir.join(rel);
let is_wiki = Path::new(rel).starts_with("wiki") && rel.ends_with(".md");
(rel.clone(), content_hash(&p, is_wiki))
})
.collect()
};
let hashes_a = hash_all(&paths_a);
let wiki_dir = out_dir.join("wiki").join("zh");
let manual_target = std::fs::read_dir(&wiki_dir)
.expect("wiki 目录应存在")
.flatten()
.map(|e| e.path())
.find(|p| p.extension().map(|x| x == "md").unwrap_or(false) && p.file_name().map(|n| n != "api.md" && n != "overview.md" && n != "architecture.md").unwrap_or(false))
.expect("应存在模块页");
std::fs::write(&manual_target, "# 人工修改\n\n用户覆盖的内容\n").expect("写入人工修改失败");
let manual_rel = manual_target.strip_prefix(&out_dir).unwrap().to_string_lossy().to_string();
let module_stem = manual_target.file_stem().unwrap().to_string_lossy().to_string();
let manual_card_rel = out_dir
.join("cards")
.join("zh")
.join(format!("{module_stem}.md"))
.strip_prefix(&out_dir)
.unwrap()
.to_string_lossy()
.to_string();
let second = code_repo_wiki::run_pipeline(Some(&config_path), None, false, &root, &code_repo_wiki::GenerationMode::Full).expect("第二次生成失败");
let paths_b = artifact_paths(&out_dir);
let hashes_b = hash_all(&paths_b);
assert_eq!(paths_a, paths_b, "两次生成的产物路径集合必须一致");
let mut differs = 0usize;
for rel in &paths_a {
let h1 = hashes_a.get(rel).expect("第一次哈希应存在");
let h2 = hashes_b.get(rel).expect("第二次哈希应存在");
if h1 != h2 {
differs += 1;
}
if rel != &manual_rel && rel != &manual_card_rel {
if h1 != h2 { eprintln!("DIFF: {rel}");
let t1 = std::fs::read_to_string(out_dir.join(rel)).unwrap_or_default();
eprintln!("---RUN1---\n{t1}"); }
assert_eq!(h1, h2, "产物内容必须确定性一致: {rel}");
}
}
assert_eq!(first.documents.len(), second.documents.len(), "文档数量应一致");
assert_eq!(first.cards.len(), second.cards.len(), "卡片数量应一致");
let mut m1: Vec<String> = first.graph.modules.iter().map(|m| m.name.clone()).collect();
let mut m2: Vec<String> = second.graph.modules.iter().map(|m| m.name.clone()).collect();
m1.sort();
m2.sort();
assert_eq!(m1, m2, "模块划分应确定性一致");
assert_ne!(differs, 0, "反向验证失败:断言工具未捕获人工修改页的差异");
let manual_after = std::fs::read_to_string(&manual_target).unwrap_or_default();
assert!(manual_after.contains("人工修改"), "人工修改页应保持用户内容");
let h_manual_first = hashes_a.get(&manual_rel).expect("人工页第一次哈希应存在");
assert_ne!(
content_hash(&manual_target, true),
*h_manual_first,
"人工修改后内容必须与首次生成不同"
);
let _ = std::fs::remove_dir_all(&repo);
}