dev-manager-tauri/src-tauri/src/commands/commit_metrics.rs
lanrtop 0ac809970f feat(flywheel): commit_files 采集——文件级风险画像数据地基
- 新表 commit_files(sha,path 联合主键 + path 索引),migration 入 migrate()
- read_git_log 加 --name-only:单次 git 调用同时取 conventional 指标、
  Rules-Applied trailer 与触碰文件,零额外进程开销
- parse_git_log_output 纯函数解析 header/文件行混排输出(含 \x1f 的行
  是 header,其后非空行归属该 commit)
- record_commit_entry 落库文件清单(INSERT OR IGNORE 幂等),buff 增量
  与 ingest 全量两条链路自动覆盖,无需各自改动

Rules-Applied: R05,R06,R07
2026-07-04 14:07:57 +09:00

995 lines
38 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.

//! Conventional Commit 解析与执行质量指标(飞轮 L0 数据地基)。
//!
//! 从 git commit message 解析出 type/scope/返工/CI 自修信号,写入 commit_metrics 表,
//! 取代枯竭的「复盘笔记解析」路径。纯解析逻辑(`parse_conventional`)与 DB 无关,可独立单测。
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
/// 一条 commit 的解析结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedCommit {
/// conventional typefeat/fix/chore...None 表示非 conventional commit。
pub commit_type: Option<String>,
/// scope如 estate1/cadNone 表示无 scope。
pub scope: Option<String>,
/// 是否返工type == fix。
pub is_rework: bool,
/// 是否 CI 自动修复(含 [DeepSeek-V3]/[R1] 等标记),聚合返工率时剔除。
pub is_ci_auto: bool,
}
/// CI 自动修复 commit 的标记(来自 enterprise-system CI`fix(ci): auto-fix ... [DeepSeek-V3]`)。
const CI_AUTO_MARKERS: [&str; 3] = ["[DeepSeek", "[R1]", "[V3]"];
/// 解析一条 commit message 为 Conventional Commit 结构。
///
/// 纯函数,不触碰 DB。无法识别为 conventional 的merge commit、随手写的
/// `commit_type` 记为 None聚合时单列、不污染 fix 率。
pub fn parse_conventional(message: &str) -> ParsedCommit {
let is_ci_auto = CI_AUTO_MARKERS.iter().any(|m| message.contains(m));
// 只看首行subject
let subject = message.lines().next().unwrap_or("").trim();
// 必须有 "<prefix>: <desc>" 结构
let prefix = match subject.split_once(':') {
Some((p, _desc)) => p.trim(),
None => return non_conventional(is_ci_auto),
};
// 去掉 breaking change 的末尾 '!'
let prefix = prefix.trim_end_matches('!');
// 拆出 type 与可选 scopetype(scope)
let (typ, scope) = match prefix.split_once('(') {
Some((t, rest)) => {
let scope = rest
.strip_suffix(')')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
(t.trim(), scope)
}
None => (prefix, None),
};
// type 必须是纯小写字母feat/fix/...),否则视为非 conventional如 "Merge pull request"
if typ.is_empty() || !typ.chars().all(|c| c.is_ascii_lowercase()) {
return non_conventional(is_ci_auto);
}
ParsedCommit {
is_rework: typ == "fix",
commit_type: Some(typ.to_string()),
scope,
is_ci_auto,
}
}
fn non_conventional(is_ci_auto: bool) -> ParsedCommit {
ParsedCommit {
commit_type: None,
scope: None,
is_rework: false,
is_ci_auto,
}
}
/// 把 trailer 原始值(如 "R01, r06 R01")归一化为大写去重逗号串。
/// 只接受 R+数字 形式的 token无有效 token 返回 None。
pub fn normalize_rule_tokens(raw: &str) -> Option<String> {
let mut ids: Vec<String> = raw
.split([',', ' ', ''])
.map(|t| t.trim().to_uppercase())
.filter(|t| {
t.len() >= 2 && t.starts_with('R') && t[1..].chars().all(|c| c.is_ascii_digit())
})
.collect();
ids.sort();
ids.dedup();
if ids.is_empty() { None } else { Some(ids.join(",")) }
}
/// 解析 commit message 中的 `Rules-Applied: R01,R06` trailer飞轮 R1 规则有效性数据源)。
/// 只接受 R+数字 形式的 token规范化为大写去重逗号串无 trailer 或无有效 token 返回 None。
pub fn parse_rules_applied(message: &str) -> Option<String> {
for line in message.lines() {
let trimmed = line.trim();
let Some(rest) = trimmed
.strip_prefix("Rules-Applied:")
.or_else(|| trimmed.strip_prefix("rules-applied:"))
.or_else(|| trimmed.strip_prefix("Rules-applied:"))
else {
continue;
};
return normalize_rule_tokens(rest);
}
None
}
/// UPSERT 写入一条 commit 记录:新 sha 插入;已存在且 rules_applied 为空时仅回填
/// rules_applied绝不覆盖已有值——回填幂等的关键约束。返回是否产生了写入。
fn upsert_metrics(
conn: &Connection,
project_id: &str,
sha: &str,
parsed: &ParsedCommit,
rules_applied: Option<&str>,
committed_at: Option<&str>,
) -> Result<bool, String> {
let n = conn
.execute(
"INSERT INTO commit_metrics
(sha, project_id, commit_type, scope, is_rework, is_ci_auto, committed_at, rules_applied)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(sha) DO UPDATE SET rules_applied = excluded.rules_applied
WHERE commit_metrics.rules_applied IS NULL
AND excluded.rules_applied IS NOT NULL",
rusqlite::params![
sha,
project_id,
parsed.commit_type,
parsed.scope,
parsed.is_rework as i32,
parsed.is_ci_auto as i32,
committed_at,
rules_applied,
],
)
.map_err(|e| e.to_string())?;
Ok(n > 0)
}
/// 解析并写入一条 commit 记录(从完整 message 提取 conventional + Rules-Applied trailer
/// 生产链路的 git log 只给 subjecttrailer 走 `record_commit_entry`;本函数保留给
/// 持有完整 message 的调用方与测试。
pub fn record_commit(
conn: &Connection,
project_id: &str,
sha: &str,
message: &str,
committed_at: Option<&str>,
) -> Result<(), String> {
let p = parse_conventional(message);
let rules_applied = parse_rules_applied(message);
upsert_metrics(conn, project_id, sha, &p, rules_applied.as_deref(), committed_at)?;
Ok(())
}
/// 写入一条来自 `read_git_log` 的记录subject 与 trailer 分列trailer 由 git 原生解析)。
/// 同时把触碰文件写入 commit_filesINSERT OR IGNORE 幂等,供文件级风险画像)。
pub fn record_commit_entry(
conn: &Connection,
project_id: &str,
entry: &GitLogEntry,
) -> Result<bool, String> {
let p = parse_conventional(&entry.subject);
let rules = normalize_rule_tokens(&entry.rules_trailer);
let date = if entry.committed_at.is_empty() {
None
} else {
Some(entry.committed_at.as_str())
};
let changed = upsert_metrics(conn, project_id, &entry.sha, &p, rules.as_deref(), date)?;
for path in &entry.files {
conn.execute(
"INSERT OR IGNORE INTO commit_files (sha, path) VALUES (?1, ?2)",
rusqlite::params![entry.sha, path],
)
.map_err(|e| e.to_string())?;
}
Ok(changed)
}
/// 单条规则的命中统计(跨项目聚合)
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RuleHitStat {
pub rule_id: String,
/// 附带该规则的 commit 总数
pub hits: u32,
/// 其中 type=fix非 CI 自修)的返工 commit 数——规则触发了但仍出问题的信号
pub rework_hits: u32,
/// 覆盖的项目数
pub project_count: u32,
}
/// 聚合所有项目的 Rules-Applied 命中 × 返工交叉统计(供梦核置信度分析)。
pub fn rule_hit_stats(conn: &Connection) -> Result<Vec<RuleHitStat>, String> {
let mut stmt = conn
.prepare(
"SELECT rules_applied, is_rework, is_ci_auto, project_id
FROM commit_metrics WHERE rules_applied IS NOT NULL",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)? != 0,
r.get::<_, i64>(2)? != 0,
r.get::<_, String>(3)?,
))
})
.map_err(|e| e.to_string())?;
use std::collections::{BTreeMap, HashSet};
let mut hits: BTreeMap<String, (u32, u32, HashSet<String>)> = BTreeMap::new();
for row in rows {
let (rules, is_rework, is_ci_auto, project_id) = row.map_err(|e| e.to_string())?;
for rule in rules.split(',').map(str::trim).filter(|s| !s.is_empty()) {
let entry = hits.entry(rule.to_string()).or_default();
entry.0 += 1;
if is_rework && !is_ci_auto {
entry.1 += 1;
}
entry.2.insert(project_id.clone());
}
}
Ok(hits
.into_iter()
.map(|(rule_id, (h, rw, projects))| RuleHitStat {
rule_id,
hits: h,
rework_hits: rw,
project_count: projects.len() as u32,
})
.collect())
}
/// 项目的有效返工次数type=fix 且非 CI 自动修复。
pub fn rework_count(conn: &Connection, project_id: &str) -> Result<u32, String> {
conn.query_row(
"SELECT COUNT(*) FROM commit_metrics
WHERE project_id = ?1 AND is_rework = 1 AND is_ci_auto = 0",
rusqlite::params![project_id],
|r| r.get::<_, i64>(0),
)
.map(|n| n as u32)
.map_err(|e| e.to_string())
}
/// commit 规范化统计:(总数, conventional 数)。供数据可信度提示(卡 D使用。
pub fn conventional_stats(conn: &Connection, project_id: &str) -> Result<(u32, u32), String> {
let total: i64 = conn
.query_row(
"SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1",
rusqlite::params![project_id],
|r| r.get(0),
)
.map_err(|e| e.to_string())?;
let conv: i64 = conn
.query_row(
"SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND commit_type IS NOT NULL",
rusqlite::params![project_id],
|r| r.get(0),
)
.map_err(|e| e.to_string())?;
Ok((total as u32, conv as u32))
}
/// 项目 commit 执行质量统计(供前端数据可信度展示)。
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CommitStats {
pub total: u32,
pub conventional: u32,
pub rework: u32,
}
/// 查询某项目的 commit 执行质量统计(总数 / conventional 数 / 有效返工数)。
#[tauri::command]
pub fn get_commit_stats(project_id: String) -> Result<CommitStats, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
let (total, conventional) = conventional_stats(&conn, &project_id)?;
let rework = rework_count(&conn, &project_id)?;
Ok(CommitStats {
total,
conventional,
rework,
})
}
// ── 全量历史导入 ──────────────────────────────────────────────────────────────
const FULL_HISTORY_LIMIT: usize = 500;
/// 全量历史补录结果。
#[derive(Debug, Serialize)]
pub struct IngestResult {
/// 本次产生写入的 commit 数(新插入 + rules_applied 回填)
pub imported: u32,
/// 已存在且无需回填(跳过)
pub skipped: u32,
}
/// git log 单条记录。`rules_trailer` 是 git 原生解析出的 Rules-Applied trailer 值
/// (如 "R01,R06"),无 trailer 时为空串——trailer 在 commit body 里,`%s` 取不到,
/// 必须靠 `%(trailers:...)` 提取(曾因只用 %s 导致 R1 管道死链)。
/// `files` 来自 --name-onlyP3-B 文件级风险画像数据源merge commit 为空。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitLogEntry {
pub sha: String,
pub subject: String,
pub committed_at: String,
pub rules_trailer: String,
pub files: Vec<String>,
}
/// git log 取数范围:最近 N 条(全量/首次),或 last..HEAD增量 watcher
pub enum GitLogRange<'a> {
Limit(usize),
Since(&'a str),
}
/// sha \x1f subject \x1f committer-date(ISO) \x1f Rules-Applied值逗号分隔
/// 配合 --name-onlyheader 行后跟随该 commit 触碰的文件路径行。
const GIT_LOG_FMT: &str =
"--format=%H%x1f%s%x1f%cI%x1f%(trailers:key=Rules-Applied,valueonly,separator=%x2C)";
/// 解析 GIT_LOG_FMT + --name-only 的完整输出。纯函数,可独立单测。
/// 规则:含 \x1f 的行是 commit header其后的非空行是该 commit 的文件路径。
pub fn parse_git_log_output(output: &str) -> Vec<GitLogEntry> {
let mut entries: Vec<GitLogEntry> = Vec::new();
for line in output.lines() {
if line.contains('\u{1f}') {
let mut parts = line.split('\u{1f}');
let sha = parts.next().unwrap_or("").trim().to_string();
if sha.is_empty() {
continue;
}
entries.push(GitLogEntry {
sha,
subject: parts.next().unwrap_or("").to_string(),
committed_at: parts.next().unwrap_or("").to_string(),
rules_trailer: parts.next().unwrap_or("").trim().to_string(),
files: Vec::new(),
});
} else {
let path = line.trim();
if !path.is_empty() {
if let Some(cur) = entries.last_mut() {
cur.files.push(path.to_string());
}
}
}
}
entries
}
/// 读取 git log含 Rules-Applied trailer 与触碰文件)。增量与全量两条链路的
/// 单一取数入口buff watcher 与 ingest_full_git_history 共用,禁止再各自定义 format。
pub fn read_git_log(root: &std::path::Path, range: GitLogRange) -> Result<Vec<GitLogEntry>, String> {
let mut cmd = crate::silent_cmd("git");
cmd.arg("log");
match range {
GitLogRange::Limit(n) => {
cmd.args(["-n", &n.to_string()]);
}
GitLogRange::Since(prev) => {
cmd.arg(format!("{prev}..HEAD"));
}
}
let out = cmd
.args([GIT_LOG_FMT, "--name-only"])
.current_dir(root)
.output()
.map_err(|e| format!("git log 失败: {e}"))?;
if !out.status.success() {
return Err(format!("git log 错误: {}", String::from_utf8_lossy(&out.stderr)));
}
Ok(parse_git_log_output(&String::from_utf8_lossy(&out.stdout)))
}
/// 全量历史导入:扫描最近 500 条 commit幂等写入 commit_metrics 表。
/// 与 buff 的增量写入互不干扰(同 sha INSERT OR IGNORE
/// 若该路径在 blueprint_buffs 中已有 UUID project_id优先用 UUID保持与增量写入一致。
#[tauri::command]
pub fn ingest_full_git_history(
project_id: String,
project_path: String,
) -> Result<IngestResult, String> {
let root = std::path::Path::new(project_path.trim_end_matches(['/', '\\']));
if !root.exists() {
return Err(format!("目录不存在: {}", root.display()));
}
let commits = read_git_log(root, GitLogRange::Limit(FULL_HISTORY_LIMIT))?;
let total = commits.len() as u32;
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
// 优先用 blueprint_buffs 里的 UUID project_id避免同一 sha 用两个不同 project_id 冲突
let effective_id = conn
.query_row(
"SELECT project_id FROM blueprint_buffs WHERE project_path = ?1",
rusqlite::params![project_path],
|r| r.get::<_, String>(0),
)
.unwrap_or(project_id);
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
let mut imported = 0u32;
for entry in &commits {
// 新 sha 插入;已存在的行 UPSERT 回填 rules_appliedtrailer 死链修复后的存量补数路径)
if record_commit_entry(&conn, &effective_id, entry)? {
imported += 1;
}
}
tx.commit().map_err(|e| e.to_string())?;
Ok(IngestResult { imported, skipped: total - imported })
}
// ── 跨项目 Git 健康度聚合 ─────────────────────────────────────────────────────
/// 单个 scope 的活跃度摘要。
#[derive(Debug, Serialize)]
pub struct ScopeActivity {
pub scope: String,
pub commit_count: u32,
pub rework_count: u32,
}
/// 单项目 Git 健康度指标。
#[derive(Debug, Serialize)]
pub struct ProjectCommitHealth {
pub project_id: String,
pub total_commits: u32,
pub conventional_commits: u32,
/// conventional / total0.0 1.0,三位小数)
pub conventional_rate: f64,
pub rework_commits: u32,
/// fix(非CI) / conventional0.0 1.0,三位小数)
pub rework_rate: f64,
/// 按 commit 数降序的前 5 个 scope
pub top_scopes: Vec<ScopeActivity>,
}
/// 内部聚合逻辑,接受 Connection 引用,方便测试直接调用。
pub fn project_commit_health_inner(
conn: &rusqlite::Connection,
project_ids: &[String],
) -> Result<Vec<ProjectCommitHealth>, String> {
let round3 = |v: f64| (v * 1000.0).round() / 1000.0;
let mut result = Vec::new();
for pid in project_ids {
let total: u32 = conn
.query_row(
"SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1",
rusqlite::params![pid],
|r| r.get::<_, i64>(0),
)
.unwrap_or(0) as u32;
if total == 0 { continue; }
let conventional: u32 = conn
.query_row(
"SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND commit_type IS NOT NULL",
rusqlite::params![pid],
|r| r.get::<_, i64>(0),
)
.unwrap_or(0) as u32;
let rework: u32 = conn
.query_row(
"SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND is_rework=1 AND is_ci_auto=0",
rusqlite::params![pid],
|r| r.get::<_, i64>(0),
)
.unwrap_or(0) as u32;
let mut stmt = conn
.prepare(
"SELECT scope,
COUNT(*) AS cnt,
SUM(CASE WHEN is_rework=1 AND is_ci_auto=0 THEN 1 ELSE 0 END) AS rc
FROM commit_metrics
WHERE project_id = ?1 AND scope IS NOT NULL
GROUP BY scope
ORDER BY cnt DESC
LIMIT 5",
)
.map_err(|e| e.to_string())?;
let top_scopes: Vec<ScopeActivity> = stmt
.query_map(rusqlite::params![pid], |row| {
Ok(ScopeActivity {
scope: row.get(0)?,
commit_count: row.get::<_, i64>(1)? as u32,
rework_count: row.get::<_, i64>(2)? as u32,
})
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
result.push(ProjectCommitHealth {
conventional_rate: round3(if total > 0 { conventional as f64 / total as f64 } else { 0.0 }),
rework_rate: round3(if conventional > 0 { rework as f64 / conventional as f64 } else { 0.0 }),
project_id: pid.clone(),
total_commits: total,
conventional_commits: conventional,
rework_commits: rework,
top_scopes,
});
}
Ok(result)
}
/// 批量查询各项目的 Git 健康度(直接从 commit_metrics 表聚合,不经 usage.json
#[tauri::command]
pub fn get_project_commit_health(
project_ids: Vec<String>,
) -> Result<Vec<ProjectCommitHealth>, String> {
if project_ids.is_empty() {
return Ok(vec![]);
}
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
project_commit_health_inner(&conn, &project_ids)
}
/// commit_metrics 中所有项目的健康度(含项目名解析)。
#[derive(Debug, Serialize)]
pub struct CommitHealthWithName {
pub project_id: String,
/// 解析出的项目名:优先从 projects 表直接匹配,其次通过 blueprint_buffs 路径关联 projects再次用路径末段
pub project_name: String,
pub total_commits: u32,
pub conventional_commits: u32,
pub conventional_rate: f64,
pub rework_commits: u32,
pub rework_rate: f64,
pub top_scopes: Vec<ScopeActivity>,
}
/// 查询 commit_metrics 里所有有数据的项目,自动关联名称。无需前端传 project_ids。
#[tauri::command]
pub fn get_all_commit_health() -> Result<Vec<CommitHealthWithName>, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
let round3 = |v: f64| (v * 1000.0).round() / 1000.0;
// 聚合各 project_id 的指标,四条路径解析名称:
// 1) projects 直接匹配(名称字符串 id
// 2) blueprint_buffs + projects 路径关联
// 3) project_profiles "prof-UUID" 格式(孤儿 UUID 的名称来源)
// 4) blueprint_buffs.project_path后处理提取末段目录名
let mut stmt = conn.prepare(
"SELECT
cm.project_id,
COALESCE(
p_direct.name,
p_via_buff.name,
pp_prof.name,
bb.project_path,
cm.project_id
) AS name,
COUNT(*) AS total,
SUM(CASE WHEN cm.commit_type IS NOT NULL THEN 1 ELSE 0 END) AS conventional,
SUM(CASE WHEN cm.is_rework=1 AND cm.is_ci_auto=0 THEN 1 ELSE 0 END) AS rework
FROM commit_metrics cm
LEFT JOIN projects p_direct ON p_direct.id = cm.project_id
LEFT JOIN blueprint_buffs bb ON bb.project_id = cm.project_id
LEFT JOIN projects p_via_buff
ON p_via_buff.win_path = bb.project_path
OR p_via_buff.wsl_path = bb.project_path
LEFT JOIN project_profiles pp_prof ON pp_prof.id = ('prof-' || cm.project_id)
GROUP BY cm.project_id
ORDER BY total DESC",
).map_err(|e| e.to_string())?;
let rows: Vec<(String, String, u32, u32, u32)> = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)? as u32,
row.get::<_, i64>(3)? as u32,
row.get::<_, i64>(4)? as u32,
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
let mut result = Vec::with_capacity(rows.len());
for (pid, name, total, conventional, rework) in rows {
// 查 top_scopes
let mut scope_stmt = conn.prepare(
"SELECT scope,
COUNT(*) AS cnt,
SUM(CASE WHEN is_rework=1 AND is_ci_auto=0 THEN 1 ELSE 0 END) AS rc
FROM commit_metrics
WHERE project_id = ?1 AND scope IS NOT NULL
GROUP BY scope
ORDER BY cnt DESC
LIMIT 5",
).map_err(|e| e.to_string())?;
let top_scopes: Vec<ScopeActivity> = scope_stmt
.query_map(rusqlite::params![pid], |row| {
Ok(ScopeActivity {
scope: row.get(0)?,
commit_count: row.get::<_, i64>(1)? as u32,
rework_count: row.get::<_, i64>(2)? as u32,
})
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
// 如果 name 解析为路径(含 / 或 \),取末段目录名
let display_name = if name.contains('/') || name.contains('\\') {
name.split(['/', '\\']).filter(|s| !s.is_empty()).last()
.unwrap_or(&name)
.to_string()
} else {
name
};
result.push(CommitHealthWithName {
conventional_rate: round3(if total > 0 { conventional as f64 / total as f64 } else { 0.0 }),
rework_rate: round3(if conventional > 0 { rework as f64 / conventional as f64 } else { 0.0 }),
project_id: pid,
project_name: display_name,
total_commits: total,
conventional_commits: conventional,
rework_commits: rework,
top_scopes,
});
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_feat_with_scope() {
let p = parse_conventional("feat(hub/rpc): add shared type system");
assert_eq!(p.commit_type.as_deref(), Some("feat"));
assert_eq!(p.scope.as_deref(), Some("hub/rpc"));
assert!(!p.is_rework);
assert!(!p.is_ci_auto);
}
#[test]
fn parses_fix_as_rework() {
let p = parse_conventional("fix(estate1/view): correct sort order");
assert_eq!(p.commit_type.as_deref(), Some("fix"));
assert_eq!(p.scope.as_deref(), Some("estate1/view"));
assert!(p.is_rework);
assert!(!p.is_ci_auto);
}
#[test]
fn parses_type_without_scope() {
let p = parse_conventional("chore: update AGENTS.md");
assert_eq!(p.commit_type.as_deref(), Some("chore"));
assert_eq!(p.scope, None);
assert!(!p.is_rework);
}
#[test]
fn detects_ci_auto_fix() {
let p = parse_conventional("fix(ci): auto-fix type/lint errors [DeepSeek-V3]");
assert_eq!(p.commit_type.as_deref(), Some("fix"));
assert!(p.is_rework);
assert!(p.is_ci_auto, "应识别 [DeepSeek-V3] 为 CI 自修");
}
#[test]
fn breaking_change_marker_stripped() {
let p = parse_conventional("feat(api)!: drop legacy endpoint");
assert_eq!(p.commit_type.as_deref(), Some("feat"));
assert_eq!(p.scope.as_deref(), Some("api"));
}
#[test]
fn merge_commit_is_non_conventional() {
let p = parse_conventional("Merge pull request 'xxx' (#1) from dev into main");
assert_eq!(p.commit_type, None);
assert!(!p.is_rework);
}
#[test]
fn freeform_message_is_non_conventional() {
let p = parse_conventional("update stuff");
assert_eq!(p.commit_type, None);
assert_eq!(p.scope, None);
}
#[test]
fn uses_first_line_only() {
let p = parse_conventional("feat(x): title\n\nbody line with: colon");
assert_eq!(p.commit_type.as_deref(), Some("feat"));
assert_eq!(p.scope.as_deref(), Some("x"));
}
#[test]
fn rework_count_excludes_ci_auto_and_non_fix() {
let conn = crate::db::conn_with_schema();
record_commit(&conn, "proj", "sha1", "fix(a): real bug", None).unwrap();
record_commit(&conn, "proj", "sha2", "fix(ci): auto-fix [DeepSeek-V3]", None).unwrap();
record_commit(&conn, "proj", "sha3", "feat(a): feature", None).unwrap();
record_commit(&conn, "proj", "sha4", "update stuff", None).unwrap();
// 仅 sha1 算有效返工sha2 是 CI 自修、sha3 是 feat、sha4 非规范)
assert_eq!(rework_count(&conn, "proj").unwrap(), 1);
}
#[test]
fn record_commit_is_idempotent() {
let conn = crate::db::conn_with_schema();
record_commit(&conn, "proj", "dup", "fix: x", None).unwrap();
record_commit(&conn, "proj", "dup", "fix: x", None).unwrap();
let (total, _) = conventional_stats(&conn, "proj").unwrap();
assert_eq!(total, 1, "同 sha 重复写入应被忽略");
}
// ── P3-Atrailer 链路修复 + UPSERT 回填 ─────────────────────────────────────
fn entry(sha: &str, subject: &str, trailer: &str) -> GitLogEntry {
GitLogEntry {
sha: sha.into(),
subject: subject.into(),
committed_at: "2026-07-04T00:00:00+09:00".into(),
rules_trailer: trailer.into(),
files: Vec::new(),
}
}
fn rules_of(conn: &rusqlite::Connection, sha: &str) -> Option<String> {
conn.query_row(
"SELECT rules_applied FROM commit_metrics WHERE sha = ?1",
[sha],
|r| r.get(0),
)
.unwrap()
}
#[test]
fn parse_git_log_output_headers_trailers_and_files() {
// 两条 commit首条带 trailer + 2 个文件,次条无 trailer + 1 个文件(--name-only 真实形状)
let output = "abc\u{1f}feat(x): y\u{1f}2026-07-04T00:00:00+09:00\u{1f}R01,R06\n\
\n\
src/a.rs\n\
src/b.rs\n\
def\u{1f}fix: z\u{1f}2026-07-04T00:00:00+09:00\u{1f}\n\
\n\
src/c.rs\n";
let entries = parse_git_log_output(output);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].sha, "abc");
assert_eq!(entries[0].subject, "feat(x): y");
assert_eq!(entries[0].rules_trailer, "R01,R06");
assert_eq!(entries[0].files, vec!["src/a.rs", "src/b.rs"]);
assert_eq!(entries[1].rules_trailer, "", "无 trailer 时为空串");
assert_eq!(entries[1].files, vec!["src/c.rs"]);
assert!(parse_git_log_output("").is_empty());
// 空 commitmerge / --allow-emptyheader 后无文件行
let no_files = parse_git_log_output("abc\u{1f}chore: m\u{1f}d\u{1f}\n");
assert!(no_files[0].files.is_empty());
}
#[test]
fn normalize_rule_tokens_filters_and_dedups() {
assert_eq!(normalize_rule_tokens("R01, r06 R01").as_deref(), Some("R01,R06"));
assert_eq!(normalize_rule_tokens("无效, abc"), None);
assert_eq!(normalize_rule_tokens(""), None);
}
#[test]
fn upsert_backfills_null_rules_only() {
let conn = crate::db::conn_with_schema();
// 模拟存量行:%s 时代写入rules_applied 为 NULL
record_commit(&conn, "proj", "old", "feat(a): x", None).unwrap();
assert_eq!(rules_of(&conn, "old"), None);
// 回填:同 sha 带 trailer 重写 → 补上
let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R01,R06")).unwrap();
assert!(changed, "回填应计为写入");
assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06"));
// 不覆盖:已有值的行,不同 trailer 重写 → 保持原值
let changed = record_commit_entry(&conn, "proj", &entry("old", "feat(a): x", "R99")).unwrap();
assert!(!changed, "已有值不应被覆盖");
assert_eq!(rules_of(&conn, "old").as_deref(), Some("R01,R06"));
// 无 trailer 重写已有 NULL 行 → 不产生无意义 UPDATE
record_commit(&conn, "proj", "old2", "chore: y", None).unwrap();
let changed = record_commit_entry(&conn, "proj", &entry("old2", "chore: y", "")).unwrap();
assert!(!changed);
// 新行带 trailer 直接写入
assert!(record_commit_entry(&conn, "proj", &entry("new", "fix(b): z", "R05")).unwrap());
assert_eq!(rules_of(&conn, "new").as_deref(), Some("R05"));
}
/// 端到端:真 git 仓库验证 %(trailers) 提取——这是 R1 死链(%s 只取 subject的回归测试。
#[test]
fn read_git_log_extracts_trailer_from_real_repo() {
let dir = std::env::temp_dir().join(format!("lj-trailer-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.args(args)
.current_dir(&dir)
.output()
.expect("git 可执行");
assert!(out.status.success(), "git {:?} 失败: {}", args, String::from_utf8_lossy(&out.stderr));
};
git(&["init", "-q"]);
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-q",
"-m", "feat(a): no trailer"]);
std::fs::write(dir.join("x.rs"), "// x").unwrap();
std::fs::write(dir.join("y.rs"), "// y").unwrap();
git(&["add", "."]);
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q",
"-m", "fix(b): with trailer", "-m", "Rules-Applied: R01,R06"]);
let entries = read_git_log(&dir, GitLogRange::Limit(10)).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(entries.len(), 2);
// git log 逆序:最新在前
assert_eq!(entries[0].subject, "fix(b): with trailer");
assert_eq!(entries[0].rules_trailer, "R01,R06", "trailer 必须被提取(%s 死链回归)");
assert_eq!(entries[0].files, vec!["x.rs", "y.rs"], "--name-only 文件必须被采集");
assert_eq!(entries[1].rules_trailer, "");
assert!(entries[1].files.is_empty(), "空 commit 无文件");
}
#[test]
fn commit_files_written_and_idempotent() {
let conn = crate::db::conn_with_schema();
let mut e = entry("s1", "feat(a): x", "");
e.files = vec!["src/a.rs".into(), "src/b.rs".into()];
record_commit_entry(&conn, "proj", &e).unwrap();
record_commit_entry(&conn, "proj", &e).unwrap(); // 重跑
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM commit_files WHERE sha = 's1'", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 2, "文件写入且重跑幂等");
}
#[test]
fn conventional_stats_counts_ratio() {
let conn = crate::db::conn_with_schema();
record_commit(&conn, "proj", "s1", "feat: a", None).unwrap();
record_commit(&conn, "proj", "s2", "random text", None).unwrap();
let (total, conv) = conventional_stats(&conn, "proj").unwrap();
assert_eq!(total, 2);
assert_eq!(conv, 1);
}
// ── F1 新增测试 ────────────────────────────────────────────────────────────
/// 辅助:向 in-memory DB 写入一批 commits
fn seed_commits(conn: &rusqlite::Connection, pid: &str, msgs: &[(&str, &str)]) {
for (sha, msg) in msgs {
record_commit(conn, pid, sha, msg, None).unwrap();
}
}
#[test]
fn health_empty_project_excluded() {
let conn = crate::db::conn_with_schema();
// project_id 没有任何 commitinner 函数应直接跳过返回空
let result = project_commit_health_inner(&conn, &["ghost-proj".to_string()]).unwrap();
assert!(result.is_empty(), "无数据项目应被跳过");
}
#[test]
fn health_aggregates_correctly() {
let conn = crate::db::conn_with_schema();
seed_commits(&conn, "p1", &[
("a1", "feat(api): add endpoint"),
("a2", "fix(api): correct response"), // rework
("a3", "fix(ci): auto-fix [DeepSeek-V3]"), // CI不计返工
("a4", "chore: update deps"),
("a5", "random commit"), // 非 conventional
]);
let result = project_commit_health_inner(&conn, &["p1".to_string()]).unwrap();
let h = &result[0];
assert_eq!(h.total_commits, 5);
assert_eq!(h.conventional_commits, 4); // a1/a2/a3/a4
assert_eq!(h.rework_commits, 1); // 只有 a2a3 是 CI
assert!((h.conventional_rate - 0.8).abs() < 0.001);
assert!((h.rework_rate - 0.25).abs() < 0.001); // 1/4 conventional
}
#[test]
fn health_top_scopes_ordered_by_count() {
let conn = crate::db::conn_with_schema();
seed_commits(&conn, "p2", &[
("b1", "feat(api): x"),
("b2", "feat(api): y"),
("b3", "feat(api): z"),
("b4", "fix(db): bug"),
("b5", "chore(db): cleanup"),
]);
let result = project_commit_health_inner(&conn, &["p2".to_string()]).unwrap();
let h = &result[0];
assert_eq!(h.top_scopes[0].scope, "api");
assert_eq!(h.top_scopes[0].commit_count, 3);
assert_eq!(h.top_scopes[1].scope, "db");
assert_eq!(h.top_scopes[1].commit_count, 2);
}
#[test]
fn ingest_idempotent_via_record_commit() {
let conn = crate::db::conn_with_schema();
record_commit(&conn, "p3", "sha-dup", "feat: first write", None).unwrap();
record_commit(&conn, "p3", "sha-dup", "feat: second write", None).unwrap();
let (total, _) = conventional_stats(&conn, "p3").unwrap();
assert_eq!(total, 1, "重复 sha 应被 INSERT OR IGNORE 忽略");
}
// ── R1Rules-Applied trailer ──────────────────────────────────────────
#[test]
fn parse_rules_applied_normalizes_tokens() {
let msg = "feat(core): thing
说明
Rules-Applied: r06, R01,R01, xx, R10";
assert_eq!(parse_rules_applied(msg).as_deref(), Some("R01,R06,R10"));
}
#[test]
fn parse_rules_applied_absent_or_empty() {
assert_eq!(parse_rules_applied("feat: no trailer"), None);
assert_eq!(parse_rules_applied("Rules-Applied: 无效, abc"), None);
}
#[test]
fn record_commit_stores_rules_applied() {
let conn = crate::db::conn_with_schema();
record_commit(&conn, "pr", "sha-r1", "feat(x): a
Rules-Applied: R01,R05", None).unwrap();
let stored: Option<String> = conn.query_row(
"SELECT rules_applied FROM commit_metrics WHERE sha = 'sha-r1'", [], |r| r.get(0),
).unwrap();
assert_eq!(stored.as_deref(), Some("R01,R05"));
}
#[test]
fn rule_hit_stats_cross_counts_rework() {
let conn = crate::db::conn_with_schema();
record_commit(&conn, "pa", "s1", "feat(x): ok
Rules-Applied: R01", None).unwrap();
record_commit(&conn, "pa", "s2", "fix(x): broke
Rules-Applied: R01,R06", None).unwrap();
record_commit(&conn, "pb", "s3", "feat(y): fine
Rules-Applied: R06", None).unwrap();
// CI 自修的 fix 不计返工
record_commit(&conn, "pb", "s4", "fix(y): auto [DeepSeek-V3]
Rules-Applied: R06", None).unwrap();
let stats = rule_hit_stats(&conn).unwrap();
let r01 = stats.iter().find(|s| s.rule_id == "R01").unwrap();
assert_eq!(r01.hits, 2);
assert_eq!(r01.rework_hits, 1);
assert_eq!(r01.project_count, 1);
let r06 = stats.iter().find(|s| s.rule_id == "R06").unwrap();
assert_eq!(r06.hits, 3);
assert_eq!(r06.rework_hits, 1, "CI 自修 fix 不应计入返工");
assert_eq!(r06.project_count, 2);
}
#[test]
fn rule_hit_stats_empty_when_no_trailers() {
let conn = crate::db::conn_with_schema();
record_commit(&conn, "pz", "s9", "feat: plain", None).unwrap();
assert!(rule_hit_stats(&conn).unwrap().is_empty());
}
}