fix(mcp/parity): 收尾审查修复——Claude 现状以 claude mcp get 为准,接入命令移出界面线程
收尾对抗审查指出:只读扫描 ~/.claude.json 的结论不能代表 Claude 在项目目录实际生效的条目 (共享项目键、worktree、目录迁移下会误删他项目条目或漏写),且同步 CLI 调用会阻塞界面线程。 - 快路径只认「项目根或其祖先目录键下」的一致托管条目;其余一律在项目目录执行 `claude mcp get lian-jing` 取实际生效条目,按归属决策:本项目托管条目 → 刷新/移除 (写入失败用原条目回滚);他项目托管条目或手写条目 → 不碰并记录;无 local 条目 → 写入 - 输出无法识别、CLI 出错或目录不存在时一律不写,只记 warn - 全局迁移需 claude CLI 与 git-bash 均可用才置完成标记;批量重注入的成员复查失败计入错误 - 删组/移出组先删成员关系再清 MCP 配置,避免并发重注入把项目加回;调 CLI 前释放数据库连接 - delete_group / add_group_member / remove_group_member / delete_project / update_settings / apply_blueprint_buff 改为 #[tauri::command(async)],在线程池执行 - 新增 parse_claude_mcp_get / lianjing_project_of / key_covers_root 单元测试 实测(enterprise):稳态注入 174ms 不起 CLI;条目缺失 → 写入;过期 helper → 刷新; 手写条目 → 保留;三种情况 claude mcp get 均为 Local 且 Connected;经 shim 零凭据探针认证通过。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
92e6450403
commit
0a92a5b695
@ -17,7 +17,8 @@ pub struct BuffStatus {
|
||||
// ── Tauri 命令 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// 向项目施加蓝图 Buff:注入 CONVENTIONS + CLAUDE.md,写入 DB
|
||||
#[tauri::command]
|
||||
// 会经 claude CLI 写客户端配置:在线程池执行,不占界面线程
|
||||
#[tauri::command(async)]
|
||||
pub fn apply_blueprint_buff(
|
||||
project_path: String,
|
||||
project_id: String,
|
||||
|
||||
@ -72,10 +72,10 @@ pub fn update_group(id: String, input: GroupInput) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
// 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_group(id: String) -> Result<(), String> {
|
||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||
// 先清除所有成员的 MCP 配置(DB cascade 删除之前)
|
||||
let member_ids: Vec<String> = conn
|
||||
.prepare("SELECT project_id FROM project_group_map WHERE group_id = ?1")
|
||||
.map_err(|e| e.to_string())?
|
||||
@ -83,18 +83,24 @@ pub fn delete_group(id: String) -> Result<(), String> {
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
for project_id in &member_ids {
|
||||
// 单一 lian-jing key 为全项目共用:成员还属于其他组时保留配置
|
||||
let in_other_group: i64 = conn
|
||||
.query_row(
|
||||
// 单一 lian-jing key 为全项目共用:成员还属于其他组时保留配置。删组前先算出需清理的成员
|
||||
let to_clear: Vec<&String> = member_ids
|
||||
.iter()
|
||||
.filter(|project_id| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1 AND group_id != ?2",
|
||||
params![project_id, id],
|
||||
|row| row.get(0),
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
if in_other_group > 0 {
|
||||
continue;
|
||||
}
|
||||
.unwrap_or(0)
|
||||
== 0
|
||||
})
|
||||
.collect();
|
||||
// 先删组(级联删除成员关系)再清 MCP 配置:批量重注入在锁内复查成员关系,先删可避免把它们重新注入
|
||||
conn.execute("DELETE FROM product_groups WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
drop(conn);
|
||||
for project_id in to_clear {
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(project_id) {
|
||||
let ctx = serde_json::json!({"project_id": project_id, "group_id": id}).to_string();
|
||||
log_event(
|
||||
@ -105,8 +111,6 @@ pub fn delete_group(id: String) -> Result<(), String> {
|
||||
);
|
||||
}
|
||||
}
|
||||
conn.execute("DELETE FROM product_groups WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -181,6 +185,7 @@ pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> {
|
||||
params![project_id, group_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
drop(conn);
|
||||
|
||||
if let Err(e) = mcp_inject::inject_project_mcp(project_id) {
|
||||
let ctx = serde_json::json!({"project_id": project_id, "group_id": &group_id}).to_string();
|
||||
@ -195,7 +200,8 @@ pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
// 会经 claude CLI 写客户端配置:在线程池执行,不占界面线程
|
||||
#[tauri::command(async)]
|
||||
pub fn add_group_member(
|
||||
project_id: String,
|
||||
group_id: String,
|
||||
@ -207,6 +213,7 @@ pub fn add_group_member(
|
||||
params![project_id, group_id, role],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
drop(conn);
|
||||
// DB 写入成功后注入 MCP 配置(非致命,注入失败不影响成员关系)
|
||||
let inject_warning = match mcp_inject::inject_project_mcp(&project_id) {
|
||||
Ok(()) => None,
|
||||
@ -220,7 +227,8 @@ pub fn add_group_member(
|
||||
Ok(inject_warning)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
// 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程
|
||||
#[tauri::command(async)]
|
||||
pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), String> {
|
||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||
// 仅当项目退出后不再属于任何组时才清除 MCP 配置(单一 lian-jing key 为全项目共用)
|
||||
@ -231,6 +239,13 @@ pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), S
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
// 先删成员关系再清 MCP 配置:批量重注入在锁内复查成员关系,先删可避免把它重新注入
|
||||
conn.execute(
|
||||
"DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2",
|
||||
params![project_id, group_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
drop(conn);
|
||||
if in_other_group == 0 {
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(&project_id) {
|
||||
let ctx =
|
||||
@ -243,10 +258,5 @@ pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), S
|
||||
);
|
||||
}
|
||||
}
|
||||
conn.execute(
|
||||
"DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2",
|
||||
params![project_id, group_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -327,7 +327,8 @@ pub fn update_project_tags(id: String, tags: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
// 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), String> {
|
||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||
|
||||
|
||||
@ -76,7 +76,8 @@ pub fn get_settings(space_id: Option<String>) -> Result<HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// 更新 settings:全局 key 写入 settings 表;其余写入 space_settings 表
|
||||
#[tauri::command]
|
||||
// 端口变更会重注入全部项目(含 claude CLI):在线程池执行,不占界面线程
|
||||
#[tauri::command(async)]
|
||||
pub fn update_settings(
|
||||
settings: HashMap<String, String>,
|
||||
space_id: Option<String>,
|
||||
|
||||
@ -31,7 +31,11 @@ fn header_shim_path() -> Result<PathBuf, String> {
|
||||
/// 路径里的 % 必须写成 %%,否则会和 %1 配对被当作变量展开吞掉。
|
||||
fn render_header_shim(executable: &std::path::Path) -> String {
|
||||
let exe = executable.display().to_string().replace('%', "%%");
|
||||
let codepage = if exe.is_ascii() { "" } else { "chcp 65001 >nul\r\n" };
|
||||
let codepage = if exe.is_ascii() {
|
||||
""
|
||||
} else {
|
||||
"chcp 65001 >nul\r\n"
|
||||
};
|
||||
format!("@echo off\r\n{codepage}\"{exe}\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\r\n")
|
||||
}
|
||||
|
||||
@ -214,10 +218,7 @@ fn find_claude_bin() -> Option<String> {
|
||||
}
|
||||
|
||||
// 最后尝试 where 命令(GUI 进程 PATH 可能不含用户 bin,but worth trying)
|
||||
if let Ok(out) = crate::silent_cmd("where")
|
||||
.arg("claude.exe")
|
||||
.output()
|
||||
{
|
||||
if let Ok(out) = crate::silent_cmd("where").arg("claude.exe").output() {
|
||||
if out.status.success() {
|
||||
if let Ok(stdout) = String::from_utf8(out.stdout) {
|
||||
if let Some(line) = stdout.lines().next() {
|
||||
@ -300,10 +301,24 @@ fn try_register_globally(port: u16) -> bool {
|
||||
&claude_bin,
|
||||
&git_bash,
|
||||
None,
|
||||
&["mcp", "add", "--transport", "sse", "-s", "user", MCP_KEY, &server_url],
|
||||
&[
|
||||
"mcp",
|
||||
"add",
|
||||
"--transport",
|
||||
"sse",
|
||||
"-s",
|
||||
"user",
|
||||
MCP_KEY,
|
||||
&server_url,
|
||||
],
|
||||
) {
|
||||
Ok(()) => {
|
||||
db::log_event("mcp_inject", "info", "全局 MCP 注册成功", Some(&global_log_context()));
|
||||
Ok(_) => {
|
||||
db::log_event(
|
||||
"mcp_inject",
|
||||
"info",
|
||||
"全局 MCP 注册成功",
|
||||
Some(&global_log_context()),
|
||||
);
|
||||
true
|
||||
}
|
||||
// 同名已存在不是故障——CLI 链路健康,注册目标已达成
|
||||
@ -328,7 +343,11 @@ fn project_wants_global_scope(root: &std::path::Path) -> bool {
|
||||
"canonical 项目上下文",
|
||||
)
|
||||
.ok()
|
||||
.and_then(|ctx| ctx.get("mcp_scope").and_then(Value::as_str).map(|scope| scope == "global"))
|
||||
.and_then(|ctx| {
|
||||
ctx.get("mcp_scope")
|
||||
.and_then(Value::as_str)
|
||||
.map(|scope| scope == "global")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@ -383,7 +402,10 @@ fn managed_claude_entries(config: &Value, project_id: &str) -> Vec<(String, Valu
|
||||
.filter_map(|(key, project)| {
|
||||
let entry = project.get("mcpServers")?.get(MCP_KEY)?;
|
||||
let url = entry.get("url").and_then(Value::as_str)?;
|
||||
let helper = entry.get("headersHelper").and_then(Value::as_str).unwrap_or("");
|
||||
let helper = entry
|
||||
.get("headersHelper")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
(url.starts_with("http://127.0.0.1:")
|
||||
&& url.ends_with(&suffix)
|
||||
&& helper.contains(MCP_HEADER_SHIM_NAME))
|
||||
@ -399,16 +421,16 @@ fn claude_global_registered(config: &Value) -> bool {
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作与界面。
|
||||
/// claude CLI 单次调用上限:卡住的子进程会被终止,不让它拖住持锁的接入操作。
|
||||
const CLAUDE_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// 运行 claude CLI(不弹窗、stdin 置空、带超时)。成功返回 Ok,失败返回合并后的输出。
|
||||
/// 运行 claude CLI(不弹窗、stdin 置空、带超时)。成功返回输出,失败返回合并后的输出。
|
||||
fn run_claude_cli(
|
||||
claude_bin: &str,
|
||||
git_bash: &str,
|
||||
cwd: Option<&std::path::Path>,
|
||||
args: &[&str],
|
||||
) -> Result<(), String> {
|
||||
) -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
let mut command = crate::silent_cmd(claude_bin);
|
||||
if let Some(dir) = cwd {
|
||||
@ -448,96 +470,178 @@ fn run_claude_cli(
|
||||
let _ = stderr.read_to_string(&mut output);
|
||||
}
|
||||
if status.success() {
|
||||
Ok(())
|
||||
Ok(output)
|
||||
} else {
|
||||
Err(output)
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一分隔符与大小写,便于比较 ~/.claude.json 的项目键与项目根目录。
|
||||
fn normalize_root_key(path: &str) -> String {
|
||||
path.replace('\\', "/").trim_end_matches('/').to_lowercase()
|
||||
}
|
||||
|
||||
/// Claude 以 git 根为项目键,必为项目根本身或其祖先目录。
|
||||
fn key_covers_root(key: &str, root: &str) -> bool {
|
||||
let key = normalize_root_key(key);
|
||||
let root = normalize_root_key(root);
|
||||
root == key || root.starts_with(&format!("{key}/"))
|
||||
}
|
||||
|
||||
/// 炼境项目路由 URL 里的 project_id;不是炼境项目路由则为 None(视为手写配置)。
|
||||
fn lianjing_project_of(url: &str) -> Option<String> {
|
||||
let rest = url.strip_prefix("http://127.0.0.1:")?;
|
||||
let encoded = rest.split_once("/mcp/projects/")?.1.trim_end_matches('/');
|
||||
urlencoding::decode(encoded).ok().map(|id| id.into_owned())
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum ClaudeEffective {
|
||||
/// 本目录生效的是 local 条目,附其 URL
|
||||
Local(String),
|
||||
/// 本目录没有 local 条目(生效的是 user/project 级,或根本没有 lian-jing)
|
||||
NotLocal,
|
||||
}
|
||||
|
||||
/// 纯函数:解析 `claude mcp get lian-jing` 的输出。格式无法识别时返回 None(按「未知」处理,不写入)。
|
||||
fn parse_claude_mcp_get(output: &str) -> Option<ClaudeEffective> {
|
||||
if output.contains("No MCP server found") {
|
||||
return Some(ClaudeEffective::NotLocal);
|
||||
}
|
||||
let field = |name: &str| {
|
||||
output.lines().find_map(|line| {
|
||||
line.trim()
|
||||
.strip_prefix(name)
|
||||
.map(|value| value.trim().to_string())
|
||||
})
|
||||
};
|
||||
let scope = field("Scope:")?;
|
||||
if scope.starts_with("Local config") {
|
||||
Some(ClaudeEffective::Local(field("URL:")?))
|
||||
} else {
|
||||
Some(ClaudeEffective::NotLocal)
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端侧同步(Claude local、DSH 托管块)的日志单独归类:不覆盖诊断里每个项目的「最后一次注入结果」。
|
||||
const CLIENT_SYNC_LOG: &str = "mcp_client_sync";
|
||||
|
||||
/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除炼境托管的条目。
|
||||
/// 让 Claude 的 local 条目与期望一致:desired=Some 时注册/刷新,None 时只删除本项目的炼境托管条目。
|
||||
///
|
||||
/// 写入一律交给 claude CLI(cwd=项目目录):项目键推导、Claude 自己的文件锁、配置路径与读写细节都由
|
||||
/// Claude 负责,炼境不复刻其私有实现(2026-10-01 最终审查:直接编辑 ~/.claude.json 需要复刻 git 根 /
|
||||
/// worktree / NFC 键推导、.claude.json.lock 锁协议、CLAUDE_CONFIG_DIR 等一整套内部机制,随 Claude 升级易失效)。
|
||||
/// 只读扫描判定现状:已一致则不起 CLI(稳态不阻塞);只替换/删除炼境托管条目,手写条目不碰。
|
||||
/// 项目目录已不存在时无法经 CLI 定位项目键,只记录残留、不写文件。
|
||||
/// 调用方须持有 mcp_op_lock,并在删除本地文件夹之前同步调用(避免与目录删除竞态)。
|
||||
/// 原则:写入一律交给 claude CLI(cwd=项目目录),判定以 Claude 自己的回答为准——炼境不复刻 Claude 的项目键
|
||||
/// 推导、文件锁与配置路径(2026-10-01 多轮审查结论:任何自行推断都会在 worktree、目录迁移、共享键等边界出错)。
|
||||
/// - 快路径(不起 CLI):本项目的托管条目已一致地位于项目根或其祖先目录的键下(Claude 的键是 git 根),视为已同步;
|
||||
/// 只读确认没有任何托管条目时,移除无需动作。
|
||||
/// - 其余情况在项目目录执行 `claude mcp get lian-jing` 取实际生效条目:本项目的托管条目 → 移除或刷新;
|
||||
/// 他项目的托管条目(共享项目键)或手写条目 → 不碰并记录;没有 local 条目 → 按需写入。
|
||||
/// - 刷新时先删后加,写入失败用原条目回滚;无法确认现状(读取失败、CLI 出错、目录不存在)一律不写,只记 warn。
|
||||
///
|
||||
/// 调用方须持有 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 managed = read_claude_config()
|
||||
.map(|config| managed_claude_entries(&config, project_id))
|
||||
let warn = |message: String| db::log_event(CLIENT_SYNC_LOG, "warn", &message, Some(&context));
|
||||
let config = read_claude_config();
|
||||
let root_text = root.display().to_string();
|
||||
let managed_all = config
|
||||
.as_ref()
|
||||
.map(|config| managed_claude_entries(config, project_id))
|
||||
.unwrap_or_default();
|
||||
let managed_here: Vec<&(String, Value)> = managed_all
|
||||
.iter()
|
||||
.filter(|(key, _)| key_covers_root(key, &root_text))
|
||||
.collect();
|
||||
let desired_entry = desired.map(|(url, helper)| claude_local_server_entry(&url, &helper));
|
||||
match &desired_entry {
|
||||
None if managed.is_empty() => return,
|
||||
Some(entry) if managed.iter().any(|(_, current)| current == entry) => return,
|
||||
Some(entry) if managed_here.iter().any(|(_, current)| current == entry) => return,
|
||||
None if config.is_some() && managed_all.is_empty() => return,
|
||||
_ => {}
|
||||
}
|
||||
if !root.is_dir() {
|
||||
let keys: Vec<&str> = managed.iter().map(|(key, _)| key.as_str()).collect();
|
||||
db::log_event(
|
||||
CLIENT_SYNC_LOG,
|
||||
"warn",
|
||||
&format!(
|
||||
"项目目录已不存在,无法经 claude CLI 同步 Claude 项目级条目;残留托管条目所在项目键 {keys:?},可在对应目录执行 `claude mcp remove lian-jing -s local` 清理"
|
||||
),
|
||||
Some(&context),
|
||||
);
|
||||
let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect();
|
||||
warn(format!(
|
||||
"项目目录不存在,无法向 Claude 确认本目录的实际条目,未改动;本项目的托管条目位于项目键 {keys:?},可在对应目录执行 `claude mcp remove lian-jing -s local` 清理"
|
||||
));
|
||||
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),
|
||||
);
|
||||
warn("claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步".into());
|
||||
return;
|
||||
};
|
||||
let remove_error = if managed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let effective =
|
||||
match run_claude_cli(&claude_bin, &git_bash, Some(root), &["mcp", "get", MCP_KEY]) {
|
||||
Ok(output) | Err(output) => parse_claude_mcp_get(&output),
|
||||
};
|
||||
let Some(effective) = effective else {
|
||||
warn("无法确认 Claude 在本目录实际生效的 lian-jing 条目(claude mcp get 输出无法识别),未改动".into());
|
||||
return;
|
||||
};
|
||||
let remove = || {
|
||||
run_claude_cli(
|
||||
&claude_bin,
|
||||
&git_bash,
|
||||
Some(root),
|
||||
&["mcp", "remove", MCP_KEY, "--scope", "local"],
|
||||
)
|
||||
.err()
|
||||
};
|
||||
let outcome: Result<&str, String> = match &desired_entry {
|
||||
None => match remove_error {
|
||||
None => Ok("Claude 项目级 MCP 条目已移除"),
|
||||
Some(error) => Err(error),
|
||||
},
|
||||
Some(entry) => {
|
||||
let payload = entry.to_string();
|
||||
match run_claude_cli(
|
||||
&claude_bin,
|
||||
&git_bash,
|
||||
Some(root),
|
||||
&["mcp", "add-json", MCP_KEY, payload.as_str(), "--scope", "local"],
|
||||
) {
|
||||
Ok(()) => Ok("Claude 项目级 MCP 条目已写入"),
|
||||
Err(error) if error.contains("already exists") => match remove_error {
|
||||
None => Ok("本项目已有手写的 lian-jing local 条目,炼境不覆盖"),
|
||||
Some(remove_error) => Err(format!("旧条目移除失败: {remove_error}")),
|
||||
let add = |entry: &Value| {
|
||||
let payload = entry.to_string();
|
||||
run_claude_cli(
|
||||
&claude_bin,
|
||||
&git_bash,
|
||||
Some(root),
|
||||
&[
|
||||
"mcp",
|
||||
"add-json",
|
||||
MCP_KEY,
|
||||
payload.as_str(),
|
||||
"--scope",
|
||||
"local",
|
||||
],
|
||||
)
|
||||
};
|
||||
let outcome: Result<String, String> = match (effective, &desired_entry) {
|
||||
(ClaudeEffective::Local(url), desired) => match lianjing_project_of(&url) {
|
||||
Some(owner) if owner == project_id => match remove() {
|
||||
Err(error) => Err(format!("旧条目移除失败: {error}")),
|
||||
Ok(_) => match desired {
|
||||
None => Ok("Claude 项目级 MCP 条目已移除".into()),
|
||||
Some(entry) => match add(entry) {
|
||||
Ok(_) => Ok("Claude 项目级 MCP 条目已刷新".into()),
|
||||
Err(error) => {
|
||||
// 回滚:用读到的原条目写回,避免项目失去 local 条目
|
||||
let restored = managed_here
|
||||
.first()
|
||||
.map(|(_, previous)| add(previous).is_ok())
|
||||
.unwrap_or(false);
|
||||
Err(format!(
|
||||
"新条目写入失败: {error}({})",
|
||||
if restored { "已恢复原条目" } else { "原条目未能恢复" }
|
||||
))
|
||||
}
|
||||
},
|
||||
},
|
||||
Err(error) => Err(error),
|
||||
},
|
||||
Some(owner) => Err(format!(
|
||||
"本目录生效的 lian-jing local 条目属于项目 {owner}(共享项目键,如 worktree 或嵌套项目),炼境不改动"
|
||||
)),
|
||||
None => Ok("本目录已有手写的 lian-jing local 条目,炼境不覆盖".into()),
|
||||
},
|
||||
(ClaudeEffective::NotLocal, Some(entry)) => add(entry)
|
||||
.map(|_| "Claude 项目级 MCP 条目已写入".into())
|
||||
.map_err(|error| format!("写入失败: {error}")),
|
||||
(ClaudeEffective::NotLocal, None) => {
|
||||
if managed_all.is_empty() {
|
||||
return;
|
||||
}
|
||||
let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect();
|
||||
Err(format!(
|
||||
"本目录没有生效的 local 条目,但其他项目键 {keys:?} 下残留本项目的托管条目,可在对应目录执行 `claude mcp remove lian-jing -s local` 清理"
|
||||
))
|
||||
}
|
||||
};
|
||||
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}"),
|
||||
Some(&context),
|
||||
),
|
||||
Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", &message, Some(&context)),
|
||||
Err(error) => warn(format!("Claude 项目级 MCP 同步: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
@ -546,7 +650,9 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(
|
||||
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())
|
||||
MCP_OP_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
// ── DSH 机器级配置托管块 ──────────────────────────────────────────────────────
|
||||
@ -598,9 +704,11 @@ fn dsh_project_roots(
|
||||
.filter_map(|(id, win_path)| {
|
||||
let root = validate_registered_path(&win_path, "win_path").ok()?;
|
||||
// 每个项目只读一次 context.json,同时取 project_id 与 mcp_scope
|
||||
let context =
|
||||
read_context_object(&root.join(".lian-jing").join("context.json"), "canonical 项目上下文")
|
||||
.ok()?;
|
||||
let context = read_context_object(
|
||||
&root.join(".lian-jing").join("context.json"),
|
||||
"canonical 项目上下文",
|
||||
)
|
||||
.ok()?;
|
||||
if context.get("project_id").and_then(Value::as_str) != Some(id.as_str()) {
|
||||
return None;
|
||||
}
|
||||
@ -609,7 +717,9 @@ fn dsh_project_roots(
|
||||
return Some((key, None));
|
||||
}
|
||||
let has_token = crate::credential_store::mcp_auth_digest(&id)
|
||||
.and_then(|digest| crate::credential_store::read_mcp_auth_token_for_digest(store, &digest))
|
||||
.and_then(|digest| {
|
||||
crate::credential_store::read_mcp_auth_token_for_digest(store, &digest)
|
||||
})
|
||||
.is_ok();
|
||||
has_token.then(|| (key, Some(id)))
|
||||
})
|
||||
@ -619,7 +729,10 @@ fn dsh_project_roots(
|
||||
fn render_dsh_block(port: u16, roots: &[(String, Option<String>)]) -> String {
|
||||
// 逐行显式缩进:YAML 依赖缩进,不能用字符串续行(会吞掉下一行行首空白)。
|
||||
fn indent(spaces: usize, lines: &[&str]) -> Vec<String> {
|
||||
lines.iter().map(|line| format!("{}{line}", " ".repeat(spaces))).collect()
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| format!("{}{line}", " ".repeat(spaces)))
|
||||
.collect()
|
||||
}
|
||||
// 值为 null 的键是控制面项目的停止哨兵:命中即停,回退全局地址。
|
||||
let roots_map: serde_json::Map<String, Value> = roots
|
||||
@ -637,12 +750,10 @@ fn render_dsh_block(port: u16, roots: &[(String, Option<String>)]) -> String {
|
||||
"}",
|
||||
];
|
||||
let global_url = format!("http://127.0.0.1:{port}/mcp");
|
||||
let url_return = format!(
|
||||
"return id ? '{global_url}/projects/' + encodeURIComponent(id) : '{global_url}';"
|
||||
);
|
||||
let shim_line = format!(
|
||||
"const shim = path.join(base, 'lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');"
|
||||
);
|
||||
let url_return =
|
||||
format!("return id ? '{global_url}/projects/' + encodeURIComponent(id) : '{global_url}';");
|
||||
let shim_line =
|
||||
format!("const shim = path.join(base, 'lian-jing', 'bin', '{MCP_HEADER_SHIM_NAME}');");
|
||||
let header_body = [
|
||||
"if (!id) return {};",
|
||||
"const base = process.env.LOCALAPPDATA, sys = process.env.SystemRoot;",
|
||||
@ -724,7 +835,10 @@ fn has_manual_lian_jing(outside_block: &str) -> Result<bool, String> {
|
||||
/// 块外存在手写的 lian-jing 条目则拒绝覆盖(与 Codex 手工同名 server 的处理一致)。
|
||||
fn upsert_dsh_block(current: &str, block: &str) -> Result<String, String> {
|
||||
const MANUAL: &str = "DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试";
|
||||
match (current.find(DSH_MANAGED_START), current.find(DSH_MANAGED_END)) {
|
||||
match (
|
||||
current.find(DSH_MANAGED_START),
|
||||
current.find(DSH_MANAGED_END),
|
||||
) {
|
||||
(Some(start), Some(end)) if end >= start => {
|
||||
let after = ¤t[end + DSH_MANAGED_END.len()..];
|
||||
if has_manual_lian_jing(&format!("{}{}", ¤t[..start], after))? {
|
||||
@ -766,7 +880,11 @@ pub fn ensure_dsh_patch(
|
||||
};
|
||||
let block = render_dsh_block(
|
||||
port,
|
||||
&dsh_project_roots(conn, &crate::credential_store::WindowsCredentialStore, exclude),
|
||||
&dsh_project_roots(
|
||||
conn,
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
exclude,
|
||||
),
|
||||
);
|
||||
let next = upsert_dsh_block(¤t, &block)?;
|
||||
if next != current {
|
||||
@ -843,7 +961,12 @@ pub fn cleanup_legacy_global_registrations() -> Vec<String> {
|
||||
.map(|out| out.status.success())
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
db::log_event("mcp_inject", "info", "旧组级 MCP 注册已移除", Some(&json!({ "client": "claude-global", "server": &name }).to_string()));
|
||||
db::log_event(
|
||||
"mcp_inject",
|
||||
"info",
|
||||
"旧组级 MCP 注册已移除",
|
||||
Some(&json!({ "client": "claude-global", "server": &name }).to_string()),
|
||||
);
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
@ -1114,8 +1237,10 @@ pub fn migrate_to_single_server() {
|
||||
db::log_event("mcp_inject", level, &summary, None);
|
||||
|
||||
// 全局注册失败说明 claude CLI 链路不通(CLI 缺失/git-bash 缺失/命令报错),
|
||||
// 此时旧注册也清不掉——不落标记位,等下次启动重试(整个流程幂等)
|
||||
if !register_ok {
|
||||
// 此时旧注册也清不掉——不落标记位,等下次启动重试(整个流程幂等)。
|
||||
// try_register_globally 在已注册时会只读早返回,不再证明 CLI 可用,所以另外确认 CLI 与 git-bash 都在。
|
||||
let cli_available = find_claude_bin().is_some() && find_git_bash().is_some();
|
||||
if !register_ok || !cli_available {
|
||||
return;
|
||||
}
|
||||
if let Ok(conn) = db::pool().get() {
|
||||
@ -1151,18 +1276,22 @@ pub fn reinject_all_mcp() -> Vec<String> {
|
||||
// 快照取自锁外:锁内复查成员关系,已被移出组的项目不再重新注入(避免重建刚删除的 token)
|
||||
let still_member = db::pool()
|
||||
.get()
|
||||
.ok()
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM project_group_map WHERE project_id = ?1)",
|
||||
[&project_id],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !still_member {
|
||||
continue;
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
match still_member {
|
||||
Ok(true) => {}
|
||||
Ok(false) => continue,
|
||||
Err(error) => {
|
||||
errors.push(format!("项目 {project_id}: 复查组成员关系失败: {error}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Err(e) = inject_project_mcp_locked(&project_id, false) {
|
||||
errors.push(format!("项目 {}: {}", project_id, e));
|
||||
@ -1173,8 +1302,9 @@ pub fn reinject_all_mcp() -> Vec<String> {
|
||||
errors
|
||||
}
|
||||
|
||||
/// 项目根目录或平台变更后调用:清理旧根下炼境托管的 Claude local 条目(直接编辑配置,旧目录不存在也能清理);
|
||||
/// 已接入(有 token)的项目在新根重新注入(会刷新 DSH 白名单),未接入的只刷新白名单。
|
||||
/// 项目根目录或平台变更后调用:清理旧根下本项目的 Claude local 托管条目(旧目录已不存在时无法向 Claude
|
||||
/// 确认实际条目,只记录残留与清理命令);已接入(有 token)的项目在新根重新注入(会刷新 DSH 白名单),
|
||||
/// 未接入的只刷新白名单。新根的「已同步」判定只认新根或其祖先目录键下的条目,旧键下的残留不会挡住写入。
|
||||
/// 重新注入会跑 claude CLI 做全局注册,调用方应放在后台线程。持锁执行:与并发的移除/删除串行,
|
||||
/// 移除先完成则 token 已不在,这里就不会再注入。
|
||||
pub fn on_project_root_changed(project_id: &str, old_root: Option<PathBuf>) {
|
||||
@ -1336,9 +1466,15 @@ mod tests {
|
||||
"@echo off\r\n\"C:\\Apps\\Lian Jing\\app.exe\" --lianjing-mcp-header %1\r\nexit /b %ERRORLEVEL%\r\n"
|
||||
);
|
||||
let unicode = render_header_shim(std::path::Path::new("C:\\炼境\\app.exe"));
|
||||
assert!(unicode.contains("chcp 65001 >nul\r\n"), "非 ASCII 路径须切 UTF-8 代码页");
|
||||
assert!(
|
||||
unicode.contains("chcp 65001 >nul\r\n"),
|
||||
"非 ASCII 路径须切 UTF-8 代码页"
|
||||
);
|
||||
let percent = render_header_shim(std::path::Path::new("C:\\Users\\a%b\\app.exe"));
|
||||
assert!(percent.contains("\"C:\\Users\\a%%b\\app.exe\" --lianjing-mcp-header %1"), "路径 % 须转义");
|
||||
assert!(
|
||||
percent.contains("\"C:\\Users\\a%%b\\app.exe\" --lianjing-mcp-header %1"),
|
||||
"路径 % 须转义"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1351,7 +1487,13 @@ mod tests {
|
||||
crate::credential_store::get_or_create_mcp_auth_token(&store, id).unwrap();
|
||||
}
|
||||
// notoken:已绑定但没有 token(例如退组后 token 已删),不得进入白名单
|
||||
for (id, scope, bound) in [("p1", None, true), ("ctrl", Some("global"), true), ("drift", None, false), ("gone", None, true), ("notoken", None, true)] {
|
||||
for (id, scope, bound) in [
|
||||
("p1", None, true),
|
||||
("ctrl", Some("global"), true),
|
||||
("drift", None, false),
|
||||
("gone", None, true),
|
||||
("notoken", None, true),
|
||||
] {
|
||||
let root = base.join(id);
|
||||
std::fs::create_dir_all(root.join(".lian-jing")).unwrap();
|
||||
let mut ctx = json!({ "project_id": if bound { id } else { "someone-else" }, "schema_version": 1 });
|
||||
@ -1384,9 +1526,17 @@ mod tests {
|
||||
#[test]
|
||||
fn dsh_root_keys_match_js_normalization() {
|
||||
assert_eq!(normalize_dsh_root("E:\\Code\\Demo"), "e:\\code\\demo");
|
||||
assert_eq!(normalize_dsh_root("E:/Code//Demo/"), "e:\\code\\demo", "正斜杠、重复与尾部分隔符");
|
||||
assert_eq!(
|
||||
normalize_dsh_root("E:/Code//Demo/"),
|
||||
"e:\\code\\demo",
|
||||
"正斜杠、重复与尾部分隔符"
|
||||
);
|
||||
assert_eq!(normalize_dsh_root("E:\\"), "e:", "盘符根与 JS 去尾后一致");
|
||||
assert_eq!(normalize_dsh_root("\\\\Server\\Share\\x"), "\\\\server\\share\\x", "保留 UNC 前缀");
|
||||
assert_eq!(
|
||||
normalize_dsh_root("\\\\Server\\Share\\x"),
|
||||
"\\\\server\\share\\x",
|
||||
"保留 UNC 前缀"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1397,7 +1547,10 @@ mod tests {
|
||||
);
|
||||
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%"));
|
||||
assert!(entry["headersHelper"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("%LOCALAPPDATA%"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1435,14 +1588,95 @@ mod tests {
|
||||
assert!(managed_claude_entries(&json!({}), "p 1").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_mcp_get_output_is_parsed_by_effective_scope() {
|
||||
// 样本取自 claude 2.x 真实输出(2026-10-01)
|
||||
let local = "lian-jing:\n Scope: Local config (private to you in this project)\n Status: ✔ Connected\n Type: http\n URL: http://127.0.0.1:27190/mcp/projects/p%201\n\nTo remove this server, run: claude mcp remove \"lian-jing\" -s local\n";
|
||||
assert_eq!(
|
||||
parse_claude_mcp_get(local),
|
||||
Some(ClaudeEffective::Local(
|
||||
"http://127.0.0.1:27190/mcp/projects/p%201".into()
|
||||
))
|
||||
);
|
||||
let user = "lian-jing:\r\n Scope: User config (available in all your projects)\r\n Type: sse\r\n URL: http://127.0.0.1:27190/mcp\r\n";
|
||||
assert_eq!(parse_claude_mcp_get(user), Some(ClaudeEffective::NotLocal));
|
||||
let missing = "No MCP server found with name: \"lian-jing\". Configured servers: a, b";
|
||||
assert_eq!(
|
||||
parse_claude_mcp_get(missing),
|
||||
Some(ClaudeEffective::NotLocal)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_claude_mcp_get("Error: spawn EPERM"),
|
||||
None,
|
||||
"无法识别则按未知处理,不写入"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_claude_mcp_get("lian-jing:\n Scope: Local config\n Type: stdio\n"),
|
||||
None,
|
||||
"local 条目缺 URL 时无法判定归属"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lianjing_project_routes_are_recognized_and_decoded() {
|
||||
assert_eq!(
|
||||
lianjing_project_of("http://127.0.0.1:27190/mcp/projects/p%201"),
|
||||
Some("p 1".into())
|
||||
);
|
||||
assert_eq!(
|
||||
lianjing_project_of("http://127.0.0.1:1/mcp/projects/abc/"),
|
||||
Some("abc".into())
|
||||
);
|
||||
assert_eq!(
|
||||
lianjing_project_of("http://127.0.0.1:27190/mcp"),
|
||||
None,
|
||||
"全局路由不属于任何项目"
|
||||
);
|
||||
assert_eq!(
|
||||
lianjing_project_of("http://localhost:27190/mcp/projects/abc"),
|
||||
None,
|
||||
"非炼境地址视为手写"
|
||||
);
|
||||
assert_eq!(
|
||||
lianjing_project_of("https://example.com/mcp/projects/abc"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_key_covers_root_and_its_descendants_only() {
|
||||
assert!(key_covers_root(
|
||||
"E:/code-project/demo",
|
||||
"E:\\code-project\\demo\\"
|
||||
));
|
||||
assert!(
|
||||
key_covers_root("e:\\code-project", "E:\\Code-Project\\demo"),
|
||||
"git 根是祖先目录"
|
||||
);
|
||||
assert!(
|
||||
!key_covers_root("E:/code-project/demo", "E:\\code-project\\demo-2"),
|
||||
"前缀相同的兄弟目录不算"
|
||||
);
|
||||
assert!(
|
||||
!key_covers_root("E:/code-project/demo/sub", "E:\\code-project\\demo"),
|
||||
"子目录的键不覆盖父目录"
|
||||
);
|
||||
}
|
||||
|
||||
#[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(None, Some(&new)),
|
||||
"旧根解析不出也要迁移"
|
||||
);
|
||||
assert!(!project_root_changed(Some(&old), Some(&old)));
|
||||
assert!(!project_root_changed(Some(&old), None), "新根解析不出则不动");
|
||||
assert!(
|
||||
!project_root_changed(Some(&old), None),
|
||||
"新根解析不出则不动"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1453,21 +1687,36 @@ mod tests {
|
||||
];
|
||||
let block = render_dsh_block(27190, &roots);
|
||||
assert!(
|
||||
block.contains(r#"const roots = {"e:\\code-project":null,"e:\\code-project\\demo":"p1"};"#),
|
||||
block.contains(
|
||||
r#"const roots = {"e:\\code-project":null,"e:\\code-project\\demo":"p1"};"#
|
||||
),
|
||||
"控制面以 null 作停止哨兵"
|
||||
);
|
||||
assert!(block.contains("hasOwnProperty.call(roots, key)") && block.contains(r"replace(/\\+$/, '')"));
|
||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&block).expect("托管块必须是合法 YAML");
|
||||
assert!(
|
||||
block.contains("hasOwnProperty.call(roots, key)")
|
||||
&& block.contains(r"replace(/\\+$/, '')")
|
||||
);
|
||||
let parsed: serde_yaml::Value =
|
||||
serde_yaml::from_str(&block).expect("托管块必须是合法 YAML");
|
||||
let entry = &parsed[0]["insert"][0];
|
||||
assert_eq!(entry["id"], "mcp-lian-jing");
|
||||
assert_eq!(entry["config"]["serverName"], MCP_KEY);
|
||||
assert_eq!(entry["config"]["transport"], "streamable-http");
|
||||
assert!(block.contains("url: !!js |") && block.contains("headers: !!js |"));
|
||||
assert!(!block.contains("context.json"), "身份只认登记白名单,不读目录里的 context.json");
|
||||
assert!(
|
||||
!block.contains("context.json"),
|
||||
"身份只认登记白名单,不读目录里的 context.json"
|
||||
);
|
||||
assert!(block.contains("'http://127.0.0.1:27190/mcp/projects/' + encodeURIComponent(id)"));
|
||||
assert!(block.contains(": 'http://127.0.0.1:27190/mcp';"), "未命中回退全局地址");
|
||||
assert!(
|
||||
block.contains(": 'http://127.0.0.1:27190/mcp';"),
|
||||
"未命中回退全局地址"
|
||||
);
|
||||
assert!(block.contains("path.isAbsolute(base)") && block.contains("'System32', 'cmd.exe'"));
|
||||
assert!(block.contains(MCP_HEADER_SHIM_NAME) && block.contains("windowsVerbatimArguments: true"));
|
||||
assert!(
|
||||
block.contains(MCP_HEADER_SHIM_NAME)
|
||||
&& block.contains("windowsVerbatimArguments: true")
|
||||
);
|
||||
assert!(!block.contains("Bearer "), "托管块不得含任何 token");
|
||||
}
|
||||
|
||||
@ -1481,19 +1730,36 @@ mod tests {
|
||||
|
||||
let moved = upsert_dsh_block(&first, &render_dsh_block(28000, &[])).unwrap();
|
||||
assert!(moved.contains("127.0.0.1:28000") && !moved.contains("127.0.0.1:27190"));
|
||||
assert_eq!(moved.matches(DSH_MANAGED_START).count(), 1, "原位替换不重复追加");
|
||||
assert_eq!(upsert_dsh_block(&moved, &render_dsh_block(28000, &[])).unwrap(), moved, "幂等");
|
||||
assert_eq!(
|
||||
moved.matches(DSH_MANAGED_START).count(),
|
||||
1,
|
||||
"原位替换不重复追加"
|
||||
);
|
||||
assert_eq!(
|
||||
upsert_dsh_block(&moved, &render_dsh_block(28000, &[])).unwrap(),
|
||||
moved,
|
||||
"幂等"
|
||||
);
|
||||
|
||||
for manual in [
|
||||
"- insert:\n - id: mcp-lian-jing\n config:\n serverName: lian-jing\n",
|
||||
"- insert:\n - id: other\n config:\n serverName: 'lian-jing' # 手写\n",
|
||||
"- insert: [{id: mcp-lian-jing, config: {serverName: x}}]\n",
|
||||
] {
|
||||
assert!(upsert_dsh_block(manual, &render_dsh_block(27190, &[])).is_err(), "{manual}");
|
||||
assert!(
|
||||
upsert_dsh_block(manual, &render_dsh_block(27190, &[])).is_err(),
|
||||
"{manual}"
|
||||
);
|
||||
let with_block = format!("{manual}{}", render_dsh_block(27190, &[]));
|
||||
assert!(upsert_dsh_block(&with_block, &render_dsh_block(27190, &[])).is_err(), "块外手写也须拒绝");
|
||||
assert!(
|
||||
upsert_dsh_block(&with_block, &render_dsh_block(27190, &[])).is_err(),
|
||||
"块外手写也须拒绝"
|
||||
);
|
||||
}
|
||||
assert!(upsert_dsh_block("- insert: [unclosed\n", "b").is_err(), "无法解析时保守拒绝");
|
||||
assert!(
|
||||
upsert_dsh_block("- insert: [unclosed\n", "b").is_err(),
|
||||
"无法解析时保守拒绝"
|
||||
);
|
||||
assert!(upsert_dsh_block(&format!("{DSH_MANAGED_START}\n- x\n"), "b").is_err());
|
||||
assert_eq!(upsert_dsh_block("", "BLOCK\n").unwrap(), "BLOCK\n");
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user