1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5use crate::config::schema::WikiConfig;
6use crate::incremental::state::GenerationState;
7use crate::output::lint::{lint, LintIssue};
8
9pub struct StatusReport {
11 pub ready: bool, pub wiki_pages: usize, pub cards: usize, pub issues: Vec<LintIssue>, pub config_path: String,
16}
17
18pub fn status_report(config: &WikiConfig, root: &crate::project::ProjectRoot) -> StatusReport {
22 let output_dir = config.output_dir();
23 let wiki_pages = collect_md_files(&output_dir.join("wiki")).len();
25 let cards = collect_md_files(&output_dir.join("cards")).len();
26 let issues = lint(
29 output_dir,
30 &source_roots(root),
31 );
32 StatusReport {
33 ready: wiki_pages > 0,
34 wiki_pages,
35 cards,
36 issues,
37 config_path: config.output_dir().to_string_lossy().into_owned(),
38 }
39}
40
41pub fn source_roots(root: &crate::project::ProjectRoot) -> Vec<PathBuf> {
46 vec![root.path().to_path_buf()]
47}
48
49pub fn sync_from_git(output_dir: &Path) -> Result<()> {
59 let state_dir = output_dir.join(".state");
60 let state_path = state_dir.join("generation_state.json");
61 let mut state = if !state_path.exists() {
65 GenerationState {
66 last_commit_hash: None,
67 file_fingerprints: HashMap::new(),
68 doc_fingerprints: HashMap::new(),
69 doc_modules: HashMap::new(),
70 protected_docs: Vec::new(),
71 generated_at: chrono::Utc::now().to_rfc3339(),
72 tool_version: None,
73 failed_modules: vec![],
74 }
75 } else {
76 GenerationState::load(&state_dir)
77 .with_context(|| format!("状态文件损坏,拒绝静默重置(保护信息会丢失): {}", state_path.display()))?
78 };
79
80 let mut updated = 0usize;
81 let mut skipped = 0usize;
82 for root in [output_dir.join("wiki"), output_dir.join("cards")] {
83 for path in collect_md_files(&root) {
84 let path_str = path.to_string_lossy().to_string();
85 if state.protected_docs.iter().any(|p| p == &path_str) {
86 tracing::warn!("跳过受保护页面(保留人工版): {}", path_str);
87 skipped += 1;
88 continue;
89 }
90 let fp = GenerationState::compute_file_fingerprint(&path)?;
91 if state.doc_fingerprints.get(&path_str) != Some(&fp) {
92 state.doc_fingerprints.insert(path_str, fp);
93 updated += 1;
94 tracing::info!("同步指纹(工作区内容为准): {}", path.display());
95 }
96 }
97 }
98
99 state.save(&state_dir)?;
100 tracing::info!("同步完成: 指纹更新 {} 个, 跳过受保护 {} 个", updated, skipped);
101 Ok(())
102}
103
104pub fn append_note(output_dir: &Path, language: &str, text: &str) -> Result<()> {
111 let text = text.trim();
112 if text.is_empty() {
113 anyhow::bail!("note 内容不能为空");
114 }
115 let log_dir = output_dir.join("wiki").join(language);
116 std::fs::create_dir_all(&log_dir)?;
117 let log_path = log_dir.join("_log.md");
118
119 let existing = std::fs::read_to_string(&log_path).unwrap_or_default();
121 let date = chrono::Local::now().format("%Y-%m-%d").to_string();
122 let today_header = format!("## {date}");
123 let today_seq = existing
125 .split(&today_header)
126 .nth(1)
127 .map(|after| {
128 after
129 .lines()
130 .filter(|l| l.trim().starts_with("- "))
131 .count()
132 })
133 .unwrap_or(0);
134
135 let entry = format!("- {}. {text}\n", today_seq + 1);
137 let mut append = String::new();
138 if !existing.contains(&today_header) {
139 if !existing.is_empty() && !existing.ends_with('\n') {
141 append.push('\n');
142 }
143 append.push_str(&today_header);
144 append.push('\n');
145 }
146 append.push_str(&entry);
147
148 let mut file = std::fs::OpenOptions::new()
149 .create(true)
150 .append(true)
151 .open(&log_path)?;
152 use std::io::Write;
153 file.write_all(append.as_bytes())?;
154 tracing::info!("知识记录已追加: {}", log_path.display());
155 Ok(())
156}
157
158fn collect_md_files(dir: &Path) -> Vec<PathBuf> {
160 let mut out = Vec::new();
161 let Ok(entries) = std::fs::read_dir(dir) else {
162 return out;
163 };
164 for entry in entries.flatten() {
165 let p = entry.path();
166 if p.is_dir() {
167 out.extend(collect_md_files(&p));
168 } else if p.extension().is_some_and(|e| e == "md") {
169 out.push(p);
170 }
171 }
172 out
173}
174
175#[derive(Debug, Clone, Copy, Default)]
180pub struct InstallOptions {
181 pub claude: bool,
187 pub codex: bool,
189}
190
191pub fn install(root: &crate::project::ProjectRoot, opts: &InstallOptions) -> Result<()> {
217 let project_root = root.path();
218
219 let exe_path = std::env::current_exe()
221 .context("无法定位当前可执行文件路径(集成无法绑定绝对路径)")?;
222 let exe_str = exe_path.to_string_lossy().into_owned();
223 let mcp_args = ["mcp".to_string()];
224
225 let mut oc = crate::config::opencode::OpenCodeConfig::new(root)
227 .context("读取 OpenCode 配置失败")?;
228 oc.install_plugin()?;
229 if oc.install_plugin_file()? {
230 println!("✓ OpenCode 插件已安装(用户级: ~/.config/opencode/plugins/)");
231 } else {
232 println!("✓ OpenCode 插件已是最新");
233 }
234
235 let opencode_mcp = crate::config::mcp::OpencodeMcp {
237 config_path: crate::config::mcp::OpencodeMcp::global_path()?,
238 };
239 if opencode_mcp.install("code-repo-wiki", &[exe_str.clone(), mcp_args[0].clone()])? {
240 println!("✓ OpenCode MCP 已注册(用户级全局)");
241 } else {
242 println!("✓ OpenCode MCP 已是最新");
243 }
244
245 if opts.claude {
247 let claude = crate::config::mcp::ClaudeMcp {
248 path: crate::config::mcp::ClaudeMcp::user_global_path()?,
249 };
250 if claude.install("code-repo-wiki", &exe_str, &mcp_args)? {
251 println!("✓ Claude Code MCP 已注册(用户级: ~/.claude.json)");
252 } else {
253 println!("✓ Claude Code MCP 已是最新(~/.claude.json)");
254 }
255 }
256
257 if opts.codex {
259 let codex = crate::config::mcp::CodexMcp {
260 config_path: crate::config::mcp::CodexMcp::global_path()?,
261 };
262 if codex.install("code-repo-wiki", &exe_str, &mcp_args)? {
263 println!("✓ Codex MCP 已注册(~/.codex/config.toml)");
264 } else {
265 println!("✓ Codex MCP 已是最新(~/.codex/config.toml)");
266 }
267 }
268
269 install_wiki(root, opts.claude)?;
272
273 let hooks_present = install_hooks(project_root)?;
275
276 println!("✓ code-repo-wiki 安装完成");
277 println!();
278 println!("日常使用(傻瓜式全自动,无需记忆命令):");
279 if hooks_present {
280 println!(" 1. git commit 后 wiki 自动增量更新(post-commit/post-merge hook 已配置)");
281 } else {
282 println!(
283 " 1. git commit 后 wiki 自动增量更新——hook 未安装(未检测到 .git 目录),"
284 );
285 println!(" 使用命令 2/3 手动/常驻更新");
286 }
287 println!(" 2. 手动一条命令:code-repo-wiki update(首次自动全量生成,之后自动增量;");
288 println!(" 无变更秒回,失败模块自动补偿重试,尾部自动 lint 复核)");
289 println!(" 3. 常驻实时模式:code-repo-wiki watch(代码保存即自动更新,Ctrl-C 退出)");
290 println!(" 4. 健康检查:code-repo-wiki doctor / code-repo-wiki lint");
291 Ok(())
292}
293
294pub const HOOK_MARKER: &str = "# code-repo-wiki: append-begin";
298
299pub const HOOK_END_MARKER: &str = "# code-repo-wiki: append-end";
301
302pub const LEGACY_HOOK_MARKER: &str = "# repo-wiki managed";
306
307fn hook_is_ours(content: &str) -> bool {
312 content.contains(HOOK_MARKER)
313 || content.contains(LEGACY_HOOK_MARKER)
314 || content.contains("auto-update wiki on commit")
315}
316
317fn hook_content() -> String {
326 format!(
327 "#!/bin/sh\n{0}: auto-update wiki on commit\ncd \"$(git rev-parse --show-toplevel)\"\ncommand -v code-repo-wiki >/dev/null 2>&1 || exit 0\nmkdir -p .code-repo-wiki\ncode-repo-wiki update 2>>.code-repo-wiki/update-error.log || echo \"code-repo-wiki: wiki 更新失败(详见 .code-repo-wiki/update-error.log)\" >&2\n",
328 HOOK_MARKER
329 )
330}
331
332fn hook_block() -> String {
336 format!(
337 "{0}\n# 自动更新 wiki(追加块,与仓库既有 hook 共存;用户 hook 若以 exit 结束,\n# 本块不会执行——post-commit 场景罕见,若需保证请移除既有 hook 后重装)\ncd \"$(git rev-parse --show-toplevel)\" 2>/dev/null || exit 0\ncommand -v code-repo-wiki >/dev/null 2>&1 || exit 0\nmkdir -p .code-repo-wiki 2>/dev/null || exit 0\ncode-repo-wiki update 2>>.code-repo-wiki/update-error.log || echo \"code-repo-wiki: wiki 更新失败(详见 .code-repo-wiki/update-error.log)\" >&2\n{1}\n",
338 HOOK_MARKER, HOOK_END_MARKER
339 )
340}
341
342fn strip_hook_block(content: &str) -> String {
345 let lines: Vec<&str> = content.lines().collect();
346 let begin = lines.iter().position(|l| l.trim() == HOOK_MARKER);
347 let end = lines.iter().position(|l| l.trim() == HOOK_END_MARKER);
348 match (begin, end) {
349 (Some(b), Some(e)) if b <= e => {
350 let mut kept: Vec<&str> = Vec::new();
351 for (i, line) in lines.iter().enumerate() {
352 if i < b || i > e {
353 kept.push(line);
354 }
355 }
356 kept.join("\n").trim().to_string()
357 }
358 _ => content.trim().to_string(),
360 }
361}
362
363fn append_hook_block(existing: &str, block: &str) -> String {
366 format!("{}\n\n{}", existing.trim_end(), block)
367}
368
369fn replace_hook_block(existing: &str, block: &str) -> String {
372 let stripped = strip_hook_block(existing);
373 format!("{}\n\n{}", stripped, block)
374}
375
376fn write_hook(path: &std::path::Path, content: &str) -> Result<()> {
378 crate::fs::write_file_atomic(path, content)?;
379 #[cfg(unix)]
380 std::fs::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(0o755))?;
381 Ok(())
382}
383
384fn detect_core_hooks_path(project_root: &std::path::Path) -> Option<String> {
388 let output = std::process::Command::new("git")
389 .args(["config", "--get", "core.hooksPath"])
390 .current_dir(project_root)
391 .output()
392 .ok()?;
393 if !output.status.success() {
394 return None;
395 }
396 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
397 if value.is_empty() {
398 None
399 } else {
400 Some(value)
401 }
402}
403
404fn install_hooks(project_root: &std::path::Path) -> Result<bool> {
418 let hooks_dir = project_root.join(".git").join("hooks");
419 if !hooks_dir.exists() {
420 println!("未检测到 .git 目录,跳过 git hook 安装");
421 return Ok(false);
422 }
423 if let Some(path) = detect_core_hooks_path(project_root) {
424 let hooks_dir_abs = hooks_dir.canonicalize().unwrap_or_else(|_| hooks_dir.clone());
427 let hooks_path_abs = std::path::Path::new(&path)
428 .canonicalize()
429 .unwrap_or_else(|_| std::path::PathBuf::from(&path));
430 if hooks_path_abs != hooks_dir_abs {
431 println!(
432 "? 检测到 git core.hooksPath = {path}——hook 将从该目录加载,写入 .git/hooks 不会生效(可移除该配置或将其指向 .git/hooks)"
433 );
434 }
435 }
436 let content = hook_content();
437 let block = hook_block();
438 for hook_name in &["post-commit", "post-merge"] {
439 let hook_path = hooks_dir.join(hook_name);
440 if hook_path.exists() {
441 let existing = std::fs::read_to_string(&hook_path)?;
442 if hook_is_ours(&existing) {
443 if existing.contains(HOOK_MARKER) {
444 let new_content = replace_hook_block(&existing, &block);
446 if new_content != existing {
447 write_hook(&hook_path, &new_content)?;
448 println!("✓ git {hook_name} hook 已升级(追加块已更新,用户内容保留)");
449 } else {
450 println!("✓ git {hook_name} hook 已是最新");
451 }
452 } else if existing != content {
453 write_hook(&hook_path, &content)?;
454 println!("✓ git {hook_name} hook 已升级");
455 } else {
456 println!("✓ git {hook_name} hook 已是最新");
457 }
458 } else {
459 write_hook(&hook_path, &append_hook_block(&existing, &block))?;
461 println!("✓ git {hook_name} hook 已追加 code-repo-wiki 块(原内容保留)");
462 }
463 } else {
464 write_hook(&hook_path, &content)?;
465 println!("✓ git {hook_name} hook 已安装");
466 }
467 }
468 Ok(true)
469}
470
471fn remove_hooks(project_root: &std::path::Path) -> Result<()> {
477 let hooks_dir = project_root.join(".git").join("hooks");
478 if !hooks_dir.exists() {
479 return Ok(());
480 }
481 for hook_name in &["post-commit", "post-merge"] {
482 let hook_path = hooks_dir.join(hook_name);
483 if hook_path.exists() {
484 let content = std::fs::read_to_string(&hook_path).unwrap_or_default();
485 if hook_is_ours(&content) {
486 if content.contains(HOOK_MARKER) {
487 let remaining = strip_hook_block(&content);
489 if remaining.is_empty() {
490 std::fs::remove_file(&hook_path)?;
491 } else {
492 write_hook(&hook_path, &remaining)?;
493 }
494 println!("✓ git {hook_name} hook 已移除 code-repo-wiki 块(原内容保留)");
495 } else {
496 std::fs::remove_file(&hook_path)?;
497 println!("✓ git {hook_name} hook 已移除");
498 }
499 }
500 }
501 }
502 Ok(())
503}
504
505pub fn uninstall(force: bool, root: &crate::project::ProjectRoot) -> Result<()> {
521 let project_root = root.path();
522
523 if !force {
524 println!("警告: 卸载将移除 code-repo-wiki 集成配置(插件/MCP/hook/AGENTS.md 引用块)。");
525 println!("保留:用户级 config.toml 与产物数据 .code-repo-wiki/(使用 --force 跳过确认)。");
526 anyhow::bail!("请添加 --force 参数确认卸载");
527 }
528
529 let opencode_mcp = crate::config::mcp::OpencodeMcp {
531 config_path: crate::config::mcp::OpencodeMcp::global_path()?,
532 };
533 if opencode_mcp.remove("code-repo-wiki")? {
534 println!("✓ OpenCode MCP 条目已移除(用户级全局——其他仓库如需继续使用请重新 install)");
535 } else {
536 println!("✓ OpenCode MCP 条目不存在,跳过");
537 }
538
539 let mut oc = crate::config::opencode::OpenCodeConfig::new(root)
541 .context("读取 OpenCode 配置失败")?;
542 oc.uninstall_plugin()?;
543 oc.uninstall_plugin_file()?;
544 println!("✓ OpenCode 插件已移除(用户级全局——所有仓库的 opencode 会话不再自动加载)");
545
546 let claude = crate::config::mcp::ClaudeMcp {
548 path: crate::config::mcp::ClaudeMcp::user_global_path()?,
549 };
550 if claude.remove("code-repo-wiki")? {
551 println!("✓ Claude Code MCP 条目已移除(~/.claude.json——其他仓库如需继续使用请重新 install)");
552 } else {
553 println!("✓ Claude Code MCP 条目不存在,跳过(~/.claude.json)");
554 }
555
556 let codex = crate::config::mcp::CodexMcp {
558 config_path: crate::config::mcp::CodexMcp::global_path()?,
559 };
560 if codex.remove("code-repo-wiki")? {
561 println!("✓ Codex MCP 条目已移除(~/.codex/config.toml)");
562 } else {
563 println!("✓ Codex MCP 条目不存在,跳过(~/.codex/config.toml)");
564 }
565
566 uninstall_wiki(root)?;
568
569 remove_hooks(project_root)?;
571
572 println!("✓ code-repo-wiki 卸载完成 (数据保留: .code-repo-wiki/ 与用户级配置)");
573 Ok(())
574}
575
576pub const WIKI_BLOCK_START: &str = "<!-- CODE-REPO-WIKI:START -->";
578
579pub const WIKI_BLOCK_END: &str = "<!-- CODE-REPO-WIKI:END -->";
581
582pub const LEGACY_WIKI_BLOCK_START: &str = "<!-- REPO-WIKI:START -->";
586
587pub const LEGACY_WIKI_BLOCK_END: &str = "<!-- REPO-WIKI:END -->";
589
590pub fn wiki_block_template(output_dir: &str, lang: &str) -> String {
600 format!(
601 "\
602<!-- CODE-REPO-WIKI:START -->
603本仓库使用 code-repo-wiki 维护可持续进化的项目 Wiki,产物位于 `{output_dir}/`。
604
605## AI 代理使用指引
606
6071. 先读 `{output_dir}/llms.txt` 定位目标页面(站点地图),再读
608 `{output_dir}/wiki/{lang}/overview.md` 与 `{output_dir}/wiki/{lang}/architecture.md`
609 建立全局认知,按需深入模块页;上下文预算充足时用 `{output_dir}/llms-full.txt`
610 一次获得完整实体骨架。
6112. 查找实体(函数/结构体/类)用 `code-repo-wiki search -q \"<关键词>\"`(支持
612 text/semantic/hybrid 三引擎,hybrid 含调用链补全)。
6133. 修改代码后运行 `code-repo-wiki update` 增量更新;`code-repo-wiki lint` 检查产物健康。
6144. 知识沉淀:`code-repo-wiki note \"<记录>\"` 追加到 `{output_dir}/wiki/{lang}/_log.md`。
615<!-- CODE-REPO-WIKI:END -->
616"
617 )
618}
619
620enum WikiBlockState {
622 Both(usize, usize),
624 Half,
626 None,
628}
629
630fn wiki_block_state(content: &str) -> WikiBlockState {
643 for (start_marker, end_marker) in
646 [(WIKI_BLOCK_START, WIKI_BLOCK_END), (LEGACY_WIKI_BLOCK_START, LEGACY_WIKI_BLOCK_END)]
647 {
648 let start = content.find(start_marker);
649 let end = content.find(end_marker);
650 match (start, end) {
651 (Some(s), Some(e)) if s < e => {
652 let line_start = content[..s].rfind('\n').map_or(0, |i| i + 1);
653 let line_end = content[e..].find('\n').map_or(content.len(), |i| e + i + 1);
654 return WikiBlockState::Both(line_start, line_end);
655 }
656 (None, None) => continue,
657 _ => return WikiBlockState::Half,
658 }
659 }
660 WikiBlockState::None
661}
662
663pub fn inject_wiki_block(content: &str, block: &str) -> Result<String> {
670 match wiki_block_state(content) {
671 WikiBlockState::Both(start, end) => {
672 let mut out = String::with_capacity(content.len() + block.len());
673 out.push_str(&content[..start]);
674 out.push_str(block);
675 out.push_str(&content[end..]);
676 Ok(out)
677 }
678 WikiBlockState::None => {
679 let trimmed = content.trim_end();
681 let mut out = String::with_capacity(content.len() + block.len() + 2);
682 out.push_str(trimmed);
683 if !trimmed.is_empty() {
684 out.push_str("\n\n");
685 }
686 out.push_str(block);
687 Ok(out)
688 }
689 WikiBlockState::Half => {
690 anyhow::bail!("检测到不完整的 wiki 标记对(只出现 {WIKI_BLOCK_START} 或 {WIKI_BLOCK_END} 之一,或顺序颠倒),拒绝修改,请人工检查文件")
691 }
692 }
693}
694
695pub fn remove_wiki_block(content: &str) -> Result<Option<String>> {
703 let mut out = content.to_string();
704 let mut removed = false;
705 for _ in 0..2 {
707 match wiki_block_state(&out) {
708 WikiBlockState::Both(start, end) => {
709 let mut next = String::with_capacity(out.len() - (end - start));
710 next.push_str(&out[..start]);
711 next.push_str(&out[end..]);
712 out = next;
713 removed = true;
714 }
715 WikiBlockState::None => break,
716 WikiBlockState::Half => {
717 anyhow::bail!("检测到不完整的 wiki 标记对(只出现 {WIKI_BLOCK_START} 或 {WIKI_BLOCK_END} 之一,或顺序颠倒),拒绝修改,请人工检查文件")
718 }
719 }
720 }
721 Ok(if removed { Some(out) } else { None })
722}
723
724fn write_wiki_block(path: &Path, block: &str) -> Result<()> {
726 let content = if path.exists() {
728 std::fs::read_to_string(path)
729 .with_context(|| format!("读取文件失败: {}", path.display()))?
730 } else {
731 String::new()
732 };
733 let new_content = inject_wiki_block(&content, block)?;
734 crate::fs::write_file_atomic(path, &new_content)
735}
736
737fn remove_wiki_block_from_file(path: &Path) -> Result<bool> {
739 if !path.exists() {
740 return Ok(false);
741 }
742 let content = std::fs::read_to_string(path)
743 .with_context(|| format!("读取文件失败: {}", path.display()))?;
744 match remove_wiki_block(&content)? {
745 Some(new_content) => {
746 crate::fs::write_file_atomic(path, &new_content)?;
747 Ok(true)
748 }
749 None => Ok(false),
750 }
751}
752
753pub fn install_wiki(root: &crate::project::ProjectRoot, also_claude: bool) -> Result<()> {
762 let (_, cfg) = match crate::config::load_default_config(root) {
767 Ok(pair) => pair,
768 Err(e) => {
771 println!(
772 "提示: 配置解析失败({e}),注入块按默认产物路径 (.code-repo-wiki / zh) 渲染"
773 );
774 let cfg = crate::config::load_config(&root.join(Path::new(crate::config::PROJECT_CONFIG_FILE)))
775 .unwrap_or_else(|_| crate::config::schema::WikiConfig::default());
776 let output_dir = cfg.output_dir().to_string_lossy().into_owned();
777 let lang = cfg.wiki.language;
778 let block = wiki_block_template(&output_dir, &lang);
779 let agents_path = root.join(Path::new("AGENTS.md"));
780 write_wiki_block(&agents_path, &block)?;
781 println!("✓ wiki 引用块已注入 {}", agents_path.display());
782 if also_claude {
783 let claude_path = root.join(Path::new("CLAUDE.md"));
784 write_wiki_block(&claude_path, &block)?;
785 println!("✓ wiki 引用块已注入 {}", claude_path.display());
786 }
787 return Ok(());
788 }
789 };
790 let output_dir = cfg.output_dir().to_string_lossy().into_owned();
791 let lang = cfg.wiki.language;
792 let block = wiki_block_template(&output_dir, &lang);
793 let agents_path = root.join(Path::new("AGENTS.md"));
794 write_wiki_block(&agents_path, &block)?;
795 println!("✓ wiki 引用块已注入 {}", agents_path.display());
796 if also_claude {
797 let claude_path = root.join(Path::new("CLAUDE.md"));
798 write_wiki_block(&claude_path, &block)?;
799 println!("✓ wiki 引用块已注入 {}", claude_path.display());
800 }
801 Ok(())
802}
803
804pub fn uninstall_wiki(root: &crate::project::ProjectRoot) -> Result<()> {
811 let agents_path = root.join(Path::new("AGENTS.md"));
812 if remove_wiki_block_from_file(&agents_path)? {
813 println!("✓ wiki 引用块已从 {} 移除", agents_path.display());
814 } else {
815 println!("AGENTS.md 未安装 wiki 引用块,无需卸载");
816 }
817 let claude_path = root.join(Path::new("CLAUDE.md"));
818 if remove_wiki_block_from_file(&claude_path)? {
819 println!("✓ wiki 引用块已从 {} 移除", claude_path.display());
820 }
821 Ok(())
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827
828 fn test_template() -> String {
830 wiki_block_template(".code-repo-wiki", "zh")
831 }
832
833 #[test]
835 fn test_append_note_increments_sequence() {
836 let dir = std::env::temp_dir().join(format!(
837 "code_repo_wiki_note_{}",
838 std::process::id()
839 ));
840 let _ = std::fs::remove_dir_all(&dir);
841 std::fs::create_dir_all(&dir).unwrap();
842
843 append_note(&dir, "zh", "第一条记录").unwrap();
844 append_note(&dir, "zh", "第二条记录").unwrap();
845
846 let log = std::fs::read_to_string(dir.join("wiki").join("zh").join("_log.md")).unwrap();
847 assert!(log.contains("## "), "应含日期节");
848 assert!(log.contains("- 1. 第一条记录"), "第一条应编号 1, 实际: {log}");
849 assert!(log.contains("- 2. 第二条记录"), "第二条应编号 2, 实际: {log}");
850 assert_eq!(
851 log.matches("- ").count(),
852 2,
853 "应恰好 2 条记录, 实际: {log}"
854 );
855
856 let _ = std::fs::remove_dir_all(&dir);
857 }
858
859 #[test]
861 fn test_append_note_rejects_empty() {
862 let dir = std::env::temp_dir().join(format!(
863 "code_repo_wiki_note_empty_{}",
864 std::process::id()
865 ));
866 let _ = std::fs::remove_dir_all(&dir);
867 assert!(append_note(&dir, "zh", " ").is_err(), "空内容应报错");
868 let _ = std::fs::remove_dir_all(&dir);
869 }
870
871 #[test]
875 fn test_inject_wiki_block_fresh() {
876 let out = inject_wiki_block("", &test_template()).unwrap();
877 assert_eq!(out, test_template(), "空文档注入结果应等于模板本身");
878 assert!(out.contains(WIKI_BLOCK_START) && out.contains(WIKI_BLOCK_END));
879 }
880
881 #[test]
883 fn test_inject_wiki_block_replaces_existing() {
884 let before =
885 "用户头部\n\n<!-- CODE-REPO-WIKI:START -->\n旧块内容\n<!-- CODE-REPO-WIKI:END -->\n\n用户尾部\n";
886 let out = inject_wiki_block(before, &test_template()).unwrap();
887 assert!(out.starts_with("用户头部\n\n"), "用户头部应保留, 实际: {out}");
888 assert!(out.ends_with("用户尾部\n"), "用户尾部应保留, 实际: {out}");
889 assert!(out.contains(&test_template()), "旧块应被替换为模板, 实际: {out}");
890 assert!(!out.contains("旧块内容"), "旧块内容应被替换掉, 实际: {out}");
891 }
892
893 #[test]
896 fn test_inject_wiki_block_migrates_legacy_marker() {
897 let before =
898 "用户头部\n\n<!-- REPO-WIKI:START -->\n旧名块内容\n<!-- REPO-WIKI:END -->\n用户尾部\n";
899 let out = inject_wiki_block(before, &test_template()).unwrap();
900 assert!(out.contains(WIKI_BLOCK_START) && out.contains(WIKI_BLOCK_END));
901 assert!(
902 !out.contains("<!-- REPO-WIKI:START -->") && !out.contains("<!-- REPO-WIKI:END -->"),
903 "旧标记应随迁移消失, 实际: {out}"
904 );
905 assert!(!out.contains("旧名块内容"), "旧块内容应被替换掉, 实际: {out}");
906 assert!(out.contains("用户头部") && out.contains("用户尾部"), "用户内容应保留: {out}");
907 }
908
909 #[test]
911 fn test_inject_wiki_block_twice_stable() {
912 let first = inject_wiki_block("头部\n", &test_template()).unwrap();
913 let second = inject_wiki_block(&first, &test_template()).unwrap();
914 assert_eq!(first, second, "重复注入应幂等(内容不变)");
915 }
916
917 #[test]
920 fn test_inject_wiki_block_half_marker_errors() {
921 let cases = [
922 "# 标题\n<!-- CODE-REPO-WIKI:START -->\n",
923 "<!-- CODE-REPO-WIKI:END -->\n",
924 "<!-- CODE-REPO-WIKI:END -->\n<!-- CODE-REPO-WIKI:START -->\n",
925 "# 标题\n<!-- REPO-WIKI:START -->\n",
926 "<!-- REPO-WIKI:END -->\n",
927 ];
928 for case in cases {
929 let err = inject_wiki_block(case, &test_template()).unwrap_err();
930 assert!(err.to_string().contains("不完整"), "半标记应报错: {err}");
931 }
932 }
933
934 #[test]
936 fn test_inject_wiki_block_preserves_user_content() {
937 let before = "# 我的项目\n\n这是用户写的说明。\n";
938 let out = inject_wiki_block(before, &test_template()).unwrap();
939 let marker_idx = out.find(WIKI_BLOCK_START).unwrap();
940 assert_eq!(
941 &out[..marker_idx],
942 "# 我的项目\n\n这是用户写的说明。\n\n",
943 "块前应只有用户内容加一个空行"
944 );
945 }
946
947 #[test]
949 fn test_remove_wiki_block_not_installed() {
950 assert!(remove_wiki_block("# 标题\n").unwrap().is_none());
951 }
952
953 #[test]
955 fn test_remove_wiki_block_removes_only_block() {
956 let content =
957 "用户头部\n\n<!-- CODE-REPO-WIKI:START -->\n块内容\n<!-- CODE-REPO-WIKI:END -->\n用户尾部\n";
958 let out = remove_wiki_block(content).unwrap().unwrap();
959 assert!(!out.contains(WIKI_BLOCK_START) && !out.contains(WIKI_BLOCK_END), "标记应被移除: {out}");
960 assert!(out.contains("用户头部") && out.contains("用户尾部"), "用户内容应保留: {out}");
961 }
962
963 #[test]
965 fn test_remove_wiki_block_removes_both_marker_generations() {
966 let content = "用户头部\n\n<!-- REPO-WIKI:START -->\n旧名块\n<!-- REPO-WIKI:END -->\n\n<!-- CODE-REPO-WIKI:START -->\n新块\n<!-- CODE-REPO-WIKI:END -->\n用户尾部\n";
967 let out = remove_wiki_block(content).unwrap().unwrap();
968 assert!(
969 !out.contains("REPO-WIKI") && !out.contains("CODE-REPO-WIKI"),
970 "两代标记都应被移除, 实际: {out}"
971 );
972 assert!(out.contains("用户头部") && out.contains("用户尾部"), "用户内容应保留: {out}");
973 }
974
975 #[test]
977 fn test_remove_wiki_block_half_marker_errors() {
978 let err = remove_wiki_block("<!-- CODE-REPO-WIKI:START -->\n").unwrap_err();
979 assert!(err.to_string().contains("不完整"), "半标记应报错: {err}");
980 let err = remove_wiki_block("<!-- REPO-WIKI:START -->\n").unwrap_err();
981 assert!(err.to_string().contains("不完整"), "旧标记半标记应报错: {err}");
982 }
983}