use serde::{Deserialize, Serialize}; use std::path::Path; use tauri::AppHandle; // ── 数据结构 ───────────────────────────────────────────────────────────────── #[derive(Debug, Serialize, Deserialize, Clone)] pub struct BuffStatus { pub project_path: String, pub project_id: String, pub applied_at: String, pub last_sync_at: Option, pub last_commit: Option, pub status: String, } // ── Tauri 命令 ─────────────────────────────────────────────────────────────── /// 向项目施加蓝图 Buff:注入 CONVENTIONS + CLAUDE.md,写入 DB #[tauri::command] pub fn apply_blueprint_buff( project_path: String, project_id: String, ) -> Result { let path = project_path.trim_end_matches(['/', '\\']).to_string(); // 注入蓝图规则(CONVENTIONS.md + CLAUDE.md) crate::commands::blueprint::sync_blueprint_rules(path.clone()) .map_err(|e| format!("注入蓝图规则失败: {e}"))?; // 独立项目自动创建产品组(含 MCP 注入) if let Err(e) = crate::commands::groups::ensure_standalone_group(&project_id) { // 非致命,不阻断 Buff 应用 crate::db::log_event("buff", "warn", &format!("独立产品组创建失败: {e}"), None); } // 写入 DB let conn = crate::db::pool().get().map_err(|e| e.to_string())?; conn.execute( "INSERT OR REPLACE INTO blueprint_buffs (project_path, project_id, applied_at, status) VALUES (?1, ?2, datetime('now'), 'active')", rusqlite::params![path, project_id], ) .map_err(|e| e.to_string())?; query_buff(&conn, &path) } /// 撤销蓝图 Buff #[tauri::command] pub fn remove_blueprint_buff(project_path: String) -> Result<(), String> { let path = project_path.trim_end_matches(['/', '\\']).to_string(); let conn = crate::db::pool().get().map_err(|e| e.to_string())?; conn.execute( "DELETE FROM blueprint_buffs WHERE project_path = ?1", rusqlite::params![path], ) .map_err(|e| e.to_string())?; Ok(()) } /// 查询单个项目的 Buff 状态 #[tauri::command] pub fn get_buff_status(project_path: String) -> Result, String> { let path = project_path.trim_end_matches(['/', '\\']).to_string(); let conn = crate::db::pool().get().map_err(|e| e.to_string())?; match query_buff(&conn, &path) { Ok(s) => Ok(Some(s)), Err(_) => Ok(None), } } /// 列出所有已施加 Buff 的项目 #[tauri::command] pub fn list_buffed_projects() -> Result, String> { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let mut stmt = conn .prepare( "SELECT project_path, project_id, applied_at, last_sync_at, last_commit, status FROM blueprint_buffs WHERE status = 'active'", ) .map_err(|e| e.to_string())?; let items = stmt .query_map([], |row| { Ok(BuffStatus { project_path: row.get(0)?, project_id: row.get(1)?, applied_at: row.get(2)?, last_sync_at: row.get(3)?, last_commit: row.get(4)?, status: row.get(5)?, }) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); Ok(items) } // ── 后台 git 轮询(T3)────────────────────────────────────────────────────── /// App 启动时调用,启动后台轮询任务(每 5 秒检查所有 buffed 项目的 git HEAD) pub fn start_blueprint_watchers(app: AppHandle) { tauri::async_runtime::spawn(async move { loop { tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; poll_all_buffs(&app); } }); } fn poll_all_buffs(app: &AppHandle) { let paths = match list_active_paths() { Ok(p) => p, Err(_) => return, }; for path in paths { let _ = check_project(app, &path); } } fn check_project(app: &AppHandle, project_path: &str) -> Result<(), String> { let root = Path::new(project_path); // 获取当前 HEAD commit let current_commit = match get_head_commit(root) { Ok(c) => c, Err(_) => return Ok(()), // 不是 git 仓库或无提交,跳过 }; // 查询 project_id 与上次已处理的 commit let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let (project_id, last_commit): (String, Option) = conn .query_row( "SELECT project_id, last_commit FROM blueprint_buffs WHERE project_path = ?1", rusqlite::params![project_path], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap_or_else(|_| (String::new(), None)); // 没有变化,跳过 if last_commit.as_deref() == Some(current_commit.as_str()) { return Ok(()); } // 飞轮 L0:解析新增 commit 的 conventional 指标写 commit_metrics(与下方文件匹配互不干扰) if !project_id.is_empty() { let first_time = last_commit.is_none(); let commits = get_new_commits(root, last_commit.as_deref()); if first_time && commits.len() >= FIRST_TIME_LIMIT { crate::db::log_event( "buff", "info", &format!("首次接入仅解析最近 {FIRST_TIME_LIMIT} 条 commit,更早历史跳过"), Some(project_path), ); } // 批量写入用事务包裹(首次可达数十条,避免逐条 fsync) let tx = conn.unchecked_transaction(); for entry in &commits { let _ = crate::commands::commit_metrics::record_commit_entry(&conn, &project_id, entry); } if let Ok(tx) = tx { let _ = tx.commit(); } } // 文件路径匹配 → 标记命中的任务卡 🔵 let changed = get_changed_files(root, last_commit.as_deref())?; let mut tasks_updated = false; if !changed.is_empty() && update_task_statuses(root, &changed)? > 0 { tasks_updated = true; } // 有新 commit 即刷新飞轮快照(rework_count 可能已变);任务卡有变动则通知前端重绘 let _ = crate::commands::blueprint::get_blueprint(project_path.to_string()); if tasks_updated { use tauri::Emitter; let _ = app.emit( "blueprint://changed", serde_json::json!({ "project_path": project_path }), ); } // 更新 DB:记录已处理的 commit let _ = conn.execute( "UPDATE blueprint_buffs SET last_commit = ?1, last_sync_at = datetime('now') WHERE project_path = ?2", rusqlite::params![current_commit, project_path], ); Ok(()) } // ── 辅助:WSL 路径检测与转换 ───────────────────────────────────────────────── /// 判断路径是否为 WSL UNC 路径(\\wsl.localhost\... 或 \\wsl$\...) fn is_wsl_path(path: &str) -> bool { path.starts_with("\\\\wsl.localhost\\") || path.starts_with("\\\\wsl$\\") } /// 将 WSL UNC 路径转为 Linux 绝对路径(去掉 \\wsl.localhost\Distro 前缀) fn unc_to_linux(path: &str) -> Option { let prefix = if path.starts_with("\\\\wsl.localhost\\") { "\\\\wsl.localhost\\" } else if path.starts_with("\\\\wsl$\\") { "\\\\wsl$\\" } else { return None; }; let rest = &path[prefix.len()..]; let slash_idx = rest.find('\\')?; Some("/".to_string() + &rest[slash_idx + 1..].replace('\\', "/")) } // ── 辅助:git 操作 ──────────────────────────────────────────────────────────── fn get_head_commit(root: &Path) -> Result { let path_str = root.to_string_lossy(); let out = if is_wsl_path(&path_str) { let linux = unc_to_linux(&path_str) .ok_or_else(|| "无法转换 WSL 路径".to_string())?; crate::silent_cmd("wsl") .args(["git", "-C", &linux, "rev-parse", "HEAD"]) .output() .map_err(|e| e.to_string())? } else { crate::silent_cmd("git") .args(["rev-parse", "HEAD"]) .current_dir(root) .output() .map_err(|e| e.to_string())? }; if out.status.success() { Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) } else { Err("无法获取 HEAD".to_string()) } } fn get_changed_files(root: &Path, last_commit: Option<&str>) -> Result, String> { let path_str = root.to_string_lossy(); let out = if is_wsl_path(&path_str) { let linux = unc_to_linux(&path_str) .ok_or_else(|| "无法转换 WSL 路径".to_string())?; let cmd = if let Some(prev) = last_commit { format!("git -C {linux} diff --name-only {prev} HEAD") } else { format!("git -C {linux} diff-tree --no-commit-id -r --name-only HEAD") }; crate::silent_cmd("wsl") .args(["bash", "-c", &cmd]) .output() .map_err(|e| e.to_string())? } else { let args: Vec<&str> = if let Some(prev) = last_commit { vec!["diff", "--name-only", prev, "HEAD"] } else { vec!["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"] }; crate::silent_cmd("git") .args(&args) .current_dir(root) .output() .map_err(|e| e.to_string())? }; if out.status.success() { Ok(String::from_utf8_lossy(&out.stdout) .lines() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect()) } else { Ok(vec![]) } } // ── 辅助:新增 commit 解析(飞轮 L0)───────────────────────────────────────── /// 首次接入时只解析最近这么多条 commit,避免一次性解析整个历史。 const FIRST_TIME_LIMIT: usize = 50; /// 获取 last_commit..HEAD 之间的新 commit(含 Rules-Applied trailer)。 /// 首次接入(last_commit 为 None)只取最近 FIRST_TIME_LIMIT 条。 /// 取数统一走 commit_metrics::read_git_log(单一 format 来源,trailer 不再丢失)。 /// WSL 路径分支已随 2026-07-04「放弃 WSL 路径项目」裁决清理。 fn get_new_commits( root: &Path, last_commit: Option<&str>, ) -> Vec { use crate::commands::commit_metrics::{read_git_log, GitLogRange}; let range = match last_commit { Some(prev) => GitLogRange::Since(prev), None => GitLogRange::Limit(FIRST_TIME_LIMIT), }; read_git_log(root, range).unwrap_or_default() } // ── 辅助:任务状态更新 ──────────────────────────────────────────────────────── fn update_task_statuses(root: &Path, changed_files: &[String]) -> Result { let modules_dir = root.join(".blueprint").join("modules"); if !modules_dir.exists() { return Ok(0); } let mut total = 0; for entry in std::fs::read_dir(&modules_dir) .map_err(|e| e.to_string())? .flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; } let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; let (new_content, count) = mark_tasks_in_progress(&content, changed_files); if count > 0 { std::fs::write(&path, new_content).map_err(|e| e.to_string())?; total += count; } } Ok(total) } /// 扫描 markdown 内容,将 files 字段命中 changed_files 的 📋 todo 任务标记为 🔵 in_progress fn mark_tasks_in_progress(content: &str, changed_files: &[String]) -> (String, usize) { let mut lines: Vec = content.lines().map(|l| l.to_string()).collect(); let mut updated = 0; let mut i = 0; while i < lines.len() { if !lines[i].starts_with("### 📋 ") { i += 1; continue; } // 扫描该任务卡的属性行(到下一个 ## 或 ### 为止,最多 20 行) let task_head = i; let mut files_value: Option = None; let mut status_idx: Option = None; let end = (i + 1 + 20).min(lines.len()); for j in (i + 1)..end { if j > i + 1 && (lines[j].starts_with("### ") || lines[j].starts_with("## ")) { break; } let t = lines[j].trim().trim_start_matches("- "); if let Some(v) = t.strip_prefix("files: ") { files_value = Some(v.to_string()); } if t.starts_with("status: ") { status_idx = Some(j); } } if let Some(fv) = files_value { let task_files: Vec = fv .split(',') .map(|s| normalize_path(s.trim())) .collect(); let hit = changed_files.iter().any(|cf| { let ncf = normalize_path(cf); task_files .iter() .any(|tf| tf == &ncf || ncf.ends_with(tf.as_str()) || tf.ends_with(ncf.as_str())) }); if hit { // 替换标题前缀 lines[task_head] = lines[task_head].replacen("### 📋 ", "### 🔵 ", 1); // 替换 status 行 if let Some(si) = status_idx { lines[si] = lines[si].replace("status: todo", "status: in_progress"); } updated += 1; } } i += 1; } let mut result = lines.join("\n"); if content.ends_with('\n') { result.push('\n'); } (result, updated) } fn normalize_path(p: &str) -> String { p.trim_start_matches("./").replace('\\', "/").to_string() } // ── 内部辅助 ────────────────────────────────────────────────────────────────── fn list_active_paths() -> Result, String> { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let mut stmt = conn .prepare("SELECT project_path FROM blueprint_buffs WHERE status = 'active'") .map_err(|e| e.to_string())?; let paths = stmt .query_map([], |row| row.get(0)) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); Ok(paths) } fn query_buff( conn: &rusqlite::Connection, path: &str, ) -> Result { conn.query_row( "SELECT project_path, project_id, applied_at, last_sync_at, last_commit, status FROM blueprint_buffs WHERE project_path = ?1", rusqlite::params![path], |row| { Ok(BuffStatus { project_path: row.get(0)?, project_id: row.get(1)?, applied_at: row.get(2)?, last_sync_at: row.get(3)?, last_commit: row.get(4)?, status: row.get(5)?, }) }, ) .map_err(|e| e.to_string()) }