Skip to main content

code_repo_wiki/
commands.rs

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
9/// status 报告(结构化,main 只做格式化)
10pub struct StatusReport {
11    pub ready: bool,            // 产物根存在且非空
12    pub wiki_pages: usize,      // wiki/{lang}/*.md 数量(所有语言合计)
13    pub cards: usize,           // cards/{lang}/*.md 数量
14    pub issues: Vec<LintIssue>, // lint 产物健康检查结果
15    pub config_path: String,
16}
17
18/// 汇总产物状态:页面/卡片数量 + lint 健康检查(供 `code-repo-wiki status` 使用)
19///
20/// 目录不存在时数量计 0、lint 无问题,不算缺陷(未生成也是合法状态)。
21pub fn status_report(config: &WikiConfig, root: &crate::project::ProjectRoot) -> StatusReport {
22    let output_dir = config.output_dir();
23    // ready = wiki 目录存在且含 .md 文件(有产物才算生成过)
24    let wiki_pages = collect_md_files(&output_dir.join("wiki")).len();
25    let cards = collect_md_files(&output_dir.join("cards")).len();
26    // 源码根必须相对 root 解析(见 source_roots):status 跨 cwd 运行时
27    // (--root 指向其他仓库)lint 才能扫到目标仓库
28    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
41/// 源码根(v30+:扫描范围已硬编码为全量遍历+内置过滤,源码根恒为仓库根)
42///
43/// lint 过时检查需要对比源文件 mtime,空根会导致检查静默跳过;
44/// main.rs 的 lint 命令与 status 共用此派生,避免两处内联逻辑漂移。
45pub fn source_roots(root: &crate::project::ProjectRoot) -> Vec<PathBuf> {
46    vec![root.path().to_path_buf()]
47}
48
49/// 将产物目录(wiki/{lang}/、cards/{lang}/)的工作区内容同步到指纹库
50///
51/// 官方语义:Git 目录中直接编辑 .md 后,手动触发"同步"以 Git 内容为准
52/// 合入 Wiki(团队 pull 共享知识的场景)。对每个 .md 产物:
53/// - 指纹不存在 → 记录新指纹(视为新文件)
54/// - 指纹不匹配 → 工作区内容为准,更新指纹
55/// - 受保护页面(protected_docs 含该路径)→ 跳过,保留人工版
56///
57/// 本质上 = 重新加载工作区 .md 内容到指纹库,不触发任何 LLM 生成。
58pub 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    // 状态不存在(首次 sync)→ 从空状态开始:所有产物视为新文件,全部记录指纹。
62    // 状态存在但损坏 → 显式报错(不静默重置:空状态会丢失 protected_docs,
63    // 使人工修改保护在后续 update 中失效)。
64    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
104/// 追加一条知识沉淀记录到 `{output_dir}/wiki/{lang}/_log.md`(Karpathy log.md 模式)
105///
106/// 每条记录以 `## YYYY-MM-DD` 节组织,节内按追加顺序编号(当天第 N 条)。
107/// _log.md 是人工可读、grep 可查、git 可追踪的追加式会话知识日志——
108/// 与 wiki 页(LLM 生成、受保护)分开:人工记录永不被自动生成覆盖。
109/// 文件不存在时自动创建;目录不存在时自动创建(与 render_all 的写盘约定一致)。
110pub 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    // 读取现有内容以确定今天的节内已有几条(追加式,不重写历史)
120    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    // 今天节内已有条数 = 文件中今天节之后出现的 "- N." 数量
124    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    // 追加:无今天节则新建节,否则续写
136    let entry = format!("- {}. {text}\n", today_seq + 1);
137    let mut append = String::new();
138    if !existing.contains(&today_header) {
139        // 已有内容不以换行结尾时先补一行(避免与上一个节粘连)
140        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
158/// 递归收集目录下所有 .md 文件(目录不存在时返回空列表)
159fn 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/// install 的可选集成(v33 多 Agent 支持)
176///
177/// 默认集成集 = OpenCode 插件 + OpenCode MCP(用户级全局)+ AGENTS.md
178/// + git hooks;以下 flag 扩展集成面。
179#[derive(Debug, Clone, Copy, Default)]
180pub struct InstallOptions {
181    /// 额外注册 Claude Code MCP(--claude,用户级 `~/.claude.json` 顶层
182    /// mcpServers,User scope——command 绑定本机 exe 路径=用户级内容,与
183    /// opencode/codex 全局注册对称;v39 起不再写项目根 .mcp.json);
184    /// 同时同步注入 CLAUDE.md(Claude Code 不读 AGENTS.md,注册 MCP 时
185    /// 必然需要文档指引——原 --also-claude 开关合并于此)
186    pub claude: bool,
187    /// 额外写 Codex CLI 用户级配置 `~/.codex/config.toml`(--codex)
188    pub codex: bool,
189}
190
191/// code-repo-wiki 安装(v33 合并版):OpenCode 插件 + 多 Agent MCP + AGENTS.md + git hooks
192///
193/// root 为项目根(U02:--root 注入,替代进程 cwd——插件/hook/config 全部
194/// 相对项目根解析,跨 cwd 运行不再错位)。
195///
196/// 集成步骤(全部幂等,重复执行安全;非 code-repo-wiki 内容一律保留):
197/// 1. OpenCode 插件:用户级配置根 `~/.config/opencode/plugins/code-repo-wiki.ts`
198///    (v39:插件是用户级内容——装进 Agent 配置根目录,官方自动加载目录,
199///    一次 install 全仓库 opencode 会话可用;不再写入项目 `.opencode/plugins/`,
200///    旧版项目级产物自动迁移清理);模板注入 current_exe 绝对路径
201///    (t02 摆脱 PATH 依赖);内容与模板不同即升级
202/// 2. OpenCode MCP:用户级全局 `opencode.json` 的 `mcp.code-repo-wiki` 条目
203///    (v33 拍板:一次注册所有仓库可用,server 以工作区为 cwd)
204/// 3. Claude MCP(--claude):用户级 `~/.claude.json` 顶层 mcpServers 条目
205///    (Claude Code 官方 User scope,command 绑定本机 exe=用户级内容;
206///    v39 起不再写项目根 .mcp.json——机器相关配置不入团队共享文件)
207/// 4. Codex MCP(--codex):用户级 `~/.codex/config.toml` 的
208///    `[mcp_servers.code-repo-wiki]` 表(Codex 官方用户级配置)
209/// 5. AGENTS.md:wiki 引用块(标记对幂等替换;默认执行)
210/// 6. CLAUDE.md(随 --claude,v36 起):Claude Code 不读 AGENTS.md,
211///    注册 Claude MCP 时同步注入引用块
212/// 7. git hooks:post-commit/post-merge(含 code-repo-wiki 标记则升级覆盖;
213///    用户自定义 hook 保留并提示)
214///
215/// 用户级默认配置的确保由调用方(main.rs)先行执行(v25 语义)。
216pub fn install(root: &crate::project::ProjectRoot, opts: &InstallOptions) -> Result<()> {
217    let project_root = root.path();
218
219    // MCP 与插件共用当前可执行文件绝对路径(t02:不依赖 PATH)
220    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    // 1. OpenCode 插件(用户级配置根——v39:Agent 配置根目录安装)
226    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    // 2. OpenCode MCP(用户级全局——v33 拍板)
236    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    // 3. Claude MCP(--claude → 用户级 ~/.claude.json User scope,v39)
246    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    // 4. Codex MCP(--codex → 用户级 ~/.codex/config.toml)
258    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    // 5/6. AGENTS.md(默认)与 CLAUDE.md(v36 起随 --claude 同步——
270    // Claude Code 不读 AGENTS.md,注册 MCP 时文档指引随之注入)
271    install_wiki(root, opts.claude)?;
272
273    // 7. git hooks(v41:新装独立脚本;既有用户 hook 尾部追加块共存)
274    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
294/// git hook 追加块开始标记(v41 方案:与用户既有 hook 共存——尾部追加
295/// 标记块,升级判定与卸载剥离判定共用「是否 code-repo-wiki 所有」判据;
296/// 用户自定义 hook 不含此标记,不会被覆盖,只追加块)
297pub const HOOK_MARKER: &str = "# code-repo-wiki: append-begin";
298
299/// git hook 追加块结束标记(与 [`HOOK_MARKER`] 成对,剥离时按区间删除)
300pub const HOOK_END_MARKER: &str = "# code-repo-wiki: append-end";
301
302/// v37 改名前的 hook 标记(v33 旧模板——独立脚本式)。升级/卸载时一并
303/// 识别——改名后旧 hook 里的 `repo-wiki update` 命令已不存在,残留只会
304/// 静默失效。
305pub const LEGACY_HOOK_MARKER: &str = "# repo-wiki managed";
306
307/// hook 内容是否属于本工具(追加块标记、v37 旧标记、或 v33 前旧模板都
308/// 算——旧 hook 是本工具旧版本的产物,升级剥离与卸载删除都应覆盖它)。
309/// v33 前旧模板无 managed 标记,特征行是注释里的
310/// `auto-update wiki on commit`(用户/第三方 hook 不会写这行)。
311fn 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
317/// 生成独立 hook 脚本内容(新装/整文件升级场景)
318///
319/// `#!/bin/sh` + LF:Windows 上由 Git for Windows 的 sh 执行(POSIX 语义,
320/// 绝非 PowerShell);`cd` 到仓库顶层保证 --root 无关;`command -v` 探测
321/// 二进制存在性;update 失败不阻断 git 主流程(hook 是通知型),但
322/// 失败必须可见:stderr 落 .code-repo-wiki/update-error.log 并在提交输出中
323/// 提示一行(v36 D2:此前 2>/dev/null || true 把失败完全吞掉,用户
324/// 永远不知道 wiki 已陈旧)。
325fn 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
332/// 生成追加块内容(v41:无 shebang——追加在用户 hook 尾部;`cd` 到仓库
333/// 顶层保证位置;每条命令带 `|| exit 0`/`|| echo` 满足 `set -e` 隔离——
334/// 单条失败不传播为 git hook 失败,也不被静默吞掉)
335fn 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
342/// 剥离追加块区间(begin 标记行到 end 标记行含两端),返回剩余内容。
343/// 无区间时原样返回(幂等)。剥离后 trim——去掉区间剥除留下的多余空行。
344fn 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        // 区间不完整(只有一端)→ 保守不动(不破坏用户 hook)
359        _ => content.trim().to_string(),
360    }
361}
362
363/// 在既有内容尾部追加块(块不存在时;已存在则整文件升级走
364/// [`replace_hook_block`])
365fn append_hook_block(existing: &str, block: &str) -> String {
366    format!("{}\n\n{}", existing.trim_end(), block)
367}
368
369/// 替换既有内容中的追加块区间(begin-end 整体替换为新块——升级场景,
370/// 保留区间外的用户内容)
371fn replace_hook_block(existing: &str, block: &str) -> String {
372    let stripped = strip_hook_block(existing);
373    format!("{}\n\n{}", stripped, block)
374}
375
376/// 原子写 hook 并设置执行位(unix;Windows 由 sh 解释执行无需执行位)
377fn 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
384/// 检测 core.hooksPath 配置(v41):Git 从该目录加载 hook——若指向
385/// .git/hooks 之外,写入 .git/hooks 的 hook 不会生效,安装前必须提示。
386/// 返回配置值(未配置返回 None)。
387fn 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
404/// 安装 git hooks(post-commit/post-merge);返回(新装/升级数量,
405/// 任一 hook 文件最终是否存在)——第二值供总结语区分「已配置」与
406/// 「未检测到 .git 目录」(v41:全部已最新时 installed=0 但 hook 可用,
407/// 不再误报「未安装」)
408///
409/// v41 追加块语义(与用户既有 hook 共存,业界标记块惯例——husky 等
410/// 工具同款思路):
411/// - 不存在 → 新建独立脚本
412/// - 已存在且含追加块标记 → 只替换块区间(保留区间外用户内容)
413/// - 已存在且含旧标记(v33/v37 独立脚本)→ 内容不同则整文件覆盖升级
414/// - 已存在且无标记(用户/第三方自定义 hook)→ 尾部追加块(不覆盖)
415/// - `.git/hooks` 不存在(非 git 仓库)→ 提示跳过(install 不因此失败)
416/// - core.hooksPath 指向 .git/hooks 之外 → 提示(不失败)
417fn 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        // hooksPath 指向 .git/hooks 本身 = 等同默认,无影响;指向其他
425        // 目录才提示(写入 .git/hooks 不会生效)
426        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                    // 追加块场景:只替换块区间,保留用户内容
445                    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                // 用户/第三方 hook:尾部追加块,保留原内容
460                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
471/// 移除 git hooks(仅 code-repo-wiki 的;用户自定义 hook 保留)
472///
473/// v41:追加块场景剥离块区间(用户内容保留写回);独立脚本场景删文件。
474/// 判定与 install_hooks 同源(当前标记或 v37 旧标记——旧模板无 managed
475/// 标记但含 repo-wiki 调用,同样识别,避免改名后旧 hook 残留静默失效)。
476fn 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                    // 追加块:剥离区间,用户内容保留
488                    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
505/// code-repo-wiki 卸载(v33 合并版):移除全部集成痕迹(--force 确认)
506///
507/// root 为项目根(U02:--root 注入,与 install 对称)。
508///
509/// 清理集 = install 全集的反向(全部幂等,缺省即跳过):
510/// 1. OpenCode MCP 用户级全局条目(其他 server 保留)
511/// 2. OpenCode 插件文件
512/// 3. Claude MCP `~/.claude.json` 顶层 mcpServers 条目(其他键/其他 server
513///    保留;空 mcpServers 保留文件——OAuth 会话等用户配置绝不动)
514/// 4. Codex MCP 表(其他表/注释保留)
515/// 5. AGENTS.md / CLAUDE.md wiki 块(无标记则跳过)
516/// 6. git hooks(仅 code-repo-wiki 标记的删除;用户自定义 hook 保留)
517///
518/// 保留(设计如此,配置与数据属用户资产):用户级 config.toml、
519/// `.code-repo-wiki/` 产物数据。
520pub 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    // 1. OpenCode MCP(用户级全局)
530    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    // 2. OpenCode 插件(用户级配置根 + 旧版项目级产物迁移清理)
540    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    // 3. Claude MCP(用户级 ~/.claude.json User scope,v39)
547    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    // 4. Codex MCP(~/.codex/config.toml)
557    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    // 5. AGENTS.md / CLAUDE.md wiki 块
567    uninstall_wiki(root)?;
568
569    // 6. git hooks
570    remove_hooks(project_root)?;
571
572    println!("✓ code-repo-wiki 卸载完成 (数据保留: .code-repo-wiki/ 与用户级配置)");
573    Ok(())
574}
575
576/// wiki 引用块的起始标记(注入块的唯一边界;v37 改名后为 CODE-REPO-WIKI)
577pub const WIKI_BLOCK_START: &str = "<!-- CODE-REPO-WIKI:START -->";
578
579/// wiki 引用块的结束标记
580pub const WIKI_BLOCK_END: &str = "<!-- CODE-REPO-WIKI:END -->";
581
582/// v37 改名前的旧标记(REPO-WIKI)。升级兼容:inject 把旧块整体替换为新
583/// 模板(迁移),remove 把新旧标记对一并删除——已注入旧块的仓库升级
584/// 后不会留下双份块。
585pub const LEGACY_WIKI_BLOCK_START: &str = "<!-- REPO-WIKI:START -->";
586
587/// v37 改名前的旧结束标记
588pub const LEGACY_WIKI_BLOCK_END: &str = "<!-- REPO-WIKI:END -->";
589
590/// 渲染注入块模板(install 的 AGENTS.md 注入与 CLAUDE.md 注入共用)
591///
592/// 内容为中文 markdown 指针风格:只引产物路径与常用命令,不复制 wiki
593/// 正文(避免与 LLM 生成的产物内容双份漂移)。以换行结尾,保证追加/
594/// 替换后与相邻内容衔接干净。
595///
596/// 产物路径按实际配置渲染(U02):`output_dir` 与 `lang` 来自目标仓库的
597/// config.toml(output.dir / wiki.language)——此前模板硬编码 `wiki/` 与
598/// `zh`,默认配置(output.dir=.code-repo-wiki、language 可改)下注入指引失配。
599pub 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
620/// 文档中 wiki 标记对的状态
621enum WikiBlockState {
622    /// 完整标记对:START 所在行的行首偏移,END 所在行的行尾偏移(含换行)
623    Both(usize, usize),
624    /// 只出现一个标记(或顺序颠倒):拒绝自动修复
625    Half,
626    /// 无任何标记
627    None,
628}
629
630/// 定位文档中的 wiki 标记对(当前标记与 v37 旧标记都识别)
631///
632/// 标记对候选按「新标记优先」顺序探测:
633/// - 新标记(CODE-REPO-WIKI)完整对 → 用它(升级替换的落点)
634/// - 新标记无、旧标记(REPO-WIKI)完整对 → 用它(旧块迁移落点)
635/// - 任一套只出现 START/END 之一,或顺序颠倒 → Half(不自动修复)
636/// - 两套都无 → None(干净状态,可安全追加)
637///
638/// - Both: start 对齐到 START 行首、end 对齐到 END 行尾(含换行),
639///   使"整块替换/删除"只触碰标记及其之间内容,不伤用户文本。
640/// - Half: 半标记说明文件被人为改坏或与其他工具冲突,修补方向有歧义
641///   (删哪半?补哪半?),显式报错让用户处理。
642fn wiki_block_state(content: &str) -> WikiBlockState {
643    // 探测顺序:新标记对优先,其次旧标记对;两套都存在时只处理新对
644    // (旧对残留由 remove 循环删除清理,inject 不重复迁移)
645    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
663/// 将 wiki 引用块注入文档文本(纯函数,不含 I/O,install 的 AGENTS.md/CLAUDE.md 注入共用)
664///
665/// 幂等策略:
666/// - 完整标记对 → 整块替换(只动标记之间内容,保留用户其他内容);
667/// - 无标记 → 文件尾 trim 后追加(与已有内容之间留一个空行);
668/// - 只有一半标记 → 报错(理由见 `wiki_block_state`)。
669pub 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            // 追加:trim 掉尾部空白后接一个空行再放块,避免与用户内容粘连
680            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
695/// 从文档文本移除 wiki 引用块(纯函数,不含 I/O)
696///
697/// 返回 `None` 表示无标记(未安装);`Some` 为移除后的内容。
698/// 半标记同样报错(与注入一致:不自动修复)。
699///
700/// 循环删除:新标记对删完后可能还残留旧标记对(v37 改名前的块),
701/// 一并清掉——两套对都处理完才返回,保证卸载彻底。
702pub fn remove_wiki_block(content: &str) -> Result<Option<String>> {
703    let mut out = content.to_string();
704    let mut removed = false;
705    // 最多两轮:新对 + 旧对各一轮;每轮删除后重扫(对位置随删除前移)
706    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
724/// 向单个文件写入 wiki 引用块(读 → 注入 → 原子写)
725fn write_wiki_block(path: &Path, block: &str) -> Result<()> {
726    // 文件不存在视为空文档(正常创建路径),读取失败才显式报错
727    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
737/// 移除单个文件中的 wiki 引用块;返回是否实际移除
738fn 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
753/// install: 向项目根 AGENTS.md 注入 wiki 引用块(--claude 时同步写 CLAUDE.md)
754///
755/// 文件不存在则创建;已存在完整标记对则整块替换(只动标记之间内容);
756/// 半标记报错(不修,理由见 `wiki_block_state`)。
757///
758/// 注入块按目标仓库配置渲染(U02):读 `root/config.toml` 取
759/// output.dir 与 wiki.language;配置缺失(首次运行/未 install)时回退默认值
760/// (".code-repo-wiki", "zh") 不报错——wiki 块缺失比注入失败更隐蔽。
761pub fn install_wiki(root: &crate::project::ProjectRoot, also_claude: bool) -> Result<()> {
762    // 渲染配置走完整配置链(v41 修复:与运行命令同源——项目级 config.toml
763    // 字段级合并覆盖用户级 ~/.code-repo-wiki/config.toml;两者皆无时自动
764    // 创建用户级默认模板)。此前只读项目级单文件,用户级配置存在时仍误报
765    // 「未找到有效配置」,且渲染值忽略用户级偏好(如 wiki.language)。
766    let (_, cfg) = match crate::config::load_default_config(root) {
767        Ok(pair) => pair,
768        // 配置畸形(如 TOML 语法错误):按默认值继续注入——wiki 块缺失
769        // 比注入失败更隐蔽(U02 语义保持)
770        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
804/// uninstall: 移除 AGENTS.md 中的 wiki 引用块(含标记本身)
805///
806/// - AGENTS.md 无标记 → 提示"未安装",退出码 0(幂等,与卸载语义一致);
807/// - 半标记 → 报错(不修);
808/// - CLAUDE.md 只在含标记对时清理(install --also-claude 的对称卸载),
809///   从未注入过则静默跳过——CLAUDE.md 不被无标记情况下改动。
810pub 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    /// 注入测试用的模板块(默认产物路径形态,模板函数化后的断言锚点)
829    fn test_template() -> String {
830        wiki_block_template(".code-repo-wiki", "zh")
831    }
832
833    /// append_note 追加式日志:同一日期节内序号递增;两次调用不覆盖历史
834    #[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    /// 空内容拒绝写入(显式报错,不产生空记录)
860    #[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    // ==================== wiki 引用块注入/移除 ====================
872
873    /// 全新注入:空文档 → 追加完整标记对,结果恰等于模板本身
874    #[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    /// 幂等替换:已含完整标记对 → 旧块整体替换为模板,用户前后内容保留
882    #[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    /// v37 旧标记迁移:含旧标记(REPO-WIKI)完整块 → 同样整体替换为新模板
894    /// (改名前的仓库升级路径,块标记随之换代)
895    #[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    /// 幂等:同一文档注入两次 → 结果一致(第二次走替换路径)
910    #[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    /// 半标记报错:只有 START / 只有 END / 顺序颠倒 → 均显式报错
918    /// (当前标记与 v37 旧标记两套都要检)
919    #[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    /// 保留用户内容:无标记追加场景下用户内容完整保留在块之前
935    #[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    /// remove:无标记 → None(未安装)
948    #[test]
949    fn test_remove_wiki_block_not_installed() {
950        assert!(remove_wiki_block("# 标题\n").unwrap().is_none());
951    }
952
953    /// remove:完整标记对 → 移除标记及内容,用户前后内容保留
954    #[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    /// remove:新旧标记块并存(升级残留的双块)→ 一并移除,用户内容保留
964    #[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    /// remove:半标记同样报错(当前标记与旧标记都检)
976    #[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}