use assert_cmd::cargo::CommandCargoExt;
use insta::{assert_snapshot, assert_yaml_snapshot};
use rusqlite::Connection;
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
const FIXTURE_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/regression");
const HOME_TEMPLATE_CURSOR_DB: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/regression/home_template/cursor/state.vscdb"
);
const PACEFLOW_BUNDLE: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/regression/repos/paceflow.bundle"
);
struct TestEnv {
_tempdir: TempDir,
home: PathBuf,
paceflow_repo: PathBuf,
cursor_dir: PathBuf,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
struct SessionSummarySnapshot {
sessions: String,
average_user_prompts: String,
avg_time_to_first_accepted_change_minutes: String,
debug_loop_rate: String,
s6_rate: String,
s9_rate: String,
no_output_session_rate: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
struct ChangeSummarySnapshot {
commits: String,
heavy_commits: String,
merge_rate: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
struct LifecycleSummarySnapshot {
heavy_commits: String,
code_churn_rate: String,
bug_after_merge_rate: String,
revert_rate: String,
}
impl TestEnv {
fn new_live_fixture() -> anyhow::Result<Self> {
let tempdir = TempDir::new()?;
let home = tempdir.path().to_path_buf();
let work_dir = home.join("work");
let paceflow_repo = work_dir.join("fixture-paceflow");
let cursor_dir = work_dir.join("fixture-cursor");
fs::create_dir_all(&work_dir)?;
fs::create_dir_all(&cursor_dir)?;
materialize_paceflow_repo(&paceflow_repo)?;
copy_codex_sessions(
&home,
&["__REPO_PACEFLOW__", "__REPO_CURSOR__", "__HOME__"],
&[
&paceflow_repo.to_string_lossy(),
&cursor_dir.to_string_lossy(),
&home.to_string_lossy(),
],
)?;
copy_claude_sessions(
&home,
&["__REPO_PACEFLOW__", "__REPO_CURSOR__", "__HOME__"],
&[
&paceflow_repo.to_string_lossy(),
&cursor_dir.to_string_lossy(),
&home.to_string_lossy(),
],
)?;
install_cursor_fixture(&home, &paceflow_repo, &cursor_dir)?;
install_opencode_fixture(&home, &paceflow_repo, &cursor_dir)?;
Ok(Self {
_tempdir: tempdir,
home,
paceflow_repo,
cursor_dir,
})
}
fn new_seeded_reporting() -> anyhow::Result<Self> {
let tempdir = TempDir::new()?;
let home = tempdir.path().to_path_buf();
let work_dir = home.join("work");
let paceflow_repo = work_dir.join("fixture-paceflow");
let cursor_dir = work_dir.join("fixture-cursor");
fs::create_dir_all(&paceflow_repo)?;
fs::create_dir_all(&cursor_dir)?;
let env = Self {
_tempdir: tempdir,
home,
paceflow_repo,
cursor_dir,
};
env.initialize_db_schema()?;
env.seed_reporting_fixture()?;
Ok(env)
}
fn run_paceflow(&self, args: &[&str]) -> anyhow::Result<String> {
let output = Command::cargo_bin("paceflow")?
.args(args)
.current_dir(&self.home)
.env("PACEFLOW_HOME", &self.home)
.env("HOME", &self.home)
.env("USERPROFILE", &self.home)
.env_remove("HOMEDRIVE")
.env_remove("HOMEPATH")
.env_remove("XDG_CONFIG_HOME")
.env_remove("PACEFLOW_GITHUB_TOKEN")
.output()?;
if !output.status.success() {
anyhow::bail!(
"paceflow {:?} failed\nstdout:\n{}\nstderr:\n{}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8(output.stdout)?)
}
fn ingest(&self) -> anyhow::Result<()> {
self.run_paceflow(&["ingest"])?;
Ok(())
}
fn initialize_db_schema(&self) -> anyhow::Result<()> {
let _ = self.run_paceflow(&["session"])?;
Ok(())
}
fn db_path(&self) -> PathBuf {
self.home.join(".paceflow").join("paceflow.db")
}
fn seed_reporting_fixture(&self) -> anyhow::Result<()> {
fs::create_dir_all(self.home.join(".paceflow"))?;
let conn = Connection::open(self.db_path())?;
let repo_root = "__REPO_PACEFLOW__";
let sessions = [
(
"codex",
"019cb311-b93e-7423-aeb4-81249c578638",
Some(repo_root),
"codex/gpt-5.3-codex",
"2026-03-03T09:40:09.156Z",
"2026-03-03T16:10:00.000Z",
117,
0,
1,
1,
Some("2026-03-03T15:22:37.117Z"),
Some(342.46601678431034),
1,
),
(
"cursor",
"b052dae4-a1d2-43f0-a3ba-6faf6a40489e",
None,
"cursor/default",
"2026-03-05T07:02:58+00:00",
"2026-03-05T07:05:00+00:00",
1,
0,
0,
1,
Some("2026-03-05T07:02:58+00:00"),
Some(0.0),
0,
),
(
"codex",
"019cd678-8f72-7b43-a07f-d49cdbbb81c9",
Some(repo_root),
"codex/gpt-5.3-codex",
"2026-03-10T06:39:11.222Z",
"2026-03-10T12:00:00.000Z",
15,
0,
1,
1,
Some("2026-03-10T11:37:27.402Z"),
Some(298.26966628432274),
1,
),
(
"claude",
"11111111-2222-4333-8444-555555555555",
Some(repo_root),
"claude/claude-sonnet-4-5-20250929",
"2026-03-10T09:20:21.240Z",
"2026-03-10T09:22:10.000Z",
1,
0,
0,
1,
Some("2026-03-10T09:20:22.240Z"),
Some(0.016666666666666666),
1,
),
];
for (
provider,
session_id,
repo_root,
model_name,
started_at,
ended_at,
user_turn_count,
debug_loop_flag,
mid_session_error_paste_flag,
accepted_output_flag,
first_accepted_change_at,
minutes_to_first_accepted_change,
session_commit_within_4h_flag,
) in sessions
{
conn.execute(
"INSERT INTO event_session_quality (
provider, session_id, repo_root, model_name, started_at, ended_at, user_turn_count,
debug_loop_flag, mid_session_error_paste_flag, accepted_output_flag,
first_accepted_change_at, minutes_to_first_accepted_change, session_commit_within_4h_flag
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
(
provider,
session_id,
repo_root,
model_name,
started_at,
ended_at,
user_turn_count,
debug_loop_flag,
mid_session_error_paste_flag,
accepted_output_flag,
first_accepted_change_at,
minutes_to_first_accepted_change,
session_commit_within_4h_flag,
),
)?;
}
let commits = [
(
"de8ab41b5cec40dd3dc63fc8bd0ca743cea1d46c",
"2026-03-03T09:37:41.000Z",
0,
1,
0,
135,
2283,
135,
37,
"staging",
"staging",
1,
0.5,
),
(
"17a3ee9fd5e70b92092dd9ec160a63cd5fb01b1d",
"2026-03-05T08:40:50.000Z",
0,
1,
0,
1096,
3003,
1091,
42,
"staging",
"staging",
1,
0.5,
),
(
"2dcb057965747739cea8bb891a260c347be8cb39",
"2026-03-06T08:24:52.000Z",
1,
1,
0,
1515,
1573,
1505,
24,
"staging",
"staging",
1,
0.5,
),
(
"f3d68adc96fbfb533c2e76cda51ca174f6d7954f",
"2026-03-10T08:33:33.000Z",
1,
1,
0,
1306,
1374,
1225,
21,
"staging",
"staging",
1,
0.5,
),
(
"75c5d97151a6a681a26737f88ec4cc1e3ad7fe23",
"2026-03-10T11:54:22.000Z",
0,
0,
0,
0,
2,
0,
0,
"PAC-999",
"codex/PAC-999-task-stats-demo",
0,
1.0,
),
(
"9ffe1bf647c827de2b2def4ccfb32ce7cee1b207",
"2026-03-11T13:44:10.000Z",
1,
1,
0,
1336,
1466,
1276,
14,
"main",
"main",
1,
0.5,
),
(
"47d03452796ad3cda5c12edeb61a28965e76b4bd",
"2026-03-12T06:20:19.000Z",
0,
1,
0,
5,
49,
1,
0,
"main",
"main",
1,
0.5,
),
(
"77abc1af8a5af3614fbd7d6639bfc9ef557b8f29",
"2026-03-13T16:06:41.000Z",
0,
1,
0,
0,
1055,
0,
0,
"main",
"main",
1,
0.5,
),
(
"32dfa74d2e2bf9c06c815b31c9de01037db5ffbf",
"2026-03-13T16:17:59.000Z",
0,
1,
0,
28,
107,
28,
0,
"main",
"main",
1,
0.5,
),
];
for (
commit_sha,
commit_time,
heavy_ai_flag,
merged_to_mainline_flag,
reverted_later_flag,
total_matched_ai_lines,
commit_total_changed_lines,
ai_added_lines_reaching_mainline,
ai_added_lines_removed_within_window,
task_key,
branch_name,
fallback_flag,
confidence,
) in commits
{
conn.execute(
"INSERT INTO event_commit_outcome (
repo_root, commit_sha, commit_time, heavy_ai_flag, merged_to_mainline_flag,
reverted_later_flag, total_matched_ai_lines, commit_total_changed_lines
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
(
repo_root,
commit_sha,
commit_time,
heavy_ai_flag,
merged_to_mainline_flag,
reverted_later_flag,
total_matched_ai_lines,
commit_total_changed_lines,
),
)?;
conn.execute(
"INSERT INTO event_commit_churn (
repo_root, commit_sha, ai_added_lines_reaching_mainline,
ai_added_lines_removed_within_window, churn_window_days
) VALUES (?1, ?2, ?3, ?4, 14)",
(
repo_root,
commit_sha,
ai_added_lines_reaching_mainline,
ai_added_lines_removed_within_window,
),
)?;
conn.execute(
"INSERT INTO event_commit_bug_signal (
repo_root, commit_sha, bug_after_merge_flag, first_bug_signal_commit_sha,
first_bug_signal_commit_time, bug_signal_count, window_days, signal_source
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 60, 'git_fix_commit')",
(
repo_root,
commit_sha,
i64::from(commit_sha == "f3d68adc96fbfb533c2e76cda51ca174f6d7954f"),
if commit_sha == "f3d68adc96fbfb533c2e76cda51ca174f6d7954f" {
Some("2f00f00d2f00f00d2f00f00d2f00f00d2f00f00d")
} else {
None
},
if commit_sha == "f3d68adc96fbfb533c2e76cda51ca174f6d7954f" {
Some("2026-03-15T09:00:00.000Z")
} else {
None
},
i64::from(commit_sha == "f3d68adc96fbfb533c2e76cda51ca174f6d7954f"),
),
)?;
conn.execute(
"INSERT INTO event_task_commit (
repo_root, task_key, branch_name, commit_sha, fallback_flag, confidence, commit_time
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
(
repo_root,
task_key,
branch_name,
commit_sha,
fallback_flag,
confidence,
commit_time,
),
)?;
}
let commit_sessions = [
(
"de8ab41b5cec40dd3dc63fc8bd0ca743cea1d46c",
"codex",
"019cb311-b93e-7423-aeb4-81249c578638",
"codex/gpt-5.3-codex",
132.66666666666666,
0.05811067440984121,
0.9827160493827161,
),
(
"de8ab41b5cec40dd3dc63fc8bd0ca743cea1d46c",
"codex",
"019cd678-8f72-7b43-a07f-d49cdbbb81c9",
"codex/gpt-5.3-codex",
1.3333333333333333,
0.0005840268652358008,
0.009876543209876543,
),
(
"de8ab41b5cec40dd3dc63fc8bd0ca743cea1d46c",
"claude",
"11111111-2222-4333-8444-555555555555",
"claude/claude-sonnet-4-5-20250929",
1.0,
0.0004380188300477442,
0.007407407407407408,
),
];
for (
commit_sha,
provider,
session_id,
model_name,
matched_lines,
share_of_commit,
share_of_ai,
) in commit_sessions
{
conn.execute(
"INSERT INTO event_commit_session (
repo_root, commit_sha, provider, session_id, commit_time, model_name,
matched_lines, share_of_commit, share_of_ai
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
(
repo_root,
commit_sha,
provider,
session_id,
"2026-03-03T09:37:41.000Z",
model_name,
matched_lines,
share_of_commit,
share_of_ai,
),
)?;
}
Ok(())
}
fn normalize_output(&self, output: String) -> String {
let mut normalized = output;
let mut replacements = Vec::new();
for (path, placeholder) in [
(&self.paceflow_repo, "__REPO_PACEFLOW__"),
(&self.cursor_dir, "__REPO_CURSOR__"),
(&self.home, "__HOME__"),
] {
replacements.extend(
path_variants(path)
.into_iter()
.map(|variant| (variant, placeholder)),
);
}
replacements.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
for (variant, placeholder) in replacements {
normalized = normalized.replace(&variant, placeholder);
}
normalized
}
}
#[test]
fn category_reports_match_snapshots() -> anyhow::Result<()> {
let env = TestEnv::new_seeded_reporting()?;
let session = env.normalize_output(env.run_paceflow(&["session", "--overall"])?);
let change = env.normalize_output(env.run_paceflow(&["delivery", "--overall"])?);
let lifecycle = env.normalize_output(env.run_paceflow(&["quality", "--overall"])?);
assert!(session.contains("Session Metrics"));
assert!(change.contains("Delivery Metrics"));
assert!(lifecycle.contains("Quality Metrics"));
let structured_session = parse_session_summary(&session)?;
let structured_change = parse_change_summary(&change)?;
let structured_lifecycle = parse_lifecycle_summary(&lifecycle)?;
assert_snapshot!("fixture_corpus_session_report_text", session);
assert_snapshot!("fixture_corpus_change_report_text", change);
assert_snapshot!("fixture_corpus_lifecycle_report_text", lifecycle);
assert_yaml_snapshot!(
"fixture_corpus_session_report_structured",
structured_session
);
assert_yaml_snapshot!("fixture_corpus_change_report_structured", structured_change);
assert_yaml_snapshot!(
"fixture_corpus_lifecycle_report_structured",
structured_lifecycle
);
Ok(())
}
#[test]
fn default_category_reports_group_by_model() -> anyhow::Result<()> {
let env = TestEnv::new_seeded_reporting()?;
let session = env.normalize_output(env.run_paceflow(&["session"])?);
let change = env.normalize_output(env.run_paceflow(&["delivery"])?);
let lifecycle = env.normalize_output(env.run_paceflow(&["quality"])?);
assert!(session.contains("Model"));
assert!(session.contains("codex/gpt-5.3-codex"));
assert!(!session.contains("Sessions analyzed:"));
assert!(change.contains("Model"));
assert!(change.contains("codex/gpt-5.3-codex"));
assert!(!change.contains("Commits analyzed:"));
assert!(change.contains("PR Reach"));
assert!(change.contains("Mainline Reach"));
assert!(change.contains("PR Merge"));
assert!(!change.contains("Merge Rate"));
assert!(!change.contains("C1(PR)"));
assert!(!change.contains("C2(merge)"));
assert!(!change.contains("C3(PR merge)"));
assert!(lifecycle.contains("Model"));
assert!(lifecycle.contains("codex/gpt-5.3-codex"));
assert!(!lifecycle.contains("Heavy commits analyzed:"));
assert!(lifecycle.contains("Churn Rate"));
assert!(lifecycle.contains("Bug Rate"));
assert!(lifecycle.contains("Revert Rate"));
assert!(!lifecycle.contains("L1(churn)"));
assert!(!lifecycle.contains("L3(bug)"));
assert!(!lifecycle.contains("L4(revert)"));
Ok(())
}
#[test]
fn claude_provider_filters_return_rows() -> anyhow::Result<()> {
let env = TestEnv::new_seeded_reporting()?;
let session = env.normalize_output(env.run_paceflow(&[
"session",
"--provider",
"claude",
"--overall",
])?);
let delivery = env.normalize_output(env.run_paceflow(&[
"delivery",
"--provider",
"claude",
"--overall",
])?);
let quality = env.normalize_output(env.run_paceflow(&[
"quality",
"--provider",
"claude",
"--overall",
])?);
assert!(session.contains("Session Metrics"));
assert!(session.contains("Sessions analyzed: 1"));
assert!(!session.contains("No session rows found."));
assert!(delivery.contains("Delivery Metrics"));
assert!(delivery.contains("Commits analyzed:"));
assert!(!delivery.contains("No delivery rows found."));
assert!(quality.contains("Quality Metrics"));
assert!(quality.contains("Heavy commits analyzed:"));
assert!(!quality.contains("No quality rows found."));
Ok(())
}
#[test]
fn grouped_and_weekly_reports_match_snapshots() -> anyhow::Result<()> {
let env = TestEnv::new_seeded_reporting()?;
let session_grouped =
env.normalize_output(env.run_paceflow(&["session", "--group-by", "provider"])?);
let change_grouped =
env.normalize_output(env.run_paceflow(&["delivery", "--group-by", "repo"])?);
let lifecycle_grouped =
env.normalize_output(env.run_paceflow(&["quality", "--group-by", "repo"])?);
let session_weekly = env.normalize_output(env.run_paceflow(&[
"session",
"--weekly",
"--group-by",
"provider",
])?);
assert_snapshot!(
"fixture_corpus_session_grouped_by_provider_text",
session_grouped
);
assert_snapshot!("fixture_corpus_change_grouped_by_repo_text", change_grouped);
assert_snapshot!(
"fixture_corpus_lifecycle_grouped_by_repo_text",
lifecycle_grouped
);
assert_snapshot!(
"fixture_corpus_session_weekly_by_provider_text",
session_weekly
);
Ok(())
}
#[test]
fn claude_branch_grouped_reports_match_snapshots() -> anyhow::Result<()> {
let env = TestEnv::new_seeded_reporting()?;
let session = env.normalize_output(env.run_paceflow(&[
"session",
"--provider",
"claude",
"--group-by",
"branch",
])?);
let delivery = env.normalize_output(env.run_paceflow(&[
"delivery",
"--provider",
"claude",
"--group-by",
"branch",
])?);
let quality = env.normalize_output(env.run_paceflow(&[
"quality",
"--provider",
"claude",
"--group-by",
"branch",
])?);
assert_snapshot!(
"fixture_corpus_session_grouped_by_branch_claude_text",
session
);
assert_snapshot!(
"fixture_corpus_delivery_grouped_by_branch_claude_text",
delivery
);
assert_snapshot!(
"fixture_corpus_quality_grouped_by_branch_claude_text",
quality
);
Ok(())
}
#[test]
fn claude_task_grouped_reports_suggest_branch_view_when_only_non_ticket_rows_exist()
-> anyhow::Result<()> {
let env = TestEnv::new_seeded_reporting()?;
let session = env.normalize_output(env.run_paceflow(&[
"session",
"--provider",
"claude",
"--group-by",
"task",
])?);
let delivery = env.normalize_output(env.run_paceflow(&[
"delivery",
"--provider",
"claude",
"--group-by",
"task",
])?);
let quality = env.normalize_output(env.run_paceflow(&[
"quality",
"--provider",
"claude",
"--group-by",
"task",
])?);
assert!(session.contains("No ticket-style task rows matched."));
assert!(session.contains("`paceflow session --group-by branch`"));
assert!(delivery.contains("No ticket-style task rows matched."));
assert!(delivery.contains("`paceflow delivery --group-by branch`"));
assert!(quality.contains("No ticket-style task rows matched."));
assert!(quality.contains("`paceflow quality --group-by branch`"));
Ok(())
}
#[test]
fn event_stream_matches_snapshots() -> anyhow::Result<()> {
let env = TestEnv::new_seeded_reporting()?;
let session_stream =
env.normalize_output(env.run_paceflow(&["event-stream", "--stream", "session-base"])?);
let task_commit_stream = env.normalize_output(env.run_paceflow(&[
"event-stream",
"--stream",
"task-commit-base",
])?);
let all_streams_smoke =
env.normalize_output(env.run_paceflow(&["event-stream", "--limit", "5"])?);
assert_snapshot!("fixture_corpus_event_stream_session_base", session_stream);
assert_snapshot!(
"fixture_corpus_event_stream_task_commit_base",
task_commit_stream
);
assert_snapshot!("fixture_corpus_event_stream_all_smoke", all_streams_smoke);
Ok(())
}
fn path_variants(path: &Path) -> Vec<String> {
let mut variants = Vec::new();
push_path_variant(&mut variants, &path.to_string_lossy());
if let Ok(canonical) = fs::canonicalize(path) {
push_path_variant(&mut variants, &canonical.to_string_lossy());
}
variants
}
fn push_path_variant(variants: &mut Vec<String>, path: &str) {
for candidate in [
path.to_string(),
path.replace('\\', "/"),
path.replace('/', "\\"),
] {
if !candidate.is_empty() && !variants.contains(&candidate) {
variants.push(candidate);
}
}
}
#[test]
fn ingest_is_idempotent_for_fixture_corpus() -> anyhow::Result<()> {
let env = TestEnv::new_live_fixture()?;
env.ingest()?;
let session_before = env.run_paceflow(&["session"])?;
let change_before = env.run_paceflow(&["delivery"])?;
let lifecycle_before = env.run_paceflow(&["quality"])?;
let second_ingest = env.run_paceflow(&["ingest"])?;
assert!(second_ingest.contains("Ingest progress: 100%"));
let session_after = env.run_paceflow(&["session"])?;
let change_after = env.run_paceflow(&["delivery"])?;
let lifecycle_after = env.run_paceflow(&["quality"])?;
assert_eq!(
session_before, session_after,
"session output drifted after rerun"
);
assert_eq!(
change_before, change_after,
"change output drifted after rerun"
);
assert_eq!(
lifecycle_before, lifecycle_after,
"lifecycle output drifted after rerun"
);
Ok(())
}
#[test]
fn ingest_reports_commit_event_progress() -> anyhow::Result<()> {
let env = TestEnv::new_live_fixture()?;
let ingest_output = env.run_paceflow(&["ingest"])?;
assert!(ingest_output.contains("Planning ingest..."));
assert!(ingest_output.contains("Stage: claude sessions"));
assert!(ingest_output.contains("Stage: codex sessions"));
assert!(ingest_output.contains("Stage: cursor sessions"));
assert!(ingest_output.contains("Stage: opencode sessions"));
assert!(ingest_output.contains("Stage: claude changes"));
assert!(ingest_output.contains("Stage: codex changes"));
assert!(ingest_output.contains("Stage: cursor changes"));
assert!(ingest_output.contains("Stage: opencode changes"));
assert!(ingest_output.contains("Stage: Commit Association"));
assert!(ingest_output.contains("Stage: Commit Materialization"));
assert!(ingest_output.contains("Ingest progress: 100%"));
assert!(ingest_output.contains("GitHub sync: skipped"));
assert!(ingest_output.contains("Rows written:"));
assert!(!ingest_output.contains("rows written"));
assert!(!ingest_output.contains("Association summary:"));
assert!(!ingest_output.contains("Commit events materialized: repos="));
assert!(!ingest_output.contains("GitHub PR sync:"));
Ok(())
}
#[test]
fn fixture_corpus_ingest_smoke_is_cross_platform_friendly() -> anyhow::Result<()> {
let env = TestEnv::new_live_fixture()?;
let ingest_output = env.normalize_output(env.run_paceflow(&["ingest"])?);
assert!(ingest_output.contains("Planning ingest..."));
assert!(ingest_output.contains("Stage: claude sessions"));
assert!(ingest_output.contains("Stage: codex sessions"));
assert!(ingest_output.contains("Stage: cursor sessions"));
assert!(ingest_output.contains("Stage: opencode sessions"));
assert!(ingest_output.contains("Stage: Commit Materialization"));
assert!(ingest_output.contains("Rows written:"));
assert!(!ingest_output.contains("Association summary:"));
assert!(!ingest_output.contains("Commit events materialized: repos="));
assert!(!ingest_output.contains("commits_scanned="));
let session = env.normalize_output(env.run_paceflow(&["session"])?);
let change = env.normalize_output(env.run_paceflow(&["delivery"])?);
let change_overall = env.normalize_output(env.run_paceflow(&["delivery", "--overall"])?);
let lifecycle = env.normalize_output(env.run_paceflow(&["quality"])?);
let change_grouped =
env.normalize_output(env.run_paceflow(&["delivery", "--group-by", "repo"])?);
let event_stream = env.normalize_output(env.run_paceflow(&["event-stream", "--limit", "5"])?);
assert!(session.contains("Session Metrics"));
assert!(change.contains("Delivery Metrics"));
assert!(lifecycle.contains("Quality Metrics"));
assert!(change_grouped.contains("Delivery Metrics"));
assert!(change_grouped.contains("Repo") || change_grouped.contains("No delivery rows found."));
assert!(event_stream.trim().is_empty() || event_stream.contains("\"stream_type\""));
let structured_change = parse_change_summary(&change_overall)?;
let commits: i64 = structured_change.commits.parse()?;
let heavy_commits: i64 = structured_change.heavy_commits.parse()?;
assert!((0..=commits).contains(&heavy_commits));
Ok(())
}
#[test]
fn verbose_ingest_retains_detailed_summaries() -> anyhow::Result<()> {
let env = TestEnv::new_live_fixture()?;
let ingest_output = env.run_paceflow(&["-v", "ingest"])?;
assert!(ingest_output.contains("Association summary:"));
assert!(ingest_output.contains("Commit events materialized: repos="));
assert!(ingest_output.contains("code changes ["));
Ok(())
}
fn materialize_paceflow_repo(repo_path: &Path) -> anyhow::Result<()> {
run_command(
Command::new("git")
.arg("clone")
.arg(PACEFLOW_BUNDLE)
.arg(repo_path),
)?;
run_command(
Command::new("git")
.arg("-C")
.arg(repo_path)
.arg("branch")
.arg("staging")
.arg("origin/staging"),
)?;
run_command(
Command::new("git")
.arg("-C")
.arg(repo_path)
.arg("branch")
.arg("codex/PAC-999-task-stats-demo")
.arg("origin/codex/PAC-999-task-stats-demo"),
)?;
run_command(
Command::new("git")
.arg("-C")
.arg(repo_path)
.arg("checkout")
.arg("main"),
)?;
Ok(())
}
fn copy_codex_sessions(home: &Path, from: &[&str], to: &[&str]) -> anyhow::Result<()> {
let src_root = Path::new(FIXTURE_ROOT)
.join("home_template")
.join(".codex")
.join("sessions");
let dst_root = home.join(".codex").join("sessions");
copy_dir_recursive(&src_root, &dst_root)?;
for path in collect_files(&dst_root)? {
let mut content = fs::read_to_string(&path)?;
for (from, to) in from.iter().zip(to.iter()) {
content = content.replace(from, to);
}
fs::write(path, content)?;
}
Ok(())
}
fn copy_claude_sessions(home: &Path, from: &[&str], to: &[&str]) -> anyhow::Result<()> {
let src_root = Path::new(FIXTURE_ROOT)
.join("home_template")
.join(".claude")
.join("projects");
if !src_root.exists() {
return Ok(());
}
let dst_root = home.join(".claude").join("projects");
copy_dir_recursive(&src_root, &dst_root)?;
for path in collect_files(&dst_root)? {
let mut content = fs::read_to_string(&path)?;
for (from, to) in from.iter().zip(to.iter()) {
content = content.replace(from, to);
}
fs::write(path, content)?;
}
Ok(())
}
fn install_cursor_fixture(
home: &Path,
paceflow_repo: &Path,
cursor_dir: &Path,
) -> anyhow::Result<()> {
for rel in [
Path::new("Library")
.join("Application Support")
.join("Cursor")
.join("User"),
Path::new(".config").join("Cursor").join("User"),
] {
let user_dir = home.join(rel);
let global_storage = user_dir.join("globalStorage");
let history = user_dir.join("History");
fs::create_dir_all(&global_storage)?;
fs::create_dir_all(&history)?;
let db_path = global_storage.join("state.vscdb");
fs::copy(HOME_TEMPLATE_CURSOR_DB, &db_path)?;
rewrite_cursor_db(&db_path, home, paceflow_repo, cursor_dir)?;
}
Ok(())
}
fn install_opencode_fixture(
home: &Path,
paceflow_repo: &Path,
cursor_dir: &Path,
) -> anyhow::Result<()> {
let opencode_dir = home.join(".local").join("share").join("opencode");
let diff_dir = opencode_dir.join("storage").join("session_diff");
fs::create_dir_all(&diff_dir)?;
let db_path = opencode_dir.join("opencode.db");
let conn = Connection::open(&db_path)?;
conn.execute_batch(
"CREATE TABLE project (
id TEXT PRIMARY KEY,
worktree TEXT NOT NULL
);
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
directory TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL
);
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE part (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);",
)?;
conn.execute(
"INSERT INTO project (id, worktree) VALUES ('fixture-paceflow', ?1), ('fixture-cursor', ?2)",
(
paceflow_repo.to_string_lossy().to_string(),
cursor_dir.to_string_lossy().to_string(),
),
)?;
insert_opencode_session(
&conn,
"ses_opencode_output",
"fixture-paceflow",
&paceflow_repo.to_string_lossy(),
1_773_576_000_000,
1_773_576_120_000,
)?;
insert_opencode_message(
&conn,
"msg_opencode_user_output",
"ses_opencode_output",
1_773_576_000_100,
serde_json::json!({
"role": "user",
"agent": "build",
"model": {"providerID": "openai", "modelID": "gpt-5.5-fast"}
}),
)?;
insert_opencode_part(
&conn,
"prt_opencode_user_output",
"msg_opencode_user_output",
"ses_opencode_output",
1_773_576_000_101,
serde_json::json!({"type": "text", "text": "add an opencode fixture file"}),
)?;
insert_opencode_message(
&conn,
"msg_opencode_assistant_output",
"ses_opencode_output",
1_773_576_001_000,
serde_json::json!({
"role": "assistant",
"modelID": "gpt-5.5-fast",
"providerID": "openai"
}),
)?;
insert_opencode_part(
&conn,
"prt_opencode_assistant_output",
"msg_opencode_assistant_output",
"ses_opencode_output",
1_773_576_001_001,
serde_json::json!({"type": "text", "text": "Added the fixture file."}),
)?;
insert_opencode_part(
&conn,
"prt_opencode_tool_output",
"msg_opencode_assistant_output",
"ses_opencode_output",
1_773_576_001_002,
serde_json::json!({"type": "tool", "tool": "apply_patch"}),
)?;
insert_opencode_session(
&conn,
"ses_opencode_no_output",
"fixture-cursor",
&cursor_dir.to_string_lossy(),
1_773_576_300_000,
1_773_576_305_000,
)?;
insert_opencode_message(
&conn,
"msg_opencode_user_no_output",
"ses_opencode_no_output",
1_773_576_300_100,
serde_json::json!({
"role": "user",
"agent": "build",
"model": {"providerID": "openai", "modelID": "gpt-5.5-fast"}
}),
)?;
insert_opencode_part(
&conn,
"prt_opencode_user_no_output",
"msg_opencode_user_no_output",
"ses_opencode_no_output",
1_773_576_300_101,
serde_json::json!({"type": "text", "text": "hi"}),
)?;
fs::write(
diff_dir.join("ses_opencode_output.json"),
serde_json::json!([
{
"file": "src/opencode_fixture.rs",
"status": "added",
"additions": 3,
"deletions": 0,
"patch": "Index: src/opencode_fixture.rs\n===================================================================\n--- src/opencode_fixture.rs\n+++ src/opencode_fixture.rs\n@@ -0,0 +1,3 @@\n+pub fn opencode_fixture() -> &'static str {\n+ \"fixture\"\n+}\n"
}
])
.to_string(),
)?;
fs::write(diff_dir.join("ses_opencode_no_output.json"), "[]")?;
Ok(())
}
fn insert_opencode_session(
conn: &Connection,
id: &str,
project_id: &str,
directory: &str,
created: i64,
updated: i64,
) -> anyhow::Result<()> {
conn.execute(
"INSERT INTO session (id, project_id, directory, time_created, time_updated)
VALUES (?1, ?2, ?3, ?4, ?5)",
(id, project_id, directory, created, updated),
)?;
Ok(())
}
fn insert_opencode_message(
conn: &Connection,
id: &str,
session_id: &str,
created: i64,
data: serde_json::Value,
) -> anyhow::Result<()> {
conn.execute(
"INSERT INTO message (id, session_id, time_created, time_updated, data)
VALUES (?1, ?2, ?3, ?3, ?4)",
(id, session_id, created, data.to_string()),
)?;
Ok(())
}
fn insert_opencode_part(
conn: &Connection,
id: &str,
message_id: &str,
session_id: &str,
created: i64,
data: serde_json::Value,
) -> anyhow::Result<()> {
conn.execute(
"INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
VALUES (?1, ?2, ?3, ?4, ?4, ?5)",
(id, message_id, session_id, created, data.to_string()),
)?;
Ok(())
}
fn rewrite_cursor_db(
db_path: &Path,
home: &Path,
paceflow_repo: &Path,
cursor_dir: &Path,
) -> anyhow::Result<()> {
let conn = Connection::open(db_path)?;
conn.execute(
"UPDATE cursorDiskKV
SET value = replace(
replace(
replace(value, '__REPO_PACEFLOW__', ?1),
'__REPO_CURSOR__', ?2
),
'__HOME__', ?3
)",
(
paceflow_repo.to_string_lossy().to_string(),
cursor_dir.to_string_lossy().to_string(),
home.to_string_lossy().to_string(),
),
)?;
Ok(())
}
fn collect_files(root: &Path) -> anyhow::Result<Vec<PathBuf>> {
let mut out = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
out.extend(collect_files(&path)?);
} else {
out.push(path);
}
}
Ok(out)
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
fn run_command(cmd: &mut Command) -> anyhow::Result<()> {
let output = cmd.output()?;
if output.status.success() {
return Ok(());
}
anyhow::bail!(
"command failed: {:?}\nstdout:\n{}\nstderr:\n{}",
cmd,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn parse_session_summary(output: &str) -> anyhow::Result<SessionSummarySnapshot> {
Ok(SessionSummarySnapshot {
sessions: line_value(output, "Sessions analyzed: ")?,
average_user_prompts: scorecard_value(output, "Avg prompts")?,
avg_time_to_first_accepted_change_minutes: scorecard_value(output, "Time to first change")?,
debug_loop_rate: scorecard_value(output, "Debug loops")?,
s6_rate: scorecard_value(output, "Error pastes")?,
s9_rate: scorecard_value(output, "Sessions to commit")?,
no_output_session_rate: scorecard_value(output, "No-output sessions")?,
})
}
fn parse_change_summary(output: &str) -> anyhow::Result<ChangeSummarySnapshot> {
Ok(ChangeSummarySnapshot {
commits: line_value(output, "Commits analyzed: ")?,
heavy_commits: first_token(&line_value(output, "Heavy commits: ")?)?.to_string(),
merge_rate: scorecard_value(output, "Mainline Reach")?,
})
}
fn parse_lifecycle_summary(output: &str) -> anyhow::Result<LifecycleSummarySnapshot> {
Ok(LifecycleSummarySnapshot {
heavy_commits: line_value(output, "Heavy commits analyzed: ")?,
code_churn_rate: scorecard_value(output, "Code churn")?,
bug_after_merge_rate: scorecard_value(output, "Bug-after-merge")?,
revert_rate: scorecard_value(output, "Reverts")?,
})
}
fn line_value(output: &str, prefix: &str) -> anyhow::Result<String> {
output
.lines()
.find_map(|line| {
line.strip_prefix(prefix)
.map(|value| value.trim().to_string())
})
.ok_or_else(|| anyhow::anyhow!("missing line for prefix {}", prefix))
}
fn scorecard_value(output: &str, label: &str) -> anyhow::Result<String> {
output
.lines()
.find_map(|line| {
let cells = line
.split('│')
.map(str::trim)
.filter(|cell| !cell.is_empty())
.collect::<Vec<_>>();
if cells.len() == 3 && cells[0] == label {
Some(cells[1].to_string())
} else {
None
}
})
.ok_or_else(|| anyhow::anyhow!("missing scorecard row for label {}", label))
}
fn first_token(value: &str) -> anyhow::Result<&str> {
value
.split_whitespace()
.next()
.ok_or_else(|| anyhow::anyhow!("missing token in value {}", value))
}