dev-manager-tauri/src-tauri/src/commands/onboarding.rs
lanrtop ed83f2874a
All checks were successful
Push & PR Check / check (pull_request) Successful in 57s
Push & PR Check / check (push) Successful in 1m3s
feat(onboarding): add Codex MCP project integration
Feature-Confirmed: true
2026-09-21 12:35:10 +09:00

910 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 接入包模板分发(基线档)。
//!
//! 版本化分发(T1,2026-07-03):AGENTS.md / git-workflow.md 的炼境管理内容
//! 包在 ONBOARDING_MANAGED 标记块中——文件不存在写全新;已有标记原位升级块;
//! 无标记则末尾追加块。块外用户内容永不触碰。
//! lefthook.yml 因用户常深度定制(monorepo filter 等),保持"存在即跳过"。
//! 模板蓝本取自 enterprise-system,已通用化。
use serde::{Deserialize, Serialize};
use std::path::Path;
const MANAGED_START: &str = "<!-- ONBOARDING_MANAGED_START";
const MANAGED_END: &str = "<!-- ONBOARDING_MANAGED_END -->";
// 标准模板(编译期内嵌)
const TMPL_AGENTS: &str = include_str!("../../resources/onboarding/AGENTS.md.tmpl");
const TMPL_LEFTHOOK: &str = include_str!("../../resources/onboarding/lefthook.yml.tmpl");
const TMPL_GIT_WORKFLOW: &str = include_str!("../../resources/onboarding/git-workflow.md.tmpl");
const TMPL_ENVOY: &str = include_str!("../../resources/onboarding/.claude/commands/envoy.md");
const TMPL_BLUEPRINT_GATE: &str = include_str!("../../resources/onboarding/.claude/hooks/blueprint-gate.cjs");
const TMPL_LIANJING_SKILL: &str = include_str!("../../resources/onboarding/.agents/skills/lian-jing/SKILL.md");
/// 单个模板的分发结果。
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct TemplateResult {
/// 相对项目根的目标路径
pub name: String,
/// "written" | "skipped" | "failed"
pub action: String,
pub detail: String,
}
/// 分发单个模板:存在则跳过(绝不覆盖),不存在则写入。
fn dispatch_one(root: &Path, relative: &str, content: &str) -> TemplateResult {
let target = root.join(relative);
if target.exists() {
return TemplateResult {
name: relative.to_string(),
action: "skipped".into(),
detail: "已存在,未覆盖".into(),
};
}
if let Some(parent) = target.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
return TemplateResult {
name: relative.to_string(),
action: "failed".into(),
detail: format!("建目录失败: {e}"),
};
}
}
match std::fs::write(&target, content) {
Ok(_) => TemplateResult {
name: relative.to_string(),
action: "written".into(),
detail: "已写入".into(),
},
Err(e) => TemplateResult {
name: relative.to_string(),
action: "failed".into(),
detail: format!("写入失败: {e}"),
},
}
}
/// 全托管文件分发(三档升级语义之②,U1):炼境独占管理、用户不定制(文件头有托管声明)。
/// 不存在→written;与内嵌模板一致→skipped;不一致→覆盖 updated。
/// 比较前归一化行尾(子项目 git autocrlf 可能把 LF 检出为 CRLF,避免永久 updated 抖动)。
fn dispatch_fully_managed(root: &Path, relative: &str, content: &str) -> TemplateResult {
let target = root.join(relative);
if !target.exists() {
return dispatch_one(root, relative, content);
}
let normalize = |s: &str| s.replace("\r\n", "\n");
if let Ok(existing) = std::fs::read_to_string(&target) {
if normalize(&existing) == normalize(content) {
return TemplateResult {
name: relative.to_string(),
action: "skipped".into(),
detail: "内容已是最新模板".into(),
};
}
}
match std::fs::write(&target, content) {
Ok(_) => TemplateResult {
name: relative.to_string(),
action: "updated".into(),
detail: "全托管文件已覆盖为最新模板".into(),
},
Err(e) => TemplateResult {
name: relative.to_string(),
action: "failed".into(),
detail: format!("覆盖失败: {e}"),
},
}
}
/// 从模板中提取 ONBOARDING_MANAGED 块(含首尾标记行)。
fn extract_managed_block(template: &str) -> Option<&str> {
let start = template.find(MANAGED_START)?;
let end = template.find(MANAGED_END)? + MANAGED_END.len();
template.get(start..end)
}
/// 从标记行提取版本号(`version="x.y.z"`)。
fn block_version(text: &str) -> Option<&str> {
let start = text.find(MANAGED_START)?;
let rest = &text[start..];
let v = rest.find("version=\"")? + "version=\"".len();
let tail = &rest[v..];
tail.split('"').next()
}
/// 版本化分发(三态):不存在→写全新;有标记→原位升级块;无标记→末尾追加块。
fn dispatch_versioned(root: &Path, relative: &str, template: &str) -> TemplateResult {
let target = root.join(relative);
let tpl_block = match extract_managed_block(template) {
Some(b) => b,
None => return dispatch_one(root, relative, template), // 模板无标记,退化为旧行为
};
let tpl_ver = block_version(tpl_block).unwrap_or("?");
if !target.exists() {
return dispatch_one(root, relative, template);
}
let content = match std::fs::read_to_string(&target) {
Ok(c) => c,
Err(e) => {
return TemplateResult {
name: relative.to_string(),
action: "failed".into(),
detail: format!("读取失败: {e}"),
}
}
};
let (new_content, action, detail) = if let (Some(s), Some(e)) =
(content.find(MANAGED_START), content.find(MANAGED_END))
{
let old_ver = block_version(&content).unwrap_or("?").to_string();
if old_ver == tpl_ver {
return TemplateResult {
name: relative.to_string(),
action: "skipped".into(),
detail: format!("管理块已是 v{tpl_ver}"),
};
}
let mut nc = String::new();
nc.push_str(&content[..s]);
nc.push_str(tpl_block);
nc.push_str(&content[e + MANAGED_END.len()..]);
(nc, "updated", format!("管理块 v{old_ver} → v{tpl_ver},块外内容未动"))
} else {
(
format!("{}\n\n{}\n", content.trim_end(), tpl_block),
"appended",
format!("追加管理块 v{tpl_ver}(存量文件无标记,原内容保留在上方)"),
)
};
match std::fs::write(&target, new_content) {
Ok(_) => TemplateResult {
name: relative.to_string(),
action: action.into(),
detail,
},
Err(e) => TemplateResult {
name: relative.to_string(),
action: "failed".into(),
detail: format!("写入失败: {e}"),
},
}
}
/// 向项目分发全部基线模板,返回每个的处理结果。
/// project_path 为项目根(WSL 项目为 \\wsl.localhost\... UNC 路径,可直接写)。
pub fn dispatch_templates(project_path: &str) -> Vec<TemplateResult> {
let root = Path::new(project_path.trim_end_matches(['/', '\\']));
vec![
dispatch_versioned(root, "AGENTS.md", TMPL_AGENTS),
dispatch_one(root, "lefthook.yml", TMPL_LEFTHOOK),
dispatch_versioned(root, "docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW),
dispatch_fully_managed(root, ".claude/commands/envoy.md", TMPL_ENVOY),
dispatch_fully_managed(root, ".claude/hooks/blueprint-gate.cjs", TMPL_BLUEPRINT_GATE),
dispatch_fully_managed(root, ".agents/skills/lian-jing/SKILL.md", TMPL_LIANJING_SKILL),
]
}
// ── V1:lefthook 激活(分发 ≠ 生效,文件写入后必须 lefthook install 挂上 hook)──
/// lefthook 激活结果。status: "activated" | "missing_binary" | "failed"
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct HookActivation {
pub status: String,
pub detail: String,
}
/// 执行 lefthook install:优先项目本地 node_modules/.bin,回退全局 PATH。
/// Windows 的 PATH 兜底须额外尝试 "lefthook.cmd"——pnpm 全局安装的是 .cmd shim,
/// 而 CreateProcess 对裸名 "lefthook" 只解析 .exe(2026-07-04 批量接入实测踩坑)。
fn run_lefthook_install(root: &Path) -> HookActivation {
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
#[cfg(target_os = "windows")]
{
candidates.push(root.join("node_modules/.bin/lefthook.exe"));
candidates.push(root.join("node_modules/.bin/lefthook.cmd"));
}
#[cfg(not(target_os = "windows"))]
candidates.push(root.join("node_modules/.bin/lefthook"));
let local = candidates.into_iter().find(|p| p.exists());
let mut attempts: Vec<(String, &str)> = Vec::new();
match &local {
Some(p) => attempts.push((p.to_string_lossy().to_string(), "项目本地")),
None => {
attempts.push(("lefthook".to_string(), "全局 PATH"));
#[cfg(target_os = "windows")]
attempts.push(("lefthook.cmd".to_string(), "全局 PATH(.cmd shim)"));
}
}
for (program, source) in &attempts {
match crate::silent_cmd(program).arg("install").current_dir(root).output() {
Ok(out) if out.status.success() => {
return HookActivation {
status: "activated".into(),
detail: format!("lefthook install 完成({source})"),
}
}
Ok(out) => {
return HookActivation {
status: "failed".into(),
detail: format!(
"lefthook install 退出码 {:?}:{}",
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
),
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
return HookActivation {
status: "failed".into(),
detail: format!("执行失败: {e}"),
}
}
}
}
HookActivation {
status: "missing_binary".into(),
detail: "未找到 lefthook(node_modules/.bin 与 PATH 均无)。安装:pnpm add -g lefthook 或项目内 pnpm add -D lefthook 后重跑接入包".into(),
}
}
/// 接入包应用报告:模板分发结果 + hook 激活 + buff 状态。
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct OnboardingReport {
pub templates: Vec<TemplateResult>,
pub hook: HookActivation,
pub buff: crate::commands::buff::BuffStatus,
}
/// 应用接入包(基线档):分发标准模板 + 激活 lefthook + 挂 buff(炼境登记 + git 观测/飞轮采集)。
#[tauri::command]
pub fn apply_onboarding_pack(
project_path: String,
project_id: String,
) -> Result<OnboardingReport, String> {
// 空/无效路径必须在此拦截:Path::new("") 是相对路径,模板会写进炼境进程的
// cwd(2026-07-15 批量实调实锤:空路径登记把接入包写进了 src-tauri/)
let root = Path::new(project_path.trim_end_matches(['/', '\\']));
if project_path.trim().is_empty() || !root.is_dir() {
return Err(format!("项目路径为空或不存在,拒绝分发: {project_path:?}"));
}
let templates = dispatch_templates(&project_path);
let hook = run_lefthook_install(Path::new(project_path.trim_end_matches(['/', '\\'])));
let buff = crate::commands::buff::apply_blueprint_buff(project_path, project_id)?;
Ok(OnboardingReport { templates, hook, buff })
}
// ── V1:接入生效验收(verify_onboarding 检查链,判定逻辑纯函数化可单测)──────
/// 单项验收结果。
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VerifyCheck {
pub name: String,
pub pass: bool,
pub detail: String,
}
/// 纯函数:pre-commit hook 内容是否由 lefthook 接管。
fn hook_is_lefthook(content: &str) -> bool {
content.contains("lefthook")
}
/// 纯函数:从 .git/config 文本提取 origin remote 的 url。
fn parse_origin_url(git_config: &str) -> Option<String> {
let mut in_origin = false;
for line in git_config.lines() {
let t = line.trim();
if t.starts_with('[') {
in_origin = t.replace(' ', "").eq_ignore_ascii_case("[remote\"origin\"]");
continue;
}
if in_origin {
if let Some(rest) = t.strip_prefix("url") {
if let Some(v) = rest.trim_start().strip_prefix('=') {
return Some(v.trim().to_string());
}
}
}
}
None
}
/// 纯函数:提取 URL 的 host(支持 https:// 与 git@host: 两种形式,忽略端口与用户名)。
fn url_host(url: &str) -> Option<String> {
let u = url.trim();
if let Some(rest) = u.split("://").nth(1) {
let authority = rest.split('/').next()?;
let host = authority.rsplit('@').next()?.split(':').next()?;
return Some(host.to_lowercase());
}
// ssh 形式 git@host:owner/repo.git
let after_at = u.rsplit('@').next()?;
let host = after_at.split([':', '/']).next()?;
if host.contains('.') {
Some(host.to_lowercase())
} else {
None
}
}
fn check_codex_mcp(content: Option<&str>) -> (bool, String) {
match content {
Some(content)
if content.contains("[mcp_servers.lian-jing]")
&& content.contains("url = \"http://127.0.0.1:")
&& content.contains("/mcp\"") =>
{
(true, "lian-jing 指向本地 MCP 服务".into())
}
Some(_) => (false, "未找到炼境的 Codex MCP 配置——调用 inject_mcp 修复".into()),
None => (false, ".codex/config.toml 缺失——调用 inject_mcp 后生成".into()),
}
}
fn check_skill(content: Option<&str>) -> (bool, String) {
match content {
Some(content) if content.contains("name: lian-jing") => (true, "炼境项目技能已分发".into()),
_ => (false, "缺少 .agents/skills/lian-jing/SKILL.md——重跑接入包".into()),
}
}
/// 纯函数:管理块版本是否与最新模板一致。
fn check_managed_version(content: Option<&str>, expected: &str) -> (bool, String) {
match content.and_then(block_version) {
Some(v) if v == expected => (true, format!("管理块 v{v}(最新)")),
Some(v) => (false, format!("管理块 v{v},最新模板 v{expected},重跑接入包可升级")),
None => (false, "文件缺失或无管理块标记".into()),
}
}
/// 纯函数:origin host 是否命中已登记的 Gitea 实例。
fn check_origin(origin_host: Option<&str>, instance_hosts: &[String]) -> (bool, String) {
match origin_host {
None => (false, "未配置 origin remote(或无 .git)".into()),
Some(h) if instance_hosts.is_empty() => {
(false, format!("origin={h},但炼境未登记任何 Gitea 实例"))
}
Some(h) if instance_hosts.iter().any(|i| i == h) => {
(true, format!("origin → {h}(已登记 Gitea 实例)"))
}
Some(h) => (
false,
format!("origin={h},不在已登记 Gitea 实例({})中", instance_hosts.join(", ")),
),
}
}
/// 从 lefthook.yml 内容提取 commands 键集合(` commands:` 下缩进 4 空格的键行)。
/// 纯函数;注释与更深缩进(run: | 多行体等)忽略。用于 U1 检查项自省——
/// 期望键集合取自内嵌模板,禁止硬编码具体检查项名(模板演进校验器零维护)。
pub fn lefthook_command_keys(yml: &str) -> Vec<String> {
let mut keys = Vec::new();
let mut in_commands = false;
for line in yml.lines() {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let indent = line.len() - trimmed.len();
if indent == 2 && trimmed.trim_end() == "commands:" {
in_commands = true;
continue;
}
if indent < 4 {
in_commands = false;
continue;
}
if in_commands && indent == 4 {
if let Some(key) = trimmed.trim_end().strip_suffix(':') {
keys.push(key.to_string());
}
}
}
keys
}
/// U1 检查:项目 lefthook.yml 是否缺少模板要求的检查段(lefthook 不自动覆盖,只检测落后)。
fn check_lefthook_keys(project_yml: Option<&str>, template_yml: &str) -> (bool, String) {
let Some(yml) = project_yml else {
return (false, "lefthook.yml 缺失——重跑接入包写入".to_string());
};
let have: Vec<String> = lefthook_command_keys(yml);
let missing: Vec<String> = lefthook_command_keys(template_yml)
.into_iter()
.filter(|k| !have.contains(k))
.collect();
if missing.is_empty() {
(true, "模板要求的检查段齐全".to_string())
} else {
(
false,
format!(
"缺少检查段: {}——lefthook.yml 不自动覆盖,请手动对照模板补齐",
missing.join(", ")
),
)
}
}
/// 接入生效验收:七项检查链。分发成功 ≠ 功能生效,本函数验证"真的用起来了"。
/// conn 由调用方注入(R06)。
pub fn verify_onboarding(
conn: &rusqlite::Connection,
project_id: &str,
root: &Path,
) -> Vec<VerifyCheck> {
let read = |rel: &str| std::fs::read_to_string(root.join(rel)).ok();
let mut checks = Vec::new();
// 1/2 模板管理块版本到位(期望版本从内嵌模板自省,不硬编码)
for (rel, tmpl, label) in [
("AGENTS.md", TMPL_AGENTS, "AGENTS.md 管理块版本"),
("docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW, "git-workflow 管理块版本"),
] {
let expected = extract_managed_block(tmpl).and_then(block_version).unwrap_or("?");
let (pass, detail) = check_managed_version(read(rel).as_deref(), expected);
checks.push(VerifyCheck { name: label.into(), pass, detail });
}
let (pass, detail) = check_skill(read(".agents/skills/lian-jing/SKILL.md").as_deref());
checks.push(VerifyCheck { name: "Codex 炼境技能已分发".into(), pass, detail });
let (pass, detail) = check_codex_mcp(read(".codex/config.toml").as_deref());
checks.push(VerifyCheck { name: "Codex MCP 已配置".into(), pass, detail });
// 3 lefthook hook 真挂在 .git/hooks
let (pass, detail) = match read(".git/hooks/pre-commit") {
Some(c) if hook_is_lefthook(&c) => (true, "pre-commit 由 lefthook 接管".to_string()),
Some(_) => (false, "pre-commit 存在但非 lefthook(被其他工具占用?)".to_string()),
None => (false, "无 pre-commit hook——lefthook install 未执行".to_string()),
};
checks.push(VerifyCheck { name: "lefthook hook 挂载".into(), pass, detail });
// 4 lefthook 检查段齐全(期望键从内嵌模板自省,U1)
let (pass, detail) = check_lefthook_keys(read("lefthook.yml").as_deref(), TMPL_LEFTHOOK);
checks.push(VerifyCheck { name: "lefthook 检查段齐全".into(), pass, detail });
// 5 origin 指向已登记 Gitea 实例
let origin_host = read(".git/config")
.as_deref()
.and_then(parse_origin_url)
.as_deref()
.and_then(url_host);
let mut instance_hosts: Vec<String> = Vec::new();
if let Ok(mut stmt) = conn.prepare("SELECT url FROM gitea_instances") {
if let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0)) {
for u in rows.flatten() {
if let Some(h) = url_host(&u) {
instance_hosts.push(h);
}
}
}
}
let (pass, detail) = check_origin(origin_host.as_deref(), &instance_hosts);
checks.push(VerifyCheck { name: "origin 指向 Gitea".into(), pass, detail });
// 6 CI workflow 存在
let yml_count = std::fs::read_dir(root.join(".gitea/workflows"))
.map(|rd| {
rd.flatten()
.filter(|e| {
e.path()
.extension()
.map(|x| x == "yml" || x == "yaml")
.unwrap_or(false)
})
.count()
})
.unwrap_or(0);
checks.push(VerifyCheck {
name: "CI workflow 存在".into(),
pass: yml_count > 0,
detail: if yml_count > 0 {
format!(".gitea/workflows/ 有 {yml_count} 个 workflow")
} else {
"无 .gitea/workflows/*.yml——可调 distribute_cicd 分发".into()
},
});
// 7 飞轮数据在流入(近 30 天 commit_metrics)
let recent: i64 = conn
.query_row(
"SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now','-30 days')",
rusqlite::params![project_id],
|r| r.get(0),
)
.unwrap_or(0);
checks.push(VerifyCheck {
name: "飞轮 commit 数据流入".into(),
pass: recent > 0,
detail: if recent > 0 {
format!("近 30 天 {recent} 条 commit 已采集")
} else {
"近 30 天无数据——确认 buff 已挂且有 commit 活动(或调 poll_now)".into()
},
});
checks
}
// ── B4:使者回传覆盖率(「沉默项目」信号)──────────────────────────────────────
/// 「沉默项目」:近 30 天有 commit 采集但零使者回传的已登记项目(名称, commit 数)。
///
/// 回流触发不自动化(决策 2026-07-15):不在子项目装 hook 催回传,
/// 而是让遗忘可被看见——人看到信号后自行决定是否去该项目跑 /envoy。
/// commit_metrics.project_id 可能是 blueprint_buffs 的 UUID,经 buff 路径
/// 关联回 project_workspaces;未登记项目(无法跑 /envoy)不列入。
pub fn silent_projects(conn: &rusqlite::Connection) -> Result<Vec<(String, i64)>, String> {
let mut stmt = conn
.prepare(
"SELECT
COALESCE(pp.name, pw.id) AS name,
COUNT(*) AS commits
FROM commit_metrics cm
LEFT JOIN blueprint_buffs bb ON bb.project_id = cm.project_id
LEFT JOIN project_workspaces pw
ON pw.id = cm.project_id OR pw.win_path = bb.project_path
LEFT JOIN project_profiles pp ON pw.profile_id = pp.id
WHERE cm.committed_at >= datetime('now','-30 days')
AND pw.id IS NOT NULL
GROUP BY pw.id
HAVING (SELECT COUNT(*) FROM project_notes pn
WHERE pn.project_id = pw.id
AND pn.content LIKE '【炼境使者回传】%'
AND pn.created_at >= datetime('now','localtime','-30 days')) = 0
ORDER BY commits DESC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_then_skips_never_overwrites() {
let base = std::env::temp_dir()
.join(format!("lianjing_onboarding_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let pp = base.to_str().unwrap();
// 首次:全部写入
let r1 = dispatch_templates(pp);
assert!(r1.iter().all(|t| t.action == "written"), "首次应全部写入");
assert!(base.join("AGENTS.md").exists());
assert!(base.join("lefthook.yml").exists());
assert!(base.join("docs/ai-context/git-workflow.md").exists());
// 改一个文件(无管理块标记),验证用户内容不被覆盖:
// T1 后的契约——AGENTS/git-workflow 追加管理块(原内容保留),lefthook 仍跳过
std::fs::write(base.join("AGENTS.md"), "用户自己的内容").unwrap();
let r2 = dispatch_templates(pp);
let agents_r = r2.iter().find(|t| t.name == "AGENTS.md").unwrap();
assert_eq!(agents_r.action, "appended", "无标记存量文件应追加管理块");
let kept = std::fs::read_to_string(base.join("AGENTS.md")).unwrap();
assert!(kept.starts_with("用户自己的内容"), "用户内容必须保留在原位");
let lefthook_r = r2.iter().find(|t| t.name == "lefthook.yml").unwrap();
assert_eq!(lefthook_r.action, "skipped", "lefthook 保持存在即跳过");
let _ = std::fs::remove_dir_all(&base);
}
// ── T1:版本化三态分发 ────────────────────────────────────────────────
#[test]
fn versioned_dispatch_three_states() {
let base = std::env::temp_dir()
.join(format!("lianjing_onb_ver_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let pp = base.to_str().unwrap();
// 态一:不存在 → 写全新(含管理块标记)
let r1 = dispatch_templates(pp);
assert!(r1.iter().all(|t| t.action == "written"));
let agents = std::fs::read_to_string(base.join("AGENTS.md")).unwrap();
assert!(agents.contains(MANAGED_START) && agents.contains(MANAGED_END));
// 态二(同版本):有标记且版本一致 → skipped,文件不动
let r2 = dispatch_templates(pp);
let agents_r = r2.iter().find(|t| t.name == "AGENTS.md").unwrap();
assert_eq!(agents_r.action, "skipped");
// 态二(旧版本):把版本改旧 + 块外加用户内容 → updated,块升级、用户内容保留
// 版本号从模板动态取,避免模板升版时测试跟着改(曾因硬编码 1.2.0 断裂)
let tpl_ver = extract_managed_block(TMPL_AGENTS)
.and_then(block_version)
.expect("模板必须带版本标记");
let old = agents
.replace(
&format!("version=\"{tpl_ver}\""),
"version=\"1.0.0\"",
)
.replace("## 项目概况", "## 项目概况
我的项目说明")
+ "
## 用户自加的章节
内容
";
std::fs::write(base.join("AGENTS.md"), &old).unwrap();
let r3 = dispatch_templates(pp);
let agents_r = r3.iter().find(|t| t.name == "AGENTS.md").unwrap();
assert_eq!(agents_r.action, "updated");
let upgraded = std::fs::read_to_string(base.join("AGENTS.md")).unwrap();
assert!(
upgraded.contains(&format!("version=\"{tpl_ver}\"")),
"块应升级到模板版本"
);
assert!(upgraded.contains("我的项目说明"), "块外用户内容(前)须保留");
assert!(upgraded.contains("用户自加的章节"), "块外用户内容(后)须保留");
assert_eq!(upgraded.matches(MANAGED_START).count(), 1, "只有一个管理块");
// 态三:存量文件无标记 → appended,原内容保留在上方
std::fs::write(base.join("AGENTS.md"), "# 手写的 AGENTS
自定义内容").unwrap();
let r4 = dispatch_templates(pp);
let agents_r = r4.iter().find(|t| t.name == "AGENTS.md").unwrap();
assert_eq!(agents_r.action, "appended");
let appended = std::fs::read_to_string(base.join("AGENTS.md")).unwrap();
assert!(appended.starts_with("# 手写的 AGENTS"));
assert!(appended.contains(MANAGED_START));
// lefthook 始终保持"存在即跳过"
let lefthook_r = r4.iter().find(|t| t.name == "lefthook.yml").unwrap();
assert_eq!(lefthook_r.action, "skipped");
let _ = std::fs::remove_dir_all(&base);
}
// ── V1:接入生效验收纯函数 ────────────────────────────────────────────
#[test]
fn hook_detection_covers_mounted_and_foreign() {
assert!(hook_is_lefthook("#!/bin/sh\ncall lefthook run pre-commit"), "lefthook 接管应判 true");
assert!(!hook_is_lefthook("#!/bin/sh\nnpx husky run"), "非 lefthook hook 应判 false");
}
#[test]
fn origin_parsing_and_host_extraction() {
let cfg = "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = https://git.example.com/o/r.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n[remote \"mirror\"]\n\turl = https://gitee.com/x/y.git\n";
assert_eq!(
parse_origin_url(cfg).as_deref(),
Some("https://git.example.com/o/r.git"),
"应取 origin 而非其他 remote"
);
assert_eq!(parse_origin_url("[core]\n\tbare = false"), None, "无 origin 应返回 None");
assert_eq!(url_host("https://git.example.com/o/r.git").as_deref(), Some("git.example.com"));
assert_eq!(url_host("https://user@git.example.com:3000/o/r.git").as_deref(), Some("git.example.com"), "应忽略用户名与端口");
assert_eq!(url_host("git@git.example.com:o/r.git").as_deref(), Some("git.example.com"), "ssh 形式");
}
#[test]
fn origin_check_match_and_mismatch() {
let hosts = vec!["git.example.com".to_string()];
assert!(check_origin(Some("git.example.com"), &hosts).0, "命中实例应 pass");
assert!(!check_origin(Some("github.com"), &hosts).0, "未命中应 fail");
assert!(!check_origin(None, &hosts).0, "无 origin 应 fail");
assert!(!check_origin(Some("git.example.com"), &[]).0, "无登记实例应 fail");
}
#[test]
fn verify_onboarding_full_chain() {
let conn = crate::db::conn_with_schema();
let base = std::env::temp_dir()
.join(format!("lianjing_verify_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
// 空目录:七项全不通过(真实反映"什么都没接")
let checks = verify_onboarding(&conn, "p1", &base);
assert_eq!(checks.len(), 9, "检查链应为九项");
assert!(checks.iter().all(|c| !c.pass), "空项目应全部不通过");
// 铺齐全部生效条件后:七项全通过
dispatch_templates(base.to_str().unwrap());
std::fs::create_dir_all(base.join(".codex")).unwrap();
std::fs::write(base.join(".codex/config.toml"), "[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp\"\n").unwrap();
std::fs::create_dir_all(base.join(".git/hooks")).unwrap();
std::fs::write(base.join(".git/hooks/pre-commit"), "call lefthook run pre-commit").unwrap();
std::fs::write(
base.join(".git/config"),
"[remote \"origin\"]\n\turl = https://git.example.com/o/r.git\n",
)
.unwrap();
std::fs::create_dir_all(base.join(".gitea/workflows")).unwrap();
std::fs::write(base.join(".gitea/workflows/pr-check.yml"), "name: x").unwrap();
conn.execute(
"INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i1','t','https://git.example.com','tok')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO commit_metrics (sha, project_id, commit_type, is_rework, is_ci_auto, committed_at, parsed_at)
VALUES ('abc','p1','feat',0,0,datetime('now'),datetime('now'))",
[],
)
.unwrap();
let checks = verify_onboarding(&conn, "p1", &base);
for c in &checks {
assert!(c.pass, "「{}」应通过,实际: {}", c.name, c.detail);
}
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn managed_version_check_states() {
let expected = extract_managed_block(TMPL_AGENTS).and_then(block_version).unwrap();
assert!(check_managed_version(Some(TMPL_AGENTS), expected).0, "同版本应 pass");
let old = TMPL_AGENTS.replacen(
&format!("version=\"{expected}\""),
"version=\"0.0.1\"",
1,
);
let (pass, detail) = check_managed_version(Some(&old), expected);
assert!(!pass && detail.contains("0.0.1"), "旧版本应 fail 且报出版本号");
assert!(!check_managed_version(None, expected).0, "文件缺失应 fail");
assert!(!check_managed_version(Some("手写内容,无标记"), expected).0, "无标记应 fail");
}
/// 回归:空路径登记曾把接入包写进炼境进程 cwd(src-tauri/,2026-07-15 批量实调实锤)。
#[test]
fn apply_rejects_empty_or_missing_path() {
let err = apply_onboarding_pack(String::new(), "p".into()).unwrap_err();
assert!(err.contains("拒绝分发"), "空路径应拒绝: {err}");
let err = apply_onboarding_pack(" ".into(), "p".into()).unwrap_err();
assert!(err.contains("拒绝分发"), "空白路径应拒绝: {err}");
let err =
apply_onboarding_pack("E:/不存在的目录/xyz-404".into(), "p".into()).unwrap_err();
assert!(err.contains("拒绝分发"), "不存在路径应拒绝: {err}");
}
// ── U1:全托管文件三态 + lefthook 检查段自省 ─────────────────────────────
#[test]
fn fully_managed_dispatch_three_states() {
let base = std::env::temp_dir()
.join(format!("lianjing_fm_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
// 不存在 → written
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容");
assert_eq!(r.action, "written");
// 一致 → skipped(CRLF 检出也视为一致)
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容");
assert_eq!(r.action, "skipped");
std::fs::write(base.join("envoy.md"), "v2 内容".replace('\n', "\r\n")).unwrap();
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容");
assert_eq!(r.action, "skipped", "仅行尾差异不应触发覆盖");
// 不一致(旧版模板/被改动)→ 覆盖 updated
std::fs::write(base.join("envoy.md"), "v1 旧内容").unwrap();
let r = dispatch_fully_managed(&base, "envoy.md", "v2 内容");
assert_eq!(r.action, "updated");
assert_eq!(std::fs::read_to_string(base.join("envoy.md")).unwrap(), "v2 内容");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn dispatch_templates_upgrades_fully_managed_files() {
let base = std::env::temp_dir()
.join(format!("lianjing_fm_e2e_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let pp = base.to_str().unwrap();
dispatch_templates(pp);
// 模拟旧版本 envoy:内容与当前模板不同 → 重跑接入包应覆盖为最新
std::fs::write(base.join(".claude/commands/envoy.md"), "旧版使者指令").unwrap();
let r2 = dispatch_templates(pp);
let envoy = r2.iter().find(|t| t.name == ".claude/commands/envoy.md").unwrap();
assert_eq!(envoy.action, "updated", "全托管文件应被升级,而非跳过");
let gate = r2.iter().find(|t| t.name == ".claude/hooks/blueprint-gate.cjs").unwrap();
assert_eq!(gate.action, "skipped", "未动过的全托管文件重跑应 skipped");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn lefthook_keys_extraction_and_check() {
// 从内嵌模板自省:至少含 typecheck 与 commit-msg 侧检查段
let tpl_keys = lefthook_command_keys(TMPL_LEFTHOOK);
assert!(tpl_keys.contains(&"typecheck".to_string()), "模板应含 typecheck: {tpl_keys:?}");
assert!(tpl_keys.len() >= 3, "模板检查段应不止一个: {tpl_keys:?}");
// 注释掉的示例段不提取
assert!(!tpl_keys.contains(&"api-typecheck".to_string()), "注释行不算键");
// 项目文件与模板一致 → pass
let (pass, _) = check_lefthook_keys(Some(TMPL_LEFTHOOK), TMPL_LEFTHOOK);
assert!(pass);
// 缺段 → fail 且列出缺失键
let partial = "pre-commit:\n commands:\n typecheck:\n run: pnpm typecheck\n";
let (pass, detail) = check_lefthook_keys(Some(partial), TMPL_LEFTHOOK);
assert!(!pass);
assert!(detail.contains("缺少检查段"), "应列出缺失键: {detail}");
assert!(!detail.contains("typecheck,") && detail.contains("blueprint-gate"), "已有键不在缺失列表: {detail}");
// 文件缺失 → fail
assert!(!check_lefthook_keys(None, TMPL_LEFTHOOK).0);
}
// ── B4:使者回传覆盖率(沉默项目信号)────────────────────────────────────
#[test]
fn silent_projects_flags_active_without_returns() {
let conn = crate::db::conn_with_schema();
// 两个已登记项目:pw-a 有 commit 无回传(沉默);pw-b 有 commit 有回传
conn.execute_batch(
"INSERT INTO project_profiles (id, name) VALUES ('prof-a','项目A'),('prof-b','项目B');
INSERT INTO project_workspaces (id, profile_id, win_path)
VALUES ('pw-a','prof-a','E:/a'),('pw-b','prof-b','E:/b');",
)
.unwrap();
// pw-a 的 commit 走 blueprint_buffs UUID 关联(生产真实形状)
conn.execute(
"INSERT INTO blueprint_buffs (project_id, project_path) VALUES ('uuid-a','E:/a')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO commit_metrics (sha, project_id, commit_type, is_rework, is_ci_auto, committed_at, parsed_at)
VALUES ('c1','uuid-a','feat',0,0,datetime('now'),datetime('now')),
('c2','uuid-a','fix',1,0,datetime('now'),datetime('now')),
('c3','pw-b','feat',0,0,datetime('now'),datetime('now'))",
[],
)
.unwrap();
// pw-b 近 30 天有使者回传;pw-a 只有普通笔记(不算回传)
conn.execute(
"INSERT INTO project_notes (project_id, content) VALUES
('pw-b','【炼境使者回传】来源: 项目B ...'),
('pw-a','普通笔记,不是回传')",
[],
)
.unwrap();
// 未登记项目的 commit 不列入(无法跑 /envoy)
conn.execute(
"INSERT INTO commit_metrics (sha, project_id, commit_type, is_rework, is_ci_auto, committed_at, parsed_at)
VALUES ('c9','orphan','feat',0,0,datetime('now'),datetime('now'))",
[],
)
.unwrap();
let silent = silent_projects(&conn).unwrap();
assert_eq!(silent.len(), 1, "只有 pw-a 沉默:{silent:?}");
assert_eq!(silent[0].0, "项目A", "经 buff UUID 关联解析出名称");
assert_eq!(silent[0].1, 2, "commit 数按项目聚合");
// 老回传(>30 天)不算覆盖:把 pw-b 的回传改旧后应变沉默
conn.execute(
"UPDATE project_notes SET created_at = datetime('now','localtime','-40 days') WHERE project_id = 'pw-b'",
[],
)
.unwrap();
let silent = silent_projects(&conn).unwrap();
assert_eq!(silent.len(), 2, "pw-b 的回传过期后也应列为沉默");
}
}