dev-manager-tauri/src-tauri/src/commands/projects.rs
lanrtop 0851aa9783 feat(projects/parity): register_project 控制面投影,登记本体拒绝同目录重复登记(P8f)
- 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>
2026-10-03 12:29:26 +09:00

1201 lines
44 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 开头的 `\\`)、
/// 去首尾空白与尾部分隔符、不区分大小写。只做字面规范化,不解析 `..`、符号链接或 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(&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"));
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(&registered.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();
}
}