use assert_cmd::Command;
use predicates::prelude::*;
use quorum_core::memory::identity::finding_identity_hash;
use quorum_core::memory::{
DismissalReason, FindingIdentityHash, LocalSqliteMemoryStore, MemoryStore, PromotionState,
};
use quorum_core::review::{Finding, FindingSource, Severity};
use std::path::Path;
use tempfile::TempDir;
fn quorum() -> Command {
Command::cargo_bin("quorum").expect("quorum binary built")
}
fn init_repo() -> TempDir {
let td = TempDir::new().unwrap();
git2::Repository::init(td.path()).unwrap();
td
}
fn sample_finding(title: &str) -> Finding {
Finding {
severity: Severity::High,
title: title.to_string(),
body: format!("Body for {title}."),
source: FindingSource::Divergence,
supported_by: vec!["m".into()],
confidence: Some(0.9),
}
}
fn seed_dismissal(
store: &LocalSqliteMemoryStore,
title: &str,
state: PromotionState,
) -> FindingIdentityHash {
let f = sample_finding(title);
store
.dismiss(
&f,
"head-sha",
"main",
DismissalReason::FalsePositive,
None,
Some(time::Duration::days(365)),
)
.unwrap();
let h = finding_identity_hash(&f);
if state != PromotionState::Candidate {
let conn = rusqlite::Connection::open(store.path()).unwrap();
conn.execute(
"UPDATE dismissals SET promotion_state = ?1 WHERE finding_identity_hash = ?2",
rusqlite::params![state.as_db_str(), h.to_hex()],
)
.unwrap();
}
h
}
fn read_state(store: &LocalSqliteMemoryStore, h: &FindingIdentityHash) -> String {
let conn = rusqlite::Connection::open(store.path()).unwrap();
conn.query_row(
"SELECT promotion_state FROM dismissals WHERE finding_identity_hash = ?1",
[h.to_hex()],
|r| r.get::<_, String>(0),
)
.unwrap()
}
fn count_conventions(store: &LocalSqliteMemoryStore, h: &FindingIdentityHash) -> i64 {
let conn = rusqlite::Connection::open(store.path()).unwrap();
conn.query_row(
"SELECT COUNT(*) FROM conventions WHERE finding_identity_hash = ?1",
[h.to_hex()],
|r| r.get(0),
)
.unwrap()
}
fn count_transitions(store: &LocalSqliteMemoryStore, h: &FindingIdentityHash) -> i64 {
let conn = rusqlite::Connection::open(store.path()).unwrap();
conn.query_row(
"SELECT COUNT(*) FROM state_transitions WHERE finding_identity_hash = ?1",
[h.to_hex()],
|r| r.get(0),
)
.unwrap()
}
fn last_trigger(store: &LocalSqliteMemoryStore, h: &FindingIdentityHash) -> Option<String> {
let conn = rusqlite::Connection::open(store.path()).unwrap();
conn.query_row(
"SELECT trigger FROM state_transitions
WHERE finding_identity_hash = ?1
ORDER BY ts DESC, id DESC LIMIT 1",
[h.to_hex()],
|r| r.get::<_, String>(0),
)
.ok()
}
fn conv_md_path(td: &Path) -> std::path::PathBuf {
td.join(".quorum").join("conventions.md")
}
#[test]
fn promote_happy_path_writes_file_and_flips_sqlite() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_dismissal(&store, "stylistic ABC", PromotionState::LocalOnly);
drop(store);
let hex = h.to_hex();
let short = &hex[..12];
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"promote",
short,
"--text",
"Function bodies under 8 lines do not require docstrings.",
])
.assert()
.success()
.stdout(predicate::str::contains(format!("promoted {short}")));
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
assert_eq!(read_state(&store, &h), "promoted_convention");
assert_eq!(count_conventions(&store, &h), 1);
assert_eq!(
last_trigger(&store, &h).as_deref(),
Some("explicit_promote")
);
let content = std::fs::read_to_string(conv_md_path(td.path())).unwrap();
assert!(content.contains("<!-- quorum:managed-section v=1 -->"));
assert!(content.contains(&format!("<!-- quorum:convention id={short}")));
assert!(content.contains("### Convention: stylistic ABC"));
assert!(content.contains("Function bodies under 8 lines do not require docstrings."));
}
#[test]
fn promote_title_only_default_no_body() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_dismissal(&store, "title-only", PromotionState::LocalOnly);
drop(store);
let short = &h.to_hex()[..12];
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"promote",
short,
])
.assert()
.success();
let content = std::fs::read_to_string(conv_md_path(td.path())).unwrap();
assert!(content.contains("### Convention: title-only"));
assert!(
content.contains("### Convention: title-only\n<!-- /quorum:convention -->")
|| content.contains("### Convention: title-only\r\n<!-- /quorum:convention -->")
);
assert!(
!content.contains("Body for title-only"),
"dismissal body_snapshot must not leak into the managed block"
);
}
#[test]
fn promote_from_editor_uses_test_env_seam() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_dismissal(&store, "editor-fed", PromotionState::LocalOnly);
drop(store);
let short = &h.to_hex()[..12];
quorum()
.env("QUORUM_TEST_EDITOR_BODY", "Body authored in the editor.")
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"promote",
short,
"--from-editor",
])
.assert()
.success();
let content = std::fs::read_to_string(conv_md_path(td.path())).unwrap();
assert!(content.contains("Body authored in the editor."));
}
#[test]
fn promote_rejects_candidate_state() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_dismissal(&store, "still candidate", PromotionState::Candidate);
drop(store);
let short = &h.to_hex()[..12];
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"promote",
short,
"--text",
"x",
])
.assert()
.failure()
.stderr(predicate::str::contains("still a candidate"));
}
#[test]
fn promote_rejects_already_promoted() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_dismissal(&store, "already up", PromotionState::PromotedConvention);
drop(store);
let short = &h.to_hex()[..12];
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"promote",
short,
"--text",
"x",
])
.assert()
.failure()
.stderr(predicate::str::contains("already a promoted_convention"));
}
#[test]
fn promote_rejects_empty_text() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_dismissal(&store, "empty-text", PromotionState::LocalOnly);
drop(store);
let short = &h.to_hex()[..12];
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"promote",
short,
"--text",
"",
])
.assert()
.failure()
.stderr(predicate::str::contains("--text"));
}
#[test]
fn promote_preflight_emits_parser_diagnostic_warning() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_dismissal(&store, "preflight target", PromotionState::LocalOnly);
drop(store);
let short = &h.to_hex()[..12];
let conv_dir = td.path().join(".quorum");
std::fs::create_dir_all(&conv_dir).unwrap();
let bad = "<!-- quorum:managed-section v=1 -->\n<!-- quorum:convention id=deadbeef0001 v=1 -->\nno close here\n<!-- /quorum:managed-section -->\n";
std::fs::write(conv_dir.join("conventions.md"), bad).unwrap();
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"promote",
short,
"--text",
"ok",
])
.assert()
.success()
.stderr(predicate::str::contains("warning:"));
}
#[test]
fn demote_happy_path_removes_block_and_flips_state() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_dismissal(&store, "to be demoted", PromotionState::LocalOnly);
drop(store);
let short = &h.to_hex()[..12];
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"promote",
short,
"--text",
"body",
])
.assert()
.success();
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"demote",
short,
])
.assert()
.success()
.stdout(predicate::str::contains(format!("demoted {short}")));
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
assert_eq!(read_state(&store, &h), "local_only");
assert_eq!(count_conventions(&store, &h), 0);
assert_eq!(last_trigger(&store, &h).as_deref(), Some("explicit_demote"));
let content = std::fs::read_to_string(conv_md_path(td.path())).unwrap();
assert!(
!content.contains(&format!("id={short}")),
"demoted block must be removed from conventions.md"
);
}
#[test]
fn demote_missing_conventions_md_q7_lean() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_dismissal(&store, "missing-file", PromotionState::PromotedConvention);
{
let conn = rusqlite::Connection::open(store.path()).unwrap();
conn.execute(
"INSERT INTO conventions (finding_identity_hash, convention_text, promoted_at, conventions_md_block_id)
VALUES (?1, 'old body', 1000, ?2)",
rusqlite::params![h.to_hex(), &h.to_hex()[..12]],
)
.unwrap();
}
drop(store);
let short = &h.to_hex()[..12];
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"demote",
short,
])
.assert()
.success()
.stderr(predicate::str::contains(".quorum/conventions.md not found"));
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
assert_eq!(read_state(&store, &h), "local_only");
assert_eq!(count_conventions(&store, &h), 0);
}
#[test]
fn demote_rejects_non_promoted_state() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_dismissal(&store, "wrong state", PromotionState::LocalOnly);
drop(store);
let short = &h.to_hex()[..12];
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"demote",
short,
])
.assert()
.failure()
.stderr(predicate::str::contains("not in promoted_convention"));
}
fn seed_stale_candidate(
store: &LocalSqliteMemoryStore,
title: &str,
days_ago: i64,
) -> FindingIdentityHash {
let h = seed_dismissal(store, title, PromotionState::Candidate);
let stale = time::OffsetDateTime::now_utc() - time::Duration::days(days_ago);
let stale_s = stale
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
let conn = rusqlite::Connection::open(store.path()).unwrap();
conn.execute(
"UPDATE dismissals SET last_seen_at = ?1 WHERE finding_identity_hash = ?2",
rusqlite::params![stale_s, h.to_hex()],
)
.unwrap();
h
}
#[test]
fn prune_dry_run_reports_without_deleting() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_stale_candidate(&store, "stale", 120);
let _fresh = seed_dismissal(&store, "fresh", PromotionState::Candidate);
let _kept = seed_dismissal(&store, "kept", PromotionState::PromotedConvention);
drop(store);
let out = quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"prune",
"--dry-run",
])
.assert()
.success();
let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap();
assert!(stdout.contains("would prune"));
assert!(stdout.contains(&h.to_hex()[..12]));
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
assert_eq!(read_state(&store, &h), "candidate");
}
#[test]
fn prune_yes_deletes_stale_candidates_only() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let stale = seed_stale_candidate(&store, "stale", 120);
let fresh = seed_dismissal(&store, "fresh", PromotionState::Candidate);
let promoted = seed_dismissal(&store, "promoted", PromotionState::PromotedConvention);
{
let conn = rusqlite::Connection::open(store.path()).unwrap();
conn.execute(
"INSERT INTO state_transitions
(finding_identity_hash, from_state, to_state, trigger, ts, by_review_session_id, recurrence_at_transition)
VALUES (?1, 'candidate', 'candidate', 'auto_recurrence', 1, NULL, 1)",
rusqlite::params![stale.to_hex()],
)
.unwrap_or(0); }
drop(store);
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"prune",
"--yes",
])
.assert()
.success()
.stdout(predicate::str::contains("pruned 1 candidate"));
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let conn = rusqlite::Connection::open(store.path()).unwrap();
let stale_present: i64 = conn
.query_row(
"SELECT COUNT(*) FROM dismissals WHERE finding_identity_hash = ?1",
[stale.to_hex()],
|r| r.get(0),
)
.unwrap();
assert_eq!(stale_present, 0, "stale candidate was deleted");
assert_eq!(read_state(&store, &fresh), "candidate");
assert_eq!(read_state(&store, &promoted), "promoted_convention");
let total_transitions: i64 = conn
.query_row(
"SELECT COUNT(*) FROM state_transitions WHERE trigger = 'auto_recurrence'",
[],
|r| r.get(0),
)
.unwrap();
let _ = total_transitions; let bad_trigger: i64 = conn
.query_row(
"SELECT COUNT(*) FROM state_transitions
WHERE trigger NOT IN ('auto_recurrence','explicit_promote','explicit_demote')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
bad_trigger, 0,
"no `prune`/`deleted`/etc. trigger value exists"
);
}
#[test]
fn prune_disabled_when_candidate_expire_days_is_zero() {
let td = init_repo();
let conf_path = td.path().join(".quorum").join("config.toml");
std::fs::create_dir_all(conf_path.parent().unwrap()).unwrap();
std::fs::write(
&conf_path,
"project_id = \"p1\"\nbase_url = \"https://app.lippa.ai\"\nremote_url = true\n[memory]\ncandidate_threshold = 3\nlocal_convention_bundle_cap = 500\ncandidate_expire_days = 0\n",
)
.unwrap();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_stale_candidate(&store, "stale", 9999);
drop(store);
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"prune",
"--yes",
])
.assert()
.success()
.stderr(predicate::str::contains("prune disabled"));
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
assert_eq!(
read_state(&store, &h),
"candidate",
"row preserved when expire-days = 0"
);
}
#[test]
fn delete_cascades_state_transitions_audit_silent() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_dismissal(&store, "to delete", PromotionState::LocalOnly);
let short = &h.to_hex()[..12];
drop(store);
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"promote",
short,
"--text",
"body",
])
.assert()
.success();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
assert_eq!(count_transitions(&store, &h), 1);
let id = {
let conn = rusqlite::Connection::open(store.path()).unwrap();
conn.query_row(
"SELECT id FROM dismissals WHERE finding_identity_hash = ?1",
[h.to_hex()],
|r| r.get::<_, i64>(0),
)
.unwrap()
};
let removed = store.delete(quorum_core::memory::DismissalId(id)).unwrap();
assert!(removed);
assert_eq!(count_transitions(&store, &h), 0, "cascade dropped audit");
let conn = rusqlite::Connection::open(store.path()).unwrap();
let new_undismiss: i64 = conn
.query_row(
"SELECT COUNT(*) FROM state_transitions
WHERE trigger NOT IN ('auto_recurrence','explicit_promote','explicit_demote')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
new_undismiss, 0,
"audit-silent: no `explicit_undismiss` row"
);
let conv_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM conventions WHERE finding_identity_hash = ?1",
[h.to_hex()],
|r| r.get(0),
)
.unwrap();
assert_eq!(conv_count, 0, "conventions row cascade-dropped");
}
#[test]
fn above_fence_content_preserved_byte_for_byte_through_promote() {
let td = init_repo();
let conv_dir = td.path().join(".quorum");
std::fs::create_dir_all(&conv_dir).unwrap();
let original = b"# Project conventions\n\nHand-written prose.\n";
std::fs::write(conv_dir.join("conventions.md"), original).unwrap();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_dismissal(&store, "preserve me", PromotionState::LocalOnly);
drop(store);
let short = &h.to_hex()[..12];
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"promote",
short,
"--text",
"body",
])
.assert()
.success();
let content = std::fs::read(conv_md_path(td.path())).unwrap();
assert!(
content.starts_with(original),
"above-fence content preserved byte-for-byte (AC 149)"
);
}
#[test]
fn crlf_line_endings_preserved_through_promote() {
let td = init_repo();
let conv_dir = td.path().join(".quorum");
std::fs::create_dir_all(&conv_dir).unwrap();
let original = b"# CRLF header\r\n\r\nUser content.\r\n";
std::fs::write(conv_dir.join("conventions.md"), original).unwrap();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_dismissal(&store, "crlf-promote", PromotionState::LocalOnly);
drop(store);
let short = &h.to_hex()[..12];
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"promote",
short,
"--text",
"body",
])
.assert()
.success();
let content = std::fs::read(conv_md_path(td.path())).unwrap();
assert!(
content.windows(2).any(|w| w == b"\r\n"),
"CRLF preserved (AC 150)"
);
let managed_start = b"<!-- quorum:managed-section v=1 -->\r\n";
assert!(
content
.windows(managed_start.len())
.any(|w| w == managed_start),
"managed-section open marker uses CRLF"
);
}
#[test]
fn crash_harness_panics_after_rename_then_recovers_via_repromote() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_dismissal(&store, "crash-target", PromotionState::LocalOnly);
drop(store);
let short = &h.to_hex()[..12];
let assert = quorum()
.env(quorum_core::conventions::stage4_test_seam::CRASH_ENV, "1")
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"promote",
short,
"--text",
"body v1",
])
.assert()
.failure();
let _ = assert;
let content = std::fs::read_to_string(conv_md_path(td.path())).unwrap();
assert!(
content.contains(&format!("id={short}")),
"file holds new block after crash"
);
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
assert_eq!(
read_state(&store, &h),
"local_only",
"SQLite did NOT advance"
);
assert_eq!(count_transitions(&store, &h), 0);
assert_eq!(count_conventions(&store, &h), 0);
drop(store);
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"promote",
short,
"--text",
"body v1",
])
.assert()
.success();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
assert_eq!(read_state(&store, &h), "promoted_convention");
assert_eq!(count_transitions(&store, &h), 1, "single audit row");
assert_eq!(count_conventions(&store, &h), 1);
let content = std::fs::read_to_string(conv_md_path(td.path())).unwrap();
let occurrences = content.matches(&format!("id={short}")).count();
assert_eq!(occurrences, 1, "no duplicate block after re-promote");
}
#[test]
fn round_trip_promote_demote_through_bundle_render_paths() {
let td = init_repo();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let h = seed_dismissal(&store, "round-trip", PromotionState::LocalOnly);
drop(store);
let short = &h.to_hex()[..12];
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let pre = store.load_local_only_conventions().unwrap();
assert!(
pre.iter().any(|d| d.finding_identity_hash == h),
"pre-promote: row is local_only, lives in memory section"
);
drop(store);
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"promote",
short,
"--text",
"body",
])
.assert()
.success();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let mid = store.load_local_only_conventions().unwrap();
assert!(
mid.iter().any(|d| d.finding_identity_hash == h),
"post-promote (uncommitted): row still surfaced for memory-section bridge"
);
assert_eq!(read_state(&store, &h), "promoted_convention");
drop(store);
quorum()
.args([
"convention",
"--quorum-dir",
td.path().to_str().unwrap(),
"demote",
short,
])
.assert()
.success();
let store = LocalSqliteMemoryStore::new(td.path()).unwrap();
let post = store.load_local_only_conventions().unwrap();
assert!(
post.iter().any(|d| d.finding_identity_hash == h),
"post-demote: row back as local_only"
);
assert_eq!(read_state(&store, &h), "local_only");
}