Skip to main content

seshat_cli/
decisions.rs

1//! Implementation of the `seshat decisions` subcommands.
2//!
3//! Exposes `seshat decisions list` (US-013), `seshat decisions forget`
4//! (US-014), and `seshat decisions export` / `seshat decisions import`
5//! (US-015).
6
7use std::fmt::Write as _;
8use std::io::Write;
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use seshat_core::BranchId;
14use seshat_storage::{
15    Database, Decision, DecisionNature, DecisionRepository, DecisionState, DecisionWeight,
16    ExampleEvidence, SqliteDecisionRepository,
17};
18
19use crate::args::{DecisionStateFilter, DecisionsCommand, DecisionsListFormat};
20use crate::db;
21use crate::error::CliError;
22
23/// Maximum width of the description column in table output.
24///
25/// Long descriptions are truncated with an ellipsis. JSON output is always
26/// full-fidelity.
27const TABLE_DESCRIPTION_MAX: usize = 60;
28
29/// Number of leading hash characters shown in the table.
30///
31/// Eight characters distinguishes most decisions visually while keeping the
32/// table inside typical terminal widths. Full hashes are preserved in JSON
33/// output and in the underlying `decisions` table.
34const TABLE_HASH_LEN: usize = 8;
35
36/// Minimum prefix length accepted by `seshat decisions forget`.
37///
38/// Hashes are 16 hex characters (`compute_description_hash` returns SHA-256
39/// truncated to the first 8 bytes). 4 hex chars = 16 bits ≈ 65k buckets,
40/// which is sufficient discrimination for projects with up to a few thousand
41/// decisions. Anything shorter is rejected up-front to avoid surfacing
42/// "ambiguous prefix" errors that the user can't easily resolve.
43const MIN_FORGET_PREFIX_LEN: usize = 4;
44
45/// Dispatch a `seshat decisions <subcommand>` invocation.
46pub fn run_decisions(command: DecisionsCommand) -> Result<(), CliError> {
47    match command {
48        DecisionsCommand::List {
49            state,
50            branch,
51            format,
52        } => run_list(state, branch.as_deref(), format),
53        DecisionsCommand::Forget { hash, yes } => run_forget(&hash, yes),
54        DecisionsCommand::Export { file } => run_export(&file),
55        DecisionsCommand::Import { file, strict } => run_import(&file, strict),
56    }
57}
58
59/// Implement `seshat decisions list`.
60fn run_list(
61    state_filter: Option<DecisionStateFilter>,
62    branch_filter: Option<&str>,
63    format: DecisionsListFormat,
64) -> Result<(), CliError> {
65    let resolved = db::resolve_project(None, "decisions")?;
66
67    if !resolved.db_path.exists() {
68        return Err(CliError::CommandFailed {
69            command: "decisions".to_owned(),
70            reason: "No database found. Run `seshat scan` first.".to_owned(),
71        });
72    }
73
74    let database = Database::open(&resolved.db_path).map_err(|e| CliError::CommandFailed {
75        command: "decisions".to_owned(),
76        reason: format!("failed to open database: {e}"),
77    })?;
78
79    let decisions = load_decisions(&database, state_filter, branch_filter)?;
80
81    let rendered = match format {
82        DecisionsListFormat::Json => format_decisions_json(&decisions)?,
83        DecisionsListFormat::Table => format_decisions_table(&decisions),
84    };
85
86    let stdout = std::io::stdout();
87    let mut out = stdout.lock();
88    write_tolerating_broken_pipe(&mut out, rendered.as_bytes())?;
89    Ok(())
90}
91
92/// Write `bytes` to `out`, treating `ErrorKind::BrokenPipe` as a clean
93/// exit signal rather than an error.
94///
95/// P33: a downstream pipeline like `seshat decisions list | head -1`
96/// closes the read side after the first line. Pre-fix the resulting
97/// `BrokenPipe` from `write_all` propagated up as `CliError::Io` and
98/// the process exited non-zero — surprising for what looks like a
99/// successful pipeline. The Unix CLI convention is to treat early
100/// reader exit as a normal termination, mirroring how `cat` / `seq`
101/// behave under `head`.
102fn write_tolerating_broken_pipe<W: Write>(out: &mut W, bytes: &[u8]) -> Result<(), CliError> {
103    match out.write_all(bytes) {
104        Ok(()) => Ok(()),
105        Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
106        Err(e) => Err(CliError::Io(e)),
107    }
108}
109
110/// Load decisions from the project DB, applying the optional filters.
111///
112/// State pushes down into the repository (`list_by_state`) so the lookup is
113/// index-supported. The branch filter is applied in-memory because (a) the
114/// V12 schema doesn't have a composite (state, branch) index and (b) the
115/// total decision count is typically small (tens to low thousands).
116fn load_decisions(
117    database: &Database,
118    state_filter: Option<DecisionStateFilter>,
119    branch_filter: Option<&str>,
120) -> Result<Vec<Decision>, CliError> {
121    let repo = SqliteDecisionRepository::new(database.connection().clone());
122
123    let mut decisions = match state_filter {
124        Some(state) => repo.list_by_state(DecisionState::from(state)),
125        None => repo.list(),
126    }
127    .map_err(|e| CliError::CommandFailed {
128        command: "decisions".to_owned(),
129        reason: format!("failed to read decisions: {e}"),
130    })?;
131
132    if let Some(branch) = branch_filter {
133        decisions.retain(|d| d.decided_on_branch.0 == branch);
134    }
135
136    Ok(decisions)
137}
138
139/// JSON DTO mirroring the row shape of the `decisions` table.
140///
141/// Local to this module so the storage crate stays free of `serde::Serialize`
142/// derives on `Decision` — and so the CLI can pin the wire shape (snake_case
143/// enum strings, full hash) independently of internal types.
144#[derive(Debug, Serialize)]
145struct DecisionJson<'a> {
146    description_hash: &'a str,
147    description: &'a str,
148    state: &'a str,
149    nature: &'a str,
150    weight: &'a str,
151    category: Option<&'a str>,
152    reason: Option<&'a str>,
153    examples: &'a [ExampleEvidence],
154    decided_on_branch: &'a str,
155    decided_at: i64,
156    updated_at: i64,
157}
158
159impl<'a> From<&'a Decision> for DecisionJson<'a> {
160    fn from(d: &'a Decision) -> Self {
161        Self {
162            description_hash: &d.description_hash,
163            description: &d.description,
164            state: d.state.as_sql_str(),
165            nature: d.nature.as_sql_str(),
166            weight: d.weight.as_sql_str(),
167            category: d.category.as_deref(),
168            reason: d.reason.as_deref(),
169            examples: &d.examples,
170            decided_on_branch: &d.decided_on_branch.0,
171            decided_at: d.decided_at,
172            updated_at: d.updated_at,
173        }
174    }
175}
176
177fn format_decisions_json(decisions: &[Decision]) -> Result<String, CliError> {
178    let dtos: Vec<DecisionJson<'_>> = decisions.iter().map(DecisionJson::from).collect();
179    let mut json = serde_json::to_string_pretty(&dtos).map_err(|e| CliError::CommandFailed {
180        command: "decisions".to_owned(),
181        reason: format!("failed to serialise decisions to JSON: {e}"),
182    })?;
183    json.push('\n');
184    Ok(json)
185}
186
187fn format_decisions_table(decisions: &[Decision]) -> String {
188    if decisions.is_empty() {
189        return "No decisions recorded.\n".to_owned();
190    }
191
192    // Column headers — match the AC literally:
193    // "state | hash | description | decided_on_branch | decided_at".
194    const H_STATE: &str = "state";
195    const H_HASH: &str = "hash";
196    const H_DESCRIPTION: &str = "description";
197    const H_BRANCH: &str = "decided_on_branch";
198    const H_DECIDED_AT: &str = "decided_at";
199
200    // Pre-compute per-row formatted values so column widths are dimensioned
201    // off the actually-rendered strings (truncated description, fixed-prefix
202    // hash, formatted timestamp).
203    let rows: Vec<[String; 5]> = decisions
204        .iter()
205        .map(|d| {
206            [
207                d.state.as_sql_str().to_owned(),
208                short_hash(&d.description_hash),
209                truncate_chars(&d.description, TABLE_DESCRIPTION_MAX),
210                d.decided_on_branch.0.clone(),
211                format_decided_at(d.decided_at),
212            ]
213        })
214        .collect();
215
216    let widths = [
217        column_width(H_STATE, &rows, 0),
218        column_width(H_HASH, &rows, 1),
219        column_width(H_DESCRIPTION, &rows, 2),
220        column_width(H_BRANCH, &rows, 3),
221        column_width(H_DECIDED_AT, &rows, 4),
222    ];
223
224    let mut out = String::new();
225    write_row(
226        &mut out,
227        &[H_STATE, H_HASH, H_DESCRIPTION, H_BRANCH, H_DECIDED_AT],
228        &widths,
229    );
230    for row in &rows {
231        let cells = [
232            row[0].as_str(),
233            row[1].as_str(),
234            row[2].as_str(),
235            row[3].as_str(),
236            row[4].as_str(),
237        ];
238        write_row(&mut out, &cells, &widths);
239    }
240    out
241}
242
243/// Display width of `s` measured in terminal columns rather than chars
244/// or bytes. CJK ideographs and most emoji occupy 2 columns; combining
245/// marks and zero-width chars 0; ASCII 1. P30: pre-fix this used
246/// `chars().count()` which broke alignment for any non-ASCII content.
247fn display_width(s: &str) -> usize {
248    use unicode_width::UnicodeWidthStr;
249    UnicodeWidthStr::width(s)
250}
251
252fn column_width(header: &str, rows: &[[String; 5]], idx: usize) -> usize {
253    let header_w = display_width(header);
254    rows.iter()
255        .map(|r| display_width(&r[idx]))
256        .max()
257        .map(|w| w.max(header_w))
258        .unwrap_or(header_w)
259}
260
261fn write_row(out: &mut String, cells: &[&str; 5], widths: &[usize; 5]) {
262    // Two-space gutter between columns; trailing column is unpadded so users
263    // can copy-paste lines without trailing whitespace. Pad each non-last
264    // cell explicitly using the unicode-width-aware delta so CJK / emoji
265    // line up with their declared column. The {:<N$} formatter pads to N
266    // *chars*, not to N display columns, which is wrong for any cell
267    // whose `display_width != chars().count()`.
268    fn pad_cell(out: &mut String, cell: &str, target_width: usize) {
269        out.push_str(cell);
270        let w = display_width(cell);
271        if target_width > w {
272            for _ in 0..(target_width - w) {
273                out.push(' ');
274            }
275        }
276    }
277
278    pad_cell(out, cells[0], widths[0]);
279    out.push_str("  ");
280    pad_cell(out, cells[1], widths[1]);
281    out.push_str("  ");
282    pad_cell(out, cells[2], widths[2]);
283    out.push_str("  ");
284    pad_cell(out, cells[3], widths[3]);
285    out.push_str("  ");
286    out.push_str(cells[4]);
287    out.push('\n');
288}
289
290fn short_hash(hash: &str) -> String {
291    hash.chars().take(TABLE_HASH_LEN).collect()
292}
293
294fn truncate_chars(s: &str, max: usize) -> String {
295    let count = s.chars().count();
296    if count <= max {
297        s.to_owned()
298    } else if max == 0 {
299        String::new()
300    } else {
301        let mut out: String = s.chars().take(max - 1).collect();
302        out.push('…');
303        out
304    }
305}
306
307fn format_decided_at(epoch: i64) -> String {
308    chrono::DateTime::from_timestamp(epoch, 0)
309        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
310        .unwrap_or_else(|| epoch.to_string())
311}
312
313// ══════════════════════════════════════════════════════════════════════
314// `seshat decisions forget`
315// ══════════════════════════════════════════════════════════════════════
316
317/// Implement `seshat decisions forget <hash> [--yes]`.
318///
319/// Resolves `hash` (full description_hash or unambiguous prefix ≥ 4 chars)
320/// against the project's decisions table, prints the matched decision, then
321/// prompts the user for confirmation unless `--yes` was passed. On
322/// confirmation the decision row is hard-deleted; the next `seshat scan`
323/// will re-emit the convention into the review queue (per US-008's bulk
324/// decision lookup, removing the row removes the dedup signal).
325fn run_forget(hash: &str, yes: bool) -> Result<(), CliError> {
326    let resolved = db::resolve_project(None, "decisions")?;
327
328    if !resolved.db_path.exists() {
329        return Err(CliError::CommandFailed {
330            command: "decisions forget".to_owned(),
331            reason: "No database found. Run `seshat scan` first.".to_owned(),
332        });
333    }
334
335    let database = Database::open(&resolved.db_path).map_err(|e| CliError::CommandFailed {
336        command: "decisions forget".to_owned(),
337        reason: format!("failed to open database: {e}"),
338    })?;
339    let repo = SqliteDecisionRepository::new(database.connection().clone());
340
341    let decision = resolve_decision_for_forget(&repo, hash)?;
342
343    let mut stdout = std::io::stdout().lock();
344    let summary = format_decision_summary(&decision);
345    write_tolerating_broken_pipe(&mut stdout, summary.as_bytes())?;
346
347    if !yes && !prompt_for_confirmation(&mut stdout, &mut std::io::stdin().lock())? {
348        writeln!(stdout, "Aborted; decision not removed.")?;
349        return Ok(());
350    }
351
352    repo.delete(&decision.description_hash)
353        .map_err(|e| CliError::CommandFailed {
354            command: "decisions forget".to_owned(),
355            reason: format!("failed to delete decision: {e}"),
356        })?;
357
358    writeln!(
359        stdout,
360        "Removed decision {}.",
361        short_hash(&decision.description_hash)
362    )?;
363    Ok(())
364}
365
366/// Look up a decision by full hash or prefix, returning the unique match.
367///
368/// Errors for prefixes shorter than [`MIN_FORGET_PREFIX_LEN`], for prefixes
369/// that match no decisions, and for prefixes that match more than one. The
370/// ambiguous-match error message lists the short forms of the matched
371/// hashes so the user can disambiguate by lengthening the prefix.
372fn resolve_decision_for_forget<R: DecisionRepository>(
373    repo: &R,
374    hash: &str,
375) -> Result<Decision, CliError> {
376    if hash.len() < MIN_FORGET_PREFIX_LEN {
377        return Err(CliError::InvalidArgument(format!(
378            "decision hash prefix '{hash}' is too short; need at least \
379             {MIN_FORGET_PREFIX_LEN} characters"
380        )));
381    }
382
383    // P25: push the prefix filter into SQL (index-backed range scan)
384    // rather than materialising the full table client-side.
385    let mut matches: Vec<Decision> =
386        repo.find_by_hash_prefix(hash)
387            .map_err(|e| CliError::CommandFailed {
388                command: "decisions forget".to_owned(),
389                reason: format!("failed to read decisions: {e}"),
390            })?;
391
392    match matches.len() {
393        0 => Err(CliError::CommandFailed {
394            command: "decisions forget".to_owned(),
395            reason: format!("no decision matches hash '{hash}'"),
396        }),
397        1 => Ok(matches.swap_remove(0)),
398        _ => {
399            let listed = matches
400                .iter()
401                .map(|d| short_hash(&d.description_hash))
402                .collect::<Vec<_>>()
403                .join(", ");
404            Err(CliError::CommandFailed {
405                command: "decisions forget".to_owned(),
406                reason: format!(
407                    "prefix '{hash}' is ambiguous; matches {} decisions: {listed}",
408                    matches.len()
409                ),
410            })
411        }
412    }
413}
414
415/// Render a decision as a multi-line key:value summary suitable for the
416/// confirmation prompt. The full hash is shown (not truncated) so the user
417/// can verify the exact row that's about to be deleted.
418fn format_decision_summary(decision: &Decision) -> String {
419    let mut out = String::new();
420    let _ = writeln!(out, "Found decision:");
421    let _ = writeln!(out, "  hash:        {}", decision.description_hash);
422    let _ = writeln!(out, "  state:       {}", decision.state.as_sql_str());
423    let _ = writeln!(out, "  nature:      {}", decision.nature.as_sql_str());
424    let _ = writeln!(out, "  weight:      {}", decision.weight.as_sql_str());
425    let _ = writeln!(out, "  description: {}", decision.description);
426    let _ = writeln!(out, "  branch:      {}", decision.decided_on_branch.0);
427    let _ = writeln!(
428        out,
429        "  decided_at:  {}",
430        format_decided_at(decision.decided_at)
431    );
432    out
433}
434
435/// Prompt the user for confirmation on `out`, then read a line from `input`
436/// and return whether the response is an affirmative.
437///
438/// Accepts `y` or `yes` (case-insensitive) as positive; everything else —
439/// including the empty default response — is treated as decline. Mirrors the
440/// `[y/N]` style that `git` and other CLI tools use, with the lowercase `n`
441/// signalling that "no" is the safe default.
442fn prompt_for_confirmation<W: Write, R: std::io::BufRead>(
443    out: &mut W,
444    input: &mut R,
445) -> Result<bool, CliError> {
446    write!(out, "Forget this decision? [y/N]: ")?;
447    out.flush()?;
448    let mut response = String::new();
449    let bytes = input.read_line(&mut response)?;
450    // P31: distinguish "user pressed Enter" (1 byte: `\n`) from EOF
451    // (0 bytes — happens when stdin is closed, e.g. piped from
452    // /dev/null or run under a non-interactive shell). The intent
453    // there is "I cannot answer; do not delete by default", which
454    // is a refusal — but the caller deserves a clear error so they
455    // can pass --yes if they meant the unattended path.
456    if bytes == 0 {
457        return Err(CliError::CommandFailed {
458            command: "decisions forget".to_owned(),
459            reason: "stdin closed before confirmation; pass --yes to skip the \
460                     prompt for unattended runs"
461                .to_owned(),
462        });
463    }
464    let trimmed = response.trim().to_ascii_lowercase();
465    Ok(trimmed == "y" || trimmed == "yes")
466}
467
468// ══════════════════════════════════════════════════════════════════════
469// `seshat decisions export` / `seshat decisions import`
470// ══════════════════════════════════════════════════════════════════════
471
472/// Owned mirror of [`DecisionJson`] used for round-trip
473/// serialisation/deserialisation.
474///
475/// [`DecisionJson`] borrows from a [`Decision`] for efficient export, but
476/// import needs an owned shape that `serde_json` can deserialise into. Both
477/// types share the same field names so the wire format is identical: a JSON
478/// array produced by `seshat decisions export` deserialises cleanly into
479/// `Vec<DecisionJsonOwned>`.
480#[derive(Debug, Clone, Serialize, Deserialize)]
481struct DecisionJsonOwned {
482    description_hash: String,
483    description: String,
484    state: String,
485    nature: String,
486    weight: String,
487    category: Option<String>,
488    reason: Option<String>,
489    examples: Vec<ExampleEvidence>,
490    decided_on_branch: String,
491    decided_at: i64,
492    updated_at: i64,
493}
494
495impl DecisionJsonOwned {
496    fn into_decision(self) -> Result<Decision, CliError> {
497        let state =
498            DecisionState::from_sql_str(&self.state).map_err(|e| CliError::CommandFailed {
499                command: "decisions import".to_owned(),
500                reason: format!("invalid state for hash '{}': {e}", self.description_hash),
501            })?;
502        let nature =
503            DecisionNature::from_sql_str(&self.nature).map_err(|e| CliError::CommandFailed {
504                command: "decisions import".to_owned(),
505                reason: format!("invalid nature for hash '{}': {e}", self.description_hash),
506            })?;
507        let weight =
508            DecisionWeight::from_sql_str(&self.weight).map_err(|e| CliError::CommandFailed {
509                command: "decisions import".to_owned(),
510                reason: format!("invalid weight for hash '{}': {e}", self.description_hash),
511            })?;
512        Ok(Decision {
513            description_hash: self.description_hash,
514            description: self.description,
515            state,
516            nature,
517            weight,
518            category: self.category,
519            reason: self.reason,
520            examples: self.examples,
521            decided_on_branch: BranchId(self.decided_on_branch),
522            decided_at: self.decided_at,
523            updated_at: self.updated_at,
524        })
525    }
526}
527
528/// Outcome of an import operation.
529///
530/// Returned by [`import_decisions_from_str`] so the caller (CLI or test)
531/// can render or assert against the result.
532#[derive(Debug, Clone, PartialEq, Eq)]
533pub struct ImportSummary {
534    /// Total rows in the import payload.
535    pub total: usize,
536    /// Rows newly inserted (no prior row with this hash).
537    pub inserted: usize,
538    /// Rows that updated an existing row because the imported `decided_at`
539    /// was strictly greater than the existing one.
540    pub updated: usize,
541    /// Rows skipped because an existing row with a `decided_at` ≥ the
542    /// imported one was already present (incumbent kept).
543    pub skipped: usize,
544}
545
546/// Atomically write `bytes` to `path` using a temp-file-and-rename
547/// pattern. P32: pre-fix `std::fs::write` left a truncated file behind
548/// if the process was killed mid-write, and a subsequent import would
549/// fail with a JSON parse error against half a payload.
550///
551/// The temp file is named `.path.<pid>.tmp` next to the target so the
552/// rename happens within the same filesystem. Rename is atomic on
553/// POSIX/NTFS for files on the same volume.
554fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), CliError> {
555    use std::io::Write;
556
557    let parent = path.parent().unwrap_or(Path::new("."));
558    let file_name = path
559        .file_name()
560        .and_then(|n| n.to_str())
561        .unwrap_or("decisions-export");
562    let tmp_name = format!(".{file_name}.{}.tmp", std::process::id());
563    let tmp_path = parent.join(tmp_name);
564
565    {
566        let mut tmp = std::fs::File::create(&tmp_path).map_err(|e| CliError::IoWithPath {
567            message: format!("failed to create export temp file: {e}"),
568            path: tmp_path.clone(),
569        })?;
570        tmp.write_all(bytes).map_err(|e| CliError::IoWithPath {
571            message: format!("failed to write decisions export: {e}"),
572            path: tmp_path.clone(),
573        })?;
574        tmp.sync_all().map_err(|e| CliError::IoWithPath {
575            message: format!("failed to fsync export temp file: {e}"),
576            path: tmp_path.clone(),
577        })?;
578    }
579
580    std::fs::rename(&tmp_path, path).map_err(|e| {
581        // Best-effort cleanup so we don't leave the temp file behind.
582        let _ = std::fs::remove_file(&tmp_path);
583        CliError::IoWithPath {
584            message: format!("failed to atomically rename export to target: {e}"),
585            path: path.to_owned(),
586        }
587    })?;
588    Ok(())
589}
590
591/// Implement `seshat decisions export <file>`.
592fn run_export(file: &Path) -> Result<(), CliError> {
593    let resolved = db::resolve_project(None, "decisions")?;
594
595    if !resolved.db_path.exists() {
596        return Err(CliError::CommandFailed {
597            command: "decisions export".to_owned(),
598            reason: "No database found. Run `seshat scan` first.".to_owned(),
599        });
600    }
601
602    let database = Database::open(&resolved.db_path).map_err(|e| CliError::CommandFailed {
603        command: "decisions export".to_owned(),
604        reason: format!("failed to open database: {e}"),
605    })?;
606
607    let json = export_decisions_to_string(&database)?;
608    write_atomic(file, json.as_bytes())?;
609
610    let count = export_count(&database)?;
611    let mut stdout = std::io::stdout().lock();
612    writeln!(
613        stdout,
614        "Exported {count} decision{plural} to {path}",
615        plural = if count == 1 { "" } else { "s" },
616        path = file.display(),
617    )?;
618    Ok(())
619}
620
621/// Read all decisions from `database` and serialise them as a pretty-printed
622/// JSON array. Matches the wire shape used by `seshat decisions list
623/// --format json` so a round-trip via `decisions import` is lossless.
624///
625/// Public seam for the integration / round-trip test.
626pub fn export_decisions_to_string(database: &Database) -> Result<String, CliError> {
627    let repo = SqliteDecisionRepository::new(database.connection().clone());
628    let decisions = repo.list().map_err(|e| CliError::CommandFailed {
629        command: "decisions export".to_owned(),
630        reason: format!("failed to read decisions: {e}"),
631    })?;
632
633    let dtos: Vec<DecisionJson<'_>> = decisions.iter().map(DecisionJson::from).collect();
634    let mut json = serde_json::to_string_pretty(&dtos).map_err(|e| CliError::CommandFailed {
635        command: "decisions export".to_owned(),
636        reason: format!("failed to serialise decisions to JSON: {e}"),
637    })?;
638    json.push('\n');
639    Ok(json)
640}
641
642fn export_count(database: &Database) -> Result<usize, CliError> {
643    let repo = SqliteDecisionRepository::new(database.connection().clone());
644    repo.list()
645        .map(|v| v.len())
646        .map_err(|e| CliError::CommandFailed {
647            command: "decisions export".to_owned(),
648            reason: format!("failed to read decisions: {e}"),
649        })
650}
651
652/// Implement `seshat decisions import <file> [--strict]`.
653fn run_import(file: &Path, strict: bool) -> Result<(), CliError> {
654    let resolved = db::resolve_project(None, "decisions")?;
655
656    if !resolved.db_path.exists() {
657        return Err(CliError::CommandFailed {
658            command: "decisions import".to_owned(),
659            reason: "No database found. Run `seshat scan` first.".to_owned(),
660        });
661    }
662
663    let database = Database::open(&resolved.db_path).map_err(|e| CliError::CommandFailed {
664        command: "decisions import".to_owned(),
665        reason: format!("failed to open database: {e}"),
666    })?;
667
668    let json = std::fs::read_to_string(file).map_err(|e| CliError::IoWithPath {
669        message: format!("failed to read decisions import file: {e}"),
670        path: file.to_owned(),
671    })?;
672
673    let summary = import_decisions_from_str(&database, &json, strict)?;
674
675    let mut stdout = std::io::stdout().lock();
676    writeln!(
677        stdout,
678        "Imported {} decision{plural} ({} new, {} updated, {} skipped).",
679        summary.inserted + summary.updated,
680        summary.inserted,
681        summary.updated,
682        summary.skipped,
683        plural = if summary.inserted + summary.updated == 1 {
684            ""
685        } else {
686            "s"
687        },
688    )?;
689    Ok(())
690}
691
692/// Parse `json` as a `Vec<DecisionJsonOwned>` and apply each row against
693/// `database` per the conflict policy:
694///
695/// - `strict = false` (default): for each incoming row, compare its
696///   `decided_at` against the existing row's `decided_at` (if any). If the
697///   incoming row is strictly newer, UPSERT it; otherwise leave the existing
698///   row untouched. New rows (no existing match) are always inserted.
699/// - `strict = true`: any incoming hash that already has a row in the DB
700///   aborts the import with [`CliError::CommandFailed`]; no writes happen.
701///
702/// Errors:
703/// - Malformed JSON → `CommandFailed`.
704/// - Invalid enum string in any row → `CommandFailed`.
705/// - DB read/write failure → `CommandFailed`.
706///
707/// Public seam for unit and round-trip tests so they can drive the import
708/// without going through `db::resolve_project` and the project-wide DB
709/// path resolution.
710pub fn import_decisions_from_str(
711    database: &Database,
712    json: &str,
713    strict: bool,
714) -> Result<ImportSummary, CliError> {
715    let parsed: Vec<DecisionJsonOwned> =
716        serde_json::from_str(json).map_err(|e| CliError::CommandFailed {
717            command: "decisions import".to_owned(),
718            reason: format!("failed to parse decisions JSON: {e}"),
719        })?;
720
721    let total = parsed.len();
722    let repo = SqliteDecisionRepository::new(database.connection().clone());
723
724    // Strict mode: pre-flight all hashes BEFORE any writes so a single
725    // conflict aborts the entire import. We collect every conflicting hash
726    // (not just the first) so the error message is actionable.
727    if strict {
728        let hash_refs: Vec<&str> = parsed.iter().map(|d| d.description_hash.as_str()).collect();
729        let existing = repo
730            .get_by_hashes(&hash_refs)
731            .map_err(|e| CliError::CommandFailed {
732                command: "decisions import".to_owned(),
733                reason: format!("failed to look up existing decisions: {e}"),
734            })?;
735        if !existing.is_empty() {
736            let mut conflicts: Vec<&str> = existing.keys().map(String::as_str).collect();
737            conflicts.sort_unstable();
738            return Err(CliError::CommandFailed {
739                command: "decisions import".to_owned(),
740                reason: format!(
741                    "strict mode: {} hash conflict{} detected; aborting import: {}",
742                    conflicts.len(),
743                    if conflicts.len() == 1 { "" } else { "s" },
744                    conflicts.join(", "),
745                ),
746            });
747        }
748    }
749
750    let mut summary = ImportSummary {
751        total,
752        inserted: 0,
753        updated: 0,
754        skipped: 0,
755    };
756
757    // P27: wrap the whole import in one transaction. Pre-fix every
758    // repo.upsert ran in its own implicit transaction, so a failure
759    // halfway through left the DB partially populated — bad for both
760    // atomicity (caller sees partial state) and performance (per-row
761    // commit overhead). With BEGIN IMMEDIATE the loop becomes a single
762    // unit: full success → COMMIT; any failure → ROLLBACK and the user
763    // can re-run the import with the original payload unchanged.
764    //
765    // The connection's transaction state is per-connection, so the
766    // BEGIN here covers every subsequent repo.upsert / repo.get_by_hash
767    // (each re-acquires the same Mutex<Connection>) until the closing
768    // COMMIT/ROLLBACK below.
769    {
770        let guard = database
771            .connection()
772            .lock()
773            .map_err(|e| CliError::CommandFailed {
774                command: "decisions import".to_owned(),
775                reason: format!("failed to acquire DB lock for transaction: {e}"),
776            })?;
777        guard
778            .execute_batch("BEGIN IMMEDIATE")
779            .map_err(|e| CliError::CommandFailed {
780                command: "decisions import".to_owned(),
781                reason: format!("failed to begin transaction: {e}"),
782            })?;
783    }
784
785    // P26: bulk-fetch existing rows in ONE SELECT and look them up
786    // in-memory, instead of issuing one repo.get_by_hash per entry
787    // (the pre-fix N+1: ~5k SELECTs for a 5k-row import). The HashMap
788    // returned by get_by_hashes mirrors what the per-row path
789    // produced; the rest of the loop's branching stays identical.
790    // Note: strict-mode does its own get_by_hashes pre-flight above
791    // and returns early on conflict, so this fetch is non-strict only.
792    let existing_map = {
793        let hash_refs: Vec<&str> = parsed.iter().map(|d| d.description_hash.as_str()).collect();
794        repo.get_by_hashes(&hash_refs)
795            .map_err(|e| CliError::CommandFailed {
796                command: "decisions import".to_owned(),
797                reason: format!("failed to bulk-look up existing decisions: {e}"),
798            })?
799    };
800
801    let txn_result: Result<ImportSummary, CliError> = (|| {
802        for entry in parsed {
803            let decision = entry.into_decision()?;
804            match existing_map.get(&decision.description_hash).cloned() {
805                None => {
806                    repo.upsert(&decision)
807                        .map_err(|e| CliError::CommandFailed {
808                            command: "decisions import".to_owned(),
809                            reason: format!(
810                                "failed to insert decision '{}': {e}",
811                                decision.description_hash
812                            ),
813                        })?;
814                    summary.inserted += 1;
815                }
816                Some(existing) => {
817                    // "Latest decided_at wins" — strict greater-than so equal
818                    // timestamps preserve the incumbent (deterministic, avoids
819                    // churn on round-trips of unchanged rows).
820                    if decision.decided_at > existing.decided_at {
821                        repo.upsert(&decision)
822                            .map_err(|e| CliError::CommandFailed {
823                                command: "decisions import".to_owned(),
824                                reason: format!(
825                                    "failed to update decision '{}': {e}",
826                                    decision.description_hash
827                                ),
828                            })?;
829                        summary.updated += 1;
830                    } else {
831                        summary.skipped += 1;
832                    }
833                }
834            }
835        }
836        Ok(summary)
837    })();
838
839    // Commit or rollback the transaction opened above. A best-effort
840    // ROLLBACK on failure keeps the DB at its pre-import state.
841    {
842        let guard = database
843            .connection()
844            .lock()
845            .map_err(|e| CliError::CommandFailed {
846                command: "decisions import".to_owned(),
847                reason: format!("failed to re-acquire DB lock for COMMIT: {e}"),
848            })?;
849        match &txn_result {
850            Ok(_) => guard
851                .execute_batch("COMMIT")
852                .map_err(|e| CliError::CommandFailed {
853                    command: "decisions import".to_owned(),
854                    reason: format!("failed to commit transaction: {e}"),
855                })?,
856            Err(_) => {
857                // Errors during ROLLBACK are logged but never overwrite the
858                // primary error — the caller cares about the original cause.
859                if let Err(rb) = guard.execute_batch("ROLLBACK") {
860                    tracing::warn!("decisions import: ROLLBACK after error failed: {rb}");
861                }
862            }
863        }
864    }
865
866    txn_result
867}
868
869// ══════════════════════════════════════════════════════════════════════
870// Test-only seam for the `forget` integration test
871// ══════════════════════════════════════════════════════════════════════
872
873/// Resolve and hard-delete a decision by hash or prefix, returning the
874/// removed [`Decision`]. Public seam for the integration test
875/// (`tests/decisions_forget.rs`) that exercises the full
876/// "scan → confirm → forget → rescan re-emits" flow without involving stdin.
877///
878/// This bypasses both project resolution and the interactive prompt: the
879/// caller supplies an already-open [`Database`], and the helper performs
880/// the same resolve-then-delete sequence the `--yes` path uses.
881pub fn forget_decision_with_database(
882    database: &Database,
883    hash: &str,
884) -> Result<Decision, CliError> {
885    let repo = SqliteDecisionRepository::new(database.connection().clone());
886    let decision = resolve_decision_for_forget(&repo, hash)?;
887    repo.delete(&decision.description_hash)
888        .map_err(|e| CliError::CommandFailed {
889            command: "decisions forget".to_owned(),
890            reason: format!("failed to delete decision: {e}"),
891        })?;
892    Ok(decision)
893}
894
895// ══════════════════════════════════════════════════════════════════════
896// Tests
897// ══════════════════════════════════════════════════════════════════════
898
899#[cfg(test)]
900mod tests {
901    use super::*;
902    use seshat_core::BranchId;
903    use seshat_storage::{DecisionNature, DecisionWeight};
904
905    fn make_db() -> Database {
906        Database::open(":memory:").expect("in-memory DB")
907    }
908
909    fn make_decision(
910        hash: &str,
911        description: &str,
912        state: DecisionState,
913        branch: &str,
914        decided_at: i64,
915    ) -> Decision {
916        Decision {
917            description_hash: hash.to_owned(),
918            description: description.to_owned(),
919            state,
920            nature: DecisionNature::Convention,
921            weight: DecisionWeight::Rule,
922            category: Some("logging".to_owned()),
923            reason: Some("because tests".to_owned()),
924            examples: vec![ExampleEvidence {
925                file: "src/lib.rs".to_owned(),
926                line: 1,
927                end_line: 3,
928                snippet: "tracing::info!()".to_owned(),
929            }],
930            decided_on_branch: BranchId(branch.to_owned()),
931            decided_at,
932            updated_at: decided_at,
933        }
934    }
935
936    fn populate(db: &Database) {
937        let repo = SqliteDecisionRepository::new(db.connection().clone());
938        repo.upsert(&make_decision(
939            "aaaaaaaa1111",
940            "Use anyhow for error propagation",
941            DecisionState::Approved,
942            "main",
943            1_700_000_100,
944        ))
945        .unwrap();
946        repo.upsert(&make_decision(
947            "bbbbbbbb2222",
948            "Allow unwrap() in production",
949            DecisionState::Rejected,
950            "feature/x",
951            1_700_000_200,
952        ))
953        .unwrap();
954        repo.upsert(&make_decision(
955            "cccccccc3333",
956            "Partial: tracing::info for hot paths only",
957            DecisionState::Partial,
958            "main",
959            1_700_000_300,
960        ))
961        .unwrap();
962        repo.upsert(&make_decision(
963            "dddddddd4444",
964            "Recorded decision via MCP",
965            DecisionState::Recorded,
966            "main",
967            1_700_000_400,
968        ))
969        .unwrap();
970    }
971
972    // ── format_decisions_table ───────────────────────────────────────
973
974    #[test]
975    fn format_decisions_table_empty_returns_friendly_message() {
976        let out = format_decisions_table(&[]);
977        assert_eq!(out, "No decisions recorded.\n");
978    }
979
980    #[test]
981    fn format_decisions_table_populated_includes_header_and_rows() {
982        let db = make_db();
983        populate(&db);
984        let decisions = load_decisions(&db, None, None).unwrap();
985
986        let table = format_decisions_table(&decisions);
987
988        // Header row.
989        assert!(table.contains("state"), "missing state header: {table}");
990        assert!(table.contains("hash"), "missing hash header: {table}");
991        assert!(
992            table.contains("description"),
993            "missing description header: {table}"
994        );
995        assert!(
996            table.contains("decided_on_branch"),
997            "missing branch header: {table}"
998        );
999        assert!(
1000            table.contains("decided_at"),
1001            "missing decided_at header: {table}"
1002        );
1003
1004        // Each state value appears at least once.
1005        for state in ["approved", "rejected", "partial", "recorded"] {
1006            assert!(table.contains(state), "missing state {state}: {table}");
1007        }
1008
1009        // Hash prefix appears (TABLE_HASH_LEN = 8).
1010        assert!(table.contains("aaaaaaaa"));
1011        assert!(table.contains("bbbbbbbb"));
1012
1013        // Branches appear.
1014        assert!(table.contains("main"));
1015        assert!(table.contains("feature/x"));
1016
1017        // Description text appears (un-truncated since it fits in 60 chars).
1018        assert!(table.contains("Use anyhow for error propagation"));
1019    }
1020
1021    #[test]
1022    fn format_decisions_table_aligns_cjk_descriptions_by_display_width() {
1023        // P30: a CJK ideograph occupies 2 terminal columns but counts as
1024        // 1 char. Pre-fix `chars().count()` width gave us a column that
1025        // visually overflowed — columns past the description shifted
1026        // right by N×1 bytes per CJK char in the description.
1027        //
1028        // Construct two rows where descriptions have IDENTICAL display
1029        // width (10 columns) but different char counts: 5 CJK chars
1030        // vs 10 ASCII chars. After formatting, the next column
1031        // (`decided_on_branch`) must start at the same byte offset on
1032        // both lines.
1033        let cjk_desc = "中文中文中"; // 5 chars × 2 cols = 10 cols
1034        let ascii_desc = "0123456789"; // 10 chars × 1 col = 10 cols
1035        let d1 = make_decision("aaaa1111", cjk_desc, DecisionState::Approved, "main", 0);
1036        let d2 = make_decision("bbbb2222", ascii_desc, DecisionState::Approved, "main", 0);
1037        let table = format_decisions_table(&[d1, d2]);
1038        let lines: Vec<&str> = table.lines().collect();
1039        // Header + 2 rows.
1040        assert_eq!(lines.len(), 3, "expected header + 2 rows in:\n{table}");
1041
1042        // Compare DISPLAY-COLUMN position of the `main` token on each
1043        // row, not byte offset (CJK chars take 3 bytes but render in 2
1044        // columns). Width up to and including "main" must match.
1045        use unicode_width::UnicodeWidthStr;
1046        let pos1 = lines[1].find("main").expect("row1 has main");
1047        let pos2 = lines[2].find("main").expect("row2 has main");
1048        let cols1 = UnicodeWidthStr::width(&lines[1][..pos1]);
1049        let cols2 = UnicodeWidthStr::width(&lines[2][..pos2]);
1050        assert_eq!(
1051            cols1, cols2,
1052            "decided_on_branch column must start at the same DISPLAY column \
1053             on both rows; got CJK row at col {cols1}, ASCII row at col \
1054             {cols2}.\n{table}"
1055        );
1056    }
1057
1058    #[test]
1059    fn format_decisions_table_truncates_long_description() {
1060        let long = "x".repeat(200);
1061        let d = make_decision("h", &long, DecisionState::Approved, "main", 1_700_000_000);
1062        let table = format_decisions_table(std::slice::from_ref(&d));
1063
1064        // Should NOT contain the full 200-char string.
1065        assert!(!table.contains(&long));
1066        // Should contain ellipsis indicating truncation.
1067        assert!(table.contains('…'), "expected ellipsis: {table}");
1068    }
1069
1070    // ── format_decisions_json ────────────────────────────────────────
1071
1072    #[test]
1073    fn format_decisions_json_empty_is_valid_json_array() {
1074        let out = format_decisions_json(&[]).unwrap();
1075        let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
1076        assert!(parsed.is_array());
1077        assert_eq!(parsed.as_array().unwrap().len(), 0);
1078        assert!(out.ends_with('\n'));
1079    }
1080
1081    #[test]
1082    fn format_decisions_json_populated_is_valid_json_array() {
1083        let db = make_db();
1084        populate(&db);
1085        let decisions = load_decisions(&db, None, None).unwrap();
1086        assert_eq!(decisions.len(), 4);
1087
1088        let out = format_decisions_json(&decisions).unwrap();
1089        let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
1090        let arr = parsed.as_array().expect("top-level array");
1091        assert_eq!(arr.len(), 4);
1092
1093        // Each item has the required Decision shape.
1094        for item in arr {
1095            let obj = item.as_object().expect("object");
1096            for key in [
1097                "description_hash",
1098                "description",
1099                "state",
1100                "nature",
1101                "weight",
1102                "category",
1103                "reason",
1104                "examples",
1105                "decided_on_branch",
1106                "decided_at",
1107                "updated_at",
1108            ] {
1109                assert!(obj.contains_key(key), "missing key {key} in {item}");
1110            }
1111        }
1112    }
1113
1114    #[test]
1115    fn format_decisions_json_uses_sql_state_strings() {
1116        // Pin the wire shape: enum values render as the same lowercase strings
1117        // used in the SQL CHECK constraints, not as PascalCase Rust variants.
1118        let d = make_decision("h", "x", DecisionState::Approved, "main", 1_700_000_000);
1119        let out = format_decisions_json(std::slice::from_ref(&d)).unwrap();
1120        assert!(out.contains("\"state\": \"approved\""), "got: {out}");
1121        assert!(out.contains("\"nature\": \"convention\""));
1122        assert!(out.contains("\"weight\": \"rule\""));
1123    }
1124
1125    // ── load_decisions filters ───────────────────────────────────────
1126
1127    #[test]
1128    fn load_decisions_empty_db_returns_empty_vec() {
1129        let db = make_db();
1130        let result = load_decisions(&db, None, None).unwrap();
1131        assert!(result.is_empty());
1132    }
1133
1134    #[test]
1135    fn load_decisions_no_filter_returns_all() {
1136        let db = make_db();
1137        populate(&db);
1138        let result = load_decisions(&db, None, None).unwrap();
1139        assert_eq!(result.len(), 4);
1140    }
1141
1142    #[test]
1143    fn load_decisions_filters_by_state() {
1144        let db = make_db();
1145        populate(&db);
1146
1147        let approved = load_decisions(&db, Some(DecisionStateFilter::Approved), None).unwrap();
1148        assert_eq!(approved.len(), 1);
1149        assert_eq!(approved[0].state, DecisionState::Approved);
1150
1151        let rejected = load_decisions(&db, Some(DecisionStateFilter::Rejected), None).unwrap();
1152        assert_eq!(rejected.len(), 1);
1153        assert_eq!(rejected[0].state, DecisionState::Rejected);
1154
1155        let partial = load_decisions(&db, Some(DecisionStateFilter::Partial), None).unwrap();
1156        assert_eq!(partial.len(), 1);
1157
1158        let recorded = load_decisions(&db, Some(DecisionStateFilter::Recorded), None).unwrap();
1159        assert_eq!(recorded.len(), 1);
1160    }
1161
1162    #[test]
1163    fn load_decisions_filters_by_branch() {
1164        let db = make_db();
1165        populate(&db);
1166
1167        let main_only = load_decisions(&db, None, Some("main")).unwrap();
1168        assert_eq!(main_only.len(), 3);
1169        assert!(main_only.iter().all(|d| d.decided_on_branch.0 == "main"));
1170
1171        let feature = load_decisions(&db, None, Some("feature/x")).unwrap();
1172        assert_eq!(feature.len(), 1);
1173        assert_eq!(feature[0].decided_on_branch.0, "feature/x");
1174
1175        let unknown = load_decisions(&db, None, Some("does-not-exist")).unwrap();
1176        assert!(unknown.is_empty());
1177    }
1178
1179    #[test]
1180    fn load_decisions_combined_state_and_branch_filter() {
1181        let db = make_db();
1182        populate(&db);
1183
1184        // Approved on main → exactly one (the "Use anyhow…" one).
1185        let result =
1186            load_decisions(&db, Some(DecisionStateFilter::Approved), Some("main")).unwrap();
1187        assert_eq!(result.len(), 1);
1188        assert_eq!(result[0].description_hash, "aaaaaaaa1111");
1189
1190        // Rejected on main → none (the rejected row is on feature/x).
1191        let result =
1192            load_decisions(&db, Some(DecisionStateFilter::Rejected), Some("main")).unwrap();
1193        assert!(result.is_empty());
1194    }
1195
1196    // ── helpers ──────────────────────────────────────────────────────
1197
1198    #[test]
1199    fn short_hash_truncates_to_eight_chars() {
1200        assert_eq!(short_hash("abcdef0123456789"), "abcdef01");
1201        // Short inputs are returned as-is.
1202        assert_eq!(short_hash("abc"), "abc");
1203        // Exactly TABLE_HASH_LEN is returned unchanged.
1204        assert_eq!(short_hash("abcdefgh"), "abcdefgh");
1205    }
1206
1207    #[test]
1208    fn truncate_chars_returns_input_when_short_enough() {
1209        assert_eq!(truncate_chars("hello", 10), "hello");
1210        // Boundary: equal length is unchanged (no ellipsis).
1211        assert_eq!(truncate_chars("hello", 5), "hello");
1212    }
1213
1214    #[test]
1215    fn truncate_chars_appends_ellipsis_when_too_long() {
1216        let out = truncate_chars("0123456789", 6);
1217        // 5 chars + ellipsis = 6 visible glyphs.
1218        assert_eq!(out, "01234…");
1219    }
1220
1221    #[test]
1222    fn format_decided_at_formats_unix_timestamp() {
1223        // 1_700_000_000 is 2023-11-14 22:13:20 UTC.
1224        let out = format_decided_at(1_700_000_000);
1225        assert_eq!(out, "2023-11-14 22:13:20");
1226    }
1227
1228    // ── arg conversion ───────────────────────────────────────────────
1229
1230    #[test]
1231    fn decision_state_filter_converts_to_storage_enum() {
1232        assert_eq!(
1233            DecisionState::from(DecisionStateFilter::Approved),
1234            DecisionState::Approved
1235        );
1236        assert_eq!(
1237            DecisionState::from(DecisionStateFilter::Rejected),
1238            DecisionState::Rejected
1239        );
1240        assert_eq!(
1241            DecisionState::from(DecisionStateFilter::Partial),
1242            DecisionState::Partial
1243        );
1244        assert_eq!(
1245            DecisionState::from(DecisionStateFilter::Recorded),
1246            DecisionState::Recorded
1247        );
1248    }
1249
1250    // ── resolve_decision_for_forget ──────────────────────────────────
1251
1252    #[test]
1253    fn resolve_decision_for_forget_returns_exact_match_for_full_hash() {
1254        let db = make_db();
1255        populate(&db);
1256        let repo = SqliteDecisionRepository::new(db.connection().clone());
1257
1258        let resolved = resolve_decision_for_forget(&repo, "aaaaaaaa1111").unwrap();
1259        assert_eq!(resolved.description_hash, "aaaaaaaa1111");
1260        assert_eq!(resolved.state, DecisionState::Approved);
1261    }
1262
1263    #[test]
1264    fn resolve_decision_for_forget_returns_unique_match_for_prefix() {
1265        let db = make_db();
1266        populate(&db);
1267        let repo = SqliteDecisionRepository::new(db.connection().clone());
1268
1269        // 4-char prefix uniquely identifying the "Use anyhow…" row.
1270        let resolved = resolve_decision_for_forget(&repo, "aaaa").unwrap();
1271        assert_eq!(resolved.description_hash, "aaaaaaaa1111");
1272    }
1273
1274    #[test]
1275    fn resolve_decision_for_forget_rejects_short_prefix() {
1276        let db = make_db();
1277        populate(&db);
1278        let repo = SqliteDecisionRepository::new(db.connection().clone());
1279
1280        let err = resolve_decision_for_forget(&repo, "abc").unwrap_err();
1281        let msg = err.to_string();
1282        // The min-length guard fires BEFORE the lookup, so the error must
1283        // mention the minimum-length contract regardless of whether a
1284        // matching prefix exists.
1285        assert!(msg.contains("too short"), "got: {msg}");
1286        assert!(msg.contains("4"), "must mention the 4-char minimum: {msg}");
1287    }
1288
1289    #[test]
1290    fn resolve_decision_for_forget_rejects_short_prefix_even_when_unique() {
1291        // The min-length guard is a CLI-level safety rail, not just a
1292        // disambiguation aid. Even when "abc" would uniquely match a row
1293        // (DB has only one decision starting with "abc"), the rule applies.
1294        let db = make_db();
1295        let repo = SqliteDecisionRepository::new(db.connection().clone());
1296        repo.upsert(&make_decision(
1297            "abc",
1298            "test",
1299            DecisionState::Approved,
1300            "main",
1301            1,
1302        ))
1303        .unwrap();
1304
1305        let err = resolve_decision_for_forget(&repo, "abc").unwrap_err();
1306        assert!(err.to_string().contains("too short"));
1307    }
1308
1309    #[test]
1310    fn resolve_decision_for_forget_returns_not_found_for_unmatched_prefix() {
1311        let db = make_db();
1312        populate(&db);
1313        let repo = SqliteDecisionRepository::new(db.connection().clone());
1314
1315        let err = resolve_decision_for_forget(&repo, "ffff0000").unwrap_err();
1316        let msg = err.to_string();
1317        assert!(msg.contains("no decision matches"), "got: {msg}");
1318        assert!(msg.contains("ffff0000"), "must echo the input: {msg}");
1319    }
1320
1321    #[test]
1322    fn resolve_decision_for_forget_returns_ambiguous_for_multiple_matches() {
1323        let db = make_db();
1324        let repo = SqliteDecisionRepository::new(db.connection().clone());
1325        // Two decisions sharing a 4-char prefix.
1326        repo.upsert(&make_decision(
1327            "aaaa1111",
1328            "first",
1329            DecisionState::Approved,
1330            "main",
1331            1,
1332        ))
1333        .unwrap();
1334        repo.upsert(&make_decision(
1335            "aaaa2222",
1336            "second",
1337            DecisionState::Rejected,
1338            "main",
1339            2,
1340        ))
1341        .unwrap();
1342
1343        let err = resolve_decision_for_forget(&repo, "aaaa").unwrap_err();
1344        let msg = err.to_string();
1345        assert!(msg.contains("ambiguous"), "got: {msg}");
1346        // Should list both matched (short) hashes so the user can lengthen.
1347        assert!(msg.contains("aaaa1111"), "missing first hash: {msg}");
1348        assert!(msg.contains("aaaa2222"), "missing second hash: {msg}");
1349    }
1350
1351    // ── format_decision_summary ──────────────────────────────────────
1352
1353    #[test]
1354    fn format_decision_summary_includes_full_hash_and_key_fields() {
1355        let d = make_decision(
1356            "aaaaaaaa1111",
1357            "Use anyhow for error propagation",
1358            DecisionState::Approved,
1359            "main",
1360            1_700_000_000,
1361        );
1362        let summary = format_decision_summary(&d);
1363        // Full hash, not truncated, so the user can confirm the exact row.
1364        assert!(summary.contains("aaaaaaaa1111"));
1365        assert!(summary.contains("approved"));
1366        assert!(summary.contains("convention"));
1367        assert!(summary.contains("rule"));
1368        assert!(summary.contains("Use anyhow for error propagation"));
1369        assert!(summary.contains("main"));
1370        // Formatted timestamp, not raw epoch.
1371        assert!(summary.contains("2023-11-14 22:13:20"));
1372    }
1373
1374    // ── prompt_for_confirmation ──────────────────────────────────────
1375
1376    #[test]
1377    fn prompt_for_confirmation_treats_y_as_affirmative() {
1378        let mut out: Vec<u8> = Vec::new();
1379        let mut input = std::io::Cursor::new(b"y\n".to_vec());
1380        assert!(prompt_for_confirmation(&mut out, &mut input).unwrap());
1381        let prompt = String::from_utf8(out).unwrap();
1382        assert!(prompt.contains("Forget this decision?"));
1383        assert!(prompt.contains("[y/N]"), "must show the [y/N] hint");
1384    }
1385
1386    #[test]
1387    fn prompt_for_confirmation_accepts_uppercase_yes() {
1388        let mut out: Vec<u8> = Vec::new();
1389        let mut input = std::io::Cursor::new(b"YES\n".to_vec());
1390        assert!(prompt_for_confirmation(&mut out, &mut input).unwrap());
1391    }
1392
1393    #[test]
1394    fn prompt_for_confirmation_treats_n_as_decline() {
1395        let mut out: Vec<u8> = Vec::new();
1396        let mut input = std::io::Cursor::new(b"n\n".to_vec());
1397        assert!(!prompt_for_confirmation(&mut out, &mut input).unwrap());
1398    }
1399
1400    #[test]
1401    fn prompt_for_confirmation_treats_empty_default_as_decline() {
1402        // Pressing Enter with no input must NOT delete — the [y/N] convention
1403        // is "lowercase n is the default, deletions are explicit only".
1404        let mut out: Vec<u8> = Vec::new();
1405        let mut input = std::io::Cursor::new(b"\n".to_vec());
1406        assert!(!prompt_for_confirmation(&mut out, &mut input).unwrap());
1407    }
1408
1409    #[test]
1410    fn prompt_for_confirmation_treats_unrelated_input_as_decline() {
1411        // Anything that isn't y/yes is a decline — preserves the safe default.
1412        let mut out: Vec<u8> = Vec::new();
1413        let mut input = std::io::Cursor::new(b"maybe\n".to_vec());
1414        assert!(!prompt_for_confirmation(&mut out, &mut input).unwrap());
1415    }
1416
1417    #[test]
1418    fn prompt_for_confirmation_returns_error_on_eof_before_input() {
1419        // P31: stdin closed (0-byte read) is distinguishable from "user
1420        // pressed Enter" (1-byte `\n`). The 0-byte case must surface a
1421        // typed CommandFailed error pointing the user at --yes for
1422        // unattended use, not silently treat it as decline.
1423        let mut out: Vec<u8> = Vec::new();
1424        let mut input = std::io::Cursor::new(Vec::<u8>::new());
1425        let result = prompt_for_confirmation(&mut out, &mut input);
1426        match result {
1427            Err(CliError::CommandFailed { reason, .. }) => {
1428                assert!(
1429                    reason.contains("--yes"),
1430                    "EOF error must hint at --yes for unattended runs; got: {reason}"
1431                );
1432                assert!(
1433                    reason.contains("stdin"),
1434                    "EOF error must mention stdin so the user can debug; got: {reason}"
1435                );
1436            }
1437            other => panic!("expected CommandFailed on EOF, got: {other:?}"),
1438        }
1439    }
1440
1441    // ── forget_decision_with_database ────────────────────────────────
1442
1443    #[test]
1444    fn forget_decision_with_database_deletes_by_full_hash() {
1445        let db = make_db();
1446        populate(&db);
1447        // Sanity: row exists pre-delete.
1448        let repo = SqliteDecisionRepository::new(db.connection().clone());
1449        assert!(repo.get_by_hash("aaaaaaaa1111").unwrap().is_some());
1450
1451        let removed = forget_decision_with_database(&db, "aaaaaaaa1111").unwrap();
1452        assert_eq!(removed.description_hash, "aaaaaaaa1111");
1453        assert_eq!(removed.state, DecisionState::Approved);
1454        // Row is hard-deleted; no soft-delete column to set.
1455        assert!(repo.get_by_hash("aaaaaaaa1111").unwrap().is_none());
1456    }
1457
1458    #[test]
1459    fn forget_decision_with_database_deletes_by_prefix() {
1460        let db = make_db();
1461        populate(&db);
1462        let repo = SqliteDecisionRepository::new(db.connection().clone());
1463
1464        let removed = forget_decision_with_database(&db, "bbbb").unwrap();
1465        assert_eq!(removed.description_hash, "bbbbbbbb2222");
1466        assert!(repo.get_by_hash("bbbbbbbb2222").unwrap().is_none());
1467    }
1468
1469    #[test]
1470    fn forget_decision_with_database_propagates_resolution_errors() {
1471        let db = make_db();
1472        populate(&db);
1473
1474        // Not found.
1475        let err = forget_decision_with_database(&db, "ffff0000").unwrap_err();
1476        assert!(err.to_string().contains("no decision matches"));
1477
1478        // Too short — never even hits the DB.
1479        let err = forget_decision_with_database(&db, "ab").unwrap_err();
1480        assert!(err.to_string().contains("too short"));
1481    }
1482
1483    // ══════════════════════════════════════════════════════════════════
1484    // export_decisions_to_string / import_decisions_from_str (US-015)
1485    // ══════════════════════════════════════════════════════════════════
1486
1487    #[test]
1488    fn export_decisions_to_string_empty_db_returns_empty_array() {
1489        let db = make_db();
1490        let json = export_decisions_to_string(&db).unwrap();
1491        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
1492        assert!(parsed.is_array());
1493        assert_eq!(parsed.as_array().unwrap().len(), 0);
1494        assert!(json.ends_with('\n'));
1495    }
1496
1497    #[test]
1498    fn export_decisions_to_string_populated_db_returns_all_rows() {
1499        let db = make_db();
1500        populate(&db);
1501        let json = export_decisions_to_string(&db).unwrap();
1502        let parsed: Vec<DecisionJsonOwned> =
1503            serde_json::from_str(&json).expect("parses back into owned DTOs");
1504        assert_eq!(parsed.len(), 4);
1505
1506        // Each row carries the wire-format enum strings (lowercase, SQL form).
1507        let states: Vec<&str> = parsed.iter().map(|d| d.state.as_str()).collect();
1508        for expected in ["approved", "rejected", "partial", "recorded"] {
1509            assert!(
1510                states.contains(&expected),
1511                "missing state {expected} in {states:?}"
1512            );
1513        }
1514    }
1515
1516    #[test]
1517    fn import_decisions_from_str_inserts_into_empty_db() {
1518        let db_src = make_db();
1519        populate(&db_src);
1520        let json = export_decisions_to_string(&db_src).unwrap();
1521
1522        let db_dst = make_db();
1523        let summary = import_decisions_from_str(&db_dst, &json, false).unwrap();
1524        assert_eq!(summary.total, 4);
1525        assert_eq!(summary.inserted, 4);
1526        assert_eq!(summary.updated, 0);
1527        assert_eq!(summary.skipped, 0);
1528
1529        // All four rows landed in the destination DB.
1530        let dst_repo = SqliteDecisionRepository::new(db_dst.connection().clone());
1531        assert_eq!(dst_repo.list().unwrap().len(), 4);
1532    }
1533
1534    #[test]
1535    fn import_decisions_from_str_empty_array_is_no_op() {
1536        let db = make_db();
1537        populate(&db);
1538        let summary = import_decisions_from_str(&db, "[]", false).unwrap();
1539        assert_eq!(summary.total, 0);
1540        assert_eq!(summary.inserted, 0);
1541        assert_eq!(summary.updated, 0);
1542        assert_eq!(summary.skipped, 0);
1543
1544        // Existing rows untouched.
1545        let repo = SqliteDecisionRepository::new(db.connection().clone());
1546        assert_eq!(repo.list().unwrap().len(), 4);
1547    }
1548
1549    #[test]
1550    fn import_decisions_from_str_updates_when_imported_is_newer() {
1551        let db = make_db();
1552        // Existing row at decided_at = 1_700_000_100.
1553        populate(&db);
1554        let repo = SqliteDecisionRepository::new(db.connection().clone());
1555        let before = repo.get_by_hash("aaaaaaaa1111").unwrap().unwrap();
1556        assert_eq!(before.state, DecisionState::Approved);
1557
1558        // Build an import that flips the state and bumps decided_at.
1559        let newer = make_decision(
1560            "aaaaaaaa1111",
1561            "Use anyhow for error propagation (revised)",
1562            DecisionState::Rejected,
1563            "feature/x",
1564            1_800_000_000,
1565        );
1566        let json = serde_json::to_string(&[DecisionJson::from(&newer)]).unwrap();
1567
1568        let summary = import_decisions_from_str(&db, &json, false).unwrap();
1569        assert_eq!(summary.total, 1);
1570        assert_eq!(summary.inserted, 0);
1571        assert_eq!(summary.updated, 1);
1572        assert_eq!(summary.skipped, 0);
1573
1574        let after = repo.get_by_hash("aaaaaaaa1111").unwrap().unwrap();
1575        assert_eq!(after.state, DecisionState::Rejected);
1576        assert_eq!(after.decided_at, 1_800_000_000);
1577        assert_eq!(
1578            after.description,
1579            "Use anyhow for error propagation (revised)"
1580        );
1581    }
1582
1583    #[test]
1584    fn import_decisions_from_str_skips_when_existing_is_newer() {
1585        let db = make_db();
1586        populate(&db);
1587        let repo = SqliteDecisionRepository::new(db.connection().clone());
1588        let before = repo.get_by_hash("aaaaaaaa1111").unwrap().unwrap();
1589        assert_eq!(before.decided_at, 1_700_000_100);
1590        assert_eq!(before.state, DecisionState::Approved);
1591
1592        // Older imported row — must be silently skipped (incumbent kept).
1593        let older = make_decision(
1594            "aaaaaaaa1111",
1595            "STALE",
1596            DecisionState::Rejected,
1597            "old-branch",
1598            1_600_000_000, // older than the existing 1_700_000_100
1599        );
1600        let json = serde_json::to_string(&[DecisionJson::from(&older)]).unwrap();
1601
1602        let summary = import_decisions_from_str(&db, &json, false).unwrap();
1603        assert_eq!(summary.total, 1);
1604        assert_eq!(summary.inserted, 0);
1605        assert_eq!(summary.updated, 0);
1606        assert_eq!(summary.skipped, 1);
1607
1608        // Existing row unchanged.
1609        let after = repo.get_by_hash("aaaaaaaa1111").unwrap().unwrap();
1610        assert_eq!(after.decided_at, before.decided_at);
1611        assert_eq!(after.state, before.state);
1612        assert_eq!(after.description, before.description);
1613    }
1614
1615    #[test]
1616    fn import_decisions_from_str_skips_on_equal_decided_at() {
1617        // Defensive: equal decided_at means neither row is "later" — keep the
1618        // incumbent so a round-trip of unchanged data doesn't churn updated_at
1619        // timestamps or trigger spurious writes.
1620        let db = make_db();
1621        populate(&db);
1622        let repo = SqliteDecisionRepository::new(db.connection().clone());
1623        let before = repo.get_by_hash("aaaaaaaa1111").unwrap().unwrap();
1624
1625        let same = make_decision(
1626            "aaaaaaaa1111",
1627            "DIFFERENT",
1628            DecisionState::Rejected,
1629            "main",
1630            before.decided_at, // exactly equal
1631        );
1632        let json = serde_json::to_string(&[DecisionJson::from(&same)]).unwrap();
1633
1634        let summary = import_decisions_from_str(&db, &json, false).unwrap();
1635        assert_eq!(summary.skipped, 1);
1636        assert_eq!(summary.updated, 0);
1637        let after = repo.get_by_hash("aaaaaaaa1111").unwrap().unwrap();
1638        assert_eq!(after.description, before.description);
1639        assert_eq!(after.state, before.state);
1640    }
1641
1642    #[test]
1643    fn import_decisions_from_str_strict_fails_on_conflict() {
1644        let db = make_db();
1645        populate(&db); // includes hash aaaaaaaa1111
1646
1647        let conflicting = make_decision(
1648            "aaaaaaaa1111",
1649            "newer description",
1650            DecisionState::Rejected,
1651            "main",
1652            1_900_000_000,
1653        );
1654        let json = serde_json::to_string(&[DecisionJson::from(&conflicting)]).unwrap();
1655
1656        let err = import_decisions_from_str(&db, &json, true).unwrap_err();
1657        let msg = err.to_string();
1658        assert!(msg.contains("strict mode"), "got: {msg}");
1659        assert!(
1660            msg.contains("aaaaaaaa1111"),
1661            "must list conflicting hash: {msg}"
1662        );
1663
1664        // No writes happened: existing row is untouched.
1665        let repo = SqliteDecisionRepository::new(db.connection().clone());
1666        let after = repo.get_by_hash("aaaaaaaa1111").unwrap().unwrap();
1667        assert_eq!(after.state, DecisionState::Approved); // original
1668        assert_eq!(after.decided_at, 1_700_000_100); // original
1669    }
1670
1671    #[test]
1672    fn import_decisions_from_str_strict_succeeds_when_no_conflict() {
1673        // A clean import on an empty target should succeed regardless of
1674        // --strict — strict only fires on hash collisions.
1675        let db_src = make_db();
1676        populate(&db_src);
1677        let json = export_decisions_to_string(&db_src).unwrap();
1678
1679        let db_dst = make_db();
1680        let summary = import_decisions_from_str(&db_dst, &json, true).unwrap();
1681        assert_eq!(summary.inserted, 4);
1682        assert_eq!(summary.updated, 0);
1683        assert_eq!(summary.skipped, 0);
1684    }
1685
1686    #[test]
1687    fn import_decisions_from_str_strict_lists_all_conflicts() {
1688        let db = make_db();
1689        populate(&db);
1690
1691        // Build a payload where two of the four hashes conflict and one is new.
1692        let conflict_a = make_decision(
1693            "aaaaaaaa1111",
1694            "x",
1695            DecisionState::Approved,
1696            "main",
1697            1_900_000_000,
1698        );
1699        let conflict_b = make_decision(
1700            "bbbbbbbb2222",
1701            "y",
1702            DecisionState::Rejected,
1703            "feature/x",
1704            1_900_000_000,
1705        );
1706        let new_one = make_decision(
1707            "ffffffff9999",
1708            "new",
1709            DecisionState::Recorded,
1710            "main",
1711            1_900_000_000,
1712        );
1713        let dtos = vec![
1714            DecisionJson::from(&conflict_a),
1715            DecisionJson::from(&conflict_b),
1716            DecisionJson::from(&new_one),
1717        ];
1718        let json = serde_json::to_string(&dtos).unwrap();
1719
1720        let err = import_decisions_from_str(&db, &json, true).unwrap_err();
1721        let msg = err.to_string();
1722        assert!(
1723            msg.contains("aaaaaaaa1111"),
1724            "missing first conflict: {msg}"
1725        );
1726        assert!(
1727            msg.contains("bbbbbbbb2222"),
1728            "missing second conflict: {msg}"
1729        );
1730        // The non-conflicting hash must NOT trigger an alarm.
1731        assert!(
1732            !msg.contains("ffffffff9999"),
1733            "non-conflicting hash leaked: {msg}"
1734        );
1735
1736        // No partial writes — even the non-conflicting row is NOT inserted.
1737        let repo = SqliteDecisionRepository::new(db.connection().clone());
1738        assert!(repo.get_by_hash("ffffffff9999").unwrap().is_none());
1739    }
1740
1741    #[test]
1742    fn import_decisions_from_str_invalid_json_returns_error() {
1743        let db = make_db();
1744        let err = import_decisions_from_str(&db, "{not json", false).unwrap_err();
1745        assert!(err.to_string().contains("failed to parse"), "{err}");
1746    }
1747
1748    #[test]
1749    fn import_decisions_from_str_invalid_state_returns_error() {
1750        let db = make_db();
1751        // Hand-rolled JSON with a state value the V12 CHECK rejects.
1752        let json = r#"[{
1753            "description_hash": "abc",
1754            "description": "x",
1755            "state": "BOGUS",
1756            "nature": "convention",
1757            "weight": "rule",
1758            "category": null,
1759            "reason": null,
1760            "examples": [],
1761            "decided_on_branch": "main",
1762            "decided_at": 1,
1763            "updated_at": 1
1764        }]"#;
1765
1766        let err = import_decisions_from_str(&db, json, false).unwrap_err();
1767        let msg = err.to_string();
1768        assert!(msg.contains("invalid state"), "got: {msg}");
1769        assert!(msg.contains("abc"), "must mention offending hash: {msg}");
1770    }
1771
1772    #[test]
1773    fn round_trip_export_then_import_yields_identical_table() {
1774        // AC #3: export → wipe → import → table identical.
1775        let db_src = make_db();
1776        populate(&db_src);
1777        let src_repo = SqliteDecisionRepository::new(db_src.connection().clone());
1778        let mut before = src_repo.list().unwrap();
1779        before.sort_by(|a, b| a.description_hash.cmp(&b.description_hash));
1780
1781        // Export.
1782        let json = export_decisions_to_string(&db_src).unwrap();
1783
1784        // "Wipe" by importing into a fresh DB — equivalent to deleting all rows
1785        // and re-importing in-place, but cleaner to assert against.
1786        let db_dst = make_db();
1787        let summary = import_decisions_from_str(&db_dst, &json, false).unwrap();
1788        assert_eq!(summary.total, 4);
1789        assert_eq!(summary.inserted, 4);
1790
1791        // Read back and compare row-by-row, sorted on hash for stable order.
1792        let dst_repo = SqliteDecisionRepository::new(db_dst.connection().clone());
1793        let mut after = dst_repo.list().unwrap();
1794        after.sort_by(|a, b| a.description_hash.cmp(&b.description_hash));
1795
1796        assert_eq!(before.len(), after.len());
1797        for (b, a) in before.iter().zip(after.iter()) {
1798            // PartialEq on Decision compares every field including timestamps,
1799            // so this is the strongest "table identical" assertion available.
1800            assert_eq!(b, a, "round-trip mismatch on hash {}", b.description_hash);
1801        }
1802    }
1803
1804    #[test]
1805    fn round_trip_in_place_wipe_then_import_yields_identical_table() {
1806        // Stronger variant of the AC: do the wipe in-place (delete all rows
1807        // from the same DB) so the only thing remaining is what import wrote.
1808        let db = make_db();
1809        populate(&db);
1810        let repo = SqliteDecisionRepository::new(db.connection().clone());
1811        let mut before = repo.list().unwrap();
1812        before.sort_by(|a, b| a.description_hash.cmp(&b.description_hash));
1813
1814        let json = export_decisions_to_string(&db).unwrap();
1815
1816        // Delete every row.
1817        for d in &before {
1818            repo.delete(&d.description_hash).unwrap();
1819        }
1820        assert!(
1821            repo.list().unwrap().is_empty(),
1822            "wipe should clear the table"
1823        );
1824
1825        // Import — every row is "new" again because we deleted everything.
1826        let summary = import_decisions_from_str(&db, &json, false).unwrap();
1827        assert_eq!(summary.inserted, before.len());
1828        assert_eq!(summary.skipped, 0);
1829        assert_eq!(summary.updated, 0);
1830
1831        let mut after = repo.list().unwrap();
1832        after.sort_by(|a, b| a.description_hash.cmp(&b.description_hash));
1833        assert_eq!(before, after);
1834    }
1835
1836    #[test]
1837    fn decision_json_owned_into_decision_round_trips_via_export_format() {
1838        // Defensive: confirm DecisionJsonOwned is a faithful inverse of
1839        // DecisionJson. If a new field is added to one but not the other,
1840        // the round-trip equality breaks first here.
1841        let original = make_decision(
1842            "h1",
1843            "Use anyhow",
1844            DecisionState::Approved,
1845            "main",
1846            1_700_000_000,
1847        );
1848        let json = serde_json::to_string(&DecisionJson::from(&original)).unwrap();
1849        let parsed: DecisionJsonOwned = serde_json::from_str(&json).unwrap();
1850        let restored = parsed.into_decision().unwrap();
1851        assert_eq!(original, restored);
1852    }
1853}