use std::fmt::Write as _;
use std::io::Write;
use std::path::Path;
use serde::{Deserialize, Serialize};
use seshat_core::BranchId;
use seshat_storage::{
Database, Decision, DecisionNature, DecisionRepository, DecisionState, DecisionWeight,
ExampleEvidence, SqliteDecisionRepository,
};
use crate::args::{DecisionStateFilter, DecisionsCommand, DecisionsListFormat};
use crate::db;
use crate::error::CliError;
const TABLE_DESCRIPTION_MAX: usize = 60;
const TABLE_HASH_LEN: usize = 8;
const MIN_FORGET_PREFIX_LEN: usize = 4;
pub fn run_decisions(command: DecisionsCommand) -> Result<(), CliError> {
match command {
DecisionsCommand::List {
state,
branch,
format,
} => run_list(state, branch.as_deref(), format),
DecisionsCommand::Forget { hash, yes } => run_forget(&hash, yes),
DecisionsCommand::Export { file } => run_export(&file),
DecisionsCommand::Import { file, strict } => run_import(&file, strict),
}
}
fn run_list(
state_filter: Option<DecisionStateFilter>,
branch_filter: Option<&str>,
format: DecisionsListFormat,
) -> Result<(), CliError> {
let resolved = db::resolve_project(None, "decisions")?;
if !resolved.db_path.exists() {
return Err(CliError::CommandFailed {
command: "decisions".to_owned(),
reason: "No database found. Run `seshat scan` first.".to_owned(),
});
}
let database = Database::open(&resolved.db_path).map_err(|e| CliError::CommandFailed {
command: "decisions".to_owned(),
reason: format!("failed to open database: {e}"),
})?;
let decisions = load_decisions(&database, state_filter, branch_filter)?;
let rendered = match format {
DecisionsListFormat::Json => format_decisions_json(&decisions)?,
DecisionsListFormat::Table => format_decisions_table(&decisions),
};
let stdout = std::io::stdout();
let mut out = stdout.lock();
write_tolerating_broken_pipe(&mut out, rendered.as_bytes())?;
Ok(())
}
fn write_tolerating_broken_pipe<W: Write>(out: &mut W, bytes: &[u8]) -> Result<(), CliError> {
match out.write_all(bytes) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
Err(e) => Err(CliError::Io(e)),
}
}
fn load_decisions(
database: &Database,
state_filter: Option<DecisionStateFilter>,
branch_filter: Option<&str>,
) -> Result<Vec<Decision>, CliError> {
let repo = SqliteDecisionRepository::new(database.connection().clone());
let mut decisions = match state_filter {
Some(state) => repo.list_by_state(DecisionState::from(state)),
None => repo.list(),
}
.map_err(|e| CliError::CommandFailed {
command: "decisions".to_owned(),
reason: format!("failed to read decisions: {e}"),
})?;
if let Some(branch) = branch_filter {
decisions.retain(|d| d.decided_on_branch.0 == branch);
}
Ok(decisions)
}
#[derive(Debug, Serialize)]
struct DecisionJson<'a> {
description_hash: &'a str,
description: &'a str,
state: &'a str,
nature: &'a str,
weight: &'a str,
category: Option<&'a str>,
reason: Option<&'a str>,
examples: &'a [ExampleEvidence],
decided_on_branch: &'a str,
decided_at: i64,
updated_at: i64,
}
impl<'a> From<&'a Decision> for DecisionJson<'a> {
fn from(d: &'a Decision) -> Self {
Self {
description_hash: &d.description_hash,
description: &d.description,
state: d.state.as_sql_str(),
nature: d.nature.as_sql_str(),
weight: d.weight.as_sql_str(),
category: d.category.as_deref(),
reason: d.reason.as_deref(),
examples: &d.examples,
decided_on_branch: &d.decided_on_branch.0,
decided_at: d.decided_at,
updated_at: d.updated_at,
}
}
}
fn format_decisions_json(decisions: &[Decision]) -> Result<String, CliError> {
let dtos: Vec<DecisionJson<'_>> = decisions.iter().map(DecisionJson::from).collect();
let mut json = serde_json::to_string_pretty(&dtos).map_err(|e| CliError::CommandFailed {
command: "decisions".to_owned(),
reason: format!("failed to serialise decisions to JSON: {e}"),
})?;
json.push('\n');
Ok(json)
}
fn format_decisions_table(decisions: &[Decision]) -> String {
if decisions.is_empty() {
return "No decisions recorded.\n".to_owned();
}
const H_STATE: &str = "state";
const H_HASH: &str = "hash";
const H_DESCRIPTION: &str = "description";
const H_BRANCH: &str = "decided_on_branch";
const H_DECIDED_AT: &str = "decided_at";
let rows: Vec<[String; 5]> = decisions
.iter()
.map(|d| {
[
d.state.as_sql_str().to_owned(),
short_hash(&d.description_hash),
truncate_chars(&d.description, TABLE_DESCRIPTION_MAX),
d.decided_on_branch.0.clone(),
format_decided_at(d.decided_at),
]
})
.collect();
let widths = [
column_width(H_STATE, &rows, 0),
column_width(H_HASH, &rows, 1),
column_width(H_DESCRIPTION, &rows, 2),
column_width(H_BRANCH, &rows, 3),
column_width(H_DECIDED_AT, &rows, 4),
];
let mut out = String::new();
write_row(
&mut out,
&[H_STATE, H_HASH, H_DESCRIPTION, H_BRANCH, H_DECIDED_AT],
&widths,
);
for row in &rows {
let cells = [
row[0].as_str(),
row[1].as_str(),
row[2].as_str(),
row[3].as_str(),
row[4].as_str(),
];
write_row(&mut out, &cells, &widths);
}
out
}
fn display_width(s: &str) -> usize {
use unicode_width::UnicodeWidthStr;
UnicodeWidthStr::width(s)
}
fn column_width(header: &str, rows: &[[String; 5]], idx: usize) -> usize {
let header_w = display_width(header);
rows.iter()
.map(|r| display_width(&r[idx]))
.max()
.map(|w| w.max(header_w))
.unwrap_or(header_w)
}
fn write_row(out: &mut String, cells: &[&str; 5], widths: &[usize; 5]) {
fn pad_cell(out: &mut String, cell: &str, target_width: usize) {
out.push_str(cell);
let w = display_width(cell);
if target_width > w {
for _ in 0..(target_width - w) {
out.push(' ');
}
}
}
pad_cell(out, cells[0], widths[0]);
out.push_str(" ");
pad_cell(out, cells[1], widths[1]);
out.push_str(" ");
pad_cell(out, cells[2], widths[2]);
out.push_str(" ");
pad_cell(out, cells[3], widths[3]);
out.push_str(" ");
out.push_str(cells[4]);
out.push('\n');
}
fn short_hash(hash: &str) -> String {
hash.chars().take(TABLE_HASH_LEN).collect()
}
fn truncate_chars(s: &str, max: usize) -> String {
let count = s.chars().count();
if count <= max {
s.to_owned()
} else if max == 0 {
String::new()
} else {
let mut out: String = s.chars().take(max - 1).collect();
out.push('…');
out
}
}
fn format_decided_at(epoch: i64) -> String {
chrono::DateTime::from_timestamp(epoch, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| epoch.to_string())
}
fn run_forget(hash: &str, yes: bool) -> Result<(), CliError> {
let resolved = db::resolve_project(None, "decisions")?;
if !resolved.db_path.exists() {
return Err(CliError::CommandFailed {
command: "decisions forget".to_owned(),
reason: "No database found. Run `seshat scan` first.".to_owned(),
});
}
let database = Database::open(&resolved.db_path).map_err(|e| CliError::CommandFailed {
command: "decisions forget".to_owned(),
reason: format!("failed to open database: {e}"),
})?;
let repo = SqliteDecisionRepository::new(database.connection().clone());
let decision = resolve_decision_for_forget(&repo, hash)?;
let mut stdout = std::io::stdout().lock();
let summary = format_decision_summary(&decision);
write_tolerating_broken_pipe(&mut stdout, summary.as_bytes())?;
if !yes && !prompt_for_confirmation(&mut stdout, &mut std::io::stdin().lock())? {
writeln!(stdout, "Aborted; decision not removed.")?;
return Ok(());
}
repo.delete(&decision.description_hash)
.map_err(|e| CliError::CommandFailed {
command: "decisions forget".to_owned(),
reason: format!("failed to delete decision: {e}"),
})?;
writeln!(
stdout,
"Removed decision {}.",
short_hash(&decision.description_hash)
)?;
Ok(())
}
fn resolve_decision_for_forget<R: DecisionRepository>(
repo: &R,
hash: &str,
) -> Result<Decision, CliError> {
if hash.len() < MIN_FORGET_PREFIX_LEN {
return Err(CliError::InvalidArgument(format!(
"decision hash prefix '{hash}' is too short; need at least \
{MIN_FORGET_PREFIX_LEN} characters"
)));
}
let mut matches: Vec<Decision> =
repo.find_by_hash_prefix(hash)
.map_err(|e| CliError::CommandFailed {
command: "decisions forget".to_owned(),
reason: format!("failed to read decisions: {e}"),
})?;
match matches.len() {
0 => Err(CliError::CommandFailed {
command: "decisions forget".to_owned(),
reason: format!("no decision matches hash '{hash}'"),
}),
1 => Ok(matches.swap_remove(0)),
_ => {
let listed = matches
.iter()
.map(|d| short_hash(&d.description_hash))
.collect::<Vec<_>>()
.join(", ");
Err(CliError::CommandFailed {
command: "decisions forget".to_owned(),
reason: format!(
"prefix '{hash}' is ambiguous; matches {} decisions: {listed}",
matches.len()
),
})
}
}
}
fn format_decision_summary(decision: &Decision) -> String {
let mut out = String::new();
let _ = writeln!(out, "Found decision:");
let _ = writeln!(out, " hash: {}", decision.description_hash);
let _ = writeln!(out, " state: {}", decision.state.as_sql_str());
let _ = writeln!(out, " nature: {}", decision.nature.as_sql_str());
let _ = writeln!(out, " weight: {}", decision.weight.as_sql_str());
let _ = writeln!(out, " description: {}", decision.description);
let _ = writeln!(out, " branch: {}", decision.decided_on_branch.0);
let _ = writeln!(
out,
" decided_at: {}",
format_decided_at(decision.decided_at)
);
out
}
fn prompt_for_confirmation<W: Write, R: std::io::BufRead>(
out: &mut W,
input: &mut R,
) -> Result<bool, CliError> {
write!(out, "Forget this decision? [y/N]: ")?;
out.flush()?;
let mut response = String::new();
let bytes = input.read_line(&mut response)?;
if bytes == 0 {
return Err(CliError::CommandFailed {
command: "decisions forget".to_owned(),
reason: "stdin closed before confirmation; pass --yes to skip the \
prompt for unattended runs"
.to_owned(),
});
}
let trimmed = response.trim().to_ascii_lowercase();
Ok(trimmed == "y" || trimmed == "yes")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DecisionJsonOwned {
description_hash: String,
description: String,
state: String,
nature: String,
weight: String,
category: Option<String>,
reason: Option<String>,
examples: Vec<ExampleEvidence>,
decided_on_branch: String,
decided_at: i64,
updated_at: i64,
}
impl DecisionJsonOwned {
fn into_decision(self) -> Result<Decision, CliError> {
let state =
DecisionState::from_sql_str(&self.state).map_err(|e| CliError::CommandFailed {
command: "decisions import".to_owned(),
reason: format!("invalid state for hash '{}': {e}", self.description_hash),
})?;
let nature =
DecisionNature::from_sql_str(&self.nature).map_err(|e| CliError::CommandFailed {
command: "decisions import".to_owned(),
reason: format!("invalid nature for hash '{}': {e}", self.description_hash),
})?;
let weight =
DecisionWeight::from_sql_str(&self.weight).map_err(|e| CliError::CommandFailed {
command: "decisions import".to_owned(),
reason: format!("invalid weight for hash '{}': {e}", self.description_hash),
})?;
Ok(Decision {
description_hash: self.description_hash,
description: self.description,
state,
nature,
weight,
category: self.category,
reason: self.reason,
examples: self.examples,
decided_on_branch: BranchId(self.decided_on_branch),
decided_at: self.decided_at,
updated_at: self.updated_at,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportSummary {
pub total: usize,
pub inserted: usize,
pub updated: usize,
pub skipped: usize,
}
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), CliError> {
use std::io::Write;
let parent = path.parent().unwrap_or(Path::new("."));
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("decisions-export");
let tmp_name = format!(".{file_name}.{}.tmp", std::process::id());
let tmp_path = parent.join(tmp_name);
{
let mut tmp = std::fs::File::create(&tmp_path).map_err(|e| CliError::IoWithPath {
message: format!("failed to create export temp file: {e}"),
path: tmp_path.clone(),
})?;
tmp.write_all(bytes).map_err(|e| CliError::IoWithPath {
message: format!("failed to write decisions export: {e}"),
path: tmp_path.clone(),
})?;
tmp.sync_all().map_err(|e| CliError::IoWithPath {
message: format!("failed to fsync export temp file: {e}"),
path: tmp_path.clone(),
})?;
}
std::fs::rename(&tmp_path, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
CliError::IoWithPath {
message: format!("failed to atomically rename export to target: {e}"),
path: path.to_owned(),
}
})?;
Ok(())
}
fn run_export(file: &Path) -> Result<(), CliError> {
let resolved = db::resolve_project(None, "decisions")?;
if !resolved.db_path.exists() {
return Err(CliError::CommandFailed {
command: "decisions export".to_owned(),
reason: "No database found. Run `seshat scan` first.".to_owned(),
});
}
let database = Database::open(&resolved.db_path).map_err(|e| CliError::CommandFailed {
command: "decisions export".to_owned(),
reason: format!("failed to open database: {e}"),
})?;
let json = export_decisions_to_string(&database)?;
write_atomic(file, json.as_bytes())?;
let count = export_count(&database)?;
let mut stdout = std::io::stdout().lock();
writeln!(
stdout,
"Exported {count} decision{plural} to {path}",
plural = if count == 1 { "" } else { "s" },
path = file.display(),
)?;
Ok(())
}
pub fn export_decisions_to_string(database: &Database) -> Result<String, CliError> {
let repo = SqliteDecisionRepository::new(database.connection().clone());
let decisions = repo.list().map_err(|e| CliError::CommandFailed {
command: "decisions export".to_owned(),
reason: format!("failed to read decisions: {e}"),
})?;
let dtos: Vec<DecisionJson<'_>> = decisions.iter().map(DecisionJson::from).collect();
let mut json = serde_json::to_string_pretty(&dtos).map_err(|e| CliError::CommandFailed {
command: "decisions export".to_owned(),
reason: format!("failed to serialise decisions to JSON: {e}"),
})?;
json.push('\n');
Ok(json)
}
fn export_count(database: &Database) -> Result<usize, CliError> {
let repo = SqliteDecisionRepository::new(database.connection().clone());
repo.list()
.map(|v| v.len())
.map_err(|e| CliError::CommandFailed {
command: "decisions export".to_owned(),
reason: format!("failed to read decisions: {e}"),
})
}
fn run_import(file: &Path, strict: bool) -> Result<(), CliError> {
let resolved = db::resolve_project(None, "decisions")?;
if !resolved.db_path.exists() {
return Err(CliError::CommandFailed {
command: "decisions import".to_owned(),
reason: "No database found. Run `seshat scan` first.".to_owned(),
});
}
let database = Database::open(&resolved.db_path).map_err(|e| CliError::CommandFailed {
command: "decisions import".to_owned(),
reason: format!("failed to open database: {e}"),
})?;
let json = std::fs::read_to_string(file).map_err(|e| CliError::IoWithPath {
message: format!("failed to read decisions import file: {e}"),
path: file.to_owned(),
})?;
let summary = import_decisions_from_str(&database, &json, strict)?;
let mut stdout = std::io::stdout().lock();
writeln!(
stdout,
"Imported {} decision{plural} ({} new, {} updated, {} skipped).",
summary.inserted + summary.updated,
summary.inserted,
summary.updated,
summary.skipped,
plural = if summary.inserted + summary.updated == 1 {
""
} else {
"s"
},
)?;
Ok(())
}
pub fn import_decisions_from_str(
database: &Database,
json: &str,
strict: bool,
) -> Result<ImportSummary, CliError> {
let parsed: Vec<DecisionJsonOwned> =
serde_json::from_str(json).map_err(|e| CliError::CommandFailed {
command: "decisions import".to_owned(),
reason: format!("failed to parse decisions JSON: {e}"),
})?;
let total = parsed.len();
let repo = SqliteDecisionRepository::new(database.connection().clone());
if strict {
let hash_refs: Vec<&str> = parsed.iter().map(|d| d.description_hash.as_str()).collect();
let existing = repo
.get_by_hashes(&hash_refs)
.map_err(|e| CliError::CommandFailed {
command: "decisions import".to_owned(),
reason: format!("failed to look up existing decisions: {e}"),
})?;
if !existing.is_empty() {
let mut conflicts: Vec<&str> = existing.keys().map(String::as_str).collect();
conflicts.sort_unstable();
return Err(CliError::CommandFailed {
command: "decisions import".to_owned(),
reason: format!(
"strict mode: {} hash conflict{} detected; aborting import: {}",
conflicts.len(),
if conflicts.len() == 1 { "" } else { "s" },
conflicts.join(", "),
),
});
}
}
let mut summary = ImportSummary {
total,
inserted: 0,
updated: 0,
skipped: 0,
};
{
let guard = database
.connection()
.lock()
.map_err(|e| CliError::CommandFailed {
command: "decisions import".to_owned(),
reason: format!("failed to acquire DB lock for transaction: {e}"),
})?;
guard
.execute_batch("BEGIN IMMEDIATE")
.map_err(|e| CliError::CommandFailed {
command: "decisions import".to_owned(),
reason: format!("failed to begin transaction: {e}"),
})?;
}
let existing_map = {
let hash_refs: Vec<&str> = parsed.iter().map(|d| d.description_hash.as_str()).collect();
repo.get_by_hashes(&hash_refs)
.map_err(|e| CliError::CommandFailed {
command: "decisions import".to_owned(),
reason: format!("failed to bulk-look up existing decisions: {e}"),
})?
};
let txn_result: Result<ImportSummary, CliError> = (|| {
for entry in parsed {
let decision = entry.into_decision()?;
match existing_map.get(&decision.description_hash).cloned() {
None => {
repo.upsert(&decision)
.map_err(|e| CliError::CommandFailed {
command: "decisions import".to_owned(),
reason: format!(
"failed to insert decision '{}': {e}",
decision.description_hash
),
})?;
summary.inserted += 1;
}
Some(existing) => {
if decision.decided_at > existing.decided_at {
repo.upsert(&decision)
.map_err(|e| CliError::CommandFailed {
command: "decisions import".to_owned(),
reason: format!(
"failed to update decision '{}': {e}",
decision.description_hash
),
})?;
summary.updated += 1;
} else {
summary.skipped += 1;
}
}
}
}
Ok(summary)
})();
{
let guard = database
.connection()
.lock()
.map_err(|e| CliError::CommandFailed {
command: "decisions import".to_owned(),
reason: format!("failed to re-acquire DB lock for COMMIT: {e}"),
})?;
match &txn_result {
Ok(_) => guard
.execute_batch("COMMIT")
.map_err(|e| CliError::CommandFailed {
command: "decisions import".to_owned(),
reason: format!("failed to commit transaction: {e}"),
})?,
Err(_) => {
if let Err(rb) = guard.execute_batch("ROLLBACK") {
tracing::warn!("decisions import: ROLLBACK after error failed: {rb}");
}
}
}
}
txn_result
}
pub fn forget_decision_with_database(
database: &Database,
hash: &str,
) -> Result<Decision, CliError> {
let repo = SqliteDecisionRepository::new(database.connection().clone());
let decision = resolve_decision_for_forget(&repo, hash)?;
repo.delete(&decision.description_hash)
.map_err(|e| CliError::CommandFailed {
command: "decisions forget".to_owned(),
reason: format!("failed to delete decision: {e}"),
})?;
Ok(decision)
}
#[cfg(test)]
mod tests {
use super::*;
use seshat_core::BranchId;
use seshat_storage::{DecisionNature, DecisionWeight};
fn make_db() -> Database {
Database::open(":memory:").expect("in-memory DB")
}
fn make_decision(
hash: &str,
description: &str,
state: DecisionState,
branch: &str,
decided_at: i64,
) -> Decision {
Decision {
description_hash: hash.to_owned(),
description: description.to_owned(),
state,
nature: DecisionNature::Convention,
weight: DecisionWeight::Rule,
category: Some("logging".to_owned()),
reason: Some("because tests".to_owned()),
examples: vec![ExampleEvidence {
file: "src/lib.rs".to_owned(),
line: 1,
end_line: 3,
snippet: "tracing::info!()".to_owned(),
}],
decided_on_branch: BranchId(branch.to_owned()),
decided_at,
updated_at: decided_at,
}
}
fn populate(db: &Database) {
let repo = SqliteDecisionRepository::new(db.connection().clone());
repo.upsert(&make_decision(
"aaaaaaaa1111",
"Use anyhow for error propagation",
DecisionState::Approved,
"main",
1_700_000_100,
))
.unwrap();
repo.upsert(&make_decision(
"bbbbbbbb2222",
"Allow unwrap() in production",
DecisionState::Rejected,
"feature/x",
1_700_000_200,
))
.unwrap();
repo.upsert(&make_decision(
"cccccccc3333",
"Partial: tracing::info for hot paths only",
DecisionState::Partial,
"main",
1_700_000_300,
))
.unwrap();
repo.upsert(&make_decision(
"dddddddd4444",
"Recorded decision via MCP",
DecisionState::Recorded,
"main",
1_700_000_400,
))
.unwrap();
}
#[test]
fn format_decisions_table_empty_returns_friendly_message() {
let out = format_decisions_table(&[]);
assert_eq!(out, "No decisions recorded.\n");
}
#[test]
fn format_decisions_table_populated_includes_header_and_rows() {
let db = make_db();
populate(&db);
let decisions = load_decisions(&db, None, None).unwrap();
let table = format_decisions_table(&decisions);
assert!(table.contains("state"), "missing state header: {table}");
assert!(table.contains("hash"), "missing hash header: {table}");
assert!(
table.contains("description"),
"missing description header: {table}"
);
assert!(
table.contains("decided_on_branch"),
"missing branch header: {table}"
);
assert!(
table.contains("decided_at"),
"missing decided_at header: {table}"
);
for state in ["approved", "rejected", "partial", "recorded"] {
assert!(table.contains(state), "missing state {state}: {table}");
}
assert!(table.contains("aaaaaaaa"));
assert!(table.contains("bbbbbbbb"));
assert!(table.contains("main"));
assert!(table.contains("feature/x"));
assert!(table.contains("Use anyhow for error propagation"));
}
#[test]
fn format_decisions_table_aligns_cjk_descriptions_by_display_width() {
let cjk_desc = "中文中文中"; let ascii_desc = "0123456789"; let d1 = make_decision("aaaa1111", cjk_desc, DecisionState::Approved, "main", 0);
let d2 = make_decision("bbbb2222", ascii_desc, DecisionState::Approved, "main", 0);
let table = format_decisions_table(&[d1, d2]);
let lines: Vec<&str> = table.lines().collect();
assert_eq!(lines.len(), 3, "expected header + 2 rows in:\n{table}");
use unicode_width::UnicodeWidthStr;
let pos1 = lines[1].find("main").expect("row1 has main");
let pos2 = lines[2].find("main").expect("row2 has main");
let cols1 = UnicodeWidthStr::width(&lines[1][..pos1]);
let cols2 = UnicodeWidthStr::width(&lines[2][..pos2]);
assert_eq!(
cols1, cols2,
"decided_on_branch column must start at the same DISPLAY column \
on both rows; got CJK row at col {cols1}, ASCII row at col \
{cols2}.\n{table}"
);
}
#[test]
fn format_decisions_table_truncates_long_description() {
let long = "x".repeat(200);
let d = make_decision("h", &long, DecisionState::Approved, "main", 1_700_000_000);
let table = format_decisions_table(std::slice::from_ref(&d));
assert!(!table.contains(&long));
assert!(table.contains('…'), "expected ellipsis: {table}");
}
#[test]
fn format_decisions_json_empty_is_valid_json_array() {
let out = format_decisions_json(&[]).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert!(parsed.is_array());
assert_eq!(parsed.as_array().unwrap().len(), 0);
assert!(out.ends_with('\n'));
}
#[test]
fn format_decisions_json_populated_is_valid_json_array() {
let db = make_db();
populate(&db);
let decisions = load_decisions(&db, None, None).unwrap();
assert_eq!(decisions.len(), 4);
let out = format_decisions_json(&decisions).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
let arr = parsed.as_array().expect("top-level array");
assert_eq!(arr.len(), 4);
for item in arr {
let obj = item.as_object().expect("object");
for key in [
"description_hash",
"description",
"state",
"nature",
"weight",
"category",
"reason",
"examples",
"decided_on_branch",
"decided_at",
"updated_at",
] {
assert!(obj.contains_key(key), "missing key {key} in {item}");
}
}
}
#[test]
fn format_decisions_json_uses_sql_state_strings() {
let d = make_decision("h", "x", DecisionState::Approved, "main", 1_700_000_000);
let out = format_decisions_json(std::slice::from_ref(&d)).unwrap();
assert!(out.contains("\"state\": \"approved\""), "got: {out}");
assert!(out.contains("\"nature\": \"convention\""));
assert!(out.contains("\"weight\": \"rule\""));
}
#[test]
fn load_decisions_empty_db_returns_empty_vec() {
let db = make_db();
let result = load_decisions(&db, None, None).unwrap();
assert!(result.is_empty());
}
#[test]
fn load_decisions_no_filter_returns_all() {
let db = make_db();
populate(&db);
let result = load_decisions(&db, None, None).unwrap();
assert_eq!(result.len(), 4);
}
#[test]
fn load_decisions_filters_by_state() {
let db = make_db();
populate(&db);
let approved = load_decisions(&db, Some(DecisionStateFilter::Approved), None).unwrap();
assert_eq!(approved.len(), 1);
assert_eq!(approved[0].state, DecisionState::Approved);
let rejected = load_decisions(&db, Some(DecisionStateFilter::Rejected), None).unwrap();
assert_eq!(rejected.len(), 1);
assert_eq!(rejected[0].state, DecisionState::Rejected);
let partial = load_decisions(&db, Some(DecisionStateFilter::Partial), None).unwrap();
assert_eq!(partial.len(), 1);
let recorded = load_decisions(&db, Some(DecisionStateFilter::Recorded), None).unwrap();
assert_eq!(recorded.len(), 1);
}
#[test]
fn load_decisions_filters_by_branch() {
let db = make_db();
populate(&db);
let main_only = load_decisions(&db, None, Some("main")).unwrap();
assert_eq!(main_only.len(), 3);
assert!(main_only.iter().all(|d| d.decided_on_branch.0 == "main"));
let feature = load_decisions(&db, None, Some("feature/x")).unwrap();
assert_eq!(feature.len(), 1);
assert_eq!(feature[0].decided_on_branch.0, "feature/x");
let unknown = load_decisions(&db, None, Some("does-not-exist")).unwrap();
assert!(unknown.is_empty());
}
#[test]
fn load_decisions_combined_state_and_branch_filter() {
let db = make_db();
populate(&db);
let result =
load_decisions(&db, Some(DecisionStateFilter::Approved), Some("main")).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].description_hash, "aaaaaaaa1111");
let result =
load_decisions(&db, Some(DecisionStateFilter::Rejected), Some("main")).unwrap();
assert!(result.is_empty());
}
#[test]
fn short_hash_truncates_to_eight_chars() {
assert_eq!(short_hash("abcdef0123456789"), "abcdef01");
assert_eq!(short_hash("abc"), "abc");
assert_eq!(short_hash("abcdefgh"), "abcdefgh");
}
#[test]
fn truncate_chars_returns_input_when_short_enough() {
assert_eq!(truncate_chars("hello", 10), "hello");
assert_eq!(truncate_chars("hello", 5), "hello");
}
#[test]
fn truncate_chars_appends_ellipsis_when_too_long() {
let out = truncate_chars("0123456789", 6);
assert_eq!(out, "01234…");
}
#[test]
fn format_decided_at_formats_unix_timestamp() {
let out = format_decided_at(1_700_000_000);
assert_eq!(out, "2023-11-14 22:13:20");
}
#[test]
fn decision_state_filter_converts_to_storage_enum() {
assert_eq!(
DecisionState::from(DecisionStateFilter::Approved),
DecisionState::Approved
);
assert_eq!(
DecisionState::from(DecisionStateFilter::Rejected),
DecisionState::Rejected
);
assert_eq!(
DecisionState::from(DecisionStateFilter::Partial),
DecisionState::Partial
);
assert_eq!(
DecisionState::from(DecisionStateFilter::Recorded),
DecisionState::Recorded
);
}
#[test]
fn resolve_decision_for_forget_returns_exact_match_for_full_hash() {
let db = make_db();
populate(&db);
let repo = SqliteDecisionRepository::new(db.connection().clone());
let resolved = resolve_decision_for_forget(&repo, "aaaaaaaa1111").unwrap();
assert_eq!(resolved.description_hash, "aaaaaaaa1111");
assert_eq!(resolved.state, DecisionState::Approved);
}
#[test]
fn resolve_decision_for_forget_returns_unique_match_for_prefix() {
let db = make_db();
populate(&db);
let repo = SqliteDecisionRepository::new(db.connection().clone());
let resolved = resolve_decision_for_forget(&repo, "aaaa").unwrap();
assert_eq!(resolved.description_hash, "aaaaaaaa1111");
}
#[test]
fn resolve_decision_for_forget_rejects_short_prefix() {
let db = make_db();
populate(&db);
let repo = SqliteDecisionRepository::new(db.connection().clone());
let err = resolve_decision_for_forget(&repo, "abc").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("too short"), "got: {msg}");
assert!(msg.contains("4"), "must mention the 4-char minimum: {msg}");
}
#[test]
fn resolve_decision_for_forget_rejects_short_prefix_even_when_unique() {
let db = make_db();
let repo = SqliteDecisionRepository::new(db.connection().clone());
repo.upsert(&make_decision(
"abc",
"test",
DecisionState::Approved,
"main",
1,
))
.unwrap();
let err = resolve_decision_for_forget(&repo, "abc").unwrap_err();
assert!(err.to_string().contains("too short"));
}
#[test]
fn resolve_decision_for_forget_returns_not_found_for_unmatched_prefix() {
let db = make_db();
populate(&db);
let repo = SqliteDecisionRepository::new(db.connection().clone());
let err = resolve_decision_for_forget(&repo, "ffff0000").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("no decision matches"), "got: {msg}");
assert!(msg.contains("ffff0000"), "must echo the input: {msg}");
}
#[test]
fn resolve_decision_for_forget_returns_ambiguous_for_multiple_matches() {
let db = make_db();
let repo = SqliteDecisionRepository::new(db.connection().clone());
repo.upsert(&make_decision(
"aaaa1111",
"first",
DecisionState::Approved,
"main",
1,
))
.unwrap();
repo.upsert(&make_decision(
"aaaa2222",
"second",
DecisionState::Rejected,
"main",
2,
))
.unwrap();
let err = resolve_decision_for_forget(&repo, "aaaa").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("ambiguous"), "got: {msg}");
assert!(msg.contains("aaaa1111"), "missing first hash: {msg}");
assert!(msg.contains("aaaa2222"), "missing second hash: {msg}");
}
#[test]
fn format_decision_summary_includes_full_hash_and_key_fields() {
let d = make_decision(
"aaaaaaaa1111",
"Use anyhow for error propagation",
DecisionState::Approved,
"main",
1_700_000_000,
);
let summary = format_decision_summary(&d);
assert!(summary.contains("aaaaaaaa1111"));
assert!(summary.contains("approved"));
assert!(summary.contains("convention"));
assert!(summary.contains("rule"));
assert!(summary.contains("Use anyhow for error propagation"));
assert!(summary.contains("main"));
assert!(summary.contains("2023-11-14 22:13:20"));
}
#[test]
fn prompt_for_confirmation_treats_y_as_affirmative() {
let mut out: Vec<u8> = Vec::new();
let mut input = std::io::Cursor::new(b"y\n".to_vec());
assert!(prompt_for_confirmation(&mut out, &mut input).unwrap());
let prompt = String::from_utf8(out).unwrap();
assert!(prompt.contains("Forget this decision?"));
assert!(prompt.contains("[y/N]"), "must show the [y/N] hint");
}
#[test]
fn prompt_for_confirmation_accepts_uppercase_yes() {
let mut out: Vec<u8> = Vec::new();
let mut input = std::io::Cursor::new(b"YES\n".to_vec());
assert!(prompt_for_confirmation(&mut out, &mut input).unwrap());
}
#[test]
fn prompt_for_confirmation_treats_n_as_decline() {
let mut out: Vec<u8> = Vec::new();
let mut input = std::io::Cursor::new(b"n\n".to_vec());
assert!(!prompt_for_confirmation(&mut out, &mut input).unwrap());
}
#[test]
fn prompt_for_confirmation_treats_empty_default_as_decline() {
let mut out: Vec<u8> = Vec::new();
let mut input = std::io::Cursor::new(b"\n".to_vec());
assert!(!prompt_for_confirmation(&mut out, &mut input).unwrap());
}
#[test]
fn prompt_for_confirmation_treats_unrelated_input_as_decline() {
let mut out: Vec<u8> = Vec::new();
let mut input = std::io::Cursor::new(b"maybe\n".to_vec());
assert!(!prompt_for_confirmation(&mut out, &mut input).unwrap());
}
#[test]
fn prompt_for_confirmation_returns_error_on_eof_before_input() {
let mut out: Vec<u8> = Vec::new();
let mut input = std::io::Cursor::new(Vec::<u8>::new());
let result = prompt_for_confirmation(&mut out, &mut input);
match result {
Err(CliError::CommandFailed { reason, .. }) => {
assert!(
reason.contains("--yes"),
"EOF error must hint at --yes for unattended runs; got: {reason}"
);
assert!(
reason.contains("stdin"),
"EOF error must mention stdin so the user can debug; got: {reason}"
);
}
other => panic!("expected CommandFailed on EOF, got: {other:?}"),
}
}
#[test]
fn forget_decision_with_database_deletes_by_full_hash() {
let db = make_db();
populate(&db);
let repo = SqliteDecisionRepository::new(db.connection().clone());
assert!(repo.get_by_hash("aaaaaaaa1111").unwrap().is_some());
let removed = forget_decision_with_database(&db, "aaaaaaaa1111").unwrap();
assert_eq!(removed.description_hash, "aaaaaaaa1111");
assert_eq!(removed.state, DecisionState::Approved);
assert!(repo.get_by_hash("aaaaaaaa1111").unwrap().is_none());
}
#[test]
fn forget_decision_with_database_deletes_by_prefix() {
let db = make_db();
populate(&db);
let repo = SqliteDecisionRepository::new(db.connection().clone());
let removed = forget_decision_with_database(&db, "bbbb").unwrap();
assert_eq!(removed.description_hash, "bbbbbbbb2222");
assert!(repo.get_by_hash("bbbbbbbb2222").unwrap().is_none());
}
#[test]
fn forget_decision_with_database_propagates_resolution_errors() {
let db = make_db();
populate(&db);
let err = forget_decision_with_database(&db, "ffff0000").unwrap_err();
assert!(err.to_string().contains("no decision matches"));
let err = forget_decision_with_database(&db, "ab").unwrap_err();
assert!(err.to_string().contains("too short"));
}
#[test]
fn export_decisions_to_string_empty_db_returns_empty_array() {
let db = make_db();
let json = export_decisions_to_string(&db).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert!(parsed.is_array());
assert_eq!(parsed.as_array().unwrap().len(), 0);
assert!(json.ends_with('\n'));
}
#[test]
fn export_decisions_to_string_populated_db_returns_all_rows() {
let db = make_db();
populate(&db);
let json = export_decisions_to_string(&db).unwrap();
let parsed: Vec<DecisionJsonOwned> =
serde_json::from_str(&json).expect("parses back into owned DTOs");
assert_eq!(parsed.len(), 4);
let states: Vec<&str> = parsed.iter().map(|d| d.state.as_str()).collect();
for expected in ["approved", "rejected", "partial", "recorded"] {
assert!(
states.contains(&expected),
"missing state {expected} in {states:?}"
);
}
}
#[test]
fn import_decisions_from_str_inserts_into_empty_db() {
let db_src = make_db();
populate(&db_src);
let json = export_decisions_to_string(&db_src).unwrap();
let db_dst = make_db();
let summary = import_decisions_from_str(&db_dst, &json, false).unwrap();
assert_eq!(summary.total, 4);
assert_eq!(summary.inserted, 4);
assert_eq!(summary.updated, 0);
assert_eq!(summary.skipped, 0);
let dst_repo = SqliteDecisionRepository::new(db_dst.connection().clone());
assert_eq!(dst_repo.list().unwrap().len(), 4);
}
#[test]
fn import_decisions_from_str_empty_array_is_no_op() {
let db = make_db();
populate(&db);
let summary = import_decisions_from_str(&db, "[]", false).unwrap();
assert_eq!(summary.total, 0);
assert_eq!(summary.inserted, 0);
assert_eq!(summary.updated, 0);
assert_eq!(summary.skipped, 0);
let repo = SqliteDecisionRepository::new(db.connection().clone());
assert_eq!(repo.list().unwrap().len(), 4);
}
#[test]
fn import_decisions_from_str_updates_when_imported_is_newer() {
let db = make_db();
populate(&db);
let repo = SqliteDecisionRepository::new(db.connection().clone());
let before = repo.get_by_hash("aaaaaaaa1111").unwrap().unwrap();
assert_eq!(before.state, DecisionState::Approved);
let newer = make_decision(
"aaaaaaaa1111",
"Use anyhow for error propagation (revised)",
DecisionState::Rejected,
"feature/x",
1_800_000_000,
);
let json = serde_json::to_string(&[DecisionJson::from(&newer)]).unwrap();
let summary = import_decisions_from_str(&db, &json, false).unwrap();
assert_eq!(summary.total, 1);
assert_eq!(summary.inserted, 0);
assert_eq!(summary.updated, 1);
assert_eq!(summary.skipped, 0);
let after = repo.get_by_hash("aaaaaaaa1111").unwrap().unwrap();
assert_eq!(after.state, DecisionState::Rejected);
assert_eq!(after.decided_at, 1_800_000_000);
assert_eq!(
after.description,
"Use anyhow for error propagation (revised)"
);
}
#[test]
fn import_decisions_from_str_skips_when_existing_is_newer() {
let db = make_db();
populate(&db);
let repo = SqliteDecisionRepository::new(db.connection().clone());
let before = repo.get_by_hash("aaaaaaaa1111").unwrap().unwrap();
assert_eq!(before.decided_at, 1_700_000_100);
assert_eq!(before.state, DecisionState::Approved);
let older = make_decision(
"aaaaaaaa1111",
"STALE",
DecisionState::Rejected,
"old-branch",
1_600_000_000, );
let json = serde_json::to_string(&[DecisionJson::from(&older)]).unwrap();
let summary = import_decisions_from_str(&db, &json, false).unwrap();
assert_eq!(summary.total, 1);
assert_eq!(summary.inserted, 0);
assert_eq!(summary.updated, 0);
assert_eq!(summary.skipped, 1);
let after = repo.get_by_hash("aaaaaaaa1111").unwrap().unwrap();
assert_eq!(after.decided_at, before.decided_at);
assert_eq!(after.state, before.state);
assert_eq!(after.description, before.description);
}
#[test]
fn import_decisions_from_str_skips_on_equal_decided_at() {
let db = make_db();
populate(&db);
let repo = SqliteDecisionRepository::new(db.connection().clone());
let before = repo.get_by_hash("aaaaaaaa1111").unwrap().unwrap();
let same = make_decision(
"aaaaaaaa1111",
"DIFFERENT",
DecisionState::Rejected,
"main",
before.decided_at, );
let json = serde_json::to_string(&[DecisionJson::from(&same)]).unwrap();
let summary = import_decisions_from_str(&db, &json, false).unwrap();
assert_eq!(summary.skipped, 1);
assert_eq!(summary.updated, 0);
let after = repo.get_by_hash("aaaaaaaa1111").unwrap().unwrap();
assert_eq!(after.description, before.description);
assert_eq!(after.state, before.state);
}
#[test]
fn import_decisions_from_str_strict_fails_on_conflict() {
let db = make_db();
populate(&db);
let conflicting = make_decision(
"aaaaaaaa1111",
"newer description",
DecisionState::Rejected,
"main",
1_900_000_000,
);
let json = serde_json::to_string(&[DecisionJson::from(&conflicting)]).unwrap();
let err = import_decisions_from_str(&db, &json, true).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("strict mode"), "got: {msg}");
assert!(
msg.contains("aaaaaaaa1111"),
"must list conflicting hash: {msg}"
);
let repo = SqliteDecisionRepository::new(db.connection().clone());
let after = repo.get_by_hash("aaaaaaaa1111").unwrap().unwrap();
assert_eq!(after.state, DecisionState::Approved); assert_eq!(after.decided_at, 1_700_000_100); }
#[test]
fn import_decisions_from_str_strict_succeeds_when_no_conflict() {
let db_src = make_db();
populate(&db_src);
let json = export_decisions_to_string(&db_src).unwrap();
let db_dst = make_db();
let summary = import_decisions_from_str(&db_dst, &json, true).unwrap();
assert_eq!(summary.inserted, 4);
assert_eq!(summary.updated, 0);
assert_eq!(summary.skipped, 0);
}
#[test]
fn import_decisions_from_str_strict_lists_all_conflicts() {
let db = make_db();
populate(&db);
let conflict_a = make_decision(
"aaaaaaaa1111",
"x",
DecisionState::Approved,
"main",
1_900_000_000,
);
let conflict_b = make_decision(
"bbbbbbbb2222",
"y",
DecisionState::Rejected,
"feature/x",
1_900_000_000,
);
let new_one = make_decision(
"ffffffff9999",
"new",
DecisionState::Recorded,
"main",
1_900_000_000,
);
let dtos = vec![
DecisionJson::from(&conflict_a),
DecisionJson::from(&conflict_b),
DecisionJson::from(&new_one),
];
let json = serde_json::to_string(&dtos).unwrap();
let err = import_decisions_from_str(&db, &json, true).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("aaaaaaaa1111"),
"missing first conflict: {msg}"
);
assert!(
msg.contains("bbbbbbbb2222"),
"missing second conflict: {msg}"
);
assert!(
!msg.contains("ffffffff9999"),
"non-conflicting hash leaked: {msg}"
);
let repo = SqliteDecisionRepository::new(db.connection().clone());
assert!(repo.get_by_hash("ffffffff9999").unwrap().is_none());
}
#[test]
fn import_decisions_from_str_invalid_json_returns_error() {
let db = make_db();
let err = import_decisions_from_str(&db, "{not json", false).unwrap_err();
assert!(err.to_string().contains("failed to parse"), "{err}");
}
#[test]
fn import_decisions_from_str_invalid_state_returns_error() {
let db = make_db();
let json = r#"[{
"description_hash": "abc",
"description": "x",
"state": "BOGUS",
"nature": "convention",
"weight": "rule",
"category": null,
"reason": null,
"examples": [],
"decided_on_branch": "main",
"decided_at": 1,
"updated_at": 1
}]"#;
let err = import_decisions_from_str(&db, json, false).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("invalid state"), "got: {msg}");
assert!(msg.contains("abc"), "must mention offending hash: {msg}");
}
#[test]
fn round_trip_export_then_import_yields_identical_table() {
let db_src = make_db();
populate(&db_src);
let src_repo = SqliteDecisionRepository::new(db_src.connection().clone());
let mut before = src_repo.list().unwrap();
before.sort_by(|a, b| a.description_hash.cmp(&b.description_hash));
let json = export_decisions_to_string(&db_src).unwrap();
let db_dst = make_db();
let summary = import_decisions_from_str(&db_dst, &json, false).unwrap();
assert_eq!(summary.total, 4);
assert_eq!(summary.inserted, 4);
let dst_repo = SqliteDecisionRepository::new(db_dst.connection().clone());
let mut after = dst_repo.list().unwrap();
after.sort_by(|a, b| a.description_hash.cmp(&b.description_hash));
assert_eq!(before.len(), after.len());
for (b, a) in before.iter().zip(after.iter()) {
assert_eq!(b, a, "round-trip mismatch on hash {}", b.description_hash);
}
}
#[test]
fn round_trip_in_place_wipe_then_import_yields_identical_table() {
let db = make_db();
populate(&db);
let repo = SqliteDecisionRepository::new(db.connection().clone());
let mut before = repo.list().unwrap();
before.sort_by(|a, b| a.description_hash.cmp(&b.description_hash));
let json = export_decisions_to_string(&db).unwrap();
for d in &before {
repo.delete(&d.description_hash).unwrap();
}
assert!(
repo.list().unwrap().is_empty(),
"wipe should clear the table"
);
let summary = import_decisions_from_str(&db, &json, false).unwrap();
assert_eq!(summary.inserted, before.len());
assert_eq!(summary.skipped, 0);
assert_eq!(summary.updated, 0);
let mut after = repo.list().unwrap();
after.sort_by(|a, b| a.description_hash.cmp(&b.description_hash));
assert_eq!(before, after);
}
#[test]
fn decision_json_owned_into_decision_round_trips_via_export_format() {
let original = make_decision(
"h1",
"Use anyhow",
DecisionState::Approved,
"main",
1_700_000_000,
);
let json = serde_json::to_string(&DecisionJson::from(&original)).unwrap();
let parsed: DecisionJsonOwned = serde_json::from_str(&json).unwrap();
let restored = parsed.into_decision().unwrap();
assert_eq!(original, restored);
}
}