dev-manager-tauri/src-tauri/src/commands/projects.rs
lanrtop 2f2a9f10a3
All checks were successful
Push & PR Check / check (push) Successful in 1m9s
Push & PR Check / check (pull_request) Successful in 1m1s
fix(onboard/parity): P8d 处置独立审查——锁内重新解析登记并以根目录绑 digest、阻断项可逐项跳过、接入文件与用户改动分开报告等(P8d 审查)
- ① 接入写锁内重新解析登记:登记消失 failed,根目录或端口变了 refused 并附当前计划;
  计划新增 root 并进 digest;SystemOnboardHost 每次重读端口;删除登记持同一把写锁先清理
  MCP 配置再删登记(delete_project_registration 注入写锁与清理闭包);批量重注入在锁内取快照
- ② OnboardOptions.skip_targets:manual_entry / shared_key 可逐项跳过(进 digest,计划单列
  skipped),跳过的一端记 skipped、不调用适配器;身份冲突、混合文件、needs_human 仍只能中止
- ③ 报告新增 written_files;repo_changes 限定项目根,分出 onboard_files 与 other_changes,
  建议提交信息与 next_action commit 只针对接入文件
- ④ 一键接入补写 mcp_inject 事件(inject_event),诊断注入状态随之更新
- ⑤ ClientAdapter::project_config_file:codex 步本次由 project_files 写入时记 applied,blocked 文案如实
- ⑥ lefthook 保留启动错误,本地存在却起不来记 failed;⑦ git_hooks 移到写锁外
- ⑧⑨⑩ 登记 known_limits / P8e 指引 / 转统筹决定;project-context.yaml 同步(T1)
- cargo test 402 passed / 0 failed / 6 ignored;pnpm typecheck 通过;四轮变异验证

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:27:57 +09:00

1539 lines
60 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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

use crate::commands::gitea::{self, RemovedRepoLink};
use crate::db::pool;
use crate::mcp_inject;
use crate::models::{ActivityLog, Project};
use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// JOIN 视图 SQL:project_workspaces + project_profiles + git_repos(可选)
/// git_repos 数据优先于 profile(COALESCE),部署信息来自 git_repos
const PROJECT_VIEW_SQL: &str = "
SELECT
pw.id,
pw.id AS workspace_id,
pp.id AS profile_id,
COALESCE(
pw.repo_id,
(SELECT id FROM git_repos WHERE repo_url = pp.repo_url LIMIT 1)
) AS repo_id,
COALESCE(gr.name, pp.name) AS name,
COALESCE(gr.description, pp.description) AS description,
pp.type,
COALESCE(gr.status, pp.status) AS status,
COALESCE(gr.priority, pp.priority) AS priority,
COALESCE(gr.tech_stack, pp.tech_stack) AS tech_stack,
pp.tags AS tags,
COALESCE(gr.repo_url, pp.repo_url) AS repo_url,
COALESCE(gr.upstream_url, pp.upstream_url) AS upstream_url,
COALESCE(gr.default_branch, pp.default_branch) AS default_branch,
COALESCE(gr.staging_url, pp.staging_url) AS staging_url,
COALESCE(gr.prod_url, pp.prod_url) AS prod_url,
COALESCE(gr.server_note, pp.server_note) AS server_note,
pw.space_id,
pw.wsl_path,
pw.win_path,
pw.platform,
pw.recent_focus,
pw.startup_commands,
pw.last_push_at,
pw.updated_at
FROM project_workspaces pw
JOIN project_profiles pp ON pp.id = pw.profile_id
LEFT JOIN git_repos gr ON gr.id = pw.repo_id
";
#[derive(Debug, Default, Deserialize)]
pub struct CreateProjectInput {
pub id: String,
pub name: String,
pub description: Option<String>,
#[serde(rename = "type")]
pub type_: Option<String>,
pub status: Option<String>,
pub priority: Option<String>,
// profile 字段(共享)
pub tech_stack: Option<String>,
pub repo_url: Option<String>,
pub upstream_url: Option<String>,
pub default_branch: Option<String>,
pub staging_url: Option<String>,
pub prod_url: Option<String>,
pub server_note: Option<String>,
// workspace 字段(每空间独立)
pub space_id: Option<String>,
pub wsl_path: Option<String>,
pub win_path: Option<String>,
pub platform: Option<String>,
pub recent_focus: Option<String>,
pub startup_commands: Option<String>,
/// 若提供则跳过建 profile,直接为已有 profile 新增 workspace
pub profile_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateProjectInput {
// profile 字段(共享)
pub name: Option<String>,
pub description: Option<String>,
#[serde(rename = "type")]
pub type_: Option<String>,
pub status: Option<String>,
pub priority: Option<String>,
pub tech_stack: Option<String>,
pub tags: Option<String>,
pub staging_url: Option<String>,
pub prod_url: Option<String>,
pub server_note: Option<String>,
// workspace 字段(每空间独立)
pub wsl_path: Option<String>,
pub win_path: Option<String>,
pub platform: Option<String>,
pub recent_focus: Option<String>,
pub startup_commands: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ProjectDetail {
#[serde(flatten)]
pub project: Project,
pub recent_activities: Vec<ActivityLog>,
}
#[tauri::command]
pub fn get_projects(
status: Option<String>,
space_id: Option<String>,
) -> Result<Vec<Project>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let projects: Vec<Project> = match (&status, &space_id) {
(Some(s), Some(sid)) => {
let sql = format!("{PROJECT_VIEW_SQL} WHERE pp.status = ?1 AND pw.space_id = ?2 ORDER BY pw.updated_at DESC");
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let x = stmt
.query_map(params![s, sid], Project::from_row)
.map_err(|e| e.to_string())?
.map(|r| r.map_err(|e| e.to_string()))
.collect::<Result<Vec<_>, _>>()?;
x
}
(Some(s), None) => {
let sql =
format!("{PROJECT_VIEW_SQL} WHERE pp.status = ?1 ORDER BY pw.updated_at DESC");
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let x = stmt
.query_map(params![s], Project::from_row)
.map_err(|e| e.to_string())?
.map(|r| r.map_err(|e| e.to_string()))
.collect::<Result<Vec<_>, _>>()?;
x
}
(None, Some(sid)) => {
let sql =
format!("{PROJECT_VIEW_SQL} WHERE pw.space_id = ?1 ORDER BY pw.updated_at DESC");
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let x = stmt
.query_map(params![sid], Project::from_row)
.map_err(|e| e.to_string())?
.map(|r| r.map_err(|e| e.to_string()))
.collect::<Result<Vec<_>, _>>()?;
x
}
(None, None) => {
let sql = format!("{PROJECT_VIEW_SQL} ORDER BY pw.updated_at DESC");
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let x = stmt
.query_map([], Project::from_row)
.map_err(|e| e.to_string())?
.map(|r| r.map_err(|e| e.to_string()))
.collect::<Result<Vec<_>, _>>()?;
x
}
};
Ok(projects)
}
#[tauri::command]
pub fn get_project(id: String) -> Result<ProjectDetail, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let sql = format!("{PROJECT_VIEW_SQL} WHERE pw.id = ?1");
let project = conn
.query_row(&sql, params![id], Project::from_row)
.map_err(|_| format!("Project not found: {id}"))?;
let mut stmt = conn
.prepare(
"SELECT * FROM activity_log WHERE project_id = ?1 ORDER BY created_at DESC LIMIT 5",
)
.map_err(|e| e.to_string())?;
let activities: Vec<ActivityLog> = stmt
.query_map(params![id], ActivityLog::from_row)
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
drop(stmt);
Ok(ProjectDetail {
project,
recent_activities: activities,
})
}
// ── 登记本体(P8f)─────────────────────────────────────────────────────────────
/// 登记路径的字面比较键:去首尾空白、`/` 统一为 `\`、去 `\\?\` 长路径前缀(`\\?\UNC\x` 还原为 `\\x`),
/// 再按 DSH 白名单键([`mcp_inject::normalize_dsh_root`])合并重复分隔符(保留 UNC 开头的 `\\`)、
/// 去尾部分隔符、不区分大小写——与 DSH 白名单共用同一实现,口径不分叉。
/// 不读磁盘:`..`、8.3 短名、junction 等同目录写法由 [`registration_keys`] 的 canonical 键补上。
pub(crate) fn registration_path_key(path: &str) -> String {
let unified = path.trim().replace('/', "\\");
mcp_inject::normalize_dsh_root(&mcp_inject::strip_verbatim_prefix(&unified))
}
/// 已存在目录的规范形式:`std::fs::canonicalize` 后去掉 `\\?\` 前缀——得到真实大小写与长文件名、
/// 反斜杠写法,消掉 `.`、`..` 段与尾部分隔符;junction、符号链接与 subst 盘解析到真实目标
/// (与 DSH 白名单取 realpath 同口径)。目录不存在或无法解析时为 None。
fn canonical_dir(path: &std::path::Path) -> Option<std::path::PathBuf> {
std::fs::canonicalize(path).ok().map(|canonical| {
std::path::PathBuf::from(mcp_inject::strip_verbatim_prefix(
&canonical.to_string_lossy(),
))
})
}
/// 待登记目录的比较键:字面键,加上目录存在时的 canonical 键(与字面键不同时)。
/// 会读磁盘(WSL 目录可能唤起发行版),须在写事务外计算;已有登记一侧只取字面键、不读磁盘。
fn registration_keys(root: &std::path::Path) -> Vec<String> {
let literal = registration_path_key(&root.to_string_lossy());
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 {
conn.query_row(
"SELECT value FROM settings WHERE key = 'wsl_distro'",
[],
|row| row.get(0),
)
.unwrap_or_else(|_| "Ubuntu".to_string())
}
/// 一条登记的有效根目录:Windows 项目(platform 缺省亦然)取 win_path;WSL 项目取 wsl_path——
/// Linux 路径按 `\\wsl.localhost\<distro>\…` 换算(取列与 Linux 路径换算同 `mcp_inject::project_root_with_conn`),
/// 已是 Windows 路径(如 UI 选目录得到的 UNC)的原样使用(project_root_with_conn 对这种 wsl_path 会错误拼接,
/// 是已有缺陷,另列后续小修)。空路径与相对路径一律拒绝。
fn registration_root(
platform: Option<&str>,
win_path: Option<&str>,
wsl_path: Option<&str>,
distro: &str,
) -> Result<std::path::PathBuf, String> {
let is_wsl = platform == Some("wsl");
let raw = if is_wsl { wsl_path } else { win_path }
.map(str::trim)
.unwrap_or_default();
if raw.is_empty() {
return Err("项目路径为空:登记须给出已存在目录的绝对路径".to_string());
}
if is_wsl && raw.starts_with('/') {
let inner = raw.trim_start_matches('/').replace('/', "\\");
return Ok(std::path::PathBuf::from(format!(
r"\\wsl.localhost\{distro}\{inner}"
)));
}
let path = std::path::PathBuf::from(raw);
if !path.is_absolute() {
return Err(format!("项目路径不是绝对路径:{raw:?}"));
}
Ok(path)
}
/// 已有登记中与给定比较键([`registration_keys`])同一目录的那条,返回其 project_id 与有效根目录。
/// 每条已有登记只取其有效根目录([`registration_root`],按 platform 取列,与 project_root_with_conn
/// 同一取列口径):切换平台后残留在另一列的旧路径不参与比较,不会误拦。已有行只算字面键、不读磁盘,
/// 可在写事务内调用。
pub(crate) fn find_registration_by_keys(
conn: &rusqlite::Connection,
keys: &[String],
) -> Result<Option<(String, String)>, String> {
let distro = wsl_distro(conn);
let mut stmt = conn
.prepare("SELECT id, platform, win_path, wsl_path FROM project_workspaces ORDER BY id")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
))
})
.map_err(|e| e.to_string())?;
for row in rows {
let (id, platform, win_path, wsl_path) = row.map_err(|e| e.to_string())?;
let Ok(existing) = registration_root(
platform.as_deref(),
win_path.as_deref(),
wsl_path.as_deref(),
&distro,
) else {
continue;
};
if keys.contains(&registration_path_key(&existing.to_string_lossy())) {
return Ok(Some((id, existing.display().to_string())));
}
}
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, &registration_keys(root))
}
fn duplicate_registration_error(id: &str, path: &str) -> String {
format!(
"该目录已登记为项目 project_id={id}({path}),拒绝重复登记:同一目录两条登记会互相抢占 Codex 托管块等接入配置。请直接使用该 project_id;确需重新登记,先删除原登记"
)
}
/// 同一目录已登记时拒绝,错误里给出已有 project_id。同一目录两条登记会让 Codex 托管块等
/// 按目录落盘的接入配置被两条登记互相抢占(PR #34 审查 L1/L2)。会读磁盘,勿在写事务内调用。
pub(crate) fn ensure_path_not_registered(
conn: &rusqlite::Connection,
root: &std::path::Path,
) -> Result<(), String> {
match find_registration_by_path(conn, root)? {
None => Ok(()),
Some((id, path)) => Err(duplicate_registration_error(&id, &path)),
}
}
/// 其他登记入口(批量添加、挂载仓库)的比较键:路径缺省或无法解析时为 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(
conn: &rusqlite::Connection,
platform: Option<&str>,
win_path: Option<&str>,
wsl_path: Option<&str>,
) -> Result<Option<(String, String)>, String> {
match registration_keys_for(conn, platform, win_path, wsl_path) {
Some(keys) => find_registration_by_keys(conn, &keys),
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` 共用,连接由调用方注入。
/// 只写数据库:不建目录、不执行 git init、不接入。
/// 拒绝:空 ID、空路径(UI 概念占位除外,见 [`RootRequirement`])、相对路径、不存在的目录、空名称、
/// 同一目录已登记([`find_registration_by_keys`])、已存在的 ID。
/// Windows 目录落库为规范形式([`canonical_dir`]):下游有按字面比较的消费者(scan_unregistered_projects
/// 等),正斜杠、尾部分隔符、8.3 短名、`..` 段原样落库会与它们对不上(P8f 审查 M1);WSL 路径原样落库。
/// 返回落库的有效根目录(概念占位为 None)。
pub(crate) fn register_project_records(
conn: &rusqlite::Connection,
input: &CreateProjectInput,
requirement: RootRequirement,
) -> Result<Option<std::path::PathBuf>, String> {
if input.id.trim().is_empty() {
return Err("项目 ID 不能为空".to_string());
}
let is_wsl = input.platform.as_deref() == Some("wsl");
let effective_path = if is_wsl {
input.wsl_path.as_deref()
} else {
input.win_path.as_deref()
}
.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() {
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 事务:并发登记同一目录时第二个等到第一个提交后再查,不会各写一条
let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
.map_err(|e| e.to_string())?;
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
.query_row(
"SELECT 1 FROM project_workspaces WHERE id = ?1",
params![input.id],
|_| Ok(()),
)
.optional()
.map_err(|e| e.to_string())?
.is_some();
if id_taken {
return Err(format!("项目 ID 已存在:{}", input.id));
}
let profile_id = if let Some(ref pid) = input.profile_id {
// 快速路径:profile 已存在,直接新建 workspace
pid.clone()
} else {
// 标准路径:先建 profile
let pid = format!("prof-{}", input.id);
tx.execute(
"INSERT OR IGNORE INTO project_profiles
(id, name, description, type, status, priority, tech_stack,
repo_url, upstream_url, default_branch,
staging_url, prod_url, server_note, updated_at)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,datetime('now'))",
params![
pid,
input.name,
input.description,
input.type_,
input.status.as_deref().unwrap_or("active"),
input.priority.as_deref().unwrap_or("mid"),
input.tech_stack,
input.repo_url,
input.upstream_url,
input.default_branch,
input.staging_url,
input.prod_url,
input.server_note,
],
)
.map_err(|e| e.to_string())?;
pid
};
// 不用 OR IGNORE:冲突必须报错,不能静默返回成功
tx.execute(
"INSERT INTO project_workspaces
(id, profile_id, space_id, wsl_path, win_path, platform,
recent_focus, startup_commands, updated_at)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,datetime('now'))",
params![
input.id,
profile_id,
input.space_id,
input.wsl_path,
win_path,
input.platform.as_deref().unwrap_or("windows"),
input.recent_focus,
input.startup_commands,
],
)
.map_err(|e| {
if e.to_string().contains("UNIQUE") {
"该项目档案在当前空间已有登记".to_string()
} else {
e.to_string()
}
})?;
tx.commit().map_err(|e| e.to_string())?;
Ok(checked_root.map(|(root, _)| root))
}
/// MCP `register_project` 的返回:新登记的 project_id 与落库的规范目录。
#[derive(Debug, Serialize)]
pub(crate) struct RegisteredProject {
pub project_id: String,
pub name: String,
pub path: 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 由炼境生成
/// (UUID,与 UI 选目录新建一致);名称缺省取目录名(按规范形式取真实大小写);归入当前空间
/// (UI 首页按当前空间列项目)。校验与落库规范化全部交给 [`register_project_records`]。
pub(crate) fn register_project_at(
conn: &rusqlite::Connection,
path: &str,
name: Option<&str>,
) -> Result<RegisteredProject, String> {
let path = path.trim();
let name = name
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.unwrap_or_else(|| {
// 相对路径不解析(否则会按炼境进程 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()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string())
});
let space_id: Option<String> = conn
.query_row(
"SELECT id FROM spaces WHERE is_current = 1 ORDER BY created_at LIMIT 1",
[],
|row| row.get(0),
)
.optional()
.map_err(|e| e.to_string())?;
let input = CreateProjectInput {
id: uuid::Uuid::new_v4().to_string(),
name,
space_id,
win_path: Some(path.to_string()),
platform: Some("windows".to_string()),
..CreateProjectInput::default()
};
let root = register_project_records(conn, &input, RootRequirement::ExistingDir)?
.ok_or_else(|| "登记本体未返回项目目录".to_string())?;
let warning = stale_context_warning(&root, &input.id);
Ok(RegisteredProject {
project_id: input.id,
name: input.name,
path: root.display().to_string(),
space_id: input.space_id,
warning,
})
}
#[tauri::command]
pub fn create_project(input: CreateProjectInput) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
register_project_records(&conn, &input, RootRequirement::ExistingDirOrPlaceholder).map(|_| ())
}
#[tauri::command]
pub fn update_project(id: String, input: UpdateProjectInput) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
// 1. 更新 profile(通过 workspace.id 定位)
conn.execute(
"UPDATE project_profiles SET
name = COALESCE(?1, name),
description = COALESCE(?2, description),
type = COALESCE(?3, type),
status = COALESCE(?4, status),
priority = COALESCE(?5, priority),
tech_stack = COALESCE(?6, tech_stack),
tags = COALESCE(?7, tags),
staging_url = ?8,
prod_url = ?9,
server_note = ?10,
updated_at = datetime('now')
WHERE id = (SELECT profile_id FROM project_workspaces WHERE id = ?11)",
params![
input.name,
input.description,
input.type_,
input.status,
input.priority,
input.tech_stack,
input.tags,
input.staging_url,
input.prod_url,
input.server_note,
id
],
)
.map_err(|e| e.to_string())?;
// 2. 更新 workspace(路径或平台变化时,先记下旧根目录,供客户端侧配置迁移)
let root_may_change =
input.win_path.is_some() || input.wsl_path.is_some() || input.platform.is_some();
let old_root = root_may_change
.then(|| crate::mcp_inject::project_root_with_conn(&conn, &id).ok())
.flatten();
conn.execute(
"UPDATE project_workspaces SET
wsl_path = COALESCE(?1, wsl_path),
win_path = COALESCE(?2, win_path),
platform = COALESCE(?3, platform),
recent_focus = COALESCE(?4, recent_focus),
startup_commands = COALESCE(?5, startup_commands),
updated_at = datetime('now')
WHERE id = ?6",
params![
input.wsl_path,
input.win_path,
input.platform,
input.recent_focus,
input.startup_commands,
id
],
)
.map_err(|e| e.to_string())?;
// 根目录变化:Claude local 条目按目录挂载、DSH 白名单按根目录匹配,都要迁移到新根。
// 会调用 claude CLI,放到后台线程,避免同步命令卡住界面。
if root_may_change {
let new_root = crate::mcp_inject::project_root_with_conn(&conn, &id).ok();
if crate::mcp_inject::project_root_changed(old_root.as_ref(), new_root.as_ref()) {
drop(conn);
std::thread::spawn(move || crate::mcp_inject::on_project_root_changed(&id, old_root));
}
}
Ok(())
}
#[tauri::command]
pub fn update_project_tags(id: String, tags: String) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
conn.execute(
"UPDATE project_profiles SET tags = ?1, updated_at = datetime('now')
WHERE id = (SELECT profile_id FROM project_workspaces WHERE id = ?2)",
params![if tags.is_empty() { None } else { Some(tags) }, id],
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// 删除项目登记的数据库部分(连接由调用方注入;生产调用一律经 [`delete_project_registration`]):
/// 同一事务内清理关联表、解除 Gitea 仓库绑定、删除 workspace 与孤儿 profile。
/// 返回被解除的 Gitea 绑定,供事务提交后交给 `gitea::cleanup_removed_repo_link`
/// 删除 Gitea 侧 webhook 与本机签名凭证。
pub(crate) fn delete_project_records(
conn: &rusqlite::Connection,
id: &str,
) -> Result<Option<RemovedRepoLink>, String> {
let profile_id: Option<String> = conn
.query_row(
"SELECT profile_id FROM project_workspaces WHERE id = ?1",
params![id],
|row| row.get(0),
)
.optional()
.map_err(|e| e.to_string())?;
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
// 清理关联数据(旧 FK 指向 projects 表,需手动清理)
for table in [
"activity_log",
"project_tools",
"project_group_map",
"project_env_tools",
"deploy_commands",
] {
tx.execute(
&format!("DELETE FROM {table} WHERE project_id = ?1"),
params![id],
)
.map_err(|e| e.to_string())?;
}
tx.execute(
"DELETE FROM relations WHERE from_project = ?1 OR to_project = ?1",
params![id],
)
.map_err(|e| e.to_string())?;
// 不解绑的话,后台 Gitea 轮询会继续把 commit 与 PR 事件写到已删除的项目 ID 下
let removed_link = gitea::remove_repo_link_records(&tx, id)?;
tx.execute("DELETE FROM project_workspaces WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
// 若 profile 已无 workspace,同步删除 profile
if let Some(pid) = profile_id {
let remaining: i64 = tx
.query_row(
"SELECT COUNT(*) FROM project_workspaces WHERE profile_id = ?1",
params![pid],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
if remaining == 0 {
tx.execute("DELETE FROM project_profiles WHERE id = ?1", params![pid])
.map_err(|e| e.to_string())?;
}
}
tx.commit().map_err(|e| e.to_string())?;
Ok(removed_link)
}
/// 删除项目登记本体:持接入写锁先清理客户端侧接入(clear_clients,生产为 remove_project_mcp_locked),再删数据库
/// 部分([`delete_project_records`],单事务);释放写锁后对被解除的 Gitea 绑定做外部清理(Gitea 侧 webhook 与本机
/// 签名凭证,尽力而为,有网络超时,不占写锁)。清理与删登记在同一把锁内:一键接入在锁内重新解析登记,不会在两者之间
/// 拿到锁、按将删的登记重建 token 与客户端条目(P8d 审查 ①)。
/// 连接、凭证存储与写锁由调用方注入;UI `delete_project` 与 MCP `unregister_project` 经它删除登记。
pub(crate) fn delete_project_registration(
conn: &rusqlite::Connection,
store: &impl crate::credential_store::CredentialStore,
id: &str,
write_lock: &std::sync::Mutex<()>,
clear_clients: impl FnOnce(),
) -> Result<(), String> {
let removed_link = {
let _guard = write_lock
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
clear_clients();
delete_project_records(conn, id)?
};
if let Some(removed_link) = removed_link {
gitea::cleanup_removed_repo_link(conn, store, &removed_link);
}
Ok(())
}
// 会经 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())?;
// 删除前先读取路径(仅删除本地文件夹时使用)
let (win_path, wsl_path): (Option<String>, Option<String>) = conn
.query_row(
"SELECT win_path, wsl_path FROM project_workspaces WHERE id = ?1",
params![id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap_or((None, None));
crate::commands::dev_variables::purge_project(
&conn,
&crate::credential_store::WindowsCredentialStore,
&id,
)?;
// 清理炼境 MCP 配置(在删除 project_workspaces 记录之前,需查项目路径),与删除登记在同一把接入写锁内
delete_project_registration(
&conn,
&crate::credential_store::WindowsCredentialStore,
&id,
mcp_inject::mcp_op_write_lock(),
|| {
if let Err(e) = mcp_inject::remove_project_mcp_locked(&id) {
eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e);
}
},
)?;
// 删除本地文件夹(仅在明确要求时执行)
if delete_local.unwrap_or(false) {
// Windows 路径(直接用 std::fs)
if let Some(path) = win_path.filter(|p| !p.is_empty()) {
let p = std::path::Path::new(&path);
if p.exists() {
std::fs::remove_dir_all(p).map_err(|e| format!("删除本地文件夹失败: {e}"))?;
}
}
// WSL 路径(通过 wsl.exe 删除)
if let Some(path) = wsl_path.filter(|p| !p.is_empty()) {
let _ = crate::silent_cmd("wsl")
.args(["--", "rm", "-rf", &path])
.output();
}
}
Ok(())
}
/// 扫描指定目录,返回所有子文件夹信息 [{name, path}]
#[tauri::command]
pub fn scan_subfolders(dir: String) -> Result<Vec<Value>, String> {
use std::fs;
let entries = fs::read_dir(&dir).map_err(|e| format!("无法读取目录 \"{dir}\": {e}"))?;
let mut result = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let mut obj = serde_json::Map::new();
obj.insert("name".into(), Value::String(name));
obj.insert(
"path".into(),
Value::String(path.to_string_lossy().into_owned()),
);
result.push(Value::Object(obj));
}
}
result.sort_by(|a, b| {
a.get("name")
.and_then(|v| v.as_str())
.cmp(&b.get("name").and_then(|v| v.as_str()))
});
Ok(result)
}
#[derive(Debug, Deserialize)]
pub struct BatchProjectInput {
pub id: String,
pub name: String,
pub win_path: Option<String>,
pub wsl_path: Option<String>,
pub status: String,
pub priority: String,
pub space_id: Option<String>,
pub platform: Option<String>,
}
/// 批量创建项目(已存在的 workspace id 或已登记的同一目录跳过)
#[tauri::command]
pub fn batch_create_projects(items: Vec<BatchProjectInput>) -> Result<Vec<String>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
batch_register_projects(&conn, items)
}
fn batch_register_projects(
conn: &rusqlite::Connection,
items: Vec<BatchProjectInput>,
) -> Result<Vec<String>, String> {
let mut skipped = Vec::new();
for item in items {
let exists: bool = conn
.query_row(
"SELECT 1 FROM project_workspaces WHERE id = ?1",
params![item.id],
|_| Ok(true),
)
.unwrap_or(false);
let same_dir = duplicate_registration_of(
conn,
item.platform.as_deref(),
item.win_path.as_deref(),
item.wsl_path.as_deref(),
)?
.is_some();
if exists || same_dir {
skipped.push(item.id);
continue;
}
let profile_id = format!("prof-{}", item.id);
conn.execute(
"INSERT OR IGNORE INTO project_profiles (id, name, status, priority, updated_at)
VALUES (?1, ?2, ?3, ?4, datetime('now'))",
params![profile_id, item.name, item.status, item.priority],
)
.map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO project_workspaces
(id, profile_id, win_path, wsl_path, space_id, platform, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'))",
params![
item.id,
profile_id,
item.win_path,
item.wsl_path,
item.space_id,
item.platform,
],
)
.map_err(|e| e.to_string())?;
}
Ok(skipped)
}
#[cfg(test)]
mod tests {
use super::*;
fn seed_project(conn: &rusqlite::Connection, id: &str) {
let profile_id = format!("prof-{id}");
conn.execute(
"INSERT INTO project_profiles (id, name) VALUES (?1, ?2)",
params![profile_id, id],
)
.unwrap();
conn.execute(
"INSERT INTO project_workspaces (id, profile_id) VALUES (?1, ?2)",
params![id, profile_id],
)
.unwrap();
}
fn bind_gitea_repo(conn: &rusqlite::Connection, project_id: &str) {
conn.execute(
"INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name)
VALUES (?1, 'inst', ?2, 'owner', ?2)",
params![format!("repo-{project_id}"), project_id],
)
.unwrap();
}
fn gitea_binding_count(conn: &rusqlite::Connection, project_id: &str) -> i64 {
conn.query_row(
"SELECT COUNT(*) FROM gitea_repos WHERE project_id = ?1",
params![project_id],
|row| row.get(0),
)
.unwrap()
}
fn conn_with_two_bound_projects() -> rusqlite::Connection {
let conn = crate::db::conn_with_schema();
conn.execute(
"INSERT INTO gitea_instances (id, name, url, access_token)
VALUES ('inst', 'test', 'https://example.invalid', '')",
[],
)
.unwrap();
for id in ["gone", "kept"] {
seed_project(&conn, id);
bind_gitea_repo(&conn, id);
}
conn
}
#[test]
fn deleting_project_registration_unbinds_its_gitea_repo() {
let conn = conn_with_two_bound_projects();
delete_project_records(&conn, "gone").unwrap();
assert_eq!(
gitea_binding_count(&conn, "gone"),
0,
"删除登记后不应残留 Gitea 绑定,否则轮询会继续往已删除的项目 ID 写 commit 与 PR 事件"
);
assert_eq!(
gitea_binding_count(&conn, "kept"),
1,
"只解除被删项目的绑定"
);
let workspaces: i64 = conn
.query_row(
"SELECT COUNT(*) FROM project_workspaces WHERE id = 'gone'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(workspaces, 0);
}
#[test]
fn gitea_unbinding_rolls_back_with_failed_project_deletion() {
let conn = conn_with_two_bound_projects();
conn.execute_batch(
"CREATE TRIGGER fail_workspace_delete BEFORE DELETE ON project_workspaces
BEGIN SELECT RAISE(ABORT, 'injected failure'); END;",
)
.unwrap();
assert!(delete_project_records(&conn, "gone").is_err());
assert_eq!(
gitea_binding_count(&conn, "gone"),
1,
"解绑与删除登记同一事务:删除失败时绑定不得被单独解除"
);
}
/// 给绑定挂上 webhook 签名凭证引用,并在存储里放入对应凭证;webhook_id 保持 NULL,清理不发网络请求
fn attach_webhook_secret(
conn: &rusqlite::Connection,
store: &crate::credential_store::MemoryCredentialStore,
project_id: &str,
) -> String {
use crate::credential_store::CredentialStore;
let reference = format!("gitea/webhook-secret/{project_id}/v1");
store.put(&reference, b"synthetic-hook").unwrap();
conn.execute(
"UPDATE gitea_repos SET webhook_secret_ref = ?1 WHERE project_id = ?2",
params![reference, project_id],
)
.unwrap();
reference
}
#[test]
fn deleting_project_registration_removes_its_webhook_secret() {
use crate::credential_store::CredentialStore;
let conn = conn_with_two_bound_projects();
let store = crate::credential_store::MemoryCredentialStore::new();
let gone_ref = attach_webhook_secret(&conn, &store, "gone");
let kept_ref = attach_webhook_secret(&conn, &store, "kept");
// 回归(P8d 审查 ①):清理客户端侧接入在接入写锁内、且先于删除登记执行,锁一直持到登记删除提交——
// 一键接入在锁内重新解析登记,不会在两者之间拿到锁、按将删的登记重建 token
let lock = std::sync::Mutex::new(());
let cleared = std::cell::Cell::new(false);
delete_project_registration(&conn, &store, "gone", &lock, || {
assert!(lock.try_lock().is_err(), "清理须在接入写锁内执行");
let still_registered: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM project_workspaces WHERE id = 'gone')",
[],
|row| row.get(0),
)
.unwrap();
assert!(
still_registered,
"清理须先于删除登记(要按登记解析项目路径)"
);
cleared.set(true);
})
.unwrap();
assert!(cleared.get());
assert!(lock.try_lock().is_ok(), "删除结束后释放写锁");
assert_eq!(gitea_binding_count(&conn, "gone"), 0);
assert!(
store.get(&gone_ref).unwrap().is_none(),
"删除登记提交后应清理被删项目的 webhook 签名凭证,否则本机凭证残留"
);
assert!(
store.get(&kept_ref).unwrap().is_some(),
"不得清理其他项目的 webhook 签名凭证"
);
}
#[test]
fn unregistering_orphaned_binding_id_cleans_the_binding() {
// 孤儿绑定:workspace 已不在、绑定行还在(轮询日志提示的清理路径)
use crate::credential_store::CredentialStore;
let conn = conn_with_two_bound_projects();
let store = crate::credential_store::MemoryCredentialStore::new();
let gone_ref = attach_webhook_secret(&conn, &store, "gone");
conn.execute("DELETE FROM project_workspaces WHERE id = 'gone'", [])
.unwrap();
assert_eq!(gitea_binding_count(&conn, "gone"), 1, "前置:孤儿绑定仍在");
delete_project_registration(&conn, &store, "gone", &std::sync::Mutex::new(()), || {})
.unwrap();
assert_eq!(gitea_binding_count(&conn, "gone"), 0);
assert!(store.get(&gone_ref).unwrap().is_none());
assert_eq!(gitea_binding_count(&conn, "kept"), 1);
}
// ── 登记本体(P8f)────────────────────────────────────────────────────────
/// 临时目录(不是 git 仓库);测试结束由调用方清理
fn temp_project_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("lj_p8f_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn workspace_count(conn: &rusqlite::Connection) -> i64 {
conn.query_row("SELECT COUNT(*) FROM project_workspaces", [], |row| {
row.get(0)
})
.unwrap()
}
fn windows_input(id: &str, win_path: &str) -> CreateProjectInput {
CreateProjectInput {
id: id.to_string(),
name: id.to_string(),
win_path: Some(win_path.to_string()),
..CreateProjectInput::default()
}
}
#[test]
fn registration_path_key_folds_case_separators_and_trailing_slash() {
let key = registration_path_key(r"E:\Code-Project\My-App");
for variant in [
r"e:\code-project\my-app",
"E:/Code-Project/My-App",
r"E:\Code-Project\My-App\",
"E:/Code-Project/My-App/",
r"E:\\Code-Project\\\My-App",
r"\\?\E:\Code-Project\My-App",
" E:\\Code-Project\\My-App ",
] {
assert_eq!(registration_path_key(variant), key, "{variant}");
}
assert_ne!(
registration_path_key(r"E:\Code-Project\My-App2"),
key,
"不同目录不得被归并"
);
assert_eq!(
registration_path_key("//wsl.localhost/Ubuntu/home/u/app/"),
registration_path_key(r"\\wsl.localhost\ubuntu\home\u\app"),
"UNC 保留开头的双分隔符"
);
assert_eq!(
registration_path_key(r"\\?\UNC\server\share\app"),
registration_path_key(r"\\server\share\app")
);
assert_eq!(registration_path_key(r"E:\"), registration_path_key("e:"));
}
#[test]
fn registration_rejects_empty_relative_and_missing_paths_without_side_effects() {
let conn = crate::db::conn_with_schema();
let missing = std::env::temp_dir().join(format!(
"lj_p8f_missing_{}_never_created",
std::process::id()
));
let _ = std::fs::remove_dir_all(&missing);
let cases = [
("empty", String::new()),
("blank", " ".to_string()),
("relative", "src-tauri".to_string()),
// cargo test 的 cwd 是 crate 根,src 目录真实存在:只靠绝对路径校验拦下
("relative_existing", "src".to_string()),
("drive_relative", "E:projects".to_string()),
("missing", missing.display().to_string()),
];
for (label, path) in &cases {
let error = register_project_records(
&conn,
&windows_input(label, path),
RootRequirement::ExistingDir,
)
.expect_err(&format!("{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, "被拒绝的登记不得写入任何行");
let profiles: i64 = conn
.query_row("SELECT COUNT(*) FROM project_profiles", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(profiles, 0, "被拒绝的登记不得留下 profile");
assert!(!missing.exists(), "登记不建目录");
let wsl_empty = CreateProjectInput {
platform: Some("wsl".to_string()),
win_path: Some(r"E:\not-used".to_string()),
..windows_input("wsl-empty", "")
};
assert!(
register_project_records(&conn, &wsl_empty, RootRequirement::ExistingDir).is_err(),
"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]
fn registration_rejects_same_directory_in_any_spelling_and_names_existing_id() {
let conn = crate::db::conn_with_schema();
let dir = temp_project_dir("dup");
std::fs::create_dir_all(dir.join("sub")).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");
for (index, variant) in [
raw.to_uppercase(),
raw.to_lowercase(),
raw.replace('\\', "/"),
format!("{raw}\\"),
format!("{}/", raw.replace('\\', "/")),
canonical.to_uppercase(),
// `..` 段只有 canonical 键能识别(审查 L2)
format!(r"{raw}\sub\.."),
format!(r"{canonical}\sub\..\."),
]
.iter()
.enumerate()
{
let error = register_project_records(
&conn,
&windows_input(&format!("dup-{index}"), variant),
RootRequirement::ExistingDir,
)
.expect_err(&format!("同一目录的写法 {variant} 应被拒绝"));
assert!(
error.contains("project_id=first"),
"错误里应给出已存在的 project_id: {error}"
);
}
assert_eq!(workspace_count(&conn), 1, "重复登记不得写入第二行");
// UI 改走 WSL 平台登记同一目录(wsl_path 存 UNC 原样)同样拒绝
let via_wsl = CreateProjectInput {
platform: Some("wsl".to_string()),
wsl_path: Some(raw.to_uppercase()),
..windows_input("dup-wsl", "")
};
assert!(register_project_records(&conn, &via_wsl, RootRequirement::ExistingDir).is_err());
assert_eq!(workspace_count(&conn), 1);
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]
fn duplicate_lookup_resolves_wsl_linux_paths_like_project_root() {
let conn = crate::db::conn_with_schema();
conn.execute(
"INSERT INTO project_profiles (id, name) VALUES ('prof-w', 'w')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO project_workspaces (id, profile_id, wsl_path, platform)
VALUES ('wsl-app', 'prof-w', '/home/u/app', 'wsl')",
[],
)
.unwrap();
// 只用字面键查:测试不读 \\wsl.localhost(会唤起本机 WSL 发行版)
let lookup = |path: &str| {
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();
conn.execute(
"INSERT INTO project_workspaces (id, profile_id, win_path, wsl_path, platform)
VALUES ('moved', 'prof-moved', ?1, '/home/u/moved', 'wsl')",
params![raw],
)
.unwrap();
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]
fn registration_rejects_taken_id_instead_of_silently_ignoring() {
let conn = crate::db::conn_with_schema();
let first = temp_project_dir("id_a");
let second = temp_project_dir("id_b");
register_project_records(
&conn,
&windows_input("same-id", &first.display().to_string()),
RootRequirement::ExistingDir,
)
.unwrap();
let error = register_project_records(
&conn,
&windows_input("same-id", &second.display().to_string()),
RootRequirement::ExistingDir,
)
.expect_err("ID 已存在应报错,不能静默返回成功");
assert!(error.contains("same-id"), "{error}");
let stored: String = conn
.query_row(
"SELECT win_path FROM project_workspaces WHERE id = 'same-id'",
[],
|row| row.get(0),
)
.unwrap();
// 落库为规范形式(审查 M1)
assert_eq!(
stored,
canonical_dir(&first).unwrap().display().to_string(),
"原登记不被改写"
);
std::fs::remove_dir_all(&first).unwrap();
std::fs::remove_dir_all(&second).unwrap();
}
#[test]
fn register_project_at_generates_id_defaults_name_and_joins_current_space() {
let conn = crate::db::conn_with_schema();
conn.execute(
"INSERT INTO spaces (id, name, local_root, is_current) VALUES ('home', 'home', '', 1)",
[],
)
.unwrap();
let dir = temp_project_dir("mcp");
let canonical = canonical_dir(&dir).unwrap().display().to_string();
// 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(&registered.project_id).is_ok());
assert_eq!(
registered.name,
dir.file_name().unwrap().to_string_lossy(),
"名称缺省取目录名"
);
assert_eq!(registered.space_id.as_deref(), Some("home"));
assert_eq!(registered.path, canonical, "返回落库的规范目录");
assert!(registered.warning.is_none());
assert!(
serde_json::to_value(&registered)
.unwrap()
.get("warning")
.is_none(),
"没有提示时不输出 warning 字段"
);
let (win_path, platform, space): (String, String, Option<String>) = conn
.query_row(
"SELECT win_path, platform, space_id FROM project_workspaces WHERE id = ?1",
params![registered.project_id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.unwrap();
// 审查 M1:原断言为「存入去掉首尾空白的路径」,改为存规范形式(canonicalize 去 \\?\ 前缀)
assert_eq!(win_path, canonical, "落库为规范形式");
assert!(!win_path.contains('/') && !win_path.ends_with('\\'));
assert!(!win_path.starts_with(r"\\?\"));
assert_eq!(platform, "windows");
assert_eq!(space.as_deref(), Some("home"));
let again = register_project_at(&conn, &dir.display().to_string(), Some("别名"))
.expect_err("同一目录再次登记应被拒绝");
assert!(again.contains(&registered.project_id), "{again}");
assert!(register_project_at(&conn, "", None).is_err());
assert!(
register_project_at(&conn, "src", None).is_err(),
"相对路径不按炼境进程 cwd 解析"
);
assert_eq!(workspace_count(&conn), 1);
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(&registered.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]
fn batch_registration_skips_directories_already_registered() {
let conn = crate::db::conn_with_schema();
let dir = temp_project_dir("batch");
let path = dir.display().to_string();
register_project_records(
&conn,
&windows_input("existing", &path),
RootRequirement::ExistingDir,
)
.unwrap();
let item = |id: &str, win_path: String| BatchProjectInput {
id: id.to_string(),
name: id.to_string(),
win_path: Some(win_path),
wsl_path: None,
status: "active".to_string(),
priority: "mid".to_string(),
space_id: None,
platform: Some("windows".to_string()),
};
let fresh = r"Z:\lj-p8f-batch-fresh".to_string();
let skipped = batch_register_projects(
&conn,
vec![
item("dup", path.to_uppercase()),
item("fresh", fresh.clone()),
item("fresh-again", format!("{}\\", fresh.to_lowercase())),
],
)
.unwrap();
assert_eq!(skipped, vec!["dup".to_string(), "fresh-again".to_string()]);
assert_eq!(workspace_count(&conn), 2);
std::fs::remove_dir_all(&dir).unwrap();
}
}