mod common;
use common::{copy_dir, mock_llm_server, openai_compatible_config, run_bin_with_envs, unique_dir};
use std::path::{Path, PathBuf};
fn minimal_config(port: u16) -> String {
openai_compatible_config(port)
}
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);
let port = mock_llm_server();
std::fs::write(
work_dir.join("mock-server.toml"),
minimal_config(port),
)
.unwrap();
work_dir
}
#[test]
fn test_uninstall_requires_force() {
let work_dir = unique_dir("uninstall");
let _ = std::fs::remove_dir_all(&work_dir);
std::fs::create_dir_all(&work_dir).unwrap();
let home_dir = unique_dir("uninstall_home");
let _ = std::fs::remove_dir_all(&home_dir);
std::fs::create_dir_all(&home_dir).unwrap();
let envs: &[(&str, &str)] = &[
("HOME", home_dir.to_str().unwrap()),
("USERPROFILE", home_dir.to_str().unwrap()),
];
let out = run_bin_with_envs(&work_dir, &["uninstall"], envs);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
!out.status.success(),
"无 --force 时应拒绝卸载,退出码: {:?}",
out.status.code()
);
assert!(
combined.contains("请添加 --force"),
"拒绝提示应包含 --force 指引,实际输出: {combined}"
);
assert!(combined.contains("卸载"), "拒绝提示应提及卸载");
let out = run_bin_with_envs(&work_dir, &["uninstall", "--force"], envs);
assert!(
out.status.success(),
"--force 应卸载成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!home_dir.join(".config").join("opencode").join("opencode.json").exists(),
"隔离 HOME 下卸载不应写入 opencode.json"
);
let _ = std::fs::remove_dir_all(&work_dir);
let _ = std::fs::remove_dir_all(&home_dir);
}
#[test]
fn test_progress_json_cli() {
let work_dir = prepare_repo("progress_json");
let out = run_bin_with_envs(&work_dir, &["generate", "--config", "mock-server.toml", "--progress-json"], &[]);
assert!(
out.status.success(),
"generate --progress-json 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let events: Vec<(String, u8)> = stdout
.lines()
.filter_map(|line| {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
Some((
v.get("stage")?.as_str()?.to_string(),
v.get("progress")?.as_u64()? as u8,
))
})
.collect();
assert!(!events.is_empty(), "应输出进度事件,实际 stdout: {stdout}");
assert_eq!(events.first().unwrap().0, "scanning", "首个事件应为 scanning");
for w in events.windows(2) {
assert!(
w[1].1 >= w[0].1,
"progress 应单调递增: {} ({}) -> {} ({})",
w[0].0, w[0].1, w[1].0, w[1].1
);
}
assert_eq!(events.last().unwrap().0, "done", "末个事件应为 done");
assert_eq!(events.last().unwrap().1, 100, "末个事件 progress 应为 100");
assert!(work_dir.join(".code-repo-wiki").is_dir(), "应生成 wiki 输出目录");
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_update_progress_json_cli() {
let work_dir = prepare_repo("update_progress_json");
let gen_out = run_bin_with_envs(&work_dir, &["generate", "--config", "mock-server.toml"], &[]);
assert!(
gen_out.status.success(),
"generate 应成功,stderr: {}",
String::from_utf8_lossy(&gen_out.stderr)
);
let src = work_dir.join("src").join("main.rs");
let mut content = std::fs::read_to_string(&src).unwrap();
content.push_str("\n// update --progress-json 触发注释\n");
std::fs::write(&src, content).unwrap();
let out = run_bin_with_envs(&work_dir, &["update", "--config", "mock-server.toml", "--progress-json"], &[]);
assert!(
out.status.success(),
"update --progress-json 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let events: Vec<(String, u8)> = stdout
.lines()
.filter_map(|line| {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
Some((
v.get("stage")?.as_str()?.to_string(),
v.get("progress")?.as_u64()? as u8,
))
})
.collect();
assert!(!events.is_empty(), "update 应输出进度事件,实际 stdout: {stdout}");
assert_eq!(events.last().unwrap().0, "done", "末个事件应为 done");
assert_eq!(events.last().unwrap().1, 100, "末个事件 progress 应为 100");
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_card_cli_commands() {
let work_dir = prepare_repo("card");
let out = run_bin_with_envs(&work_dir, &["generate", "--config", "mock-server.toml"], &[]);
assert!(
out.status.success(),
"generate 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let cards_dir = work_dir.join(".code-repo-wiki").join("cards").join("zh");
let card_file = std::fs::read_dir(&cards_dir)
.unwrap_or_else(|e| panic!("卡片目录应存在 {}: {}", cards_dir.display(), e))
.filter_map(|e| e.ok())
.map(|e| e.path())
.find(|p| p.extension().is_some_and(|x| x == "md"))
.expect("generate 后应有卡片文件");
let module = card_file
.file_stem()
.unwrap()
.to_string_lossy()
.replace('_', "::");
let before = std::fs::read_to_string(&card_file).unwrap();
assert!(!before.is_empty(), "卡片文件不应为空");
let out = run_bin_with_envs(
&work_dir,
&[
"card", "modify", &module,
"--instruction", "补充一段总结",
"--config", "mock-server.toml",
],
&[],
);
assert!(
out.status.success(),
"card modify 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let after = std::fs::read_to_string(&card_file).unwrap();
assert_ne!(before, after, "card modify 后卡片内容应发生变化");
assert!(
after.contains("Mock 生成的摘要"),
"修改后卡片应包含 LLM 响应内容,实际: {after}"
);
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_card_reference_validation() {
let work_dir = prepare_repo("card_ref");
let out = run_bin_with_envs(&work_dir, &["generate", "--config", "mock-server.toml"], &[]);
assert!(
out.status.success(),
"generate 应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let cards_dir = work_dir.join(".code-repo-wiki").join("cards").join("zh");
let card_file = std::fs::read_dir(&cards_dir)
.unwrap_or_else(|e| panic!("卡片目录应存在 {}: {}", cards_dir.display(), e))
.filter_map(|e| e.ok())
.map(|e| e.path())
.find(|p| p.extension().is_some_and(|x| x == "md"))
.expect("generate 后应有卡片文件");
let module = card_file
.file_stem()
.unwrap()
.to_string_lossy()
.replace('_', "::");
let before = std::fs::read_to_string(&card_file).unwrap();
let out = run_bin_with_envs(
&work_dir,
&[
"card", "modify", &module,
"--instruction", "补充总结",
"--reference", "missing.md",
"--config", "mock-server.toml",
],
&[],
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
!out.status.success(),
"reference 不存在时应显式失败,输出: {combined}"
);
assert!(
combined.contains("os error 2"),
"报错应包含缺失文件错误,实际: {combined}"
);
assert_eq!(
std::fs::read_to_string(&card_file).unwrap(),
before,
"失败路径不应改动卡片"
);
std::fs::write(work_dir.join("refs.md"), "参考材料:新增设计约束").unwrap();
let out = run_bin_with_envs(
&work_dir,
&[
"card", "modify", &module,
"--instruction", "补充总结",
"--reference", "refs.md",
"--config", "mock-server.toml",
],
&[],
);
assert!(
out.status.success(),
"reference 存在时应成功,stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let after = std::fs::read_to_string(&card_file).unwrap();
assert_ne!(before, after, "reference 存在时卡片应被更新");
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_export_produces_html_artifacts() {
let work_dir = prepare_repo("export");
let gen_out = run_bin_with_envs(
&work_dir,
&["generate", "--config", "mock-server.toml"],
&[],
);
assert!(
gen_out.status.success(),
"generate 应成功, stderr: {}",
String::from_utf8_lossy(&gen_out.stderr)
);
let out = run_bin_with_envs(
&work_dir,
&["export", "--config", "mock-server.toml"],
&[],
);
assert!(
out.status.success(),
"export 应成功, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let html_dir = work_dir.join(".code-repo-wiki");
let html_files: Vec<_> = std::fs::read_dir(&html_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|x| x == "html")
.unwrap_or(false)
})
.collect();
assert!(
!html_files.is_empty(),
"wiki/ 目录应包含 .html 导出文件: {}",
html_dir.display()
);
assert!(
work_dir.join(".code-repo-wiki").join("index.html").exists(),
"wiki/index.html(目录页)应生成"
);
assert!(
!html_files.is_empty(),
"html 目录应包含导出文件: {}",
html_dir.display()
);
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_search_hybrid_includes_callchain() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("sample-repo");
let work_dir = unique_dir("search_hybrid");
let _ = std::fs::remove_dir_all(&work_dir);
copy_dir(&fixture, &work_dir);
let port = mock_llm_server();
let cfg = openai_compatible_config(port);
std::fs::write(work_dir.join("mock-server.toml"), cfg).unwrap();
let gen_out = run_bin_with_envs(&work_dir, &["generate", "--config", "mock-server.toml"], &[]);
assert!(
gen_out.status.success(),
"generate 应成功, stderr: {}",
String::from_utf8_lossy(&gen_out.stderr)
);
let out = run_bin_with_envs(
&work_dir,
&["search", "-q", "authenticate", "-k", "3", "--engine", "hybrid", "--json", "--config", "mock-server.toml"],
&[],
);
assert!(
out.status.success(),
"hybrid 搜索应成功, 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}"));
let auth_hit = hits.iter().find(|h| {
h.get("name")
.and_then(|n| n.as_str())
.map(|n| n.contains("authenticate"))
.unwrap_or(false)
});
assert!(
auth_hit.is_some(),
"应命中 authenticate, 实际 hits: {stdout}"
);
let hit = auth_hit.unwrap();
let callers = hit.get("callers").and_then(|c| c.as_array()).cloned().unwrap_or_default();
assert!(
!callers.is_empty(),
"authenticate 的调用链补全应含调用者 main, 实际 callers: {callers:?} (hit: {hit})"
);
assert!(
hit.get("callees").is_some(),
"命中结果应含 callees 字段"
);
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_lint_detects_issues_in_artifacts() {
let work_dir = unique_dir("lint");
let _ = std::fs::remove_dir_all(&work_dir);
let wiki = work_dir.join(".code-repo-wiki").join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
std::fs::write(wiki.join("core.md"), "# Core\n\n模块页\n").unwrap();
std::fs::write(
work_dir.join(".code-repo-wiki").join("_toc.md"),
"# 目录\n\n- [Core](wiki/zh/core.md)\n",
)
.unwrap();
std::fs::write(
work_dir.join("mock-server.toml"),
r#"
[llm]
provider = "mock"
model = "mock"
base_url = "x"
api_key = "mock"
api_key_env = ""
max_concurrent = 1
"#,
)
.unwrap();
let clean = run_bin_with_envs(&work_dir, &["lint", "--config", "mock-server.toml"], &[]);
assert!(
clean.status.success(),
"干净产物 lint 应通过, stderr: {}",
String::from_utf8_lossy(&clean.stderr)
);
std::fs::write(
wiki.join("orphan_page.md"),
"# 孤儿页\n\n- [不存在](wiki/zh/missing.md)\n",
)
.unwrap();
let dirty = run_bin_with_envs(&work_dir, &["lint", "--config", "mock-server.toml"], &[]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&dirty.stdout),
String::from_utf8_lossy(&dirty.stderr)
);
assert!(
!dirty.status.success(),
"孤儿页+断链应导致 lint 失败, 输出: {combined}"
);
assert!(
combined.contains("orphan"),
"应报告孤儿页问题, 输出: {combined}"
);
assert!(
combined.contains("missing.md"),
"应报告断链问题, 输出: {combined}"
);
let _ = std::fs::remove_dir_all(&work_dir);
}
#[test]
fn test_ast_search_finds_definition() {
let work_dir = prepare_repo("ast_search");
let out = run_bin_with_envs(
&work_dir,
&["ast-search", "authenticate", "--config", "mock-server.toml"],
&[],
);
assert!(
out.status.success(),
"ast-search 应成功, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("pub fn authenticate"),
"应输出 authenticate 函数签名, 实际: {combined}"
);
assert!(
combined.contains("auth.rs"),
"应定位到 auth.rs, 实际: {combined}"
);
let miss = run_bin_with_envs(
&work_dir,
&["ast-search", "definitely_missing", "--config", "mock-server.toml"],
&[],
);
let miss_out = format!(
"{}{}",
String::from_utf8_lossy(&miss.stdout),
String::from_utf8_lossy(&miss.stderr)
);
assert!(
miss_out.contains("未找到符号"),
"缺失符号应提示, 实际: {miss_out}"
);
let _ = std::fs::remove_dir_all(&work_dir);
}