#![cfg(test)]
use std::collections::HashMap;
use std::path::Path;
use code_repo_wiki::config::schema::{LlmProviderType, LlmSection, WikiConfig};
use code_repo_wiki::config::schema::{WikiSection};
const MODULES: usize = 15;
const FILES_PER_MODULE: usize = 10;
const GROUP_SIZE: usize = 5;
fn build_large_repo(repo: &Path) -> anyhow::Result<()> {
std::fs::write(repo.join(".gitignore"), ".code-repo-wiki/\nAGENTS.md\n")?;
for m in 0..MODULES {
let dir = repo.join("src").join(format!("m{m:02}"));
std::fs::create_dir_all(&dir)?;
for f in 0..FILES_PER_MODULE {
let mut content = String::new();
for i in 0..3 {
content.push_str(&format!("pub fn f{f:02}_{i}(x: u32) -> u32 {{ x + {i} + {f} }}\n"));
}
if f == 0 && m + 1 < MODULES && (m + 1) / GROUP_SIZE == m / GROUP_SIZE {
content.push_str(&format!(
"pub fn link_{m:02}() -> u32 {{ m_{:02}_f00_0(1) + 1 }}\n",
m + 1
));
}
std::fs::write(dir.join(format!("f{f:02}.rs")), content)?;
}
}
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)?)?;
let git = git2::Repository::init(repo)?;
let mut cfg = git.config()?;
cfg.set_str("user.name", "test")?;
cfg.set_str("user.email", "test@test.com")?;
Ok(())
}
fn git_commit_all(repo: &Path, message: &str) -> String {
let repo = git2::Repository::open(repo).expect("打开 git 仓库失败");
let mut index = repo.index().unwrap();
index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
let commit_id = match repo.head().ok() {
Some(head) => {
let parent = head.peel_to_commit().unwrap();
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent]).unwrap()
}
None => repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[]).unwrap(),
};
commit_id.to_string()
}
fn wiki_pages_snapshot(repo: &Path) -> HashMap<String, String> {
let wiki_dir = repo.join(".code-repo-wiki").join("wiki").join("zh");
let mut map = HashMap::new();
if let Ok(es) = std::fs::read_dir(&wiki_dir) {
for e in es.flatten() {
let p = e.path();
if p.extension().is_some_and(|x| x == "md")
&& let (Some(name), Ok(content)) =
(p.file_name().map(|s| s.to_string_lossy().into_owned()), std::fs::read_to_string(&p))
{
map.insert(name, content);
}
}
}
map
}
fn page_names(map: &HashMap<String, String>) -> Vec<String> {
let mut names: Vec<String> = map.keys().filter(|k| !k.ends_with("_log.md")).cloned().collect();
names.sort();
names
}
fn titles_in_group(titles: &[String], lo: usize, hi: usize) -> bool {
titles.iter().any(|t| {
(lo..=hi).any(|m| t.contains(&format!("m{m:02}")))
})
}
#[test]
fn test_large_fixture_incremental_impact() {
let repo = std::env::temp_dir().join(format!("code_repo_wiki_large_e2e_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&repo);
std::fs::create_dir_all(&repo).expect("构造临时仓库失败");
build_large_repo(&repo).expect("构造 150 文件 fixture 失败");
let root = code_repo_wiki::project::ProjectRoot::new(repo.clone());
let config_path = repo.join("config.toml");
let target = repo.join("src").join("m07").join("f00.rs");
git_commit_all(&repo, "init 150 files");
code_repo_wiki::run_pipeline(Some(&config_path), None, false, &root, &code_repo_wiki::GenerationMode::Full)
.expect("全量生成失败");
let base_pages = wiki_pages_snapshot(&repo);
let base_names = page_names(&base_pages);
assert!(
base_names.len() >= MODULES + 4,
"基线应含全部模块/文件页(≥19),实际 {} 页",
base_names.len()
);
std::fs::write(
&target,
"pub fn f00_renamed(x: u32, y: u32) -> u32 { x + y }\npub fn f00_1(x: u32) -> u32 { x + 1 }\npub fn f00_2(x: u32) -> u32 { x + 2 }\npub fn link_m07() -> u32 { m08_f00_0(1) + 1 }\n",
)
.unwrap();
git_commit_all(&repo, "rename m07 f00_0 signature");
let inc = code_repo_wiki::run_pipeline(
Some(&config_path),
None,
false,
&root,
&code_repo_wiki::GenerationMode::Incremental { watch_paths: vec![], change_kind: None },
)
.expect("增量生成失败");
let titles: Vec<String> = inc.documents.iter().map(|d| d.title.clone()).collect();
assert!(
titles_in_group(&titles, 5, 9),
"组 B 模块文档应全部重生成,实际: {titles:?}"
);
for (lo, hi, label) in [(0, 4, "组 A"), (10, 14, "组 C")] {
assert!(
!titles_in_group(&titles, lo, hi),
"{label} 与变更点无边连通,不应被重生成,实际: {titles:?}"
);
}
let after_pages = wiki_pages_snapshot(&repo);
let after_names = page_names(&after_pages);
assert_eq!(
after_names, base_names,
"改签名场景页面集合必须与基线一致(无页面增删)"
);
for name in &after_names {
let is_group_b = (5..=9).any(|m| name.contains(&format!("m{m:02}")));
let is_synthetic = ["api.md", "architecture.md", "index.md", "overview.md"]
.iter().any(|s| name == s);
if is_group_b || is_synthetic {
continue;
}
assert_eq!(
after_pages.get(name),
base_pages.get(name),
"未受影响模块页 {name} 必须零改写(内容字节一致)"
);
}
let new_api = std::fs::read_to_string(repo.join(".code-repo-wiki").join("wiki").join("zh").join("api.md"))
.unwrap_or_default();
assert!(
new_api.contains("f00_renamed"),
"api.md 应反映新签名 f00_renamed,实际: {new_api}"
);
let m07_section = new_api.split("## src::m07").nth(1).unwrap_or_default();
assert!(
!m07_section.contains("f00_0"),
"api.md m07 段不应残留旧签名 f00_0,实际: {m07_section}"
);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn test_large_fixture_delete_file_keeps_pages() {
let repo = std::env::temp_dir().join(format!("code_repo_wiki_large_del_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&repo);
std::fs::create_dir_all(&repo).expect("构造临时仓库失败");
build_large_repo(&repo).expect("构造 150 文件 fixture 失败");
let root = code_repo_wiki::project::ProjectRoot::new(repo.clone());
let config_path = repo.join("config.toml");
git_commit_all(&repo, "init 150 files");
code_repo_wiki::run_pipeline(Some(&config_path), None, false, &root, &code_repo_wiki::GenerationMode::Full)
.expect("全量生成失败");
let base_pages = wiki_pages_snapshot(&repo);
let base_names = page_names(&base_pages);
assert!(base_names.contains(&"src_m09_f07.md".to_string()), "基线应含 m09/f07 页");
std::fs::remove_file(repo.join("src").join("m09").join("f07.rs")).unwrap();
git_commit_all(&repo, "delete m09 f07");
code_repo_wiki::run_pipeline(
Some(&config_path),
None,
false,
&root,
&code_repo_wiki::GenerationMode::Incremental { watch_paths: vec![], change_kind: None },
)
.expect("删除增量失败");
let after_pages = wiki_pages_snapshot(&repo);
let after_names = page_names(&after_pages);
assert!(
!after_names.contains(&"src_m09_f07.md".to_string()),
"已删文件页应被清理(deleted_modules 判定)"
);
assert_eq!(
after_names.len(),
base_names.len() - 1,
"仅 f07 页应消失,其余全部保留;基线 {} 页 → 现在 {} 页",
base_names.len(),
after_names.len()
);
for name in &after_names {
let is_synthetic = ["api.md", "architecture.md", "index.md", "overview.md"]
.iter().any(|s| name == s);
if is_synthetic {
continue;
}
assert_eq!(
after_pages.get(name),
base_pages.get(name),
"页面 {name} 必须零改写(回填语义)"
);
}
let _ = std::fs::remove_dir_all(&repo);
}
fn build_pair_module_repo(repo: &Path) -> anyhow::Result<()> {
std::fs::write(repo.join(".gitignore"), ".code-repo-wiki/\nAGENTS.md\n")?;
std::fs::create_dir_all(repo.join("src").join("m20"))?;
std::fs::write(
repo.join("src").join("m20").join("a.rs"),
"pub fn a_alpha() -> u32 { 1 }\npub fn a_uses_b() -> u32 { b_beta() }\n",
)?;
std::fs::write(
repo.join("src").join("m20").join("b.rs"),
"pub fn b_beta() -> u32 { 2 }\npub fn b_uses_a() -> u32 { a_alpha() }\n",
)?;
std::fs::write(repo.join("src").join("solo.rs"), "pub fn solo_fn() -> u32 { 3 }\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)?)?;
let git = git2::Repository::init(repo)?;
let mut cfg = git.config()?;
cfg.set_str("user.name", "test")?;
cfg.set_str("user.email", "test@test.com")?;
Ok(())
}
#[test]
fn test_delete_one_file_in_pair_module_regenerates_module() {
let repo = std::env::temp_dir().join(format!("code_repo_wiki_pair_del_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&repo);
std::fs::create_dir_all(&repo).expect("构造临时仓库失败");
build_pair_module_repo(&repo).expect("构造双文件模块 fixture 失败");
let root = code_repo_wiki::project::ProjectRoot::new(repo.clone());
let config_path = repo.join("config.toml");
git_commit_all(&repo, "init pair module");
code_repo_wiki::run_pipeline(Some(&config_path), None, false, &root, &code_repo_wiki::GenerationMode::Full)
.expect("全量生成失败");
let base_pages = wiki_pages_snapshot(&repo);
assert!(
base_pages.keys().any(|k| k.starts_with("src_m20")),
"基线应含 m20 相关页,实际: {base_pages:?}"
);
std::fs::remove_file(repo.join("src").join("m20").join("a.rs")).unwrap();
git_commit_all(&repo, "delete m20/a.rs");
let inc = code_repo_wiki::run_pipeline(
Some(&config_path),
None,
false,
&root,
&code_repo_wiki::GenerationMode::Incremental { watch_paths: vec![], change_kind: None },
)
.expect("删除增量失败");
let titles: Vec<String> = inc.documents.iter().map(|d| d.title.clone()).collect();
assert!(
titles.iter().any(|t| t.starts_with("src::m20")),
"部分删除模块必须重生成(清除被删实体残留),实际: {titles:?}"
);
assert!(
!titles.iter().any(|t| t.contains("solo")),
"未受影响模块不得被重生成或回填,实际: {titles:?}"
);
assert!(
titles.len() <= 5,
"documents 数量应 ≤5(模块 + 3 全局),实际 {}: {titles:?}",
titles.len()
);
for key in ["架构概览", "项目概览", "index"] {
assert!(
titles.iter().any(|t| t.contains(key)),
"全局文档 {key} 应重生成(删除场景不放行全局文档回填),实际: {titles:?}"
);
}
let after_pages = wiki_pages_snapshot(&repo);
assert!(
after_pages.keys().any(|k| k.starts_with("src_m20")),
"m20 模块页必须保留,实际: {after_pages:?}"
);
let solo_page = "src.md".to_string();
assert!(after_pages.contains_key(&solo_page), "solo 页必须保留,实际页面: {after_pages:?}");
assert_eq!(
after_pages.get(&solo_page),
base_pages.get(&solo_page),
"solo 未受影响页必须零改写"
);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn test_delete_file_mixed_with_modification_regenerates_module() {
let repo = std::env::temp_dir().join(format!("code_repo_wiki_pair_mixed_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&repo);
std::fs::create_dir_all(&repo).expect("构造临时仓库失败");
build_pair_module_repo(&repo).expect("构造双文件模块 fixture 失败");
let root = code_repo_wiki::project::ProjectRoot::new(repo.clone());
let config_path = repo.join("config.toml");
git_commit_all(&repo, "init pair module");
code_repo_wiki::run_pipeline(Some(&config_path), None, false, &root, &code_repo_wiki::GenerationMode::Full)
.expect("全量生成失败");
let base_pages = wiki_pages_snapshot(&repo);
assert!(
base_pages.keys().any(|k| k.starts_with("src_m20")),
"基线应含 m20 模块页,实际: {base_pages:?}"
);
let base_api =
std::fs::read_to_string(repo.join(".code-repo-wiki").join("wiki").join("zh").join("api.md"))
.unwrap_or_default();
assert!(
base_api.contains("solo_fn"),
"基线 api.md 应含 solo_fn 实体,实际: {base_api}"
);
std::fs::remove_file(repo.join("src").join("m20").join("a.rs")).unwrap();
std::fs::write(
repo.join("src").join("solo.rs"),
"pub fn solo_renamed(x: u32, y: u32) -> u32 { x + y }\n",
)
.unwrap();
git_commit_all(&repo, "delete m20/a.rs + modify solo.rs");
let inc = code_repo_wiki::run_pipeline(
Some(&config_path),
None,
false,
&root,
&code_repo_wiki::GenerationMode::Incremental { watch_paths: vec![], change_kind: None },
)
.expect("删除+修改增量失败");
let titles: Vec<String> = inc.documents.iter().map(|d| d.title.clone()).collect();
assert!(
titles.iter().any(|t| t.starts_with("src::m20")),
"mixed 场景部分删除模块必须重生成(清除被删实体残留),实际: {titles:?}"
);
assert!(
titles.iter().any(|t| t == "src"),
"modified 文件所属模块(src)应重生成,实际: {titles:?}"
);
let after_pages = wiki_pages_snapshot(&repo);
let m20_page = after_pages
.iter()
.find(|(k, _)| k.starts_with("src_m20"))
.map(|(k, v)| (k.clone(), v.clone()))
.expect("m20 模块页必须保留");
assert!(
!m20_page.1.contains("a_alpha"),
"m20 模块页不得残留被删实体 a_alpha(缺陷: 旧页未重生成),内容: {}",
m20_page.1
);
let new_api = std::fs::read_to_string(repo.join(".code-repo-wiki").join("wiki").join("zh").join("api.md"))
.unwrap_or_default();
assert!(
!new_api.contains("a_alpha"),
"api.md 不应残留被删实体 a_alpha,实际: {new_api}"
);
assert!(
new_api.contains("solo_renamed"),
"api.md 应反映 solo.rs 新签名 solo_renamed,实际: {new_api}"
);
let _ = std::fs::remove_dir_all(&repo);
}