1use 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
23const TABLE_DESCRIPTION_MAX: usize = 60;
28
29const TABLE_HASH_LEN: usize = 8;
35
36const MIN_FORGET_PREFIX_LEN: usize = 4;
44
45pub 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
59fn 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
92fn 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
110fn 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#[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 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 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
243fn 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 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
313fn 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
366fn 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 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
415fn 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
435fn 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 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
533pub struct ImportSummary {
534 pub total: usize,
536 pub inserted: usize,
538 pub updated: usize,
541 pub skipped: usize,
544}
545
546fn 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 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
591fn 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
621pub 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
652fn 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
692pub 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 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 {
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 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 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 {
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 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
869pub 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#[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 #[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 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 for state in ["approved", "rejected", "partial", "recorded"] {
1006 assert!(table.contains(state), "missing state {state}: {table}");
1007 }
1008
1009 assert!(table.contains("aaaaaaaa"));
1011 assert!(table.contains("bbbbbbbb"));
1012
1013 assert!(table.contains("main"));
1015 assert!(table.contains("feature/x"));
1016
1017 assert!(table.contains("Use anyhow for error propagation"));
1019 }
1020
1021 #[test]
1022 fn format_decisions_table_aligns_cjk_descriptions_by_display_width() {
1023 let cjk_desc = "中文中文中"; let ascii_desc = "0123456789"; 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 assert_eq!(lines.len(), 3, "expected header + 2 rows in:\n{table}");
1041
1042 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 assert!(!table.contains(&long));
1066 assert!(table.contains('…'), "expected ellipsis: {table}");
1068 }
1069
1070 #[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 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 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 #[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 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 let result =
1192 load_decisions(&db, Some(DecisionStateFilter::Rejected), Some("main")).unwrap();
1193 assert!(result.is_empty());
1194 }
1195
1196 #[test]
1199 fn short_hash_truncates_to_eight_chars() {
1200 assert_eq!(short_hash("abcdef0123456789"), "abcdef01");
1201 assert_eq!(short_hash("abc"), "abc");
1203 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 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 assert_eq!(out, "01234…");
1219 }
1220
1221 #[test]
1222 fn format_decided_at_formats_unix_timestamp() {
1223 let out = format_decided_at(1_700_000_000);
1225 assert_eq!(out, "2023-11-14 22:13:20");
1226 }
1227
1228 #[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 #[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 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 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 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 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 assert!(msg.contains("aaaa1111"), "missing first hash: {msg}");
1348 assert!(msg.contains("aaaa2222"), "missing second hash: {msg}");
1349 }
1350
1351 #[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 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 assert!(summary.contains("2023-11-14 22:13:20"));
1372 }
1373
1374 #[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 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 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 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 #[test]
1444 fn forget_decision_with_database_deletes_by_full_hash() {
1445 let db = make_db();
1446 populate(&db);
1447 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 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 let err = forget_decision_with_database(&db, "ffff0000").unwrap_err();
1476 assert!(err.to_string().contains("no decision matches"));
1477
1478 let err = forget_decision_with_database(&db, "ab").unwrap_err();
1480 assert!(err.to_string().contains("too short"));
1481 }
1482
1483 #[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 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 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 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 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 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 let older = make_decision(
1594 "aaaaaaaa1111",
1595 "STALE",
1596 DecisionState::Rejected,
1597 "old-branch",
1598 1_600_000_000, );
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 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 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, );
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); 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 let repo = SqliteDecisionRepository::new(db.connection().clone());
1666 let after = repo.get_by_hash("aaaaaaaa1111").unwrap().unwrap();
1667 assert_eq!(after.state, DecisionState::Approved); assert_eq!(after.decided_at, 1_700_000_100); }
1670
1671 #[test]
1672 fn import_decisions_from_str_strict_succeeds_when_no_conflict() {
1673 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 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 assert!(
1732 !msg.contains("ffffffff9999"),
1733 "non-conflicting hash leaked: {msg}"
1734 );
1735
1736 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 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 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 let json = export_decisions_to_string(&db_src).unwrap();
1783
1784 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 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 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 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 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 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 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}