fix(projects/parity): 处置 P8f 独立审查——登记落库规范目录并加 canonical 键查重、恢复 UI 无目录占位、挂载仓库同事务(P8f)
- M1:Windows 目录 canonicalize 去长路径前缀后落库,register_project 回执带落库目录; scan_unregistered_projects 的已登记判定与父目录去重改用同一比较键 - M2:UI 添加项目不选目录时沿用既有概念占位(RootRequirement),MCP 入口仍拒绝空路径 - L1:已有登记按 platform 只取有效根目录,切换平台残留列不再误拦 - L2:新目录在写事务外另算 canonical 键,识别 8.3 短名与 .. 段 - L3:mount_repo 抽本体,重复检查前移、profile 与 workspace 同一 IMMEDIATE 事务 - L4(部分):registration_path_key 复用 normalize_dsh_root 与 strip_verbatim_prefix - I3:目录遗留别的 project_id 的 context.json 时回执带 warning - I4:register_project 分发抽成函数,按 schema 参数名测试 - 改动的既有断言:register_project_at 落库由「去首尾空白的原样路径」改为规范形式 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f1f1cf7709
commit
96cfe761f4
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
|||||||
use crate::db::pool;
|
use crate::db::pool;
|
||||||
use crate::models::GitRepo;
|
use crate::models::GitRepo;
|
||||||
use rusqlite::params;
|
use rusqlite::{params, Transaction, TransactionBehavior};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
// ── 输入结构体 ─────────────────────────────────────────────────────────────────
|
// ── 输入结构体 ─────────────────────────────────────────────────────────────────
|
||||||
@ -118,9 +118,25 @@ pub fn update_git_repo_deploy(id: String, input: UpdateGitRepoDeployInput) -> Re
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn mount_repo(input: MountRepoInput) -> Result<(), String> {
|
pub fn mount_repo(input: MountRepoInput) -> Result<(), String> {
|
||||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||||
|
mount_repo_records(&conn, &input)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 挂载本体(连接由调用方注入):重复目录检查在任何写入之前,profile 与 workspace 在同一 IMMEDIATE
|
||||||
|
/// 事务里写入——被拒绝(同一目录已登记、同一空间已挂载)时不留孤儿 profile(P8f 审查 L3)。
|
||||||
|
fn mount_repo_records(conn: &rusqlite::Connection, input: &MountRepoInput) -> Result<(), String> {
|
||||||
|
let platform = input.platform.as_deref().unwrap_or("windows");
|
||||||
|
// 比较键要读磁盘(WSL 目录可能唤起发行版),放在写事务外算
|
||||||
|
let keys = crate::commands::projects::registration_keys_for(
|
||||||
|
conn,
|
||||||
|
Some(platform),
|
||||||
|
input.win_path.as_deref(),
|
||||||
|
input.wsl_path.as_deref(),
|
||||||
|
);
|
||||||
|
let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// 读取 git_repo 基础信息
|
// 读取 git_repo 基础信息
|
||||||
let repo: GitRepo = conn
|
let repo: GitRepo = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT * FROM git_repos WHERE id = ?1",
|
"SELECT * FROM git_repos WHERE id = ?1",
|
||||||
params![input.repo_id],
|
params![input.repo_id],
|
||||||
@ -128,10 +144,21 @@ pub fn mount_repo(input: MountRepoInput) -> Result<(), String> {
|
|||||||
)
|
)
|
||||||
.map_err(|e| format!("仓库不存在: {e}"))?;
|
.map_err(|e| format!("仓库不存在: {e}"))?;
|
||||||
|
|
||||||
|
// 同一目录已登记则拒绝(P8f,与登记本体同一检查)
|
||||||
|
if let Some(keys) = &keys {
|
||||||
|
if let Some((existing_id, existing_path)) =
|
||||||
|
crate::commands::projects::find_registration_by_keys(&tx, keys)?
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"该目录已登记为项目 project_id={existing_id}({existing_path}),拒绝重复挂载"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let profile_id = format!("prof-{}", input.workspace_id);
|
let profile_id = format!("prof-{}", input.workspace_id);
|
||||||
|
|
||||||
// 创建 project_profile(若已存在则忽略)
|
// 创建 project_profile(若已存在则忽略)
|
||||||
conn.execute(
|
tx.execute(
|
||||||
"INSERT OR IGNORE INTO project_profiles
|
"INSERT OR IGNORE INTO project_profiles
|
||||||
(id, name, description, repo_url, upstream_url, default_branch,
|
(id, name, description, repo_url, upstream_url, default_branch,
|
||||||
staging_url, prod_url, server_note, status, priority, tech_stack)
|
staging_url, prod_url, server_note, status, priority, tech_stack)
|
||||||
@ -153,24 +180,8 @@ pub fn mount_repo(input: MountRepoInput) -> Result<(), String> {
|
|||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let platform = input.platform.as_deref().unwrap_or("windows");
|
|
||||||
|
|
||||||
// 同一目录已登记则拒绝(P8f,与登记本体同一检查)
|
|
||||||
if let Some((existing_id, existing_path)) =
|
|
||||||
crate::commands::projects::duplicate_registration_of(
|
|
||||||
&conn,
|
|
||||||
Some(platform),
|
|
||||||
input.win_path.as_deref(),
|
|
||||||
input.wsl_path.as_deref(),
|
|
||||||
)?
|
|
||||||
{
|
|
||||||
return Err(format!(
|
|
||||||
"该目录已登记为项目 project_id={existing_id}({existing_path}),拒绝重复挂载"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建 project_workspace(若同一 space 已挂载则报错)
|
// 创建 project_workspace(若同一 space 已挂载则报错)
|
||||||
conn.execute(
|
tx.execute(
|
||||||
"INSERT INTO project_workspaces
|
"INSERT INTO project_workspaces
|
||||||
(id, profile_id, repo_id, space_id, win_path, wsl_path, platform)
|
(id, profile_id, repo_id, space_id, win_path, wsl_path, platform)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
@ -192,7 +203,7 @@ pub fn mount_repo(input: MountRepoInput) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
tx.commit().map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除仓库注册记录(同时删除关联的 workspace/profile)
|
/// 删除仓库注册记录(同时删除关联的 workspace/profile)
|
||||||
@ -203,3 +214,78 @@ pub fn delete_git_repo(id: String) -> Result<(), String> {
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn count(conn: &rusqlite::Connection, table: &str) -> i64 {
|
||||||
|
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mount_input(workspace_id: &str, win_path: &std::path::Path) -> MountRepoInput {
|
||||||
|
MountRepoInput {
|
||||||
|
workspace_id: workspace_id.to_string(),
|
||||||
|
repo_id: "repo-1".to_string(),
|
||||||
|
space_id: None,
|
||||||
|
win_path: Some(win_path.display().to_string()),
|
||||||
|
wsl_path: None,
|
||||||
|
platform: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P8f 审查 L3:重复目录检查在任何写入之前,挂载失败不留孤儿 profile
|
||||||
|
#[test]
|
||||||
|
fn mount_rejections_leave_no_orphan_profile() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO git_repos (id, repo_url, name) VALUES ('repo-1', 'https://git.invalid/r1.git', 'r1')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let base = std::env::temp_dir().join(format!("lj_p8f_mount_{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
let (taken, fresh) = (base.join("taken"), base.join("fresh"));
|
||||||
|
std::fs::create_dir_all(&taken).unwrap();
|
||||||
|
std::fs::create_dir_all(&fresh).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_profiles (id, name) VALUES ('prof-legacy', 'legacy')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_workspaces (id, profile_id, win_path, platform)
|
||||||
|
VALUES ('old', 'prof-legacy', ?1, 'windows')",
|
||||||
|
params![taken.display().to_string().to_uppercase()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = mount_repo_records(&conn, &mount_input("w-dup", &taken))
|
||||||
|
.expect_err("同一目录已登记应拒绝挂载");
|
||||||
|
assert!(error.contains("project_id=old"), "{error}");
|
||||||
|
assert_eq!(
|
||||||
|
count(&conn, "project_profiles"),
|
||||||
|
1,
|
||||||
|
"被拒绝的挂载不留 profile"
|
||||||
|
);
|
||||||
|
|
||||||
|
// workspace ID 冲突(profile 是新建的)同样整体回滚
|
||||||
|
let error = mount_repo_records(&conn, &mount_input("old", &fresh))
|
||||||
|
.expect_err("workspace ID 冲突应报错");
|
||||||
|
assert!(!error.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
count(&conn, "project_profiles"),
|
||||||
|
1,
|
||||||
|
"冲突回滚,新建的 prof-old 不留下"
|
||||||
|
);
|
||||||
|
assert_eq!(count(&conn, "project_workspaces"), 1);
|
||||||
|
|
||||||
|
mount_repo_records(&conn, &mount_input("w-new", &fresh)).unwrap();
|
||||||
|
assert_eq!(count(&conn, "project_profiles"), 2);
|
||||||
|
assert_eq!(count(&conn, "project_workspaces"), 2);
|
||||||
|
std::fs::remove_dir_all(&base).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -181,29 +181,34 @@ pub fn get_project(id: String) -> Result<ProjectDetail, String> {
|
|||||||
|
|
||||||
// ── 登记本体(P8f)─────────────────────────────────────────────────────────────
|
// ── 登记本体(P8f)─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// 登记路径的比较键:去 `\\?\` 长路径前缀、`/` 统一为 `\`、合并重复分隔符(保留 UNC 开头的 `\\`)、
|
/// 登记路径的字面比较键:去首尾空白、`/` 统一为 `\`、去 `\\?\` 长路径前缀(`\\?\UNC\x` 还原为 `\\x`),
|
||||||
/// 去首尾空白与尾部分隔符、不区分大小写。只做字面规范化,不解析 `..`、符号链接或 junction。
|
/// 再按 DSH 白名单键([`mcp_inject::normalize_dsh_root`])合并重复分隔符(保留 UNC 开头的 `\\`)、
|
||||||
|
/// 去尾部分隔符、不区分大小写——与 DSH 白名单共用同一实现,口径不分叉。
|
||||||
|
/// 不读磁盘:`..`、8.3 短名、junction 等同目录写法由 [`registration_keys`] 的 canonical 键补上。
|
||||||
pub(crate) fn registration_path_key(path: &str) -> String {
|
pub(crate) fn registration_path_key(path: &str) -> String {
|
||||||
let unified = path.trim().replace('/', "\\");
|
let unified = path.trim().replace('/', "\\");
|
||||||
let unified = if let Some(rest) = unified.strip_prefix(r"\\?\UNC\") {
|
mcp_inject::normalize_dsh_root(&mcp_inject::strip_verbatim_prefix(&unified))
|
||||||
format!(r"\\{rest}")
|
}
|
||||||
} else if let Some(rest) = unified.strip_prefix(r"\\?\") {
|
|
||||||
rest.to_string()
|
/// 已存在目录的规范形式:`std::fs::canonicalize` 后去掉 `\\?\` 前缀——得到真实大小写与长文件名、
|
||||||
} else {
|
/// 反斜杠写法,消掉 `.`、`..` 段与尾部分隔符;junction、符号链接与 subst 盘解析到真实目标
|
||||||
unified
|
/// (与 DSH 白名单取 realpath 同口径)。目录不存在或无法解析时为 None。
|
||||||
};
|
fn canonical_dir(path: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||||
let (prefix, rest) = match unified.strip_prefix(r"\\") {
|
std::fs::canonicalize(path).ok().map(|canonical| {
|
||||||
Some(rest) => (r"\\", rest),
|
std::path::PathBuf::from(mcp_inject::strip_verbatim_prefix(
|
||||||
None => ("", unified.as_str()),
|
&canonical.to_string_lossy(),
|
||||||
};
|
))
|
||||||
let mut collapsed = String::with_capacity(rest.len());
|
})
|
||||||
for ch in rest.chars() {
|
}
|
||||||
if ch == '\\' && collapsed.ends_with('\\') {
|
|
||||||
continue;
|
/// 待登记目录的比较键:字面键,加上目录存在时的 canonical 键(与字面键不同时)。
|
||||||
}
|
/// 会读磁盘(WSL 目录可能唤起发行版),须在写事务外计算;已有登记一侧只取字面键、不读磁盘。
|
||||||
collapsed.push(ch);
|
fn registration_keys(root: &std::path::Path) -> Vec<String> {
|
||||||
}
|
let literal = registration_path_key(&root.to_string_lossy());
|
||||||
format!("{prefix}{}", collapsed.trim_end_matches('\\')).to_lowercase()
|
let canonical = canonical_dir(root)
|
||||||
|
.map(|path| registration_path_key(&path.to_string_lossy()))
|
||||||
|
.filter(|canonical| canonical != &literal);
|
||||||
|
std::iter::once(literal).chain(canonical).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wsl_distro(conn: &rusqlite::Connection) -> String {
|
fn wsl_distro(conn: &rusqlite::Connection) -> String {
|
||||||
@ -215,9 +220,10 @@ fn wsl_distro(conn: &rusqlite::Connection) -> String {
|
|||||||
.unwrap_or_else(|_| "Ubuntu".to_string())
|
.unwrap_or_else(|_| "Ubuntu".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一条登记指向的本机目录:Windows 项目取 win_path;WSL 项目取 wsl_path——Linux 路径按
|
/// 一条登记的有效根目录:Windows 项目(platform 缺省亦然)取 win_path;WSL 项目取 wsl_path——
|
||||||
/// `\\wsl.localhost\<distro>\…` 换算(与 `mcp_inject::project_root_with_conn` 同口径),
|
/// Linux 路径按 `\\wsl.localhost\<distro>\…` 换算(取列与 Linux 路径换算同 `mcp_inject::project_root_with_conn`),
|
||||||
/// 已是 Windows 路径(如 UI 选目录得到的 UNC)的原样使用。空路径与相对路径一律拒绝。
|
/// 已是 Windows 路径(如 UI 选目录得到的 UNC)的原样使用(project_root_with_conn 对这种 wsl_path 会错误拼接,
|
||||||
|
/// 是已有缺陷,另列后续小修)。空路径与相对路径一律拒绝。
|
||||||
fn registration_root(
|
fn registration_root(
|
||||||
platform: Option<&str>,
|
platform: Option<&str>,
|
||||||
win_path: Option<&str>,
|
win_path: Option<&str>,
|
||||||
@ -244,16 +250,17 @@ fn registration_root(
|
|||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同一目录的已有登记(按 [`registration_path_key`] 比较,win_path 与 wsl_path 两列都算):
|
/// 已有登记中与给定比较键([`registration_keys`])同一目录的那条,返回其 project_id 与有效根目录。
|
||||||
/// 返回其 project_id 与登记路径。
|
/// 每条已有登记只取其有效根目录([`registration_root`],按 platform 取列,与 project_root_with_conn
|
||||||
pub(crate) fn find_registration_by_path(
|
/// 同一取列口径):切换平台后残留在另一列的旧路径不参与比较,不会误拦。已有行只算字面键、不读磁盘,
|
||||||
|
/// 可在写事务内调用。
|
||||||
|
pub(crate) fn find_registration_by_keys(
|
||||||
conn: &rusqlite::Connection,
|
conn: &rusqlite::Connection,
|
||||||
root: &std::path::Path,
|
keys: &[String],
|
||||||
) -> Result<Option<(String, String)>, String> {
|
) -> Result<Option<(String, String)>, String> {
|
||||||
let distro = wsl_distro(conn);
|
let distro = wsl_distro(conn);
|
||||||
let key = registration_path_key(&root.to_string_lossy());
|
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT id, win_path, wsl_path FROM project_workspaces ORDER BY id")
|
.prepare("SELECT id, platform, win_path, wsl_path FROM project_workspaces ORDER BY id")
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
@ -261,85 +268,155 @@ pub(crate) fn find_registration_by_path(
|
|||||||
row.get::<_, String>(0)?,
|
row.get::<_, String>(0)?,
|
||||||
row.get::<_, Option<String>>(1)?,
|
row.get::<_, Option<String>>(1)?,
|
||||||
row.get::<_, Option<String>>(2)?,
|
row.get::<_, Option<String>>(2)?,
|
||||||
|
row.get::<_, Option<String>>(3)?,
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let (id, win_path, wsl_path) = row.map_err(|e| e.to_string())?;
|
let (id, platform, win_path, wsl_path) = row.map_err(|e| e.to_string())?;
|
||||||
let candidates = [
|
let Ok(existing) = registration_root(
|
||||||
registration_root(None, win_path.as_deref(), None, &distro),
|
platform.as_deref(),
|
||||||
registration_root(Some("wsl"), None, wsl_path.as_deref(), &distro),
|
win_path.as_deref(),
|
||||||
];
|
wsl_path.as_deref(),
|
||||||
if let Some(existing) = candidates
|
&distro,
|
||||||
.into_iter()
|
) else {
|
||||||
.flatten()
|
continue;
|
||||||
.find(|path| registration_path_key(&path.to_string_lossy()) == key)
|
};
|
||||||
{
|
if keys.contains(®istration_path_key(&existing.to_string_lossy())) {
|
||||||
return Ok(Some((id, existing.display().to_string())));
|
return Ok(Some((id, existing.display().to_string())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 同一目录的已有登记(会读磁盘算 canonical 键,勿在写事务内调用)。
|
||||||
|
pub(crate) fn find_registration_by_path(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
root: &std::path::Path,
|
||||||
|
) -> Result<Option<(String, String)>, String> {
|
||||||
|
find_registration_by_keys(conn, ®istration_keys(root))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duplicate_registration_error(id: &str, path: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"该目录已登记为项目 project_id={id}({path}),拒绝重复登记:同一目录两条登记会互相抢占 Codex 托管块等接入配置。请直接使用该 project_id;确需重新登记,先删除原登记"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// 同一目录已登记时拒绝,错误里给出已有 project_id。同一目录两条登记会让 Codex 托管块等
|
/// 同一目录已登记时拒绝,错误里给出已有 project_id。同一目录两条登记会让 Codex 托管块等
|
||||||
/// 按目录落盘的接入配置被两条登记互相抢占(PR #34 审查 L1/L2)。
|
/// 按目录落盘的接入配置被两条登记互相抢占(PR #34 审查 L1/L2)。会读磁盘,勿在写事务内调用。
|
||||||
pub(crate) fn ensure_path_not_registered(
|
pub(crate) fn ensure_path_not_registered(
|
||||||
conn: &rusqlite::Connection,
|
conn: &rusqlite::Connection,
|
||||||
root: &std::path::Path,
|
root: &std::path::Path,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
match find_registration_by_path(conn, root)? {
|
match find_registration_by_path(conn, root)? {
|
||||||
None => Ok(()),
|
None => Ok(()),
|
||||||
Some((id, path)) => Err(format!(
|
Some((id, path)) => Err(duplicate_registration_error(&id, &path)),
|
||||||
"该目录已登记为项目 project_id={id}({path}),拒绝重复登记:同一目录两条登记会互相抢占 Codex 托管块等接入配置。请直接使用该 project_id;确需重新登记,先删除原登记"
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 其他登记入口(批量添加、挂载仓库)复用的重复目录检查:路径缺省或无法解析时不在此拦截,
|
/// 其他登记入口(批量添加、挂载仓库)的比较键:路径缺省或无法解析时为 None(不在此拦截,
|
||||||
/// 沿用各入口原有行为;能解析出目录且已登记时返回已有登记。
|
/// 沿用各入口原有行为)。会读磁盘,须在写事务外计算。
|
||||||
|
pub(crate) fn registration_keys_for(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
platform: Option<&str>,
|
||||||
|
win_path: Option<&str>,
|
||||||
|
wsl_path: Option<&str>,
|
||||||
|
) -> Option<Vec<String>> {
|
||||||
|
registration_root(platform, win_path, wsl_path, &wsl_distro(conn))
|
||||||
|
.ok()
|
||||||
|
.map(|root| registration_keys(&root))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 其他登记入口复用的重复目录检查:能解析出目录且已登记时返回已有登记。
|
||||||
pub(crate) fn duplicate_registration_of(
|
pub(crate) fn duplicate_registration_of(
|
||||||
conn: &rusqlite::Connection,
|
conn: &rusqlite::Connection,
|
||||||
platform: Option<&str>,
|
platform: Option<&str>,
|
||||||
win_path: Option<&str>,
|
win_path: Option<&str>,
|
||||||
wsl_path: Option<&str>,
|
wsl_path: Option<&str>,
|
||||||
) -> Result<Option<(String, String)>, String> {
|
) -> Result<Option<(String, String)>, String> {
|
||||||
match registration_root(platform, win_path, wsl_path, &wsl_distro(conn)) {
|
match registration_keys_for(conn, platform, win_path, wsl_path) {
|
||||||
Ok(root) => find_registration_by_path(conn, &root),
|
Some(keys) => find_registration_by_keys(conn, &keys),
|
||||||
Err(_) => Ok(None),
|
None => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 登记入口对项目目录的要求。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum RootRequirement {
|
||||||
|
/// 必须给出已存在目录的绝对路径(MCP `register_project`)。
|
||||||
|
ExistingDir,
|
||||||
|
/// UI「添加项目」:不选目录(有效路径为空)时沿用既有的概念占位登记——只写 profile 与 workspace,
|
||||||
|
/// 不校验目录、不查重复目录;给了路径则与 [`RootRequirement::ExistingDir`] 相同。
|
||||||
|
/// 是否改为显式的占位概念待用户决定(P8f 审查 M2)。
|
||||||
|
ExistingDirOrPlaceholder,
|
||||||
|
}
|
||||||
|
|
||||||
/// 登记项目本体:UI `create_project` 与 MCP `register_project` 共用,连接由调用方注入。
|
/// 登记项目本体:UI `create_project` 与 MCP `register_project` 共用,连接由调用方注入。
|
||||||
/// 只写数据库:不建目录、不执行 git init、不接入。
|
/// 只写数据库:不建目录、不执行 git init、不接入。
|
||||||
/// 拒绝:空 ID、空路径、相对路径、不存在的目录、空名称、同一目录已登记
|
/// 拒绝:空 ID、空路径(UI 概念占位除外,见 [`RootRequirement`])、相对路径、不存在的目录、空名称、
|
||||||
/// ([`ensure_path_not_registered`])、已存在的 ID。
|
/// 同一目录已登记([`find_registration_by_keys`])、已存在的 ID。
|
||||||
|
/// Windows 目录落库为规范形式([`canonical_dir`]):下游有按字面比较的消费者(scan_unregistered_projects
|
||||||
|
/// 等),正斜杠、尾部分隔符、8.3 短名、`..` 段原样落库会与它们对不上(P8f 审查 M1);WSL 路径原样落库。
|
||||||
|
/// 返回落库的有效根目录(概念占位为 None)。
|
||||||
pub(crate) fn register_project_records(
|
pub(crate) fn register_project_records(
|
||||||
conn: &rusqlite::Connection,
|
conn: &rusqlite::Connection,
|
||||||
input: &CreateProjectInput,
|
input: &CreateProjectInput,
|
||||||
) -> Result<(), String> {
|
requirement: RootRequirement,
|
||||||
|
) -> Result<Option<std::path::PathBuf>, String> {
|
||||||
if input.id.trim().is_empty() {
|
if input.id.trim().is_empty() {
|
||||||
return Err("项目 ID 不能为空".to_string());
|
return Err("项目 ID 不能为空".to_string());
|
||||||
}
|
}
|
||||||
let root = registration_root(
|
let is_wsl = input.platform.as_deref() == Some("wsl");
|
||||||
input.platform.as_deref(),
|
let effective_path = if is_wsl {
|
||||||
input.win_path.as_deref(),
|
input.wsl_path.as_deref()
|
||||||
input.wsl_path.as_deref(),
|
} else {
|
||||||
&wsl_distro(conn),
|
input.win_path.as_deref()
|
||||||
)?;
|
|
||||||
if !root.is_dir() {
|
|
||||||
return Err(format!(
|
|
||||||
"项目目录不存在或不是目录:{}(登记不建目录、不执行 git init)",
|
|
||||||
root.display()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let placeholder =
|
||||||
|
requirement == RootRequirement::ExistingDirOrPlaceholder && effective_path.is_empty();
|
||||||
|
// 目录校验与比较键都在写事务外完成:要读磁盘(WSL 目录可能唤起发行版),不能占着写锁
|
||||||
|
let checked_root = if placeholder {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let root = registration_root(
|
||||||
|
input.platform.as_deref(),
|
||||||
|
input.win_path.as_deref(),
|
||||||
|
input.wsl_path.as_deref(),
|
||||||
|
&wsl_distro(conn),
|
||||||
|
)?;
|
||||||
|
if !root.is_dir() {
|
||||||
|
return Err(format!(
|
||||||
|
"项目目录不存在或不是目录:{}(登记不建目录、不执行 git init)",
|
||||||
|
root.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let keys = registration_keys(&root);
|
||||||
|
let stored = if is_wsl {
|
||||||
|
root
|
||||||
|
} else {
|
||||||
|
canonical_dir(&root).unwrap_or(root)
|
||||||
|
};
|
||||||
|
Some((stored, keys))
|
||||||
|
};
|
||||||
if input.name.trim().is_empty() {
|
if input.name.trim().is_empty() {
|
||||||
return Err("项目名称不能为空".to_string());
|
return Err("项目名称不能为空".to_string());
|
||||||
}
|
}
|
||||||
|
let win_path = match &checked_root {
|
||||||
|
Some((root, _)) if !is_wsl => Some(root.display().to_string()),
|
||||||
|
_ => input.win_path.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
// 先查后插放进 IMMEDIATE 事务:并发登记同一目录时第二个等到第一个提交后再查,不会各写一条
|
// 先查后插放进 IMMEDIATE 事务:并发登记同一目录时第二个等到第一个提交后再查,不会各写一条
|
||||||
let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
|
let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
ensure_path_not_registered(&tx, &root)?;
|
if let Some((_, keys)) = &checked_root {
|
||||||
|
if let Some((id, path)) = find_registration_by_keys(&tx, keys)? {
|
||||||
|
return Err(duplicate_registration_error(&id, &path));
|
||||||
|
}
|
||||||
|
}
|
||||||
let id_taken = tx
|
let id_taken = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT 1 FROM project_workspaces WHERE id = ?1",
|
"SELECT 1 FROM project_workspaces WHERE id = ?1",
|
||||||
@ -396,7 +473,7 @@ pub(crate) fn register_project_records(
|
|||||||
profile_id,
|
profile_id,
|
||||||
input.space_id,
|
input.space_id,
|
||||||
input.wsl_path,
|
input.wsl_path,
|
||||||
input.win_path,
|
win_path,
|
||||||
input.platform.as_deref().unwrap_or("windows"),
|
input.platform.as_deref().unwrap_or("windows"),
|
||||||
input.recent_focus,
|
input.recent_focus,
|
||||||
input.startup_commands,
|
input.startup_commands,
|
||||||
@ -409,21 +486,38 @@ pub(crate) fn register_project_records(
|
|||||||
e.to_string()
|
e.to_string()
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
tx.commit().map_err(|e| e.to_string())
|
tx.commit().map_err(|e| e.to_string())?;
|
||||||
|
Ok(checked_root.map(|(root, _)| root))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MCP `register_project` 的返回:新登记的 project_id 与目录。
|
/// MCP `register_project` 的返回:新登记的 project_id 与落库的规范目录。
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub(crate) struct RegisteredProject {
|
pub(crate) struct RegisteredProject {
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub space_id: Option<String>,
|
pub space_id: Option<String>,
|
||||||
|
/// 目录里遗留的 context.json 写着别的 project_id 时的提示(见 [`stale_context_warning`])
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub warning: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 目录里已有 `.lian-jing/context.json` 且 project_id 与新登记不同(曾经接入过、登记已删除后重登):
|
||||||
|
/// 只读提示,不沿用旧 ID——目录里的 context.json 不作为身份授权依据。读不到或解析不了时不提示。
|
||||||
|
fn stale_context_warning(root: &std::path::Path, project_id: &str) -> Option<String> {
|
||||||
|
let text = std::fs::read_to_string(root.join(".lian-jing").join("context.json")).ok()?;
|
||||||
|
let context: Value = serde_json::from_str(text.trim_start_matches('\u{feff}')).ok()?;
|
||||||
|
let previous = context.get("project_id")?.as_str()?;
|
||||||
|
(previous != project_id).then(|| {
|
||||||
|
format!(
|
||||||
|
"目录里的 .lian-jing/context.json 仍写着旧 project_id={previous}(此前接入过、登记已删除);新登记的 project_id={project_id}。接入(onboard_project)时会把 context.json 改写为新 ID,子项目会出现一处待提交改动;在此之前子项目里的 agent 按旧 ID 调用会失败"
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MCP `register_project` 的入参换算(连接由调用方注入):只登记 Windows 目录;project_id 由炼境生成
|
/// MCP `register_project` 的入参换算(连接由调用方注入):只登记 Windows 目录;project_id 由炼境生成
|
||||||
/// (UUID,与 UI 选目录新建一致);名称缺省取目录名;归入当前空间(UI 首页按当前空间列项目)。
|
/// (UUID,与 UI 选目录新建一致);名称缺省取目录名(按规范形式取真实大小写);归入当前空间
|
||||||
/// 校验全部交给 [`register_project_records`]。
|
/// (UI 首页按当前空间列项目)。校验与落库规范化全部交给 [`register_project_records`]。
|
||||||
pub(crate) fn register_project_at(
|
pub(crate) fn register_project_at(
|
||||||
conn: &rusqlite::Connection,
|
conn: &rusqlite::Connection,
|
||||||
path: &str,
|
path: &str,
|
||||||
@ -435,7 +529,15 @@ pub(crate) fn register_project_at(
|
|||||||
.filter(|name| !name.is_empty())
|
.filter(|name| !name.is_empty())
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
std::path::Path::new(path)
|
// 相对路径不解析(否则会按炼境进程 cwd 换算),交给本体拒绝
|
||||||
|
let literal = std::path::Path::new(path);
|
||||||
|
let canonical = literal
|
||||||
|
.is_absolute()
|
||||||
|
.then(|| canonical_dir(literal))
|
||||||
|
.flatten();
|
||||||
|
canonical
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(literal)
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|name| name.to_string_lossy().into_owned())
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
.unwrap_or_else(|| path.to_string())
|
.unwrap_or_else(|| path.to_string())
|
||||||
@ -456,19 +558,22 @@ pub(crate) fn register_project_at(
|
|||||||
platform: Some("windows".to_string()),
|
platform: Some("windows".to_string()),
|
||||||
..CreateProjectInput::default()
|
..CreateProjectInput::default()
|
||||||
};
|
};
|
||||||
register_project_records(conn, &input)?;
|
let root = register_project_records(conn, &input, RootRequirement::ExistingDir)?
|
||||||
|
.ok_or_else(|| "登记本体未返回项目目录".to_string())?;
|
||||||
|
let warning = stale_context_warning(&root, &input.id);
|
||||||
Ok(RegisteredProject {
|
Ok(RegisteredProject {
|
||||||
project_id: input.id,
|
project_id: input.id,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
path: path.to_string(),
|
path: root.display().to_string(),
|
||||||
space_id: input.space_id,
|
space_id: input.space_id,
|
||||||
|
warning,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn create_project(input: CreateProjectInput) -> Result<(), String> {
|
pub fn create_project(input: CreateProjectInput) -> Result<(), String> {
|
||||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||||
register_project_records(&conn, &input)
|
register_project_records(&conn, &input, RootRequirement::ExistingDirOrPlaceholder).map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@ -1006,9 +1111,25 @@ mod tests {
|
|||||||
("missing", missing.display().to_string()),
|
("missing", missing.display().to_string()),
|
||||||
];
|
];
|
||||||
for (label, path) in &cases {
|
for (label, path) in &cases {
|
||||||
let error = register_project_records(&conn, &windows_input(label, path))
|
let error = register_project_records(
|
||||||
.expect_err(&format!("{label} 应被拒绝"));
|
&conn,
|
||||||
|
&windows_input(label, path),
|
||||||
|
RootRequirement::ExistingDir,
|
||||||
|
)
|
||||||
|
.expect_err(&format!("{label} 应被拒绝"));
|
||||||
assert!(!error.is_empty(), "{label}");
|
assert!(!error.is_empty(), "{label}");
|
||||||
|
// UI 入口只对「不选目录」放行概念占位;给了路径就与 MCP 同样校验
|
||||||
|
if !path.trim().is_empty() {
|
||||||
|
assert!(
|
||||||
|
register_project_records(
|
||||||
|
&conn,
|
||||||
|
&windows_input(label, path),
|
||||||
|
RootRequirement::ExistingDirOrPlaceholder,
|
||||||
|
)
|
||||||
|
.is_err(),
|
||||||
|
"UI 入口给了 {label} 路径同样应被拒绝"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
assert_eq!(workspace_count(&conn), 0, "被拒绝的登记不得写入任何行");
|
assert_eq!(workspace_count(&conn), 0, "被拒绝的登记不得写入任何行");
|
||||||
let profiles: i64 = conn
|
let profiles: i64 = conn
|
||||||
@ -1025,32 +1146,95 @@ mod tests {
|
|||||||
..windows_input("wsl-empty", "")
|
..windows_input("wsl-empty", "")
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
register_project_records(&conn, &wsl_empty).is_err(),
|
register_project_records(&conn, &wsl_empty, RootRequirement::ExistingDir).is_err(),
|
||||||
"WSL 项目以 wsl_path 为准,为空即拒绝"
|
"WSL 项目以 wsl_path 为准,为空即拒绝"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 审查 M2:UI「添加项目」不选目录的概念占位沿用既有行为(是否改为显式占位概念待用户决定);
|
||||||
|
/// 占位不校验目录、不参与重复目录检查,MCP 入口(ExistingDir)仍拒绝空路径。
|
||||||
|
#[test]
|
||||||
|
fn ui_registration_keeps_pathless_placeholder() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
for (id, path) in [("concept-a", ""), ("concept-b", " ")] {
|
||||||
|
let root = register_project_records(
|
||||||
|
&conn,
|
||||||
|
&windows_input(id, path),
|
||||||
|
RootRequirement::ExistingDirOrPlaceholder,
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|error| panic!("{id} 占位登记应成功: {error}"));
|
||||||
|
assert!(root.is_none(), "占位没有项目目录");
|
||||||
|
}
|
||||||
|
let wsl_placeholder = CreateProjectInput {
|
||||||
|
platform: Some("wsl".to_string()),
|
||||||
|
..windows_input("concept-wsl", "")
|
||||||
|
};
|
||||||
|
register_project_records(
|
||||||
|
&conn,
|
||||||
|
&wsl_placeholder,
|
||||||
|
RootRequirement::ExistingDirOrPlaceholder,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(workspace_count(&conn), 3, "多个占位互不拦截");
|
||||||
|
let error = register_project_records(
|
||||||
|
&conn,
|
||||||
|
&windows_input("concept-a", ""),
|
||||||
|
RootRequirement::ExistingDirOrPlaceholder,
|
||||||
|
)
|
||||||
|
.expect_err("占位同样不能复用已存在的 ID");
|
||||||
|
assert!(error.contains("concept-a"), "{error}");
|
||||||
|
assert!(
|
||||||
|
register_project_records(
|
||||||
|
&conn,
|
||||||
|
&windows_input("mcp-empty", ""),
|
||||||
|
RootRequirement::ExistingDir
|
||||||
|
)
|
||||||
|
.is_err(),
|
||||||
|
"MCP 入口不接受占位"
|
||||||
|
);
|
||||||
|
assert_eq!(workspace_count(&conn), 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registration_rejects_same_directory_in_any_spelling_and_names_existing_id() {
|
fn registration_rejects_same_directory_in_any_spelling_and_names_existing_id() {
|
||||||
let conn = crate::db::conn_with_schema();
|
let conn = crate::db::conn_with_schema();
|
||||||
let dir = temp_project_dir("dup");
|
let dir = temp_project_dir("dup");
|
||||||
let canonical = dir.display().to_string();
|
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||||
register_project_records(&conn, &windows_input("first", &canonical)).unwrap();
|
// 本机 TEMP 是 8.3 短名(C:\Users\YANGZH~1\…):raw 与规范形式可能不同
|
||||||
|
let raw = dir.display().to_string();
|
||||||
|
let canonical = canonical_dir(&dir).unwrap().display().to_string();
|
||||||
|
let stored = register_project_records(
|
||||||
|
&conn,
|
||||||
|
&windows_input("first", &raw),
|
||||||
|
RootRequirement::ExistingDir,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
stored.map(|root| root.display().to_string()).as_deref(),
|
||||||
|
Some(canonical.as_str())
|
||||||
|
);
|
||||||
assert!(!dir.join(".git").exists(), "登记不执行 git init");
|
assert!(!dir.join(".git").exists(), "登记不执行 git init");
|
||||||
|
|
||||||
for (index, variant) in [
|
for (index, variant) in [
|
||||||
|
raw.to_uppercase(),
|
||||||
|
raw.to_lowercase(),
|
||||||
|
raw.replace('\\', "/"),
|
||||||
|
format!("{raw}\\"),
|
||||||
|
format!("{}/", raw.replace('\\', "/")),
|
||||||
canonical.to_uppercase(),
|
canonical.to_uppercase(),
|
||||||
canonical.to_lowercase(),
|
// `..` 段只有 canonical 键能识别(审查 L2)
|
||||||
canonical.replace('\\', "/"),
|
format!(r"{raw}\sub\.."),
|
||||||
format!("{canonical}\\"),
|
format!(r"{canonical}\sub\..\."),
|
||||||
format!("{}/", canonical.replace('\\', "/")),
|
|
||||||
]
|
]
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
{
|
{
|
||||||
let error =
|
let error = register_project_records(
|
||||||
register_project_records(&conn, &windows_input(&format!("dup-{index}"), variant))
|
&conn,
|
||||||
.expect_err(&format!("同一目录的写法 {variant} 应被拒绝"));
|
&windows_input(&format!("dup-{index}"), variant),
|
||||||
|
RootRequirement::ExistingDir,
|
||||||
|
)
|
||||||
|
.expect_err(&format!("同一目录的写法 {variant} 应被拒绝"));
|
||||||
assert!(
|
assert!(
|
||||||
error.contains("project_id=first"),
|
error.contains("project_id=first"),
|
||||||
"错误里应给出已存在的 project_id: {error}"
|
"错误里应给出已存在的 project_id: {error}"
|
||||||
@ -1061,14 +1245,52 @@ mod tests {
|
|||||||
// UI 改走 WSL 平台登记同一目录(wsl_path 存 UNC 原样)同样拒绝
|
// UI 改走 WSL 平台登记同一目录(wsl_path 存 UNC 原样)同样拒绝
|
||||||
let via_wsl = CreateProjectInput {
|
let via_wsl = CreateProjectInput {
|
||||||
platform: Some("wsl".to_string()),
|
platform: Some("wsl".to_string()),
|
||||||
wsl_path: Some(canonical.to_uppercase()),
|
wsl_path: Some(raw.to_uppercase()),
|
||||||
..windows_input("dup-wsl", "")
|
..windows_input("dup-wsl", "")
|
||||||
};
|
};
|
||||||
assert!(register_project_records(&conn, &via_wsl).is_err());
|
assert!(register_project_records(&conn, &via_wsl, RootRequirement::ExistingDir).is_err());
|
||||||
assert_eq!(workspace_count(&conn), 1);
|
assert_eq!(workspace_count(&conn), 1);
|
||||||
std::fs::remove_dir_all(&dir).unwrap();
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 审查 L2:新目录以 `..` 段或 8.3 短名写法给出时,靠 canonical 键对上已有登记(已有行用 P8f 之前
|
||||||
|
/// 常见的正斜杠 + 尾部分隔符写法落库,只算字面键、不读磁盘)。
|
||||||
|
#[test]
|
||||||
|
fn duplicate_lookup_matches_dotted_or_short_input_by_canonical_key() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
let dir = temp_project_dir("legacy");
|
||||||
|
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||||
|
let canonical = canonical_dir(&dir).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_profiles (id, name) VALUES ('prof-legacy', 'legacy')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_workspaces (id, profile_id, win_path, platform)
|
||||||
|
VALUES ('legacy', 'prof-legacy', ?1, 'windows')",
|
||||||
|
params![canonical.display().to_string().replace('\\', "/") + "/"],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let dotted = dir.join("sub").join("..");
|
||||||
|
assert_ne!(
|
||||||
|
registration_path_key(&dotted.to_string_lossy()),
|
||||||
|
registration_path_key(&canonical.to_string_lossy()),
|
||||||
|
"前提:字面键对不上"
|
||||||
|
);
|
||||||
|
// dir 本身在本机是 8.3 短名写法(TEMP 为 C:\Users\YANGZH~1\…),别的机器上与规范形式相同
|
||||||
|
for input in [dotted, dir.clone()] {
|
||||||
|
let found = find_registration_by_path(&conn, &input).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
found.map(|(id, _)| id).as_deref(),
|
||||||
|
Some("legacy"),
|
||||||
|
"{}",
|
||||||
|
input.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn duplicate_lookup_resolves_wsl_linux_paths_like_project_root() {
|
fn duplicate_lookup_resolves_wsl_linux_paths_like_project_root() {
|
||||||
let conn = crate::db::conn_with_schema();
|
let conn = crate::db::conn_with_schema();
|
||||||
@ -1083,18 +1305,50 @@ mod tests {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let found = find_registration_by_path(
|
// 只用字面键查:测试不读 \\wsl.localhost(会唤起本机 WSL 发行版)
|
||||||
&conn,
|
let lookup = |path: &str| {
|
||||||
std::path::Path::new(r"\\wsl.localhost\UBUNTU\home\u\app\"),
|
find_registration_by_keys(&conn, &[registration_path_key(path)])
|
||||||
|
.unwrap()
|
||||||
|
.map(|(id, _)| id)
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
lookup(r"\\wsl.localhost\UBUNTU\home\u\app\").as_deref(),
|
||||||
|
Some("wsl-app")
|
||||||
|
);
|
||||||
|
assert!(lookup(r"\\wsl.localhost\Ubuntu\home\u\app2").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 审查 L1:已有登记只按其 platform 取有效根目录比较;切换平台后残留在另一列的旧路径不拦别人。
|
||||||
|
#[test]
|
||||||
|
fn duplicate_lookup_ignores_stale_column_of_other_platform() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
let dir = temp_project_dir("stale");
|
||||||
|
let raw = dir.display().to_string();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_profiles (id, name) VALUES ('prof-moved', 'moved')",
|
||||||
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(found.map(|(id, _)| id).as_deref(), Some("wsl-app"));
|
conn.execute(
|
||||||
let other = find_registration_by_path(
|
"INSERT INTO project_workspaces (id, profile_id, win_path, wsl_path, platform)
|
||||||
&conn,
|
VALUES ('moved', 'prof-moved', ?1, '/home/u/moved', 'wsl')",
|
||||||
std::path::Path::new(r"\\wsl.localhost\Ubuntu\home\u\app2"),
|
params![raw],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(other.is_none());
|
assert!(
|
||||||
|
find_registration_by_keys(&conn, &[registration_path_key(&raw)])
|
||||||
|
.unwrap()
|
||||||
|
.is_none(),
|
||||||
|
"WSL 项目残留的 win_path 不算它的目录"
|
||||||
|
);
|
||||||
|
register_project_records(
|
||||||
|
&conn,
|
||||||
|
&windows_input("now-here", &raw),
|
||||||
|
RootRequirement::ExistingDir,
|
||||||
|
)
|
||||||
|
.expect("残留列不得挡住该目录的登记");
|
||||||
|
assert_eq!(workspace_count(&conn), 2);
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1105,11 +1359,13 @@ mod tests {
|
|||||||
register_project_records(
|
register_project_records(
|
||||||
&conn,
|
&conn,
|
||||||
&windows_input("same-id", &first.display().to_string()),
|
&windows_input("same-id", &first.display().to_string()),
|
||||||
|
RootRequirement::ExistingDir,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let error = register_project_records(
|
let error = register_project_records(
|
||||||
&conn,
|
&conn,
|
||||||
&windows_input("same-id", &second.display().to_string()),
|
&windows_input("same-id", &second.display().to_string()),
|
||||||
|
RootRequirement::ExistingDir,
|
||||||
)
|
)
|
||||||
.expect_err("ID 已存在应报错,不能静默返回成功");
|
.expect_err("ID 已存在应报错,不能静默返回成功");
|
||||||
assert!(error.contains("same-id"), "{error}");
|
assert!(error.contains("same-id"), "{error}");
|
||||||
@ -1120,7 +1376,12 @@ mod tests {
|
|||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(stored, first.display().to_string(), "原登记不被改写");
|
// 落库为规范形式(审查 M1)
|
||||||
|
assert_eq!(
|
||||||
|
stored,
|
||||||
|
canonical_dir(&first).unwrap().display().to_string(),
|
||||||
|
"原登记不被改写"
|
||||||
|
);
|
||||||
std::fs::remove_dir_all(&first).unwrap();
|
std::fs::remove_dir_all(&first).unwrap();
|
||||||
std::fs::remove_dir_all(&second).unwrap();
|
std::fs::remove_dir_all(&second).unwrap();
|
||||||
}
|
}
|
||||||
@ -1134,8 +1395,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let dir = temp_project_dir("mcp");
|
let dir = temp_project_dir("mcp");
|
||||||
let registered =
|
let canonical = canonical_dir(&dir).unwrap().display().to_string();
|
||||||
register_project_at(&conn, &format!(" {} ", dir.display()), None).unwrap();
|
// agent 常见写法:正斜杠 + 尾部分隔符 + 首尾空白(本机 TEMP 还是 8.3 短名)
|
||||||
|
let spelled = format!(" {}/ ", dir.display().to_string().replace('\\', "/"));
|
||||||
|
let registered = register_project_at(&conn, &spelled, None).unwrap();
|
||||||
|
|
||||||
assert!(uuid::Uuid::parse_str(®istered.project_id).is_ok());
|
assert!(uuid::Uuid::parse_str(®istered.project_id).is_ok());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@ -1144,6 +1407,15 @@ mod tests {
|
|||||||
"名称缺省取目录名"
|
"名称缺省取目录名"
|
||||||
);
|
);
|
||||||
assert_eq!(registered.space_id.as_deref(), Some("home"));
|
assert_eq!(registered.space_id.as_deref(), Some("home"));
|
||||||
|
assert_eq!(registered.path, canonical, "返回落库的规范目录");
|
||||||
|
assert!(registered.warning.is_none());
|
||||||
|
assert!(
|
||||||
|
serde_json::to_value(®istered)
|
||||||
|
.unwrap()
|
||||||
|
.get("warning")
|
||||||
|
.is_none(),
|
||||||
|
"没有提示时不输出 warning 字段"
|
||||||
|
);
|
||||||
let (win_path, platform, space): (String, String, Option<String>) = conn
|
let (win_path, platform, space): (String, String, Option<String>) = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT win_path, platform, space_id FROM project_workspaces WHERE id = ?1",
|
"SELECT win_path, platform, space_id FROM project_workspaces WHERE id = ?1",
|
||||||
@ -1151,11 +1423,10 @@ mod tests {
|
|||||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
// 审查 M1:原断言为「存入去掉首尾空白的路径」,改为存规范形式(canonicalize 去 \\?\ 前缀)
|
||||||
win_path,
|
assert_eq!(win_path, canonical, "落库为规范形式");
|
||||||
dir.display().to_string(),
|
assert!(!win_path.contains('/') && !win_path.ends_with('\\'));
|
||||||
"存入去掉首尾空白的路径"
|
assert!(!win_path.starts_with(r"\\?\"));
|
||||||
);
|
|
||||||
assert_eq!(platform, "windows");
|
assert_eq!(platform, "windows");
|
||||||
assert_eq!(space.as_deref(), Some("home"));
|
assert_eq!(space.as_deref(), Some("home"));
|
||||||
|
|
||||||
@ -1163,16 +1434,44 @@ mod tests {
|
|||||||
.expect_err("同一目录再次登记应被拒绝");
|
.expect_err("同一目录再次登记应被拒绝");
|
||||||
assert!(again.contains(®istered.project_id), "{again}");
|
assert!(again.contains(®istered.project_id), "{again}");
|
||||||
assert!(register_project_at(&conn, "", None).is_err());
|
assert!(register_project_at(&conn, "", None).is_err());
|
||||||
|
assert!(
|
||||||
|
register_project_at(&conn, "src", None).is_err(),
|
||||||
|
"相对路径不按炼境进程 cwd 解析"
|
||||||
|
);
|
||||||
assert_eq!(workspace_count(&conn), 1);
|
assert_eq!(workspace_count(&conn), 1);
|
||||||
std::fs::remove_dir_all(&dir).unwrap();
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 审查 I3:目录里遗留旧 context.json(曾接入后删除登记)时,回执带 warning 说明接入会改写 ID。
|
||||||
|
#[test]
|
||||||
|
fn register_project_at_warns_when_directory_carries_another_project_id() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
let dir = temp_project_dir("stale_ctx");
|
||||||
|
std::fs::create_dir_all(dir.join(".lian-jing")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.join(".lian-jing").join("context.json"),
|
||||||
|
r#"{"schema_version":1,"project_id":"old-id"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let registered = register_project_at(&conn, &dir.display().to_string(), None).unwrap();
|
||||||
|
let warning = registered.warning.as_deref().expect("应提示旧 project_id");
|
||||||
|
assert!(warning.contains("old-id") && warning.contains(®istered.project_id));
|
||||||
|
let context = std::fs::read_to_string(dir.join(".lian-jing").join("context.json")).unwrap();
|
||||||
|
assert!(context.contains("old-id"), "登记只读 context.json,不改写");
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn batch_registration_skips_directories_already_registered() {
|
fn batch_registration_skips_directories_already_registered() {
|
||||||
let conn = crate::db::conn_with_schema();
|
let conn = crate::db::conn_with_schema();
|
||||||
let dir = temp_project_dir("batch");
|
let dir = temp_project_dir("batch");
|
||||||
let path = dir.display().to_string();
|
let path = dir.display().to_string();
|
||||||
register_project_records(&conn, &windows_input("existing", &path)).unwrap();
|
register_project_records(
|
||||||
|
&conn,
|
||||||
|
&windows_input("existing", &path),
|
||||||
|
RootRequirement::ExistingDir,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let item = |id: &str, win_path: String| BatchProjectInput {
|
let item = |id: &str, win_path: String| BatchProjectInput {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
name: id.to_string(),
|
name: id.to_string(),
|
||||||
|
|||||||
@ -938,7 +938,8 @@ fn ensure_dsh_session_plugin() -> Result<DshSessionPlugin, String> {
|
|||||||
|
|
||||||
/// 白名单键规范化,与 JS 端 `path.resolve(d).toLowerCase().replace(/\\+$/, '')` 对齐:
|
/// 白名单键规范化,与 JS 端 `path.resolve(d).toLowerCase().replace(/\\+$/, '')` 对齐:
|
||||||
/// 反斜杠分隔、合并重复分隔符(保留 UNC 前缀 \\)、去尾部分隔符、小写。盘符根 E:\ 两端都得到 e:。
|
/// 反斜杠分隔、合并重复分隔符(保留 UNC 前缀 \\)、去尾部分隔符、小写。盘符根 E:\ 两端都得到 e:。
|
||||||
fn normalize_dsh_root(path: &str) -> String {
|
/// 登记的同目录判定(projects::registration_path_key)复用本函数,两处口径不得分叉。
|
||||||
|
pub(crate) fn normalize_dsh_root(path: &str) -> String {
|
||||||
let unified = path.replace('/', "\\");
|
let unified = path.replace('/', "\\");
|
||||||
let (prefix, rest) = match unified.strip_prefix("\\\\") {
|
let (prefix, rest) = match unified.strip_prefix("\\\\") {
|
||||||
Some(rest) => ("\\\\", rest),
|
Some(rest) => ("\\\\", rest),
|
||||||
@ -1011,7 +1012,7 @@ fn dsh_project_roots(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 去掉 std::fs::canonicalize 在 Windows 上返回的 \\?\ 前缀(\\?\UNC\ 还原为 \\),得到普通路径。
|
/// 去掉 std::fs::canonicalize 在 Windows 上返回的 \\?\ 前缀(\\?\UNC\ 还原为 \\),得到普通路径。
|
||||||
fn strip_verbatim_prefix(path: &str) -> String {
|
pub(crate) fn strip_verbatim_prefix(path: &str) -> String {
|
||||||
match path.strip_prefix(r"\\?\UNC\") {
|
match path.strip_prefix(r"\\?\UNC\") {
|
||||||
Some(rest) => format!(r"\\{rest}"),
|
Some(rest) => format!(r"\\{rest}"),
|
||||||
None => path.strip_prefix(r"\\?\").unwrap_or(path).to_string(),
|
None => path.strip_prefix(r"\\?\").unwrap_or(path).to_string(),
|
||||||
|
|||||||
@ -414,7 +414,7 @@ pub fn tools_list_result() -> Value {
|
|||||||
fn register_project_schema() -> Value {
|
fn register_project_schema() -> Value {
|
||||||
json!({
|
json!({
|
||||||
"name": "register_project",
|
"name": "register_project",
|
||||||
"description": "把本机一个已存在的目录登记为炼境项目(等价 UI「添加项目」的 agent 投影,只开放在全局地址)。只写炼境登记:不建目录、不执行 git init、不接入任何客户端。空路径、相对路径、不存在的目录一律拒绝;同一目录(大小写、斜杠方向、尾部分隔符归一后比较)已登记时拒绝并在错误里给出已有 project_id——直接复用它,不要换写法重试。project_id 由炼境生成并返回。这是写操作:调用前先向用户列出目标目录与名称并取得明确确认。登记后的接入先用 plan_onboarding 预览,经用户确认再 onboard_project。适用:scan_unregistered_projects 发现的漏登记目录、用户新建的项目目录。",
|
"description": "把本机一个已存在的目录登记为炼境项目(等价 UI「添加项目」的 agent 投影,只开放在全局地址)。只写炼境登记:不建目录、不执行 git init、不接入任何客户端。空路径、相对路径、不存在的目录一律拒绝;同一目录(大小写、斜杠方向、尾部分隔符、8.3 短名、.. 段归一后比较)已登记时拒绝并在错误里给出已有 project_id——直接复用它,不要换写法重试。目录按规范形式(真实大小写、反斜杠)登记;project_id 由炼境生成,与规范目录一并返回;目录里遗留别的 project_id 的 .lian-jing/context.json 时回执带 warning。这是写操作:调用前先向用户列出目标目录与名称并取得明确确认。登记后的接入先用 plan_onboarding 预览,经用户确认再 onboard_project。适用:scan_unregistered_projects 发现的漏登记目录、用户新建的项目目录。",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -426,6 +426,15 @@ fn register_project_schema() -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// register_project 的分发(连接由调用方注入):参数名与 [`register_project_schema`] 一一对应,
|
||||||
|
/// 测试按 schema 的参数名构造入参走这里,schema 改名而分发未跟上时会失败。
|
||||||
|
fn register_project_call(conn: &rusqlite::Connection, args: &Value) -> Result<String, String> {
|
||||||
|
let path = args.get("path").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let name = args.get("name").and_then(Value::as_str);
|
||||||
|
let registered = crate::commands::projects::register_project_at(conn, path, name)?;
|
||||||
|
serde_json::to_string_pretty(®istered).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// 使者回传两件工具(单独成函数:并入主清单会超出 json! 宏递归上限)
|
/// 使者回传两件工具(单独成函数:并入主清单会超出 json! 宏递归上限)
|
||||||
fn envoy_tool_schemas() -> Value {
|
fn envoy_tool_schemas() -> Value {
|
||||||
json!([
|
json!([
|
||||||
@ -763,12 +772,18 @@ fn structured_tool_summary(tool_name: &str, value: &Value) -> String {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or("接入验收结构化结果已返回")
|
.unwrap_or("接入验收结构化结果已返回")
|
||||||
.to_string(),
|
.to_string(),
|
||||||
"register_project" => format!(
|
"register_project" => {
|
||||||
"已登记项目:project_id={},名称={},目录={}。只写了登记:未建目录、未执行 git init、尚未接入;接入先 plan_onboarding 预览,经用户确认再 onboard_project",
|
let summary = format!(
|
||||||
value["project_id"].as_str().unwrap_or("?"),
|
"已登记项目:project_id={},名称={},目录={}。只写了登记:未建目录、未执行 git init、尚未接入;接入先 plan_onboarding 预览,经用户确认再 onboard_project",
|
||||||
value["name"].as_str().unwrap_or("?"),
|
value["project_id"].as_str().unwrap_or("?"),
|
||||||
value["path"].as_str().unwrap_or("?")
|
value["name"].as_str().unwrap_or("?"),
|
||||||
),
|
value["path"].as_str().unwrap_or("?")
|
||||||
|
);
|
||||||
|
match value["warning"].as_str() {
|
||||||
|
Some(warning) => format!("{summary}。注意:{warning}"),
|
||||||
|
None => summary,
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => "结构化结果已返回".to_string(),
|
_ => "结构化结果已返回".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1241,11 +1256,8 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
|
|||||||
"list_all_projects" => list_all_projects(),
|
"list_all_projects" => list_all_projects(),
|
||||||
"scan_unregistered_projects" => scan_unregistered_projects(),
|
"scan_unregistered_projects" => scan_unregistered_projects(),
|
||||||
"register_project" => {
|
"register_project" => {
|
||||||
let path = args.get("path").and_then(Value::as_str).unwrap_or("");
|
|
||||||
let name = args.get("name").and_then(Value::as_str);
|
|
||||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||||
let registered = crate::commands::projects::register_project_at(&conn, path, name)?;
|
register_project_call(&conn, &args)
|
||||||
serde_json::to_string_pretty(®istered).map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
"unregister_project" => {
|
"unregister_project" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
@ -2171,24 +2183,42 @@ fn render_all_projects(
|
|||||||
|
|
||||||
pub fn scan_unregistered_projects() -> Result<String, String> {
|
pub fn scan_unregistered_projects() -> Result<String, String> {
|
||||||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||||
|
scan_unregistered_projects_with(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扫描本体(连接由调用方注入)。已登记判定与父目录去重都按登记的同目录口径
|
||||||
|
/// (projects::registration_path_key:大小写、斜杠方向、重复与尾部分隔符归一),与 register_project 的
|
||||||
|
/// 重复检查一致——不会把换了写法的已登记目录列成未登记,再被登记拒绝(P8f 审查 M1)。
|
||||||
|
fn scan_unregistered_projects_with(conn: &rusqlite::Connection) -> Result<String, String> {
|
||||||
|
use crate::commands::projects::registration_path_key;
|
||||||
|
|
||||||
// 取所有已登记的 win_path
|
// 取所有已登记的 win_path
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT win_path FROM project_workspaces WHERE win_path IS NOT NULL")
|
.prepare("SELECT win_path FROM project_workspaces WHERE win_path IS NOT NULL")
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let registered_paths: std::collections::HashSet<String> = stmt
|
let registered_paths: Vec<String> = stmt
|
||||||
.query_map([], |r| r.get::<_, String>(0))
|
.query_map([], |r| r.get::<_, String>(0))
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.filter_map(|r| r.ok())
|
.filter_map(|r| r.ok())
|
||||||
.map(|p| p.trim_end_matches(['/', '\\']).to_string())
|
.map(|p| p.trim().to_string())
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.collect();
|
||||||
|
let registered_keys: std::collections::HashSet<String> = registered_paths
|
||||||
|
.iter()
|
||||||
|
.map(|p| registration_path_key(p))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// 推导父目录(去重)
|
// 推导父目录:按比较键去重(E:/x 与 E:\x 只扫一次),保留首个写法;BTreeMap 让输出顺序稳定
|
||||||
let mut parent_dirs: std::collections::HashSet<std::path::PathBuf> =
|
let mut parent_dirs: std::collections::BTreeMap<String, std::path::PathBuf> =
|
||||||
std::collections::HashSet::new();
|
std::collections::BTreeMap::new();
|
||||||
for p in ®istered_paths {
|
for p in ®istered_paths {
|
||||||
if let Some(parent) = std::path::Path::new(p).parent() {
|
if let Some(parent) = std::path::Path::new(p).parent() {
|
||||||
parent_dirs.insert(parent.to_path_buf());
|
if parent.as_os_str().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
parent_dirs
|
||||||
|
.entry(registration_path_key(&parent.to_string_lossy()))
|
||||||
|
.or_insert_with(|| parent.to_path_buf());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2198,7 +2228,7 @@ pub fn scan_unregistered_projects() -> Result<String, String> {
|
|||||||
|
|
||||||
let mut unregistered: Vec<(String, bool, Vec<String>, bool)> = Vec::new();
|
let mut unregistered: Vec<(String, bool, Vec<String>, bool)> = Vec::new();
|
||||||
|
|
||||||
for parent in &parent_dirs {
|
for parent in parent_dirs.values() {
|
||||||
let entries = std::fs::read_dir(parent).map_err(|e| e.to_string())?;
|
let entries = std::fs::read_dir(parent).map_err(|e| e.to_string())?;
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
@ -2209,7 +2239,7 @@ pub fn scan_unregistered_projects() -> Result<String, String> {
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.trim_end_matches(['/', '\\'])
|
.trim_end_matches(['/', '\\'])
|
||||||
.to_string();
|
.to_string();
|
||||||
if registered_paths.contains(&path_str) {
|
if registered_keys.contains(®istration_path_key(&path_str)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 隐藏目录跳过
|
// 隐藏目录跳过
|
||||||
@ -2255,7 +2285,7 @@ pub fn scan_unregistered_projects() -> Result<String, String> {
|
|||||||
"## 未注册目录(共 {} 个)\n\n扫描范围:{}\n\n",
|
"## 未注册目录(共 {} 个)\n\n扫描范围:{}\n\n",
|
||||||
unregistered.len(),
|
unregistered.len(),
|
||||||
parent_dirs
|
parent_dirs
|
||||||
.iter()
|
.values()
|
||||||
.map(|p| p.to_string_lossy().to_string())
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("、")
|
.join("、")
|
||||||
@ -2280,7 +2310,7 @@ pub fn scan_unregistered_projects() -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out.push_str(
|
out.push_str(
|
||||||
"---\n在炼境 UI 添加项目后,调用 `apply_onboarding_pack(project_id)` 分发接入包。",
|
"---\n经用户确认后用 `register_project(path)` 登记(等价炼境 UI「添加项目」),再调用 `apply_onboarding_pack(project_id)` 分发接入包。",
|
||||||
);
|
);
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
@ -2629,6 +2659,99 @@ mod tests {
|
|||||||
let summary = response["content"][0]["text"].as_str().unwrap();
|
let summary = response["content"][0]["text"].as_str().unwrap();
|
||||||
assert!(summary.contains("project_id=0b6c"), "{summary}");
|
assert!(summary.contains("project_id=0b6c"), "{summary}");
|
||||||
assert!(summary.contains("尚未接入"), "{summary}");
|
assert!(summary.contains("尚未接入"), "{summary}");
|
||||||
|
assert!(!summary.contains("注意"), "没有 warning 时摘要不带提示");
|
||||||
|
|
||||||
|
let with_warning = json!({
|
||||||
|
"project_id": "0b6c",
|
||||||
|
"name": "my-app",
|
||||||
|
"path": "E:\\code\\my-app",
|
||||||
|
"warning": "目录里的 .lian-jing/context.json 仍写着旧 project_id=old-id"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let response = tool_success_response("register_project", with_warning);
|
||||||
|
let summary = response["content"][0]["text"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
summary.contains("注意") && summary.contains("old-id"),
|
||||||
|
"{summary}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P8f 审查 I4:按 schema 声明的参数名构造入参走分发函数——schema 改名而分发未跟上时失败。
|
||||||
|
#[test]
|
||||||
|
fn register_project_dispatch_follows_schema_parameter_names() {
|
||||||
|
let schema = register_project_schema();
|
||||||
|
let properties = schema["inputSchema"]["properties"].as_object().unwrap();
|
||||||
|
let mut names: Vec<&str> = properties.keys().map(String::as_str).collect();
|
||||||
|
names.sort_unstable();
|
||||||
|
assert_eq!(names, ["name", "path"]);
|
||||||
|
let path_param = schema["inputSchema"]["required"][0].as_str().unwrap();
|
||||||
|
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
let dir = std::env::temp_dir().join(format!("lj_p8f_dispatch_{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let canonical = crate::mcp_inject::strip_verbatim_prefix(
|
||||||
|
&std::fs::canonicalize(&dir).unwrap().display().to_string(),
|
||||||
|
);
|
||||||
|
let mut args = json!({ "name": "别名" });
|
||||||
|
args[path_param] = json!(format!("{}/", dir.display().to_string().replace('\\', "/")));
|
||||||
|
let text = register_project_call(&conn, &args).unwrap();
|
||||||
|
let registered: Value = serde_json::from_str(&text).unwrap();
|
||||||
|
assert!(!registered["project_id"].as_str().unwrap().is_empty());
|
||||||
|
assert_eq!(registered["name"], "别名");
|
||||||
|
assert_eq!(
|
||||||
|
registered["path"],
|
||||||
|
canonical.as_str(),
|
||||||
|
"回执带落库的规范目录"
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = register_project_call(&conn, &json!({})).expect_err("缺 path 应被拒绝");
|
||||||
|
assert!(error.contains("路径为空"), "{error}");
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P8f 审查 M1:scan_unregistered_projects 与登记重复检查同一口径——已登记目录换了写法
|
||||||
|
/// (正斜杠、大小写、尾部分隔符)不再被列成未登记,两种写法的父目录只扫一次。
|
||||||
|
#[test]
|
||||||
|
fn scan_unregistered_matches_registrations_regardless_of_spelling() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
let parent = std::env::temp_dir().join(format!("lj_p8f_scan_{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&parent);
|
||||||
|
for child in ["alpha", "beta", "gamma"] {
|
||||||
|
std::fs::create_dir_all(parent.join(child)).unwrap();
|
||||||
|
}
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_profiles (id, name) VALUES ('prof-a', 'a'), ('prof-g', 'g')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let alpha = format!(
|
||||||
|
"{}/",
|
||||||
|
parent
|
||||||
|
.join("alpha")
|
||||||
|
.display()
|
||||||
|
.to_string()
|
||||||
|
.replace('\\', "/")
|
||||||
|
.to_uppercase()
|
||||||
|
);
|
||||||
|
let gamma = parent.join("gamma").display().to_string();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_workspaces (id, profile_id, win_path, platform)
|
||||||
|
VALUES ('a', 'prof-a', ?1, 'windows'), ('g', 'prof-g', ?2, 'windows')",
|
||||||
|
rusqlite::params![alpha, gamma],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let out = scan_unregistered_projects_with(&conn).unwrap();
|
||||||
|
assert!(out.contains("共 1 个"), "{out}");
|
||||||
|
assert!(out.contains("**beta**"), "{out}");
|
||||||
|
assert!(!out.contains("alpha") && !out.contains("gamma"), "{out}");
|
||||||
|
let scope = out
|
||||||
|
.lines()
|
||||||
|
.find(|line| line.starts_with("扫描范围:"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!scope.contains('、'), "两种写法的父目录只扫一次:{scope}");
|
||||||
|
std::fs::remove_dir_all(&parent).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user