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, #[serde(rename = "type")] pub type_: Option, pub status: Option, pub priority: Option, // profile 字段(共享) pub tech_stack: Option, pub repo_url: Option, pub upstream_url: Option, pub default_branch: Option, pub staging_url: Option, pub prod_url: Option, pub server_note: Option, // workspace 字段(每空间独立) pub space_id: Option, pub wsl_path: Option, pub win_path: Option, pub platform: Option, pub recent_focus: Option, pub startup_commands: Option, /// 若提供则跳过建 profile,直接为已有 profile 新增 workspace pub profile_id: Option, } #[derive(Debug, Deserialize)] pub struct UpdateProjectInput { // profile 字段(共享) pub name: Option, pub description: Option, #[serde(rename = "type")] pub type_: Option, pub status: Option, pub priority: Option, pub tech_stack: Option, pub tags: Option, pub staging_url: Option, pub prod_url: Option, pub server_note: Option, // workspace 字段(每空间独立) pub wsl_path: Option, pub win_path: Option, pub platform: Option, pub recent_focus: Option, pub startup_commands: Option, } #[derive(Debug, Serialize)] pub struct ProjectDetail { #[serde(flatten)] pub project: Project, pub recent_activities: Vec, } #[tauri::command] pub fn get_projects( status: Option, space_id: Option, ) -> Result, String> { let conn = pool().get().map_err(|e| e.to_string())?; let projects: Vec = 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::, _>>()?; 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::, _>>()?; 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::, _>>()?; 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::, _>>()?; x } }; Ok(projects) } #[tauri::command] pub fn get_project(id: String) -> Result { 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 = stmt .query_map(params![id], ActivityLog::from_row) .map_err(|e| e.to_string())? .collect::, _>>() .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\\…` 换算(与 `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 { 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, 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>(1)?, row.get::<_, Option>(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, 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, } /// 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 { 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 = 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, String> { let profile_id: Option = 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) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; // 删除前先读取路径(仅删除本地文件夹时使用) let (win_path, wsl_path): (Option, Option) = 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, 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, pub wsl_path: Option, pub status: String, pub priority: String, pub space_id: Option, pub platform: Option, } /// 批量创建项目(已存在的 workspace id 或已登记的同一目录跳过) #[tauri::command] pub fn batch_create_projects(items: Vec) -> Result, 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, ) -> Result, 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) = 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(); } }