- projects:从 create_project 抽出登记本体 register_project_records(连接注入), UI create_project 与 MCP register_project 共用;只写数据库,不建目录、不执行 git init、不接入。 拒绝空 ID、空路径、相对路径、不存在的目录、空名称、同一目录已登记、已存在的 ID; 重复检查与插入放进同一 IMMEDIATE 事务;workspace 插入去掉 OR IGNORE,ID 冲突不再静默成功 - 同目录判定 registration_path_key:去长路径前缀、斜杠统一、合并重复分隔符、去尾部分隔符、 不区分大小写;win_path 与 wsl_path 两列都比较,WSL Linux 路径按 wsl.localhost 换算; 错误信息给出已有 project_id(根治 PR #34 审查 L1/L2:同目录两条登记互相抢占 Codex 托管块) - mcp_tools:新增 register_project(path 必填、name 可选,project_id 由炼境生成 UUID,归入当前空间, structuredContent 返回);只开放在全局路由,is_project_scoped_tool 显式排除;描述写明须用户确认目标 - 批量添加对已登记目录计入 skipped,模板新建与挂载仓库复用同一重复目录检查 - project-context.yaml「MCP 工具接口」段同步(T1) cargo test 335 passed / 0 failed / 5 ignored(新增 10 条),0 warning;pnpm typecheck 通过; 9 处变异全部被对应用例捕获。 Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1201 lines
44 KiB
Rust
1201 lines
44 KiB
Rust
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 开头的 `\\`)、
|
||
/// 去首尾空白与尾部分隔符、不区分大小写。只做字面规范化,不解析 `..`、符号链接或 junction。
|
||
pub(crate) fn registration_path_key(path: &str) -> String {
|
||
let unified = path.trim().replace('/', "\\");
|
||
let unified = if let Some(rest) = unified.strip_prefix(r"\\?\UNC\") {
|
||
format!(r"\\{rest}")
|
||
} else if let Some(rest) = unified.strip_prefix(r"\\?\") {
|
||
rest.to_string()
|
||
} else {
|
||
unified
|
||
};
|
||
let (prefix, rest) = match unified.strip_prefix(r"\\") {
|
||
Some(rest) => (r"\\", rest),
|
||
None => ("", unified.as_str()),
|
||
};
|
||
let mut collapsed = String::with_capacity(rest.len());
|
||
for ch in rest.chars() {
|
||
if ch == '\\' && collapsed.ends_with('\\') {
|
||
continue;
|
||
}
|
||
collapsed.push(ch);
|
||
}
|
||
format!("{prefix}{}", collapsed.trim_end_matches('\\')).to_lowercase()
|
||
}
|
||
|
||
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 项目取 win_path;WSL 项目取 wsl_path——Linux 路径按
|
||
/// `\\wsl.localhost\<distro>\…` 换算(与 `mcp_inject::project_root_with_conn` 同口径),
|
||
/// 已是 Windows 路径(如 UI 选目录得到的 UNC)的原样使用。空路径与相对路径一律拒绝。
|
||
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_path_key`] 比较,win_path 与 wsl_path 两列都算):
|
||
/// 返回其 project_id 与登记路径。
|
||
pub(crate) fn find_registration_by_path(
|
||
conn: &rusqlite::Connection,
|
||
root: &std::path::Path,
|
||
) -> Result<Option<(String, String)>, String> {
|
||
let distro = wsl_distro(conn);
|
||
let key = registration_path_key(&root.to_string_lossy());
|
||
let mut stmt = conn
|
||
.prepare("SELECT id, 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)?,
|
||
))
|
||
})
|
||
.map_err(|e| e.to_string())?;
|
||
for row in rows {
|
||
let (id, win_path, wsl_path) = row.map_err(|e| e.to_string())?;
|
||
let candidates = [
|
||
registration_root(None, win_path.as_deref(), None, &distro),
|
||
registration_root(Some("wsl"), None, wsl_path.as_deref(), &distro),
|
||
];
|
||
if let Some(existing) = candidates
|
||
.into_iter()
|
||
.flatten()
|
||
.find(|path| registration_path_key(&path.to_string_lossy()) == key)
|
||
{
|
||
return Ok(Some((id, existing.display().to_string())));
|
||
}
|
||
}
|
||
Ok(None)
|
||
}
|
||
|
||
/// 同一目录已登记时拒绝,错误里给出已有 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(format!(
|
||
"该目录已登记为项目 project_id={id}({path}),拒绝重复登记:同一目录两条登记会互相抢占 Codex 托管块等接入配置。请直接使用该 project_id;确需重新登记,先删除原登记"
|
||
)),
|
||
}
|
||
}
|
||
|
||
/// 其他登记入口(批量添加、挂载仓库)复用的重复目录检查:路径缺省或无法解析时不在此拦截,
|
||
/// 沿用各入口原有行为;能解析出目录且已登记时返回已有登记。
|
||
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_root(platform, win_path, wsl_path, &wsl_distro(conn)) {
|
||
Ok(root) => find_registration_by_path(conn, &root),
|
||
Err(_) => Ok(None),
|
||
}
|
||
}
|
||
|
||
/// 登记项目本体:UI `create_project` 与 MCP `register_project` 共用,连接由调用方注入。
|
||
/// 只写数据库:不建目录、不执行 git init、不接入。
|
||
/// 拒绝:空 ID、空路径、相对路径、不存在的目录、空名称、同一目录已登记
|
||
/// ([`ensure_path_not_registered`])、已存在的 ID。
|
||
pub(crate) fn register_project_records(
|
||
conn: &rusqlite::Connection,
|
||
input: &CreateProjectInput,
|
||
) -> Result<(), String> {
|
||
if input.id.trim().is_empty() {
|
||
return Err("项目 ID 不能为空".to_string());
|
||
}
|
||
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()
|
||
));
|
||
}
|
||
if input.name.trim().is_empty() {
|
||
return Err("项目名称不能为空".to_string());
|
||
}
|
||
|
||
// 先查后插放进 IMMEDIATE 事务:并发登记同一目录时第二个等到第一个提交后再查,不会各写一条
|
||
let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
|
||
.map_err(|e| e.to_string())?;
|
||
ensure_path_not_registered(&tx, &root)?;
|
||
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,
|
||
input.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())
|
||
}
|
||
|
||
/// 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>,
|
||
}
|
||
|
||
/// 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(|| {
|
||
std::path::Path::new(path)
|
||
.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()
|
||
};
|
||
register_project_records(conn, &input)?;
|
||
Ok(RegisteredProject {
|
||
project_id: input.id,
|
||
name: input.name,
|
||
path: path.to_string(),
|
||
space_id: input.space_id,
|
||
})
|
||
}
|
||
|
||
#[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)
|
||
}
|
||
|
||
#[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)
|
||
}
|
||
|
||
/// 删除项目登记本体:数据库部分([`delete_project_records`],单事务)提交后,
|
||
/// 再对被解除的 Gitea 绑定做外部清理(Gitea 侧 webhook 与本机签名凭证,尽力而为)。
|
||
/// 连接与凭证存储由调用方注入;UI `delete_project` 与 MCP `unregister_project` 经它删除登记。
|
||
pub(crate) fn delete_project_registration(
|
||
conn: &rusqlite::Connection,
|
||
store: &impl crate::credential_store::CredentialStore,
|
||
id: &str,
|
||
) -> Result<(), String> {
|
||
if let Some(removed_link) = delete_project_records(conn, id)? {
|
||
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 记录之前,需查项目路径)
|
||
if let Err(e) = mcp_inject::remove_project_mcp(&id) {
|
||
eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e);
|
||
}
|
||
|
||
delete_project_registration(&conn, &crate::credential_store::WindowsCredentialStore, &id)?;
|
||
|
||
// 删除本地文件夹(仅在明确要求时执行)
|
||
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");
|
||
|
||
delete_project_registration(&conn, &store, "gone").unwrap();
|
||
|
||
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").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))
|
||
.expect_err(&format!("{label} 应被拒绝"));
|
||
assert!(!error.is_empty(), "{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).is_err(),
|
||
"WSL 项目以 wsl_path 为准,为空即拒绝"
|
||
);
|
||
}
|
||
|
||
#[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");
|
||
let canonical = dir.display().to_string();
|
||
register_project_records(&conn, &windows_input("first", &canonical)).unwrap();
|
||
assert!(!dir.join(".git").exists(), "登记不执行 git init");
|
||
|
||
for (index, variant) in [
|
||
canonical.to_uppercase(),
|
||
canonical.to_lowercase(),
|
||
canonical.replace('\\', "/"),
|
||
format!("{canonical}\\"),
|
||
format!("{}/", canonical.replace('\\', "/")),
|
||
]
|
||
.iter()
|
||
.enumerate()
|
||
{
|
||
let error =
|
||
register_project_records(&conn, &windows_input(&format!("dup-{index}"), variant))
|
||
.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(canonical.to_uppercase()),
|
||
..windows_input("dup-wsl", "")
|
||
};
|
||
assert!(register_project_records(&conn, &via_wsl).is_err());
|
||
assert_eq!(workspace_count(&conn), 1);
|
||
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();
|
||
let found = find_registration_by_path(
|
||
&conn,
|
||
std::path::Path::new(r"\\wsl.localhost\UBUNTU\home\u\app\"),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(found.map(|(id, _)| id).as_deref(), Some("wsl-app"));
|
||
let other = find_registration_by_path(
|
||
&conn,
|
||
std::path::Path::new(r"\\wsl.localhost\Ubuntu\home\u\app2"),
|
||
)
|
||
.unwrap();
|
||
assert!(other.is_none());
|
||
}
|
||
|
||
#[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()),
|
||
)
|
||
.unwrap();
|
||
let error = register_project_records(
|
||
&conn,
|
||
&windows_input("same-id", &second.display().to_string()),
|
||
)
|
||
.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();
|
||
assert_eq!(stored, first.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 registered =
|
||
register_project_at(&conn, &format!(" {} ", dir.display()), None).unwrap();
|
||
|
||
assert!(uuid::Uuid::parse_str(®istered.project_id).is_ok());
|
||
assert_eq!(
|
||
registered.name,
|
||
dir.file_name().unwrap().to_string_lossy(),
|
||
"名称缺省取目录名"
|
||
);
|
||
assert_eq!(registered.space_id.as_deref(), Some("home"));
|
||
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();
|
||
assert_eq!(
|
||
win_path,
|
||
dir.display().to_string(),
|
||
"存入去掉首尾空白的路径"
|
||
);
|
||
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(®istered.project_id), "{again}");
|
||
assert!(register_project_at(&conn, "", None).is_err());
|
||
assert_eq!(workspace_count(&conn), 1);
|
||
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)).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();
|
||
}
|
||
}
|