use once_cell::sync::OnceCell; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use std::path::PathBuf; pub type DbPool = Pool; static POOL: OnceCell = OnceCell::new(); pub fn init(data_dir: PathBuf) { std::fs::create_dir_all(&data_dir).expect("create data dir"); let db_path = data_dir.join("dev-manager.db"); let manager = SqliteConnectionManager::file(db_path); let pool = Pool::new(manager).expect("create sqlite pool"); { let conn = pool.get().expect("get conn for migration"); conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") .expect("pragma"); migrate(&conn); } POOL.set(pool).expect("pool already initialized"); } pub fn pool() -> &'static DbPool { POOL.get().expect("db not initialized") } fn migrate(conn: &rusqlite::Connection) { conn.execute_batch( " CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, type TEXT, status TEXT DEFAULT 'active', priority TEXT DEFAULT 'mid', wsl_path TEXT, win_path TEXT, tech_stack TEXT, recent_focus TEXT, startup_commands TEXT, updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS product_groups ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT ); CREATE TABLE IF NOT EXISTS project_group_map ( project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, group_id TEXT NOT NULL REFERENCES product_groups(id) ON DELETE CASCADE, role TEXT, PRIMARY KEY (project_id, group_id) ); CREATE TABLE IF NOT EXISTS relations ( id INTEGER PRIMARY KEY AUTOINCREMENT, from_project TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, to_project TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, relation_type TEXT, note TEXT ); CREATE TABLE IF NOT EXISTS activity_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, summary TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS project_tools ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, name TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '🔧', exe_path TEXT NOT NULL, args TEXT, sort_order INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS dev_tools ( id TEXT PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL, cmd TEXT, path TEXT, version TEXT, note TEXT, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS project_env_tools ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, layer INTEGER NOT NULL, name TEXT NOT NULL, source TEXT, version TEXT, note TEXT, updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS network_monitored_tools ( id TEXT PRIMARY KEY, name TEXT NOT NULL, exe_path TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS network_mode_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, mode TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) ); INSERT OR IGNORE INTO settings (key, value) VALUES ('editor', 'cursor'); INSERT OR IGNORE INTO settings (key, value) VALUES ('wsl_distro', 'Ubuntu'); INSERT OR IGNORE INTO settings (key, value) VALUES ('dev_mode', '0'); INSERT OR IGNORE INTO settings (key, value) VALUES ('github_client_id', ''); INSERT OR IGNORE INTO settings (key, value) VALUES ('github_client_secret', ''); INSERT OR IGNORE INTO settings (key, value) VALUES ('mcp_port', '27190'); ", ) .expect("migrate"); // 增量迁移:为 dev_tools 添加 env 字段(已存在则忽略) let _ = conn.execute( "ALTER TABLE dev_tools ADD COLUMN env TEXT NOT NULL DEFAULT 'windows'", [], ); // 增量迁移:Git 多空间功能所需新表 conn.execute_batch( " CREATE TABLE IF NOT EXISTS git_accounts ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, avatar_url TEXT, provider TEXT NOT NULL DEFAULT 'github', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS spaces ( id TEXT PRIMARY KEY, name TEXT NOT NULL, local_root TEXT NOT NULL, is_current INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS deploy_commands ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, name TEXT NOT NULL, command TEXT NOT NULL, run_in TEXT NOT NULL DEFAULT 'windows' ); ", ) .expect("migrate git spaces"); // 增量迁移:git_accounts 补 UNIQUE 索引(已存在则忽略) let _ = conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS idx_git_accounts_username ON git_accounts(username)", [], ); // 增量迁移:projects 表新增 Git 相关字段(已存在则忽略) let _ = conn.execute("ALTER TABLE projects ADD COLUMN space_id TEXT", []); let _ = conn.execute("ALTER TABLE projects ADD COLUMN repo_url TEXT", []); let _ = conn.execute("ALTER TABLE projects ADD COLUMN upstream_url TEXT", []); let _ = conn.execute("ALTER TABLE projects ADD COLUMN default_branch TEXT", []); let _ = conn.execute("ALTER TABLE projects ADD COLUMN last_push_at TEXT", []); // 增量迁移:platform 字段("windows" | "wsl",已存在则忽略) let _ = conn.execute("ALTER TABLE projects ADD COLUMN platform TEXT", []); // 增量迁移:product_groups 表新增 space_id(已存在则忽略) let _ = conn.execute("ALTER TABLE product_groups ADD COLUMN space_id TEXT", []); // 增量迁移:dev_tools 表新增 space_id(已存在则忽略) let _ = conn.execute("ALTER TABLE dev_tools ADD COLUMN space_id TEXT", []); // 增量迁移:network_monitored_tools 新增 space_id(已存在则忽略) let _ = conn.execute("ALTER TABLE network_monitored_tools ADD COLUMN space_id TEXT", []); // 增量迁移:空间级别的 settings 覆盖表 let _ = conn.execute_batch( "CREATE TABLE IF NOT EXISTS space_settings ( space_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (space_id, key) );" ); // 增量迁移:项目生命周期字段 let _ = conn.execute("ALTER TABLE projects ADD COLUMN staging_url TEXT", []); let _ = conn.execute("ALTER TABLE projects ADD COLUMN prod_url TEXT", []); let _ = conn.execute("ALTER TABLE projects ADD COLUMN server_note TEXT", []); // ── 方案A重构:project_profiles + project_workspaces ────────────────────── conn.execute_batch(" CREATE TABLE IF NOT EXISTS project_profiles ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, type TEXT, status TEXT DEFAULT 'active', priority TEXT DEFAULT 'mid', tech_stack TEXT, repo_url TEXT, upstream_url TEXT, default_branch TEXT, staging_url TEXT, prod_url TEXT, server_note TEXT, updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS project_workspaces ( id TEXT PRIMARY KEY, profile_id TEXT NOT NULL REFERENCES project_profiles(id) ON DELETE CASCADE, space_id TEXT, wsl_path TEXT, win_path TEXT, platform TEXT DEFAULT 'windows', recent_focus TEXT, startup_commands TEXT, last_push_at TEXT, updated_at TEXT DEFAULT (datetime('now')) ); CREATE UNIQUE INDEX IF NOT EXISTS idx_pw_profile_space ON project_workspaces(profile_id, ifnull(space_id, '')); ").expect("migrate project profiles/workspaces"); // ── git_repos:GitHub 仓库注册表(全局锚点)──────────────────────────────── conn.execute_batch(" CREATE TABLE IF NOT EXISTS git_repos ( id TEXT PRIMARY KEY, repo_url TEXT NOT NULL, name TEXT NOT NULL, description TEXT, default_branch TEXT DEFAULT 'main', upstream_url TEXT, staging_url TEXT, prod_url TEXT, server_note TEXT, status TEXT DEFAULT 'active', priority TEXT DEFAULT 'mid', tech_stack TEXT, last_synced_at TEXT, created_at TEXT DEFAULT (datetime('now')) ); CREATE UNIQUE INDEX IF NOT EXISTS idx_git_repos_url ON git_repos(repo_url); ").expect("migrate git_repos"); // project_workspaces 添加 repo_id 外键(已存在则忽略) let _ = conn.execute( "ALTER TABLE project_workspaces ADD COLUMN repo_id TEXT REFERENCES git_repos(id) ON DELETE SET NULL", [], ); // 增量迁移:项目模板系统 conn.execute_batch(" CREATE TABLE IF NOT EXISTS project_templates ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, platform TEXT NOT NULL DEFAULT 'both', tech_stack TEXT, init_commands TEXT, is_builtin INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS project_template_files ( id TEXT PRIMARY KEY, template_id TEXT NOT NULL REFERENCES project_templates(id) ON DELETE CASCADE, path TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', UNIQUE(template_id, path) ); ").expect("migrate project templates"); // 迁移 project_group_map:将外键从 projects(id) 改为 project_workspaces(id) let pgm_sql: Option = conn.query_row( "SELECT sql FROM sqlite_master WHERE type='table' AND name='project_group_map'", [], |row| row.get(0), ).ok(); if pgm_sql.map(|s| s.contains("REFERENCES projects")).unwrap_or(false) { conn.execute_batch(" PRAGMA foreign_keys=OFF; CREATE TABLE project_group_map_new ( project_id TEXT NOT NULL REFERENCES project_workspaces(id) ON DELETE CASCADE, group_id TEXT NOT NULL REFERENCES product_groups(id) ON DELETE CASCADE, role TEXT, PRIMARY KEY (project_id, group_id) ); INSERT OR IGNORE INTO project_group_map_new (project_id, group_id, role) SELECT m.project_id, m.group_id, m.role FROM project_group_map m INNER JOIN project_workspaces pw ON pw.id = m.project_id; DROP TABLE project_group_map; ALTER TABLE project_group_map_new RENAME TO project_group_map; PRAGMA foreign_keys=ON; ").expect("migrate project_group_map FK to project_workspaces"); } // 迁移 project_tools / activity_log / project_env_tools: // 将外键从 projects(id) 改为 project_workspaces(id) let pt_sql: Option = conn.query_row( "SELECT sql FROM sqlite_master WHERE type='table' AND name='project_tools'", [], |row| row.get(0), ).ok(); if pt_sql.as_deref().map(|s| s.contains("REFERENCES projects")).unwrap_or(false) { conn.execute_batch(" PRAGMA foreign_keys=OFF; CREATE TABLE project_tools_new ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES project_workspaces(id) ON DELETE CASCADE, name TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '🔧', exe_path TEXT NOT NULL, args TEXT, sort_order INTEGER DEFAULT 0 ); INSERT OR IGNORE INTO project_tools_new SELECT t.id, t.project_id, t.name, t.icon, t.exe_path, t.args, t.sort_order FROM project_tools t INNER JOIN project_workspaces pw ON pw.id = t.project_id; DROP TABLE project_tools; ALTER TABLE project_tools_new RENAME TO project_tools; PRAGMA foreign_keys=ON; ").expect("migrate project_tools FK to project_workspaces"); } let al_sql: Option = conn.query_row( "SELECT sql FROM sqlite_master WHERE type='table' AND name='activity_log'", [], |row| row.get(0), ).ok(); if al_sql.as_deref().map(|s| s.contains("REFERENCES projects")).unwrap_or(false) { conn.execute_batch(" PRAGMA foreign_keys=OFF; CREATE TABLE activity_log_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL REFERENCES project_workspaces(id) ON DELETE CASCADE, summary TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) ); INSERT OR IGNORE INTO activity_log_new (id, project_id, summary, created_at) SELECT a.id, a.project_id, a.summary, a.created_at FROM activity_log a INNER JOIN project_workspaces pw ON pw.id = a.project_id; DROP TABLE activity_log; ALTER TABLE activity_log_new RENAME TO activity_log; PRAGMA foreign_keys=ON; ").expect("migrate activity_log FK to project_workspaces"); } let pet_sql: Option = conn.query_row( "SELECT sql FROM sqlite_master WHERE type='table' AND name='project_env_tools'", [], |row| row.get(0), ).ok(); if pet_sql.as_deref().map(|s| s.contains("REFERENCES projects")).unwrap_or(false) { conn.execute_batch(" PRAGMA foreign_keys=OFF; CREATE TABLE project_env_tools_new ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES project_workspaces(id) ON DELETE CASCADE, layer INTEGER NOT NULL, name TEXT NOT NULL, source TEXT, version TEXT, note TEXT, updated_at TEXT DEFAULT (datetime('now')) ); INSERT OR IGNORE INTO project_env_tools_new SELECT t.id, t.project_id, t.layer, t.name, t.source, t.version, t.note, t.updated_at FROM project_env_tools t INNER JOIN project_workspaces pw ON pw.id = t.project_id; DROP TABLE project_env_tools; ALTER TABLE project_env_tools_new RENAME TO project_env_tools; PRAGMA foreign_keys=ON; ").expect("migrate project_env_tools FK to project_workspaces"); } // 从旧 projects 表迁移数据(INSERT OR IGNORE 保证幂等) let _ = conn.execute_batch(" 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) SELECT 'prof-' || id, name, description, type, status, priority, tech_stack, repo_url, upstream_url, default_branch, staging_url, prod_url, server_note, updated_at FROM projects; INSERT OR IGNORE INTO project_workspaces (id, profile_id, space_id, wsl_path, win_path, platform, recent_focus, startup_commands, last_push_at, updated_at) SELECT id, 'prof-' || id, space_id, wsl_path, win_path, COALESCE(platform, 'windows'), recent_focus, startup_commands, last_push_at, updated_at FROM projects; "); // 增量迁移:project_profiles 加 tags 字段 let _ = conn.execute("ALTER TABLE project_profiles ADD COLUMN tags TEXT", []); // 标签管理表 conn.execute_batch(" CREATE TABLE IF NOT EXISTS project_tags ( id TEXT PRIMARY KEY, name TEXT NOT NULL, color TEXT NOT NULL DEFAULT '#8b5cf6' ); CREATE TABLE IF NOT EXISTS project_tag_map ( profile_id TEXT NOT NULL, tag_id TEXT NOT NULL, PRIMARY KEY (profile_id, tag_id) ); ").expect("create tag tables"); // 增量迁移:product_groups 加 sort_order 字段 let _ = conn.execute( "ALTER TABLE product_groups ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0", [], ); // CI/CD 配置表(替代 deploy_commands) conn.execute_batch(" CREATE TABLE IF NOT EXISTS cicd_configs ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL UNIQUE, config_type TEXT NOT NULL DEFAULT 'web_ssh', trigger_branch TEXT NOT NULL DEFAULT 'main', build_command TEXT DEFAULT 'pnpm build', node_version TEXT DEFAULT '20', server_host TEXT, server_user TEXT DEFAULT 'ubuntu', deploy_path TEXT, start_command TEXT DEFAULT 'pm2 restart app', oss_endpoint TEXT DEFAULT 'oss-cn-hangzhou.aliyuncs.com', created_at TEXT DEFAULT (datetime('now')) ); ").expect("create cicd_configs table"); // 资源视角画布状态(每个产品组独立保存) conn.execute_batch(" CREATE TABLE IF NOT EXISTS canvas_states ( group_id TEXT PRIMARY KEY, data TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')) ); ").expect("create canvas_states table"); // 运行时可观测性日志(最多保留 500 条) conn.execute_batch(" CREATE TABLE IF NOT EXISTS runtime_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), category TEXT NOT NULL, level TEXT NOT NULL, message TEXT NOT NULL, context TEXT ); ").expect("create runtime_logs table"); // AI 笔记(Claude 通过 MCP 或用户手动写入的分析结论) conn.execute_batch(" CREATE TABLE IF NOT EXISTS project_notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'mcp', content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now', 'localtime')) ); ").expect("create project_notes table"); // 用户项目个人备忘(自由文本待办,Claude 不感知) conn.execute_batch(" CREATE TABLE IF NOT EXISTS user_project_todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, content TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now', 'localtime')) ); ").expect("create user_project_todos table"); // 蓝图 Buff:记录哪些项目施加了 buff conn.execute_batch(" CREATE TABLE IF NOT EXISTS blueprint_buffs ( project_path TEXT PRIMARY KEY, project_id TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT (datetime('now')), last_sync_at TEXT, last_commit TEXT, status TEXT NOT NULL DEFAULT 'active' ); ").expect("create blueprint_buffs table"); // 仪表盘全局快启应用(无项目 FK 约束) conn.execute_batch(" CREATE TABLE IF NOT EXISTS global_tools ( id TEXT PRIMARY KEY, name TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '🔧', exe_path TEXT NOT NULL, args TEXT, sort_order INTEGER DEFAULT 0 ); ").expect("create global_tools table"); // 增量迁移:项目历史快照(每日一条,供 Claude 做趋势分析) conn.execute_batch(" CREATE TABLE IF NOT EXISTS project_snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, snapshot_date TEXT NOT NULL, days_since_commit INTEGER, blueprint_done_rate REAL, blueprint_in_progress INTEGER DEFAULT 0, blueprint_blocked INTEGER DEFAULT 0, has_errors INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), UNIQUE(project_id, snapshot_date) ); ").expect("create project_snapshots table"); } #[cfg(test)] pub fn conn_with_schema() -> rusqlite::Connection { let conn = rusqlite::Connection::open_in_memory().unwrap(); conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") .unwrap(); migrate(&conn); conn } /// 写入运行时日志。失败时静默(不能让日志写入反过来崩溃业务逻辑) pub fn log_event(category: &str, level: &str, message: &str, context: Option<&str>) { let pool = match POOL.get() { Some(p) => p, None => return, }; let conn = match pool.get() { Ok(c) => c, Err(_) => return, }; let _ = conn.execute( "INSERT INTO runtime_logs (category, level, message, context) VALUES (?1, ?2, ?3, ?4)", rusqlite::params![category, level, message, context], ); // 超出 550 条时才触发清理,保留最新 500 条(避免每次写入都全表扫描) let count: i64 = conn .query_row("SELECT COUNT(*) FROM runtime_logs", [], |r| r.get(0)) .unwrap_or(0); if count > 550 { let _ = conn.execute( "DELETE FROM runtime_logs WHERE id NOT IN (SELECT id FROM runtime_logs ORDER BY id DESC LIMIT 500)", [], ); } } #[cfg(test)] mod tests { use super::*; #[test] fn migrate_idempotent() { // 连续两次 migrate 不应 panic let conn = conn_with_schema(); migrate(&conn); } #[test] fn core_tables_exist() { let conn = conn_with_schema(); let count: i64 = conn .query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' \ AND name IN ('projects','settings','spaces','git_repos','runtime_logs',\ 'project_profiles','project_workspaces')", [], |r| r.get(0), ) .unwrap(); assert_eq!(count, 7, "缺少核心表"); } #[test] fn default_settings_seeded() { let conn = conn_with_schema(); let editor: String = conn .query_row("SELECT value FROM settings WHERE key='editor'", [], |r| r.get(0)) .unwrap(); assert_eq!(editor, "cursor"); let port: String = conn .query_row("SELECT value FROM settings WHERE key='mcp_port'", [], |r| r.get(0)) .unwrap(); assert_eq!(port, "27190"); } #[test] fn runtime_logs_insert_and_count() { let conn = conn_with_schema(); conn.execute( "INSERT INTO runtime_logs (category, level, message) VALUES ('test','info','hello')", [], ) .unwrap(); let count: i64 = conn .query_row("SELECT COUNT(*) FROM runtime_logs", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 1); } #[test] fn log_event_silent_when_no_pool() { // pool 未初始化时应静默返回,不 panic log_event("test", "info", "no-pool test", None); } }