use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::{Arc, Mutex};
use seshat_cli::CliError;
use seshat_cli::decisions::forget_decision_with_database;
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, 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")
}
#[test]
fn forget_decision_round_trip_re_emits_on_next_scan() {
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
write_rust_sources(repo);
let conventions1 = scan_and_persist(repo);
assert!(
!conventions1.is_empty(),
"scan must produce at least one auto-detected convention"
);
let target = &conventions1[0];
let target_description = target.description.clone();
let target_node_id = target.id.0;
let target_hash = compute_description_hash(&target_description);
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::Confirm {
node_id: target_node_id,
description: target_description.clone(),
examples: Vec::new(),
}];
apply_review_actions(&conn, "main", &actions).expect("apply Confirm");
let repo_handle = SqliteDecisionRepository::new(conn.clone());
let decision_before = repo_handle
.get_by_hash(&target_hash)
.expect("get_by_hash")
.expect("decision row must exist after Confirm");
assert_eq!(decision_before.state, DecisionState::Approved);
assert_eq!(decision_before.description, target_description);
let conventions2 = scan_and_persist(repo);
let descriptions2: Vec<_> = conventions2.iter().map(|c| c.description.clone()).collect();
assert!(
!descriptions2.contains(&target_description),
"approved convention must not be re-emitted before forget; got {descriptions2:?}"
);
let removed = forget_decision_with_database(&db, &target_hash).expect("forget");
assert_eq!(removed.description_hash, target_hash);
assert_eq!(removed.state, DecisionState::Approved);
assert_eq!(removed.description, target_description);
assert!(
repo_handle.get_by_hash(&target_hash).unwrap().is_none(),
"decisions row must be hard-deleted after forget"
);
let conventions3 = scan_and_persist(repo);
let descriptions3: Vec<_> = conventions3.iter().map(|c| c.description.clone()).collect();
assert!(
descriptions3.contains(&target_description),
"convention must re-enter the auto-detected queue after forget; \
got {descriptions3:?}"
);
}
#[test]
fn forget_decision_resolves_unambiguous_prefix() {
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_node_id = target.id.0;
let target_hash = compute_description_hash(&target_description);
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();
apply_review_actions(
&conn,
"main",
&[ReviewAction::Confirm {
node_id: target_node_id,
description: target_description.clone(),
examples: Vec::new(),
}],
)
.expect("apply Confirm");
let prefix: String = target_hash.chars().take(4).collect();
let removed = forget_decision_with_database(&db, &prefix).expect("forget by prefix");
assert_eq!(removed.description_hash, target_hash);
let repo_handle = SqliteDecisionRepository::new(conn);
assert!(repo_handle.get_by_hash(&target_hash).unwrap().is_none());
}
#[test]
fn forget_decision_rejects_too_short_prefix() {
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
write_rust_sources(repo);
let db_path = repo.join("seshat.db");
let db = Database::open(&db_path).expect("open DB");
for short in ["", "a", "ab", "abc"] {
let result = forget_decision_with_database(&db, short);
match result {
Err(CliError::InvalidArgument(msg)) => {
assert!(
msg.contains("too short") || msg.contains("at least"),
"error for '{short}' must mention the length floor; got: {msg}"
);
}
other => panic!(
"forget('{short}') must return InvalidArgument for too-short prefix, \
got: {other:?}"
),
}
}
}
#[test]
fn forget_decision_errors_when_hash_not_found() {
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
write_rust_sources(repo);
let db_path = repo.join("seshat.db");
let db = Database::open(&db_path).expect("open DB");
let result = forget_decision_with_database(&db, "deadbeef");
match result {
Err(CliError::CommandFailed { reason, .. }) => {
assert!(
reason.contains("no decision matches") || reason.contains("not found"),
"error must mention the absence of a match; got: {reason}"
);
assert!(
reason.contains("deadbeef"),
"error must echo the offending hash so the user can self-debug; \
got: {reason}"
);
}
other => panic!(
"forget on empty decisions table must return CommandFailed; \
got: {other:?}"
),
}
}
#[test]
fn forget_decision_errors_on_ambiguous_prefix() {
use seshat_storage::{Decision, DecisionNature, DecisionWeight, ExampleEvidence};
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
write_rust_sources(repo);
let db_path = repo.join("seshat.db");
let db = Database::open(&db_path).expect("open DB");
let conn = db.connection().clone();
let dec_repo = SqliteDecisionRepository::new(conn);
let now = chrono::Utc::now().timestamp();
let mk = |hash: &str, desc: &str| Decision {
description_hash: hash.to_owned(),
description: desc.to_owned(),
state: DecisionState::Recorded,
nature: DecisionNature::Decision,
weight: DecisionWeight::Strong,
category: None,
reason: None,
examples: Vec::<ExampleEvidence>::new(),
decided_on_branch: BranchId::from("main"),
decided_at: now,
updated_at: now,
};
dec_repo
.upsert(&mk("abcd1111aaaaaaaa", "first decision"))
.expect("seed first decision");
dec_repo
.upsert(&mk("abcd2222bbbbbbbb", "second decision"))
.expect("seed second decision");
let result = forget_decision_with_database(&db, "abcd");
match result {
Err(CliError::CommandFailed { reason, .. }) => {
assert!(
reason.contains("ambiguous"),
"error must call out the ambiguity by name; got: {reason}"
);
assert!(
reason.contains("abcd1111") && reason.contains("abcd2222"),
"error must list the matched hashes for self-disambiguation; \
got: {reason}"
);
}
other => panic!("ambiguous prefix must return CommandFailed; got: {other:?}"),
}
assert!(
dec_repo.get_by_hash("abcd1111aaaaaaaa").unwrap().is_some(),
"first row must survive a refused ambiguous forget"
);
assert!(
dec_repo.get_by_hash("abcd2222bbbbbbbb").unwrap().is_some(),
"second row must survive a refused ambiguous forget"
);
}
#[test]
fn forget_decision_works_for_rejected_partial_recorded_states() {
use seshat_storage::{
Decision, DecisionNature, DecisionRepository, DecisionState, DecisionWeight,
ExampleEvidence, SqliteDecisionRepository,
};
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
write_rust_sources(repo);
let db_path = repo.join("seshat.db");
let db = Database::open(&db_path).expect("open DB");
let conn = db.connection().clone();
let dec_repo = SqliteDecisionRepository::new(conn);
let now = chrono::Utc::now().timestamp();
for (hash, state) in [
("aaaa1111aaaaaaaa", DecisionState::Rejected),
("bbbb2222bbbbbbbb", DecisionState::Partial),
("cccc3333cccccccc", DecisionState::Recorded),
] {
dec_repo
.upsert(&Decision {
description_hash: hash.to_owned(),
description: format!("desc for {hash}"),
state,
nature: DecisionNature::Decision,
weight: DecisionWeight::Strong,
category: None,
reason: None,
examples: Vec::<ExampleEvidence>::new(),
decided_on_branch: BranchId::from("main"),
decided_at: now,
updated_at: now,
})
.expect("seed");
}
for hash in ["aaaa1111aaaaaaaa", "bbbb2222bbbbbbbb", "cccc3333cccccccc"] {
let removed = forget_decision_with_database(&db, hash)
.unwrap_or_else(|e| panic!("forget {hash}: {e}"));
assert_eq!(removed.description_hash, hash);
assert!(
dec_repo.get_by_hash(hash).unwrap().is_none(),
"row at {hash} must be gone after forget"
);
}
}