use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::{Arc, Mutex};
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::{
FreshnessCheck, check_branch_freshness, record_branch_scan_complete, scan_project,
};
use seshat_storage::{
BranchRepository, Database, DecisionRepository, DecisionState, FileIRRepository,
NodeRepository, SqliteBranchRepository, SqliteDecisionRepository, SqliteFileIRRepository,
SqliteNodeRepository,
};
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("main.rs"),
r#"
mod lib;
fn main() {
let result = lib::add(1, 2);
println!("{}", result);
}
"#,
)
.expect("write main.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 without git");
let conn = db.connection().clone();
let file_ir_repo = SqliteFileIRRepository::new(conn.clone());
let all_files = file_ir_repo
.get_by_branch(&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 branch_repo = SqliteBranchRepository::new(conn.clone());
record_branch_scan_complete(&branch_repo, repo, &branch_id);
let node_repo = SqliteNodeRepository::new(conn);
node_repo
.find_conventions_by_branch(&branch_id)
.expect("query conventions")
}
#[test]
fn detect_branch_falls_back_to_main_when_no_git() {
let workdir = tempdir().expect("tempdir");
assert!(
!workdir.path().join(".git").exists(),
"fixture must NOT contain a .git directory"
);
let branch = seshat_cli::db::detect_branch(workdir.path());
assert_eq!(
branch, "main",
"non-git dir must yield the synthetic 'main'"
);
}
#[test]
fn full_lifecycle_works_without_git() {
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
write_rust_sources(repo);
assert!(
!repo.join(".git").exists(),
"fixture MUST NOT be a git repo for the AC to be meaningful"
);
let branch_str = seshat_cli::db::detect_branch(repo);
assert_eq!(
branch_str, "main",
"non-git dir must yield the synthetic 'main' (AC#1)"
);
let branch = BranchId::from(branch_str.as_str());
let conventions1 = scan_and_persist(repo);
assert!(
!conventions1.is_empty(),
"scan must surface at least one auto-detected convention even without git"
);
let db_path = repo.join("seshat.db");
let db = Database::open(&db_path).expect("reopen DB");
let conn = db.connection().clone();
let branch_repo = SqliteBranchRepository::new(conn.clone());
let sentinel_after_first = branch_repo
.get_last_scanned_commit(&branch)
.expect("read sentinel");
assert_eq!(
sentinel_after_first, None,
"last_scanned_commit must stay NULL after scan when git is unavailable (AC#3)"
);
assert_eq!(
check_branch_freshness(&branch_repo, repo, &branch),
FreshnessCheck::GitUnavailable,
"freshness gate must report GitUnavailable for a non-git dir (AC#2)"
);
let first = &conventions1[0];
let first_description = first.description.clone();
let conn_arc: Arc<Mutex<rusqlite::Connection>> = conn.clone();
let actions = vec![ReviewAction::Confirm {
node_id: first.id.0,
description: first_description.clone(),
examples: Vec::new(),
}];
apply_review_actions(&conn_arc, "main", &actions)
.expect("apply_review_actions must succeed without git");
let decision_repo = SqliteDecisionRepository::new(conn_arc.clone());
let hash = compute_description_hash(&first_description);
let decision = decision_repo
.get_by_hash(&hash)
.expect("get_by_hash should succeed")
.expect("decision row must exist after Confirm");
assert_eq!(decision.state, DecisionState::Approved);
assert_eq!(
decision.decided_on_branch,
BranchId::from("main"),
"decision must be scoped to the synthetic 'main' branch (AC#4)"
);
let conventions2 = scan_and_persist(repo);
let decision_after = decision_repo
.get_by_hash(&hash)
.expect("get_by_hash should succeed post-rescan")
.expect("decision row must persist across rescans (AC#5)");
assert_eq!(decision_after.state, DecisionState::Approved);
assert_eq!(decision_after.decided_on_branch, BranchId::from("main"));
let post_rescan_descriptions: Vec<_> =
conventions2.iter().map(|c| c.description.clone()).collect();
assert!(
!post_rescan_descriptions.contains(&first_description),
"decided convention must not be re-emitted on rescan (AC#5); got {post_rescan_descriptions:?}"
);
let sentinel_after_rescan = branch_repo
.get_last_scanned_commit(&branch)
.expect("read sentinel post-rescan");
assert_eq!(
sentinel_after_rescan, None,
"last_scanned_commit must stay NULL across rescans without git (AC#3)"
);
}
#[test]
fn queries_scoped_to_main_return_results_in_non_git_dir() {
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
write_rust_sources(repo);
assert!(!repo.join(".git").exists());
let conventions = scan_and_persist(repo);
assert!(!conventions.is_empty());
let db_path = repo.join("seshat.db");
let db = Database::open(&db_path).expect("open DB");
let conn = db.connection().clone();
let branch_repo = SqliteBranchRepository::new(conn.clone());
let branches = branch_repo.list_branches().expect("list branches");
assert!(
branches.contains(&BranchId::from("main")),
"branches table must contain the synthetic 'main' branch; got {branches:?}"
);
let node_repo = SqliteNodeRepository::new(conn);
let by_main = node_repo
.find_conventions_by_branch(&BranchId::from("main"))
.expect("query nodes scoped to main");
assert!(
!by_main.is_empty(),
"find_conventions_by_branch('main') must return rows in a non-git dir"
);
}
#[test]
fn decisions_survive_non_git_to_git_transition() {
use seshat_storage::DecisionRepository;
use std::process::{Command, Stdio};
fn run_git(args: &[&str], cwd: &std::path::Path) {
let out = Command::new("git")
.args(args)
.current_dir(cwd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.unwrap_or_else(|e| panic!("git {args:?}: {e}"));
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
let workdir = tempdir().expect("tempdir");
let repo = workdir.path();
write_rust_sources(repo);
assert!(!repo.join(".git").exists(), "fixture starts non-git");
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 in non-git mode");
let dec_repo = SqliteDecisionRepository::new(conn.clone());
let row_before = dec_repo.get_by_hash(&target_hash).unwrap().unwrap();
assert_eq!(row_before.state, DecisionState::Approved);
run_git(&["init", "--initial-branch=main"], repo);
run_git(&["config", "user.email", "test@seshat.dev"], repo);
run_git(&["config", "user.name", "Seshat Test"], repo);
fs::write(repo.join(".gitignore"), "seshat.db\nseshat.db-*\n").unwrap();
run_git(&["add", "."], repo);
run_git(&["commit", "-m", "initial commit"], repo);
let conventions_after = scan_and_persist(repo);
let descs: Vec<&str> = conventions_after
.iter()
.map(|c| c.description.as_str())
.collect();
assert!(
!descs.contains(&target_description.as_str()),
"decision recorded in non-git mode must dedup after `git init`; \
got {descs:?}"
);
let row_after = dec_repo.get_by_hash(&target_hash).unwrap().unwrap();
assert_eq!(row_after.state, DecisionState::Approved);
assert_eq!(row_after.description, target_description);
}