dev-manager-tauri/src-tauri/src/commands/buff.rs
lanrtop aaf3343133
All checks were successful
Push & PR Check / check (push) Successful in 1m17s
feat(flywheel/rules-validation): Rules-Applied 规则 ID ingest 层真实性校验(U3)
校验单点放统计消费端(R12 边界单点):extract_rule_ids 从项目
CONVENTIONS.md 提取合法 ID 集合,filter_valid_rules 在入库前剔除
幻觉/typo ID(无 CONVENTIONS.md 时 fail-open 不校验)。接入全部
4 条写入链路(buff 增量/webhook/gitea 轮询/全量导入);
sanitize_rules_applied 提供回溯清洗,重跑全量导入即可清历史污染。

Rules-Applied: R01,R06,R07
2026-07-15 14:05:13 +09:00

447 lines
16 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 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<String>,
pub last_commit: Option<String>,
pub status: String,
}
// ── Tauri 命令 ───────────────────────────────────────────────────────────────
/// 向项目施加蓝图 Buff注入 CONVENTIONS + CLAUDE.md写入 DB
#[tauri::command]
pub fn apply_blueprint_buff(
project_path: String,
project_id: String,
) -> Result<BuffStatus, String> {
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<Option<BuffStatus>, 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<Vec<BuffStatus>, 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<String>) = 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
// U3规则 ID 集合每批次加载一次,剔除 trailer 里的未知 ID
let valid_rules = crate::commands::commit_metrics::load_project_rule_ids(root);
let tx = conn.unchecked_transaction();
for entry in &commits {
let _ = crate::commands::commit_metrics::record_commit_entry(
&conn,
&project_id,
entry,
valid_rules.as_ref(),
);
}
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<String> {
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<String, 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())?;
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<Vec<String>, 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<crate::commands::commit_metrics::GitLogEntry> {
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<usize, String> {
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<String> = 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<String> = None;
let mut status_idx: Option<usize> = 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<String> = 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<Vec<String>, 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<BuffStatus, String> {
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())
}