use std::path::{Path, PathBuf};
use std::process::Command;
mod common;
use common::{copy_dir, mock_config, run_bin, run_bin_with_envs, unique_dir};
fn prepare_repo(tag: &str) -> PathBuf {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("sample-repo");
let work_dir = unique_dir(tag);
let _ = std::fs::remove_dir_all(&work_dir);
copy_dir(&fixture, &work_dir);
std::fs::write(work_dir.join("config.toml"), mock_config()).unwrap();
work_dir
}
#[test]
fn test_status_reports_ready() {
let work_dir = prepare_repo("status");
let out = run_bin(&work_dir, &["status", "-c", "config.toml"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"status 应成功退出,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
stdout.contains("Wiki 状态: 未生成"),
"无产物时应提示未生成,实际 stdout: {stdout}"
);
assert!(
stdout.contains("配置文件: config.toml"),
"应输出配置文件路径,实际 stdout: {stdout}"
);
let out = run_bin(&work_dir, &["generate", "-c", "config.toml"]);
assert!(
out.status.success(),
"generate 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = run_bin(&work_dir, &["status", "-c", "config.toml"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"generate 后 status 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
stdout.contains("Wiki 状态: 就绪"),
"应输出就绪状态行,实际 stdout: {stdout}"
);
assert!(
stdout.contains("配置文件: config.toml"),
"应输出配置文件路径,实际 stdout: {stdout}"
);
assert!(
stdout.contains("页面:") && stdout.contains("卡片:"),
"应输出页面/卡片统计,实际 stdout: {stdout}"
);
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_note_appends_karpathy_log() {
let work_dir = prepare_repo("note");
let out = run_bin(&work_dir, &["note", "第一条测试记录", "-c", "config.toml"]);
assert!(
out.status.success(),
"note 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let log_path = work_dir.join(".code-repo-wiki").join("wiki").join("zh").join("_log.md");
let log = std::fs::read_to_string(&log_path)
.unwrap_or_else(|e| panic!("_log.md 应存在 {}: {}", log_path.display(), e));
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
assert!(
log.contains(&format!("## {today}")),
"应含当天日期节,实际: {log}"
);
assert!(
log.contains("- 1. 第一条测试记录"),
"第一条应编号 1,实际: {log}"
);
let out = run_bin(&work_dir, &["note", "第二条测试记录", "-c", "config.toml"]);
assert!(
out.status.success(),
"第二条 note 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let log = std::fs::read_to_string(&log_path).unwrap();
assert!(
log.contains("- 2. 第二条测试记录"),
"第二条应编号 2,实际: {log}"
);
assert_eq!(
log.matches(&format!("## {today}")).count(),
1,
"同一日期节不应重复,实际: {log}"
);
assert_eq!(
log.lines().filter(|l| l.trim_start().starts_with("- ")).count(),
2,
"应恰好 2 条记录,实际: {log}"
);
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_install_ensures_user_default_config() {
let work_dir = unique_dir("install");
let _ = std::fs::remove_dir_all(&work_dir);
std::fs::create_dir_all(&work_dir).unwrap();
let home = unique_dir("install_home");
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
let envs = [
("APPDATA", home.to_string_lossy().into_owned()),
("USERPROFILE", home.to_string_lossy().into_owned()),
("HOME", home.to_string_lossy().into_owned()),
];
let envs_ref: Vec<(&str, &str)> = envs.iter().map(|(k, v)| (*k, v.as_str())).collect();
let out = run_bin_with_envs(&work_dir, &["install"], &envs_ref);
assert!(
out.status.success(),
"install 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let cfg_path = home.join(".code-repo-wiki").join("config.toml");
let content = std::fs::read_to_string(&cfg_path)
.unwrap_or_else(|e| panic!("用户级配置应存在 {}: {}", cfg_path.display(), e));
for section in ["[wiki]", "[llm]", "[embed]"] {
assert!(content.contains(section), "默认配置应含 {section} 段,实际:\n{content}");
}
assert!(!content.contains("[output]"), "默认配置不应含 [output](已硬编码),实际:\n{content}");
assert!(!content.contains("[incremental]"), "默认配置不应含 [incremental](已硬编码),实际:\n{content}");
assert!(!content.contains("[search]"), "默认配置不应含 [search](已硬编码),实际:\n{content}");
assert!(!content.contains("[plan]"), "默认配置不应含 [plan](已删除),实际:\n{content}");
assert!(
!work_dir.join("config.toml").exists(),
"install 不得在项目级自动创建 config.toml"
);
assert!(!work_dir.join(".code-repo-wiki.toml").exists(), "旧文件名已停用");
let _ = std::fs::remove_dir_all(&work_dir);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn test_sync_merges_manual_edit_into_state() {
let work_dir = prepare_repo("sync");
let out = run_bin(&work_dir, &["generate", "-c", "config.toml"]);
assert!(
out.status.success(),
"generate 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let wiki_zh = work_dir.join(".code-repo-wiki").join("wiki").join("zh");
let page = std::fs::read_dir(&wiki_zh)
.unwrap_or_else(|e| panic!("wiki/zh 目录应存在 {}: {}", wiki_zh.display(), e))
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "md"))
.min()
.expect("generate 后 wiki/zh 下应有页面文件");
let state_path = work_dir.join(".code-repo-wiki").join(".state").join("generation_state.json");
let state_before = std::fs::read_to_string(&state_path)
.unwrap_or_else(|e| panic!("generate 应写状态文件 {}: {}", state_path.display(), e));
let before: serde_json::Value = serde_json::from_str(&state_before).unwrap();
let key = page.to_string_lossy().to_string();
let fp_before = before["doc_fingerprints"][&key].as_str()
.unwrap_or_else(|| panic!("状态应含文档指纹 {key}: {state_before}"));
let manual = "# 手工编辑标记\n\nsync 后应保留此内容\n";
std::fs::write(&page, manual).unwrap();
let out = run_bin(&work_dir, &["sync", "-c", "config.toml"]);
assert!(
out.status.success(),
"sync 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
std::fs::read_to_string(&page).unwrap(),
manual,
"sync 后手工修改内容应原样保留"
);
let state_after = std::fs::read_to_string(&state_path).unwrap();
let after: serde_json::Value = serde_json::from_str(&state_after).unwrap();
let fp_after = after["doc_fingerprints"][&key].as_str()
.unwrap_or_else(|| panic!("sync 后状态应含文档指纹 {key}: {state_after}"));
assert_ne!(fp_before, fp_after, "sync 应更新 {key} 的指纹(工作区内容为准)");
let expected = code_repo_wiki::incremental::state::GenerationState::compute_file_fingerprint(&page).unwrap();
assert_eq!(fp_after, expected, "指纹应等于修改后文件内容 SHA256");
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_search_text_engine_json() {
let work_dir = prepare_repo("search_text");
let out = run_bin(&work_dir, &["generate", "-c", "config.toml"]);
assert!(
out.status.success(),
"generate 应成功(构建文本索引),stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
work_dir.join(".code-repo-wiki").join(".search").join("text_index.db").exists(),
"文本索引应生成"
);
let out = run_bin(
&work_dir,
&["search", "-q", "authenticate", "-k", "3", "-e", "text", "--json", "-c", "config.toml"],
);
assert!(
out.status.success(),
"search text 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let hits: Vec<serde_json::Value> = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("应输出合法 JSON: {e}\n实际: {stdout}"));
assert!(!hits.is_empty(), "应至少一个命中,实际: {stdout}");
let auth_hit = hits.iter().find(|h| {
h.get("name")
.and_then(|n| n.as_str())
.is_some_and(|n| n == "authenticate")
});
assert!(auth_hit.is_some(), "应命中 authenticate,实际: {stdout}");
assert!(
auth_hit.unwrap().get("file").and_then(|f| f.as_str()).is_some_and(|f| f.contains("auth.rs")),
"命中应定位到 auth.rs,实际: {stdout}"
);
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_search_top_k_falls_back_to_config() {
let work_dir = prepare_repo("search_topk");
let out = run_bin(&work_dir, &["generate", "-c", "config.toml"]);
assert!(
out.status.success(),
"generate 应成功(构建文本索引),stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = run_bin(
&work_dir,
&["search", "-q", "pub", "-e", "text", "--json", "-c", "config.toml"],
);
assert!(
out.status.success(),
"search 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let hits: Vec<serde_json::Value> = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
.unwrap_or_else(|e| panic!("应输出合法 JSON: {e}\n实际: {}", String::from_utf8_lossy(&out.stdout)));
assert!(!hits.is_empty(), "应至少一个命中,实际: {:?}", hits);
assert!(hits.len() <= 10, "未传 -k 应回退硬编码默认 10,实际 {} 条", hits.len());
let out = run_bin(
&work_dir,
&["search", "-q", "pub", "-k", "5", "-e", "text", "--json", "-c", "config.toml"],
);
let hits5: Vec<serde_json::Value> = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
assert!(hits5.len() > 3, "显式 -k 5 应返回 5 条(候选多于 3),实际 {} 条", hits5.len());
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_search_semantic_without_embed_errors() {
let work_dir = prepare_repo("search_semantic");
let out = run_bin(&work_dir, &["generate", "-c", "config.toml"]);
assert!(
out.status.success(),
"generate 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let semantic_index = work_dir.join(".code-repo-wiki").join(".search").join("semantic_index.db");
let _ = std::fs::remove_file(&semantic_index);
let out = run_bin(
&work_dir,
&["search", "-q", "authenticate", "-e", "semantic", "--json", "-c", "config.toml"],
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
!out.status.success(),
"语义索引缺失时应显式失败,输出: {combined}"
);
assert!(
combined.contains("语义索引不存在"),
"应提示语义索引缺失并指引配置嵌入 key,实际: {combined}"
);
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_search_and_status_show_semantic_degraded_hint() {
let work_dir = prepare_repo("sem_degraded_hint");
let out = run_bin(&work_dir, &["generate", "-c", "config.toml"]);
assert!(
out.status.success(),
"generate 应成功(mock embed 降级不中断主流程),stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let marker = work_dir.join(".code-repo-wiki").join(".search").join("semantic_degraded");
assert!(
marker.exists(),
"mock embed 批量失败应写入降级标记: {}",
marker.display()
);
let out = run_bin(&work_dir, &["search", "-q", "authenticate", "-e", "text", "-c", "config.toml"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("语义索引已降级(原因: "),
"search 表格模式应提示语义索引降级并携带原因前缀,实际: {stdout}"
);
let out = run_bin(&work_dir, &["status", "-c", "config.toml"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("语义索引: 已降级"),
"status 应标注语义索引降级,实际: {stdout}"
);
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_mock_footer_marks_placeholder_pages() {
let work_dir = prepare_repo("mock_footer");
let out = run_bin(&work_dir, &["generate", "-c", "config.toml"]);
assert!(
out.status.success(),
"generate 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let wiki_dir = work_dir.join(".code-repo-wiki").join("wiki").join("zh");
let mut found = 0;
for entry in std::fs::read_dir(&wiki_dir).unwrap() {
let entry = entry.unwrap();
if !entry.file_type().unwrap().is_file() {
continue;
}
let content = std::fs::read_to_string(entry.path()).unwrap();
assert!(
content.contains("<!-- 本页由 mock provider 生成,非真实内容 -->"),
"产物页应有 mock 占位页脚: {}",
entry.path().display()
);
found += 1;
}
assert!(found > 0, "应至少有一个产物页面");
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_lint_three_state_exit_codes() {
let work_dir = prepare_repo("lint_exit");
let out = run_bin(&work_dir, &["lint", "-c", "config.toml"]);
assert_eq!(
out.status.code(),
Some(0),
"空产物应视为干净(0),stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = run_bin(&work_dir, &["generate", "-c", "config.toml"]);
assert!(out.status.success(), "generate 应成功");
let out = run_bin(&work_dir, &["lint", "-c", "config.toml"]);
assert_eq!(out.status.code(), Some(0), "干净产物应为 0: {}", String::from_utf8_lossy(&out.stdout));
let orphan = work_dir.join(".code-repo-wiki").join("wiki").join("zh").join("orphan.md");
std::fs::create_dir_all(orphan.parent().unwrap()).unwrap();
std::fs::write(&orphan, "# 孤儿页\n").unwrap();
let out = run_bin(&work_dir, &["lint", "-c", "config.toml"]);
assert_eq!(
out.status.code(),
Some(1),
"孤儿页应报问题(1),stdout: {}",
String::from_utf8_lossy(&out.stdout)
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("orphan.md"),
"应指出孤儿页路径: {}",
String::from_utf8_lossy(&out.stdout)
);
let out = run_bin(&work_dir, &["lint", "-c", "missing-config.toml"]);
assert_eq!(
out.status.code(),
Some(2),
"配置失败应为 2,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_update_dry_run_lists_changes_without_generating() {
let work_dir = prepare_repo("dry_run");
let out = run_bin(&work_dir, &["generate", "-c", "config.toml"]);
assert!(out.status.success(), "generate 应成功");
let wiki_dir = work_dir.join(".code-repo-wiki").join("wiki").join("zh");
let snapshot: Vec<(String, String)> = std::fs::read_dir(&wiki_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().unwrap().is_file())
.map(|e| {
(
e.file_name().to_string_lossy().into_owned(),
std::fs::read_to_string(e.path()).unwrap(),
)
})
.collect();
let src_file = work_dir.join("src").join("lib.rs");
std::fs::write(&src_file, "// 变更\npub fn touched() {}\n").unwrap();
let out = run_bin(&work_dir, &["update", "-c", "config.toml", "--dry-run"]);
assert!(out.status.success(), "--dry-run 应成功: {}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("--dry-run:"), "应输出预览头: {stdout}");
assert!(stdout.contains("未执行生成"), "应声明未执行生成: {stdout}");
assert!(stdout.contains("个文件变更"), "应报告变更文件数: {stdout}");
let after: Vec<(String, String)> = std::fs::read_dir(&wiki_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().unwrap().is_file())
.map(|e| {
(
e.file_name().to_string_lossy().into_owned(),
std::fs::read_to_string(e.path()).unwrap(),
)
})
.collect();
assert_eq!(snapshot, after, "--dry-run 不得改写任何产物");
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_update_command_smoke() {
let work_dir = prepare_repo("update_smoke");
std::fs::create_dir_all(work_dir.join("src")).unwrap();
std::fs::write(work_dir.join("src").join("extra.rs"), "pub fn extra() -> u32 { 7 }\n").unwrap();
let out = run_bin(&work_dir, &["generate", "-c", "config.toml"]);
assert!(
out.status.success(),
"generate 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
std::fs::write(work_dir.join("src").join("extra.rs"), "pub fn extra() -> u32 { 8 }\n").unwrap();
let out = run_bin(&work_dir, &["update", "-c", "config.toml"]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.status.success(),
"update 应成功退出,输出: {combined}"
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("✓ 增量更新完成"),
"update 真实执行应打印完成摘要,stdout: {}",
String::from_utf8_lossy(&out.stdout)
);
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_watch_command_detects_change() {
use std::io::{BufRead, BufReader};
use std::process::Stdio;
use std::time::{Duration, Instant};
let work_dir = prepare_repo("watch_smoke");
std::fs::create_dir_all(work_dir.join("src")).unwrap();
let extra = work_dir.join("src").join("extra.rs");
std::fs::write(&extra, "pub fn extra() -> u32 { 7 }\n").unwrap();
let out = run_bin(&work_dir, &["generate", "-c", "config.toml"]);
assert!(out.status.success(), "generate 应成功");
assert!(
work_dir.join(".code-repo-wiki").join("wiki").join("zh").join("api.md").exists(),
"基线产物应存在"
);
let mut child = Command::new(env!("CARGO_BIN_EXE_code-repo-wiki"))
.args(["watch", "-c", "config.toml"])
.current_dir(&work_dir)
.env("RUST_LOG", "off")
.env_remove("OPENAI_API_KEY")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("启动 watch 失败");
let mut stderr_reader = BufReader::new(child.stderr.take().unwrap());
let stderr_lines = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let lines_for_reader = stderr_lines.clone();
let drain_thread = std::thread::spawn(move || {
let mut line = String::new();
while let Ok(n) = stderr_reader.read_line(&mut line) {
if n == 0 { break; }
lines_for_reader.lock().unwrap().push(line.trim().to_string());
line.clear();
}
});
let state_file = work_dir.join(".code-repo-wiki").join(".state").join("generation_state.json");
let baseline_state_mtime = std::fs::metadata(&state_file).and_then(|m| m.modified()).ok();
let ready_deadline = Instant::now() + Duration::from_secs(30);
let mut state_updated = false;
while Instant::now() < ready_deadline {
std::thread::sleep(Duration::from_millis(200));
if let Ok(m) = std::fs::metadata(&state_file).and_then(|m| m.modified())
&& baseline_state_mtime.map(|b| m > b).unwrap_or(false)
{
state_updated = true;
break;
}
}
assert!(state_updated, "watch 首次全量应更新状态文件(监听就绪信号),stderr: {:?}", *stderr_lines.lock().unwrap());
std::thread::sleep(Duration::from_millis(300));
std::fs::write(&extra, "pub fn extra() -> u32 { 9 }\n").unwrap();
let before = std::fs::metadata(work_dir.join(".code-repo-wiki").join("wiki").join("zh").join("api.md"))
.and_then(|m| m.modified())
.ok();
let deadline = Instant::now() + Duration::from_secs(8);
let mut detected = false;
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(100));
if let Ok(m) = std::fs::metadata(work_dir.join(".code-repo-wiki").join("wiki").join("zh").join("api.md"))
.and_then(|m| m.modified())
&& before.map(|b| m > b + Duration::from_millis(500)).unwrap_or(false)
{
detected = true;
break;
}
}
assert!(detected, "watch 应检测到文件变更并触发增量更新,watch stderr: {:?}", *stderr_lines.lock().unwrap());
let _ = child.kill();
let _ = child.wait();
drain_thread.join().unwrap();
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_root_missing_dir_errors() {
let work_dir = prepare_repo("root_missing");
let missing = work_dir.join("no-such-dir");
let out = run_bin(&work_dir, &["generate", "-c", "config.toml", "--root", missing.to_str().unwrap()]);
assert!(
!out.status.success(),
"--root 不存在应非 0 退出码, stdout: {}",
String::from_utf8_lossy(&out.stdout)
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(combined.contains("目录不存在"), "应报目录不存在, 实际: {combined}");
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_default_config_chain_prefers_project_config() {
let work_dir = unique_dir("e-chain");
let _ = std::fs::remove_dir_all(&work_dir);
std::fs::create_dir_all(&work_dir).unwrap();
std::fs::write(work_dir.join("config.toml"), mock_config()).unwrap();
let out = run_bin(&work_dir, &["status"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"status 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
stdout.replace('\\', "/").contains("config.toml"),
"默认链应命中项目级配置,实际: {stdout}"
);
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_incremental_update_keeps_unaffected_module_pages() {
let work_dir = prepare_repo("incr_preserve");
init_git(&work_dir, "init");
let out = run_bin(&work_dir, &["generate", "-c", "config.toml"]);
assert!(
out.status.success(),
"generate 应成功: {}",
String::from_utf8_lossy(&out.stderr)
);
let pages_before = collect_page_stems(&work_dir);
assert!(pages_before.len() >= 3, "应有全部模块页: {pages_before:?}");
let auth = work_dir.join("src").join("auth.rs");
let content = std::fs::read_to_string(&auth).unwrap();
std::fs::write(
&auth,
content.replace(
"if username == \"admin\" && password == \"secret\" {",
"if username == \"admin\" && password == \"secret\" {\n let _debug = 0; // 实现级变更",
),
)
.unwrap();
commit_all(&work_dir, "change auth");
let out = run_bin(&work_dir, &["update", "-c", "config.toml"]);
assert!(
out.status.success(),
"增量 update 应成功: {}",
String::from_utf8_lossy(&out.stderr)
);
let pages_after = collect_page_stems(&work_dir);
for p in &pages_before {
assert!(
pages_after.contains(p),
"增量更新误删了未受影响模块的页面: {p}"
);
}
let _ = std::fs::remove_dir_all(&work_dir);
}
fn collect_page_stems(work_dir: &Path) -> std::collections::HashSet<String> {
std::fs::read_dir(work_dir.join(".code-repo-wiki").join("wiki").join("zh"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().unwrap().is_file())
.map(|e| {
e.path()
.file_stem()
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect()
}
fn init_git(dir: &Path, message: &str) {
let repo = git2::Repository::init(dir).unwrap();
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "test").unwrap();
cfg.set_str("user.email", "test@test.com").unwrap();
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();
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[]).unwrap();
}
fn commit_all(dir: &Path, message: &str) {
let repo = git2::Repository::open(dir).unwrap();
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 parent = repo.head().unwrap().peel_to_commit().unwrap();
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])
.unwrap();
}
#[test]
fn test_update_noop_stdout_contract() {
let work_dir = prepare_repo("noop_stdout");
init_git(&work_dir, "init");
let out = run_bin(&work_dir, &["generate", "-c", "config.toml"]);
assert!(
out.status.success(),
"generate 应成功: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = run_bin(&work_dir, &["update", "-c", "config.toml"]);
assert!(out.status.success(), "no-op update 应成功退出");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("无文件变更,跳过更新(no-op)"),
"stdout 必须包含跳过消息: {stdout:?}"
);
assert!(
!stdout.contains("增量更新完成"),
"no-op 不得打印「增量更新完成」: {stdout:?}"
);
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_generate_warns_when_agents_md_exists() {
let work_dir = prepare_repo("agents_md_warn");
let agents = work_dir.join("AGENTS.md");
std::fs::write(&agents, "# 人工维护的 AGENTS.md\n\n自定义内容\n").unwrap();
let out = run_bin_with_envs(&work_dir, &["generate", "-c", "config.toml"], &[("RUST_LOG", "warn")]);
assert!(
out.status.success(),
"generate 应成功: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("跳过注入"),
"已存在 AGENTS.md 时必须 warn 提示: {stderr:?}"
);
assert!(
stderr.contains("code-repo-wiki install"),
"warn 必须给出补救路径: {stderr:?}"
);
let content = std::fs::read_to_string(&agents).unwrap();
assert!(content.contains("自定义内容"), "人工 AGENTS.md 被覆盖: {content}");
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_doctor_reports_and_exits() {
let work_dir = prepare_repo("doctor");
let out = run_bin(&work_dir, &["doctor", "-c", "config.toml"]);
assert!(
out.status.success(),
"mock 配置 doctor 应全过,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
for name in ["配置", "产物目录可写", "输出目录", "LLM Key", "网络"] {
assert!(stdout.contains(name), "应输出检查项 {name}: {stdout}");
}
assert!(stdout.contains("mock provider:跳过网络检查"), "应标注网络跳过: {stdout}");
let out = run_bin(&work_dir, &["doctor", "-c", "nope.toml"]);
assert_eq!(
out.status.code(),
Some(1),
"配置缺失应退出码 1,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_bench_manifest_smoke() {
let base = unique_dir("benchman");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
for name in ["repo-a", "repo-b"] {
let r = base.join(name);
std::fs::create_dir_all(r.join("src")).unwrap();
std::fs::write(r.join("src").join("main.rs"), "pub fn alpha() {}\n").unwrap();
}
let manifest_path = base.join("manifest.txt");
std::fs::write(
&manifest_path,
format!(
"{}\n{}\n{}\n",
base.join("repo-a").display(),
base.join("repo-b").display(),
base.join("missing-repo").display()
),
)
.unwrap();
let config_path = base.join("config.toml");
std::fs::write(&config_path, mock_config()).unwrap();
let out = run_bin(
&base,
&[
"bench-manifest",
"--manifest",
manifest_path.to_str().unwrap(),
"--config",
config_path.to_str().unwrap(),
],
);
assert!(
out.status.success(),
"bench-manifest 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("清单跑分报告"), "应输出报告标题: {stdout}");
assert!(stdout.contains("| repo-a |"), "矩阵应含 repo-a 行: {stdout}");
assert!(stdout.contains("**失败**"), "缺失路径应标注失败: {stdout}");
let _ = std::fs::remove_dir_all(&base);
}