use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::{Arc, Mutex};
use seshat_cli::decisions::{export_decisions_to_string, import_decisions_from_str};
use seshat_cli::tui::app::{ReviewAction, apply_review_actions};
use seshat_core::{BranchId, DetectionConfig, KnowledgeNode, ScanConfig};
use seshat_detectors::{ProjectContext, aggregate_findings, run_all_detectors};
use seshat_graph::compute_description_hash;
use seshat_scanner::scan_project;
use seshat_storage::{
Database, Decision, DecisionRepository, DecisionState, SqliteDecisionRepository,
};
use tempfile::tempdir;
fn write_rust_sources(root: &Path) {
let src = root.join("src");
fs::create_dir_all(&src).expect("create src dir");
fs::write(
src.join("lib.rs"),
r#"
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn subtract(a: i32, b: i32) -> i32 {
a - b
}
pub fn multiply(a: i32, b: i32) -> i32 {
a * b
}
"#,
)
.expect("write lib.rs");
fs::write(
src.join("errors.rs"),
r#"
use std::fmt;
#[derive(Debug)]
pub enum AppError {
NotFound(String),
InvalidInput(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::NotFound(msg) => write!(f, "Not found: {msg}"),
AppError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
}
}
}
impl std::error::Error for AppError {}
"#,
)
.expect("write errors.rs");
}
fn scan_and_persist(repo: &Path) -> Vec<KnowledgeNode> {
let db_path = repo.join("seshat.db");
let db = Database::open(&db_path).expect("open DB");
let branch_id = BranchId::from("main");
let scan_result = scan_project(repo, &ScanConfig::default(), &db, branch_id.clone())
.expect("scan must succeed");
let conn = db.connection().clone();
let file_ir_repo = seshat_storage::SqliteFileIRRepository::new(conn.clone());
let all_files = seshat_storage::FileIRRepository::get_by_branch(&file_ir_repo, &branch_id)
.expect("load files for detection");
let detection_config = DetectionConfig::default();
let project_context = ProjectContext::from_files(&all_files);
let detector_results = run_all_detectors(
&all_files,
&scan_result.source_map,
&detection_config,
&project_context,
None,
);
let all_findings: Vec<seshat_core::ConventionFinding> = detector_results
.into_iter()
.flat_map(|dr| dr.findings)
.collect();
let aggregated = aggregate_findings(
&all_findings,
&detection_config,
&HashMap::new(),
chrono::Utc::now().timestamp(),
);
seshat_graph::persist_and_index(&conn, &branch_id, &aggregated, &all_findings)
.expect("persist conventions");
let node_repo = seshat_storage::SqliteNodeRepository::new(conn);
seshat_storage::NodeRepository::find_conventions_by_branch(&node_repo, &branch_id)
.expect("query conventions")
}
fn sort_by_hash(mut rows: Vec<Decision>) -> Vec<Decision> {
rows.sort_by(|a, b| a.description_hash.cmp(&b.description_hash));
rows
}
#[test]
fn export_then_wipe_then_import_yields_identical_table() {
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
write_rust_sources(repo);
let conventions = scan_and_persist(repo);
assert!(
conventions.len() >= 2,
"scan must produce at least two conventions for a meaningful round-trip; got {}",
conventions.len()
);
let db_path = repo.join("seshat.db");
let db = Database::open(&db_path).expect("reopen DB");
let conn: Arc<Mutex<rusqlite::Connection>> = db.connection().clone();
let actions: Vec<ReviewAction> = conventions
.iter()
.take(2)
.map(|c| ReviewAction::Confirm {
node_id: c.id.0,
description: c.description.clone(),
examples: Vec::new(),
})
.collect();
apply_review_actions(&conn, "main", &actions).expect("apply Confirm");
let dec_repo = SqliteDecisionRepository::new(conn.clone());
let before = sort_by_hash(dec_repo.list().unwrap());
assert_eq!(
before.len(),
2,
"two Confirm actions should yield two decision rows"
);
for d in &before {
assert_eq!(d.state, DecisionState::Approved);
}
let export_path = workdir.path().join("decisions.json");
let json = export_decisions_to_string(&db).expect("export");
fs::write(&export_path, json.as_bytes()).expect("write export file");
let file_contents = fs::read_to_string(&export_path).expect("read export back");
assert_eq!(
file_contents, json,
"file write must be byte-for-byte the export string"
);
assert!(file_contents.starts_with('['));
assert!(file_contents.trim_end().ends_with(']'));
for d in &before {
dec_repo.delete(&d.description_hash).unwrap();
}
assert!(dec_repo.list().unwrap().is_empty());
let conventions_after_wipe = scan_and_persist(repo);
let descs: Vec<&str> = conventions_after_wipe
.iter()
.map(|c| c.description.as_str())
.collect();
for d in &before {
assert!(
descs.contains(&d.description.as_str()),
"wiped convention must re-emit; got {descs:?}"
);
}
let import_json = fs::read_to_string(&export_path).expect("read export");
let summary = import_decisions_from_str(&db, &import_json, false).expect("import");
assert_eq!(summary.total, before.len());
assert_eq!(summary.inserted, before.len());
assert_eq!(summary.updated, 0);
assert_eq!(summary.skipped, 0);
let after = sort_by_hash(dec_repo.list().unwrap());
assert_eq!(after.len(), before.len());
for (b, a) in before.iter().zip(after.iter()) {
assert_eq!(b, a, "round-trip mismatch on hash {}", b.description_hash);
}
let conventions_post_import = scan_and_persist(repo);
let descs_post: Vec<&str> = conventions_post_import
.iter()
.map(|c| c.description.as_str())
.collect();
for d in &before {
assert!(
!descs_post.contains(&d.description.as_str()),
"imported approved decision must dedup; got {descs_post:?}"
);
}
}
#[test]
fn import_strict_aborts_when_target_already_has_hashes() {
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
write_rust_sources(repo);
let conventions = scan_and_persist(repo);
assert!(!conventions.is_empty());
let db = Database::open(repo.join("seshat.db")).expect("reopen DB");
let conn = db.connection().clone();
let target = &conventions[0];
apply_review_actions(
&conn,
"main",
&[ReviewAction::Confirm {
node_id: target.id.0,
description: target.description.clone(),
examples: Vec::new(),
}],
)
.expect("Confirm");
let dec_repo = SqliteDecisionRepository::new(conn.clone());
let before = dec_repo.list().unwrap();
assert_eq!(before.len(), 1);
let target_hash = compute_description_hash(&target.description);
let json = export_decisions_to_string(&db).expect("export");
let err =
import_decisions_from_str(&db, &json, true).expect_err("strict must reject same-hash");
let msg = err.to_string();
assert!(msg.contains("strict mode"), "got: {msg}");
assert!(
msg.contains(&target_hash),
"must list conflicting hash: {msg}"
);
let after = dec_repo.get_by_hash(&target_hash).unwrap().unwrap();
assert_eq!(after.state, DecisionState::Approved);
assert_eq!(after.description, target.description);
}
#[test]
fn import_non_strict_replaces_existing_when_incoming_is_newer() {
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
write_rust_sources(repo);
let conventions = scan_and_persist(repo);
assert!(!conventions.is_empty());
let target = &conventions[0];
let target_description = target.description.clone();
let target_hash = compute_description_hash(&target_description);
let db = Database::open(repo.join("seshat.db")).expect("reopen DB");
let conn = db.connection().clone();
apply_review_actions(
&conn,
"main",
&[ReviewAction::Confirm {
node_id: target.id.0,
description: target_description.clone(),
examples: Vec::new(),
}],
)
.expect("Confirm");
let dec_repo = SqliteDecisionRepository::new(conn.clone());
let existing = dec_repo
.get_by_hash(&target_hash)
.unwrap()
.expect("row exists after Confirm");
let baseline_decided_at = existing.decided_at;
let newer_decided_at = baseline_decided_at + 1_000;
let import_json = serde_json::json!([{
"description_hash": target_hash,
"description": target_description,
"state": "approved",
"nature": "convention",
"weight": "strong",
"category": "imported-newer",
"reason": null,
"examples": [],
"decided_on_branch": "imported-branch",
"decided_at": newer_decided_at,
"updated_at": newer_decided_at,
}])
.to_string();
let summary = import_decisions_from_str(&db, &import_json, false).expect("non-strict import");
assert_eq!(summary.total, 1);
assert_eq!(summary.inserted, 0);
assert_eq!(
summary.updated, 1,
"newer incoming row must update existing (got {summary:?})"
);
assert_eq!(summary.skipped, 0);
let after = dec_repo
.get_by_hash(&target_hash)
.unwrap()
.expect("row remains");
assert_eq!(after.decided_at, newer_decided_at);
assert_eq!(after.category.as_deref(), Some("imported-newer"));
assert_eq!(after.decided_on_branch, BranchId::from("imported-branch"));
}
#[test]
fn import_non_strict_skips_when_incoming_is_older() {
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
write_rust_sources(repo);
let conventions = scan_and_persist(repo);
assert!(!conventions.is_empty());
let target = &conventions[0];
let target_description = target.description.clone();
let target_hash = compute_description_hash(&target_description);
let db = Database::open(repo.join("seshat.db")).expect("reopen DB");
let conn = db.connection().clone();
apply_review_actions(
&conn,
"main",
&[ReviewAction::Confirm {
node_id: target.id.0,
description: target_description.clone(),
examples: Vec::new(),
}],
)
.expect("Confirm");
let dec_repo = SqliteDecisionRepository::new(conn.clone());
let existing = dec_repo
.get_by_hash(&target_hash)
.unwrap()
.expect("row exists after Confirm");
let baseline_decided_at = existing.decided_at;
let baseline_category = existing.category.clone();
let older_decided_at = baseline_decided_at - 1_000;
let import_json = serde_json::json!([{
"description_hash": target_hash,
"description": target_description,
"state": "rejected",
"nature": "convention",
"weight": "rule",
"category": "imported-older-should-not-win",
"reason": "stale snapshot — must not overwrite",
"examples": [],
"decided_on_branch": "imported-branch",
"decided_at": older_decided_at,
"updated_at": older_decided_at,
}])
.to_string();
let summary = import_decisions_from_str(&db, &import_json, false).expect("non-strict import");
assert_eq!(summary.total, 1);
assert_eq!(summary.inserted, 0);
assert_eq!(summary.updated, 0);
assert_eq!(
summary.skipped, 1,
"older incoming row must be skipped (got {summary:?})"
);
let after = dec_repo
.get_by_hash(&target_hash)
.unwrap()
.expect("row remains");
assert_eq!(
after.state,
DecisionState::Approved,
"stale import must not flip state from Approved to Rejected"
);
assert_eq!(after.decided_at, baseline_decided_at);
assert_eq!(after.category, baseline_category);
assert_eq!(after.decided_on_branch, BranchId::from("main"));
}
#[test]
fn import_non_strict_mixed_payload_counts_each_class_exactly() {
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
write_rust_sources(repo);
let conventions = scan_and_persist(repo);
assert!(conventions.len() >= 2, "need ≥2 conventions for this test");
let db = Database::open(repo.join("seshat.db")).expect("reopen DB");
let conn = db.connection().clone();
let conv_a = &conventions[0];
let conv_b = &conventions[1];
let hash_a = compute_description_hash(&conv_a.description);
let hash_b = compute_description_hash(&conv_b.description);
apply_review_actions(
&conn,
"main",
&[
ReviewAction::Confirm {
node_id: conv_a.id.0,
description: conv_a.description.clone(),
examples: Vec::new(),
},
ReviewAction::Confirm {
node_id: conv_b.id.0,
description: conv_b.description.clone(),
examples: Vec::new(),
},
],
)
.expect("Confirm both");
let dec_repo = SqliteDecisionRepository::new(conn.clone());
let row_a = dec_repo.get_by_hash(&hash_a).unwrap().unwrap();
let row_b = dec_repo.get_by_hash(&hash_b).unwrap().unwrap();
let fresh_hash = "deadbeefcafebabe";
let import_json = serde_json::json!([
{
"description_hash": hash_a,
"description": conv_a.description,
"state": "approved",
"nature": "convention",
"weight": "strong",
"category": "newer-wins",
"reason": null,
"examples": [],
"decided_on_branch": "main",
"decided_at": row_a.decided_at + 5_000,
"updated_at": row_a.decided_at + 5_000,
},
{
"description_hash": hash_b,
"description": conv_b.description,
"state": "approved",
"nature": "convention",
"weight": "strong",
"category": "older-loses",
"reason": null,
"examples": [],
"decided_on_branch": "main",
"decided_at": row_b.decided_at - 5_000,
"updated_at": row_b.decided_at - 5_000,
},
{
"description_hash": fresh_hash,
"description": "fresh decision from import",
"state": "recorded",
"nature": "decision",
"weight": "strong",
"category": null,
"reason": null,
"examples": [],
"decided_on_branch": "main",
"decided_at": 1_700_000_000,
"updated_at": 1_700_000_000,
}
])
.to_string();
let summary = import_decisions_from_str(&db, &import_json, false).expect("non-strict import");
assert_eq!(summary.total, 3);
assert_eq!(summary.inserted, 1, "fresh hash must insert");
assert_eq!(summary.updated, 1, "newer hash_a must update");
assert_eq!(summary.skipped, 1, "older hash_b must be skipped");
let after_a = dec_repo.get_by_hash(&hash_a).unwrap().unwrap();
assert_eq!(after_a.category.as_deref(), Some("newer-wins"));
let after_b = dec_repo.get_by_hash(&hash_b).unwrap().unwrap();
assert_eq!(after_b.category, row_b.category);
let fresh = dec_repo
.get_by_hash(fresh_hash)
.unwrap()
.expect("fresh row inserted");
assert_eq!(fresh.description, "fresh decision from import");
assert_eq!(fresh.state, DecisionState::Recorded);
}
#[test]
fn import_empty_array_is_zero_row_no_op() {
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
let db = Database::open(repo.join("seshat.db")).expect("open DB");
let summary = import_decisions_from_str(&db, "[]", false).expect("non-strict empty import");
assert_eq!(summary.total, 0);
assert_eq!(summary.inserted, 0);
assert_eq!(summary.updated, 0);
assert_eq!(summary.skipped, 0);
}
#[test]
fn import_malformed_json_returns_typed_error() {
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
let db = Database::open(repo.join("seshat.db")).expect("open DB");
let result = import_decisions_from_str(&db, "{ this is not json", false);
assert!(result.is_err(), "malformed JSON must error");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("parse"),
"error must mention parse failure; got: {msg}"
);
}
#[test]
fn import_strict_aborts_atomically_on_multi_row_conflict() {
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
write_rust_sources(repo);
let conventions = scan_and_persist(repo);
assert!(
conventions.len() >= 2,
"fixture must produce at least two conventions for this test"
);
let db = Database::open(repo.join("seshat.db")).expect("reopen DB");
let conn = db.connection().clone();
let dec_repo = SqliteDecisionRepository::new(conn.clone());
let actions: Vec<ReviewAction> = conventions
.iter()
.take(2)
.map(|c| ReviewAction::Confirm {
node_id: c.id.0,
description: c.description.clone(),
examples: Vec::new(),
})
.collect();
apply_review_actions(&conn, "main", &actions).expect("seed two Confirms");
let baseline = dec_repo.list().unwrap();
assert_eq!(baseline.len(), 2);
let hash_a = compute_description_hash(&conventions[0].description);
let hash_b = compute_description_hash(&conventions[1].description);
let import_json = serde_json::json!([
{
"description_hash": hash_a,
"description": conventions[0].description,
"state": "approved",
"nature": "convention",
"weight": "strong",
"category": null,
"reason": null,
"examples": [],
"decided_on_branch": "imported",
"decided_at": 1_700_000_000_i64,
"updated_at": 1_700_000_000_i64,
},
{
"description_hash": hash_b,
"description": conventions[1].description,
"state": "approved",
"nature": "convention",
"weight": "strong",
"category": null,
"reason": null,
"examples": [],
"decided_on_branch": "imported",
"decided_at": 1_700_000_000_i64,
"updated_at": 1_700_000_000_i64,
},
{
"description_hash": "deadbeefcafebabe",
"description": "would be inserted if strict allowed any writes",
"state": "recorded",
"nature": "decision",
"weight": "strong",
"category": null,
"reason": null,
"examples": [],
"decided_on_branch": "imported",
"decided_at": 1_700_000_000_i64,
"updated_at": 1_700_000_000_i64,
}
])
.to_string();
let err = import_decisions_from_str(&db, &import_json, true)
.expect_err("strict must abort on multi-row conflict");
let msg = err.to_string();
assert!(msg.contains("strict mode"));
assert!(
msg.contains(&hash_a) && msg.contains(&hash_b),
"error must list BOTH conflicting hashes; got: {msg}"
);
assert!(
dec_repo.get_by_hash("deadbeefcafebabe").unwrap().is_none(),
"strict abort must NOT have written the conflict-free third row"
);
let after = dec_repo.list().unwrap();
assert_eq!(after.len(), 2);
for row in &after {
assert_eq!(row.state, DecisionState::Approved);
assert_ne!(
row.decided_on_branch,
BranchId::from("imported"),
"strict-aborted import must not have overwritten decided_on_branch"
);
}
}