diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 0b615b3..08a3cfb 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -6,7 +6,7 @@ contracts: - label: API 接口(Axum) paths: - "src-tauri/src/mcp_server.rs" - doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 ~/.claude.json 的 local 条目(优先于 user 级全局),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" + doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。scoped route 只服务已登记项目,未知 project_id 返回 404(数据库不可用同样拒绝)。各客户端经 %LOCALAPPDATA%/lian-jing/bin/lianjing-mcp-header.cmd 稳定入口取认证头(炼境每次启动指向当前 exe):Codex 写项目 .codex/config.toml,Claude 写 ~/.claude.json 的 local 条目(优先于 user 级全局;直接编辑该文件中本项目键下的 mcpServers.lian-jing,不经 claude CLI、不依赖项目目录存在,项目键用真实长路径;原子替换写入),DSH 写 ~/.dsh/cordis.patch.yml 托管块(内嵌炼境登记的「项目根目录→project_id」白名单,!!js 从启动目录向上精确匹配根目录才选项目地址并取认证,任意目录里的 context.json 不作授权依据;未命中回退全局;项目增删与端口变更时重写)。Claude local 条目与 DSH 块只替换炼境托管的条目,手写同名配置不覆盖;移除项目时一并清理。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。" - label: MCP 工具接口 paths: diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index d7a1d43..cbedfc1 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -304,11 +304,11 @@ pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), Strin // 根目录变化:Claude local 条目按目录挂载、DSH 白名单按根目录匹配,都要迁移到新根。 // 会调用 claude CLI,放到后台线程,避免同步命令卡住界面。 - if let Some(old_root) = old_root { + if root_may_change { let new_root = crate::mcp_inject::project_root_with_conn(&conn, &id).ok(); - if new_root.as_ref() != Some(&old_root) { + if crate::mcp_inject::project_root_changed(old_root.as_ref(), new_root.as_ref()) { drop(conn); - std::thread::spawn(move || crate::mcp_inject::on_project_root_changed(&id, Some(old_root))); + std::thread::spawn(move || crate::mcp_inject::on_project_root_changed(&id, old_root)); } } diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index bbad37c..8233eef 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -362,19 +362,18 @@ fn global_log_context() -> String { } /// Claude Code local scope 条目:项目专用地址 + 稳定 helper。local 优先于 user 级全局注册且不进仓库。 -fn claude_local_server_json(server_url: &str, header_helper: &str) -> String { +fn claude_local_server_entry(server_url: &str, header_helper: &str) -> Value { json!({ "type": "http", "url": server_url, "headersHelper": header_helper, }) - .to_string() } #[derive(Debug, PartialEq)] enum ClaudeLocalEntry { Absent, - /// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除;附原值供失败回滚。 + /// 炼境写入的条目(项目路由 + 稳定 helper),可安全替换或删除。 Managed(Value), /// 用户手写的同名条目:炼境不覆盖、不删除。 Manual, @@ -413,128 +412,166 @@ fn classify_claude_local_entry(claude_json: &Value, root: &std::path::Path) -> C } } -fn read_claude_user_config() -> Option { - let home = std::env::var("USERPROFILE").ok()?; - let text = std::fs::read_to_string(PathBuf::from(home).join(".claude.json")).ok()?; - serde_json::from_str(&text).ok() +#[derive(Debug, PartialEq)] +enum ClaudeLocalOutcome { + Unchanged, + Registered, + Removed, + KeptManual, } -fn run_claude_cli( - claude_bin: &str, - git_bash: &str, +/// 纯函数:把期望状态写进 ~/.claude.json 的配置对象。只动本项目条目下的 mcpServers.lian-jing, +/// 项目对象里 Claude 自己维护的其他字段与其他 server 原样保留;手写条目不碰。 +fn apply_claude_local( + config: &mut Value, root: &std::path::Path, - args: &[&str], -) -> Result<(), String> { - let out = crate::silent_cmd(claude_bin) - .current_dir(root) - .args(args) - .env("CLAUDE_CODE_GIT_BASH_PATH", git_bash) - .output() - .map_err(|error| format!("执行 claude CLI 失败: {error}"))?; - if out.status.success() { - Ok(()) + desired: Option, +) -> Result { + match (classify_claude_local_entry(config, root), &desired) { + (ClaudeLocalEntry::Manual, _) => return Ok(ClaudeLocalOutcome::KeptManual), + (ClaudeLocalEntry::Absent, None) => return Ok(ClaudeLocalOutcome::Unchanged), + (ClaudeLocalEntry::Managed(current), Some(entry)) if ¤t == entry => { + return Ok(ClaudeLocalOutcome::Unchanged) + } + _ => {} + } + let wanted = normalize_root_key(&root.display().to_string()); + let projects = config + .as_object_mut() + .ok_or("~/.claude.json 顶层不是 JSON 对象")? + .entry("projects") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or("~/.claude.json 的 projects 不是 JSON 对象")?; + // 复用 Claude 已记录的键(大小写、分隔符以它为准),没有才按它的惯例(正斜杠)新建 + let key = projects + .keys() + .find(|key| normalize_root_key(key) == wanted) + .cloned() + .unwrap_or_else(|| root.display().to_string().replace('\\', "/")); + match desired { + Some(entry) => { + projects + .entry(key) + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or("~/.claude.json 的项目条目不是 JSON 对象")? + .entry("mcpServers") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or("~/.claude.json 的 mcpServers 不是 JSON 对象")? + .insert(MCP_KEY.to_string(), entry); + Ok(ClaudeLocalOutcome::Registered) + } + None => { + if let Some(servers) = projects + .get_mut(&key) + .and_then(|project| project.get_mut("mcpServers")) + .and_then(Value::as_object_mut) + { + servers.remove(MCP_KEY); + } + Ok(ClaudeLocalOutcome::Removed) + } + } +} + +/// 同目录临时文件 + 重命名替换:读者只会看到完整的旧文件或新文件。目标被短暂占用时重试几次。 +fn write_file_atomic(path: &std::path::Path, content: &str) -> Result<(), String> { + let tmp = path.with_file_name(format!( + "{}.lianjing-tmp", + path.file_name().and_then(|name| name.to_str()).unwrap_or("config") + )); + std::fs::write(&tmp, content).map_err(|error| format!("写临时文件失败: {error}"))?; + let mut last_error = None; + for _ in 0..5 { + match std::fs::rename(&tmp, path) { + Ok(()) => return Ok(()), + Err(error) => { + last_error = Some(error); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + } + } + let _ = std::fs::remove_file(&tmp); + Err(format!("替换文件失败: {}", last_error.map(|e| e.to_string()).unwrap_or_default())) +} + +/// Claude 以真实长路径记录项目(8.3 短名与大小写都以文件系统为准;2026-10-01 实测短路径键不会被识别)。 +/// 目录存在时规范化为长路径(去掉 \\?\ 前缀),不存在(已移动或删除)时原样使用。 +fn claude_project_path(root: &std::path::Path) -> PathBuf { + let Ok(canonical) = std::fs::canonicalize(root) else { + return root.to_path_buf(); + }; + let text = canonical.display().to_string(); + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + PathBuf::from(format!(r"\\{rest}")) + } else if let Some(rest) = text.strip_prefix(r"\\?\") { + PathBuf::from(rest) } else { - Err(format!( - "{}{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - )) + canonical } } /// 客户端侧同步(Claude local、DSH 托管块)的日志单独归类:不覆盖诊断里每个项目的「最后一次注入结果」。 const CLIENT_SYNC_LOG: &str = "mcp_client_sync"; -type ClaudeSyncJob = (String, PathBuf, Option<(String, String)>); -static CLAUDE_SYNC_QUEUE: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - -/// 所有 Claude local 同步投递到同一个后台线程,按提交顺序串行执行:避免多个 claude CLI 并发 -/// 读改写 ~/.claude.json,也保证「注入后立刻移除」时移除不会被更早的注册越过。调用方不阻塞。 -fn enqueue_claude_sync(project_id: &str, root: PathBuf, desired: Option<(String, String)>) { - let queue = CLAUDE_SYNC_QUEUE.get_or_init(|| { - let (sender, receiver) = std::sync::mpsc::channel::(); - std::thread::spawn(move || { - for (project_id, root, desired) in receiver { - sync_claude_local(&project_id, &root, desired); - } - }); - std::sync::Mutex::new(sender) - }); - if let Ok(sender) = queue.lock() { - let _ = sender.send((project_id.to_string(), root, desired)); - } -} - /// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。 -/// 手写条目一律不碰;替换失败时用原值回滚。best-effort,结果只记日志。只应经 enqueue_claude_sync 调用。 +/// 直接编辑 ~/.claude.json(Claude Code 文档列出的配置方式之一),不经 claude CLI:不依赖项目目录 +/// 存在(目录已删除或移动也能清理旧条目)、不起子进程、毫秒级完成,因此可在调用方同步执行。 +/// Claude 自己也会写这个文件,读改写窗口压到最小(紧挨写入前读取 + 原子替换)。best-effort,只记日志。 +/// 调用方须持有 mcp_op_lock,保证本进程内对该文件的读改写串行。 fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(String, String)>) { - let context = inject_log_context(project_id, "claude"); - let state = read_claude_user_config() - .map(|config| classify_claude_local_entry(&config, root)) - .unwrap_or(ClaudeLocalEntry::Absent); - if matches!((&state, &desired), (ClaudeLocalEntry::Absent, None)) { + let Ok(home) = std::env::var("USERPROFILE") else { + return; + }; + let path = PathBuf::from(home).join(".claude.json"); + if !path.is_file() { + // 本机从未运行过 Claude Code:不替它创建配置 return; } - let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else { - db::log_event( - CLIENT_SYNC_LOG, - "warn", - "claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步", - Some(&context), - ); - return; - }; - let remove_args = ["mcp", "remove", MCP_KEY, "--scope", "local"]; - let outcome = match (state, desired) { - (ClaudeLocalEntry::Manual, _) => { - Ok("本项目已有手写的 lian-jing local 条目,炼境不覆盖".to_string()) + let root = claude_project_path(root); + let root = root.as_path(); + let result = (|| -> Result { + let text = std::fs::read_to_string(&path).map_err(|error| format!("读取失败: {error}"))?; + let mut config: Value = + serde_json::from_str(&text).map_err(|error| format!("解析失败,炼境不写入: {error}"))?; + let entry = desired.map(|(url, helper)| claude_local_server_entry(&url, &helper)); + let outcome = apply_claude_local(&mut config, root, entry)?; + if matches!(outcome, ClaudeLocalOutcome::Registered | ClaudeLocalOutcome::Removed) { + let content = serde_json::to_string_pretty(&config).map_err(|error| error.to_string())?; + write_file_atomic(&path, &content)?; } - (ClaudeLocalEntry::Absent, None) => return, - (ClaudeLocalEntry::Managed(_), None) => { - run_claude_cli(&claude_bin, &git_bash, root, &remove_args) - .map(|_| "Claude 项目级 MCP 条目已移除".to_string()) - } - (previous, Some((server_url, header_helper))) => { - let payload = claude_local_server_json(&server_url, &header_helper); - let add_args = ["mcp", "add-json", MCP_KEY, payload.as_str(), "--scope", "local"]; - let registered = if let ClaudeLocalEntry::Managed(old) = &previous { - if let Err(error) = run_claude_cli(&claude_bin, &git_bash, root, &remove_args) { - db::log_event( - CLIENT_SYNC_LOG, - "warn", - &format!("Claude 项目级 MCP 旧条目移除失败: {error}"), - Some(&context), - ); - return; - } - run_claude_cli(&claude_bin, &git_bash, root, &add_args).or_else(|error| { - let restore = old.to_string(); - let _ = run_claude_cli( - &claude_bin, - &git_bash, - root, - &["mcp", "add-json", MCP_KEY, restore.as_str(), "--scope", "local"], - ); - Err(format!("{error}(已尝试恢复原条目)")) - }) - } else { - run_claude_cli(&claude_bin, &git_bash, root, &add_args) + Ok(outcome) + })(); + let context = inject_log_context(project_id, "claude"); + match result { + Ok(ClaudeLocalOutcome::Unchanged) => {} + Ok(outcome) => { + let message = match outcome { + ClaudeLocalOutcome::Registered => "Claude 项目级 MCP 条目已写入", + ClaudeLocalOutcome::Removed => "Claude 项目级 MCP 条目已移除", + _ => "本项目已有手写的 lian-jing local 条目,炼境不覆盖", }; - registered.map(|_| "Claude 项目级 MCP 注册成功".to_string()) + db::log_event(CLIENT_SYNC_LOG, "info", message, Some(&context)); } - }; - match outcome { - Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", &message, Some(&context)), Err(error) => db::log_event( CLIENT_SYNC_LOG, "warn", - &format!("Claude 项目级 MCP 同步失败: {error}"), + &format!("Claude 项目级 MCP 同步失败(~/.claude.json): {error}"), Some(&context), ), } } +/// 进程内所有「炼境 MCP 接入」写操作(注入、移除、根目录迁移)串行执行: +/// 检查 token → 写项目文件 → 写客户端配置作为一个整体,不与其他接入操作交错。 +static MCP_OP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn mcp_op_lock() -> std::sync::MutexGuard<'static, ()> { + MCP_OP_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + // ── DSH 机器级配置托管块 ────────────────────────────────────────────────────── // DSH 配置无法按项目拆分,但 !!js 在加载时以启动目录求值。项目身份只认炼境登记表:托管块内嵌 // 「已登记项目根目录 → project_id」白名单(非秘密),从启动目录向上精确匹配根目录才连项目专用地址 @@ -761,8 +798,14 @@ pub fn ensure_dsh_patch( Ok(true) } +/// DSH 补丁文件的读-算-写在本进程内串行(启动线程与注入/移除可能同时刷新)。 +static DSH_PATCH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// 刷新 DSH 托管块的 best-effort 包装:失败只记日志。 pub fn refresh_dsh_patch(exclude: Option<&str>) { + let _guard = DSH_PATCH_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let result = db::pool() .get() .map_err(|error| error.to_string()) @@ -948,11 +991,13 @@ fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> { /// 向项目的 .claude/settings.json 注入单一 lian-jing MCP 配置, /// 并清理旧组级 key(迁移内建于重注入) pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { - inject_project_mcp_with(project_id, true) + let _guard = mcp_op_lock(); + inject_project_mcp_locked(project_id, true) } -/// refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新,由调用方在末尾统一刷新一次。 -fn inject_project_mcp_with(project_id: &str, refresh_dsh: bool) -> Result<(), String> { +/// 调用方须持有 mcp_op_lock。refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新, +/// 由调用方在末尾统一刷新一次。 +fn inject_project_mcp_locked(project_id: &str, refresh_dsh: bool) -> Result<(), String> { let port = crate::mcp_server::read_port(); let server_url = crate::mcp_server::scoped_mcp_url(port, project_id); let digest = crate::credential_store::mcp_auth_digest(project_id)?; @@ -999,8 +1044,7 @@ fn inject_project_mcp_with(project_id: &str, refresh_dsh: bool) -> Result<(), St try_register_globally(port); let root = project_root(project_id)?; let desired = (!project_wants_global_scope(&root)).then(|| (server_url, header_helper)); - // claude CLI 每次调用约 1-2 秒:投递到串行后台队列,不阻塞界面,也不与其他同步竞态 - enqueue_claude_sync(project_id, root, desired); + sync_claude_local(project_id, &root, desired); if refresh_dsh { refresh_dsh_patch(None); } @@ -1010,13 +1054,34 @@ fn inject_project_mcp_with(project_id: &str, refresh_dsh: bool) -> Result<(), St /// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。 /// 调用方需自行保证项目已不属于任何产品组时才调用。 pub fn remove_project_mcp(project_id: &str) -> Result<(), String> { - // 先于登记删除算出根目录并排队清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。 - // 与注册共用同一串行队列,保证移除不会被更早提交的注册越过。 + let _guard = mcp_op_lock(); + // 先于登记删除、也先于删除本地文件夹同步清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。 if let Ok(root) = project_root(project_id) { - enqueue_claude_sync(project_id, root, None); + sync_claude_local(project_id, &root, None); } - // 登记此时仍在,用 exclude 立即剔除;token 随后被删,之后的刷新也不会再把它加回白名单 + // token 最先删:DSH 白名单剔除与根目录迁移判定都以 token 为准,后面的文件清理即使失败也不影响这一点 + let token_removed = crate::credential_store::delete_mcp_auth_token_for_project( + &crate::credential_store::WindowsCredentialStore, + project_id, + ); + // 登记此时仍在,exclude 立即剔除;token 已删,之后的刷新也不会再把它加回白名单 refresh_dsh_patch(Some(project_id)); + let mut errors: Vec = Vec::new(); + if let Err(error) = remove_project_files_mcp(project_id) { + errors.push(error); + } + if let Err(error) = token_removed { + errors.push(error); + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join(";")) + } +} + +/// 清理项目文件中的炼境 MCP 配置(Codex 托管块与 .claude/settings.json 兼容条目)。 +fn remove_project_files_mcp(project_id: &str) -> Result<(), String> { remove_codex_mcp_config(&codex_config_path(project_id)?)?; let path = settings_path(project_id)?; if path.exists() { @@ -1030,10 +1095,7 @@ pub fn remove_project_mcp(project_id: &str) -> Result<(), String> { } write_json(&path, &settings)?; } - crate::credential_store::delete_mcp_auth_token_for_project( - &crate::credential_store::WindowsCredentialStore, - project_id, - ) + Ok(()) } /// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身) @@ -1108,7 +1170,8 @@ pub fn reinject_all_mcp() -> Vec { let mut errors = Vec::new(); for project_id in project_ids { - if let Err(e) = inject_project_mcp_with(&project_id, false) { + let _guard = mcp_op_lock(); + if let Err(e) = inject_project_mcp_locked(&project_id, false) { errors.push(format!("项目 {}: {}", project_id, e)); } } @@ -1117,11 +1180,14 @@ pub fn reinject_all_mcp() -> Vec { errors } -/// 项目根目录或平台变更后调用:排队清理旧根下炼境托管的 Claude local 条目;已接入(有 token)的项目 -/// 在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。会调用 claude CLI,调用方应放在后台线程。 +/// 项目根目录或平台变更后调用:清理旧根下炼境托管的 Claude local 条目(直接编辑配置,旧目录不存在也能清理); +/// 已接入(有 token)的项目在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。 +/// 重新注入会跑 claude CLI 做全局注册,调用方应放在后台线程。持锁执行:与并发的移除/删除串行, +/// 移除先完成则 token 已不在,这里就不会再注入。 pub fn on_project_root_changed(project_id: &str, old_root: Option) { + let _guard = mcp_op_lock(); if let Some(root) = old_root { - enqueue_claude_sync(project_id, root, None); + sync_claude_local(project_id, &root, None); } let injected = crate::credential_store::mcp_auth_digest(project_id) .and_then(|digest| { @@ -1135,16 +1201,24 @@ pub fn on_project_root_changed(project_id: &str, old_root: Option) { refresh_dsh_patch(None); return; } - if let Err(error) = inject_project_mcp(project_id) { + if let Err(error) = inject_project_mcp_locked(project_id, true) { + // 属于注入结果,归入 mcp_inject;注入中途失败可能没走到刷新,这里补一次 db::log_event( - CLIENT_SYNC_LOG, + "mcp_inject", "warn", &format!("项目根目录变更后重新注入失败: {error}"), Some(&inject_log_context(project_id, "all")), ); + refresh_dsh_patch(None); } } +/// 纯函数:update_project 后是否需要迁移客户端配置。只要能解析出新根且与旧根不同就迁移; +/// 旧根解析不出(例如旧路径为空)也照样迁移,只是没有旧条目要清理。 +pub(crate) fn project_root_changed(old_root: Option<&PathBuf>, new_root: Option<&PathBuf>) -> bool { + new_root.is_some() && new_root != old_root +} + #[cfg(test)] mod tests { use super::*; @@ -1345,16 +1419,91 @@ mod tests { #[test] fn claude_local_entry_uses_scoped_url_and_shim() { - let entry: Value = serde_json::from_str(&claude_local_server_json( + let entry = claude_local_server_entry( "http://127.0.0.1:27190/mcp/projects/p1", &shim_helper_command("d1"), - )) - .unwrap(); + ); assert_eq!(entry["type"], "http"); assert_eq!(entry["url"], "http://127.0.0.1:27190/mcp/projects/p1"); assert!(entry["headersHelper"].as_str().unwrap().contains("%LOCALAPPDATA%")); } + #[test] + fn claude_local_direct_edit_touches_only_lian_jing_entry() { + let root = std::path::Path::new("E:\\code-project\\Demo"); + let entry = claude_local_server_entry( + "http://127.0.0.1:27190/mcp/projects/p1", + &shim_helper_command("d1"), + ); + // 已有项目键(大小写、分隔符与 root 不同):复用该键,保留 Claude 自己的字段与其他 server + let mut config = json!({ + "numStartups": 3, + "projects": { "e:/code-project/demo": { + "lastCost": 1.5, + "mcpServers": { "other": { "type": "stdio" } } + } } + }); + assert_eq!( + apply_claude_local(&mut config, root, Some(entry.clone())).unwrap(), + ClaudeLocalOutcome::Registered + ); + let project = &config["projects"]["e:/code-project/demo"]; + assert_eq!(config["projects"].as_object().unwrap().len(), 1, "不新增重复键"); + assert_eq!(project["lastCost"], 1.5); + assert_eq!(project["mcpServers"]["other"]["type"], "stdio"); + assert_eq!(project["mcpServers"][MCP_KEY], entry); + assert_eq!(config["numStartups"], 3); + assert_eq!( + apply_claude_local(&mut config, root, Some(entry.clone())).unwrap(), + ClaudeLocalOutcome::Unchanged, + "相同条目不重写" + ); + + assert_eq!(apply_claude_local(&mut config, root, None).unwrap(), ClaudeLocalOutcome::Removed); + let project = &config["projects"]["e:/code-project/demo"]; + assert!(project["mcpServers"].get(MCP_KEY).is_none()); + assert_eq!(project["mcpServers"]["other"]["type"], "stdio", "移除只删 lian-jing"); + assert_eq!(apply_claude_local(&mut config, root, None).unwrap(), ClaudeLocalOutcome::Unchanged); + + // 尚无项目键:按 Claude 惯例(正斜杠)新建 + let mut empty = json!({}); + apply_claude_local(&mut empty, root, Some(entry.clone())).unwrap(); + assert_eq!(empty["projects"]["E:/code-project/Demo"]["mcpServers"][MCP_KEY], entry); + + // 手写条目:注册与移除都不碰 + let manual = json!({ "type": "http", "url": "https://example.invalid/mcp" }); + let mut handwritten = json!({ "projects": { "E:/code-project/Demo": { "mcpServers": { "lian-jing": manual.clone() } } } }); + assert_eq!( + apply_claude_local(&mut handwritten, root, Some(entry)).unwrap(), + ClaudeLocalOutcome::KeptManual + ); + assert_eq!(apply_claude_local(&mut handwritten, root, None).unwrap(), ClaudeLocalOutcome::KeptManual); + assert_eq!(handwritten["projects"]["E:/code-project/Demo"]["mcpServers"][MCP_KEY], manual); + } + + #[test] + fn claude_project_path_is_long_form_without_verbatim_prefix() { + let dir = std::env::temp_dir().join(format!("lianjing_claude_path_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let resolved = claude_project_path(&dir).display().to_string(); + assert!(!resolved.starts_with(r"\\?\"), "不得带 verbatim 前缀:{resolved}"); + assert!(!resolved.contains('~'), "8.3 短名须展开为长路径:{resolved}"); + assert!(std::path::Path::new(&resolved).is_dir()); + let missing = std::path::Path::new("E:\\definitely\\missing\\root"); + assert_eq!(claude_project_path(missing), missing.to_path_buf(), "不存在时原样返回"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn project_root_change_detection() { + let old = PathBuf::from("E:\\old"); + let new = PathBuf::from("E:\\new"); + assert!(project_root_changed(Some(&old), Some(&new))); + assert!(project_root_changed(None, Some(&new)), "旧根解析不出也要迁移"); + assert!(!project_root_changed(Some(&old), Some(&old))); + assert!(!project_root_changed(Some(&old), None), "新根解析不出则不动"); + } + #[test] fn dsh_block_is_valid_yaml_with_registry_routing_and_shim_auth() { let roots = vec![