Skip to main content

btctax_cli/
input_form_store.rs

1//! Per-year `return_inputs_draft(year, inputs_json, schema_version, parked)` side-table — the input form's
2//! crash-recovery scratch, INVISIBLE to `resolve.rs`. `parked = 1` marks a parked committed return (C-1).
3//!
4//! Plan-2 task 1 built the table + low-level row I/O; task 2 added `save_draft` (the autosave primitive);
5//! task 3 adds `load` (the read path: draft ⇒ committed ⇒ fresh, with the §6.3 stale split). Every low-level
6//! item now has a non-test caller — `set_draft_row`/`parked_flag` via `save_draft`, and `DraftRow`/
7//! `get_draft_row` via `load` — so none carries a `#[allow(dead_code)]` any longer.
8use crate::return_inputs::SCHEMA_VERSION;
9use crate::{CliError, Session};
10use btctax_core::tax::return_inputs::ReturnInputs;
11use btctax_core::tax::return_refuse::{screen_inputs, Refusal};
12use btctax_core::tax::tables::{FullReturnParams, TaxTable};
13use rusqlite::Connection;
14
15/// Create the draft side-table if absent. Idempotent; called first by every fn (safe on an older vault).
16pub fn init_draft_table(conn: &Connection) -> Result<(), CliError> {
17    conn.execute(
18        "CREATE TABLE IF NOT EXISTS return_inputs_draft (\
19             year INTEGER PRIMARY KEY, inputs_json TEXT NOT NULL, \
20             schema_version INTEGER NOT NULL DEFAULT 0, parked INTEGER NOT NULL DEFAULT 0)",
21        [],
22    )?;
23    Ok(())
24}
25
26pub(crate) struct DraftRow {
27    pub ri: ReturnInputs,
28    pub version: i64,
29    pub parked: bool,
30}
31
32/// The RAW draft row — does NOT gate on `SCHEMA_VERSION` (Task 3 `load` decides discard-vs-refuse per §6.3).
33pub(crate) fn get_draft_row(conn: &Connection, year: i32) -> Result<Option<DraftRow>, CliError> {
34    init_draft_table(conn)?;
35    let row = conn.query_row(
36        "SELECT inputs_json, schema_version, parked FROM return_inputs_draft WHERE year=?1",
37        [year],
38        |r| {
39            Ok((
40                r.get::<_, String>(0)?,
41                r.get::<_, i64>(1)?,
42                r.get::<_, i64>(2)?,
43            ))
44        },
45    );
46    match row {
47        Ok((json, version, parked)) => {
48            // ★ I-A: CliError has NO From<serde_json::Error> — map explicitly like return_inputs.rs:66-69
49            // (a bad blob is a typed error, not a `?`-panic). Do NOT use `?` on serde here.
50            let ri: ReturnInputs =
51                serde_json::from_str(&json).map_err(|e| CliError::BadConfigValue {
52                    key: format!("return_inputs_draft[{year}]"),
53                    value: format!("invalid JSON: {e}"),
54                })?;
55            Ok(Some(DraftRow {
56                ri,
57                version,
58                parked: parked != 0,
59            }))
60        }
61        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
62        Err(e) => Err(e.into()),
63    }
64}
65
66pub(crate) fn set_draft_row(
67    conn: &Connection,
68    year: i32,
69    ri: &ReturnInputs,
70    parked: bool,
71) -> Result<(), CliError> {
72    init_draft_table(conn)?;
73    // ★ I-A: map serde explicitly (no From<serde_json::Error> on CliError) — mirror return_inputs.rs:92-95.
74    let j = serde_json::to_string(ri).map_err(|e| CliError::BadConfigValue {
75        key: format!("return_inputs_draft[{year}]"),
76        value: format!("could not serialize: {e}"),
77    })?;
78    conn.execute(
79        "INSERT INTO return_inputs_draft(year,inputs_json,schema_version,parked) VALUES(?1,?2,?3,?4) \
80         ON CONFLICT(year) DO UPDATE SET inputs_json=?2, schema_version=?3, parked=?4",
81        rusqlite::params![year, j, SCHEMA_VERSION, parked as i64],
82    )?;
83    Ok(())
84}
85
86pub(crate) fn delete_draft(conn: &Connection, year: i32) -> Result<bool, CliError> {
87    init_draft_table(conn)?;
88    Ok(conn.execute("DELETE FROM return_inputs_draft WHERE year=?1", [year])? > 0)
89}
90
91pub fn draft_exists(conn: &Connection, year: i32) -> Result<bool, CliError> {
92    init_draft_table(conn)?;
93    // ★ P2-b: distinguish "no row" (→ false) from a real DB error (→ propagate). `.is_ok()` swallowed the
94    // latter as "no draft"; mirror `parked_flag`'s correct QueryReturnedNoRows-vs-Err split.
95    match conn.query_row(
96        "SELECT 1 FROM return_inputs_draft WHERE year=?1",
97        [year],
98        |_| Ok(()),
99    ) {
100        Ok(()) => Ok(true),
101        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
102        Err(e) => Err(e.into()),
103    }
104}
105
106pub(crate) fn parked_flag(conn: &Connection, year: i32) -> Result<Option<bool>, CliError> {
107    init_draft_table(conn)?;
108    match conn.query_row(
109        "SELECT parked FROM return_inputs_draft WHERE year=?1",
110        [year],
111        |r| r.get::<_, i64>(0),
112    ) {
113        Ok(p) => Ok(Some(p != 0)),
114        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
115        Err(e) => Err(e.into()),
116    }
117}
118
119/// Autosave a mid-entry return to the draft table (the input form's crash-recovery scratch).
120///
121/// Read-modify-write that PRESERVES the row's `parked` flag (NI-1): a parked committed return stays
122/// parked across edits. Reads the existing flag (default `false` — a fresh year is WIP, not parked),
123/// upserts `ri` with that flag, then `sess.save()` re-encrypts and atomically writes the vault to disk
124/// (I-7) — nothing survives a crash until `save()` returns. Takes `&mut Session` for `save()`; reads
125/// through the SAME session's `conn()` (never opens a second Session — N-1).
126pub fn save_draft(sess: &mut Session, year: i32, ri: &ReturnInputs) -> Result<(), CliError> {
127    let parked = parked_flag(sess.conn(), year)?.unwrap_or(false); // ★ NI-1: preserve; default WIP
128    set_draft_row(sess.conn(), year, ri, parked)?;
129    sess.save()?; // ★ I-7: reach disk
130    Ok(())
131}
132
133/// The working return for a year, resolved through the §6.1 precedence: a draft shadows the committed row.
134///
135/// - `Draft { ri, parked }` — a version-current draft exists (the crash-recovery scratch wins over committed).
136/// - `Committed(ri)` — no draft; the committed `return_inputs` row is the working return.
137/// - `Fresh` — neither exists; start a blank return.
138pub enum Loaded {
139    Draft { ri: ReturnInputs, parked: bool },
140    Committed(ReturnInputs),
141    Fresh,
142}
143
144/// The §6.3 fact that [`load`] discarded a stale work-in-progress draft (schema `found` — a version this
145/// build does not read — vs `expected`). Returned ALONGSIDE `Loaded` so the caller can surface it: a store
146/// read fn must not `eprintln!` the note itself, because its only future caller is plan 3's raw-mode,
147/// alternate-screen TUI, where stderr is invisible/screen-corrupting (I-1). `pub` so plan 3 can render it.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct StaleNote {
150    pub year: i32,
151    pub found: i64,
152    pub expected: i64,
153}
154
155impl std::fmt::Display for StaleNote {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        write!(
158            f,
159            "discarded a stale draft for {} (schema v{}, expected v{})",
160            self.year, self.found, self.expected
161        )
162    }
163}
164
165/// Resolve the working return for `year` (§6.1 precedence + §6.3 stale split).
166///
167/// A draft row takes precedence over the committed row. If the draft is at a schema version this build does
168/// not read (`d.version != SCHEMA_VERSION`), the split is by `parked`:
169///
170/// - **WIP** (`parked = 0`) → the draft is regenerable, so **DISCARD** it: delete the stale row (an
171///   in-memory delete the caller's next `save_draft` persists — this is a read path, no `sess.save()` here)
172///   and fall through to committed/Fresh, RETURNING a [`StaleNote`] so the caller (not this store fn) can
173///   surface the discard (I-1).
174/// - **parked** (`parked = 1`) → it may hold carryover that exists ONLY in the draft (C-1), so **REFUSE**
175///   with [`CliError::StaleParkedDraft`] (fail closed) rather than destroy irreplaceable data.
176///
177/// A version-current draft yields `Draft { ri, parked }`. With no draft, the committed row (if any) is
178/// `Committed`, else `Fresh`. The second tuple element is `Some(StaleNote)` ONLY on the stale-WIP discard
179/// path; every other path returns `None`.
180pub fn load(conn: &Connection, year: i32) -> Result<(Loaded, Option<StaleNote>), CliError> {
181    if let Some(d) = get_draft_row(conn, year)? {
182        if d.version != SCHEMA_VERSION {
183            if d.parked {
184                return Err(CliError::StaleParkedDraft {
185                    year,
186                    found: d.version,
187                    expected: SCHEMA_VERSION,
188                });
189            }
190            // ★ §6.3 / I-1: a stale WIP draft is regenerable — discard it and RETURN the note (never
191            // eprintln! from a store read fn: plan 3's raw-mode TUI would swallow/garble it), fall through.
192            delete_draft(conn, year)?;
193            let note = StaleNote {
194                year,
195                found: d.version,
196                expected: SCHEMA_VERSION,
197            };
198            return Ok((committed_or_fresh(conn, year)?, Some(note)));
199        } else {
200            return Ok((
201                Loaded::Draft {
202                    ri: d.ri,
203                    parked: d.parked,
204                },
205                None,
206            ));
207        }
208    }
209    Ok((committed_or_fresh(conn, year)?, None))
210}
211
212/// The no-draft tail of [`load`]: the committed `return_inputs` row (if any) else `Fresh`.
213fn committed_or_fresh(conn: &Connection, year: i32) -> Result<Loaded, CliError> {
214    match crate::return_inputs::get(conn, year)? {
215        Some(ri) => Ok(Loaded::Committed(ri)),
216        None => Ok(Loaded::Fresh),
217    }
218}
219
220/// §6.2 draft-coherence: reconcile that year's input-form draft with an authoritative committed-row write.
221///
222/// The four writers of the committed `return_inputs` row (`income import`, `income answer`, carryover
223/// write-back, `income clear`) are ignorant of the crash-recovery draft. A stale draft would then silently
224/// shadow the freshly-written committed row at the next `load` (§6.1 precedence), and a PARKED draft — the
225/// sole copy of a screened return (C-1) — could be clobbered out of existence. This helper closes both:
226///
227/// - **no draft** → `Ok(())` (a no-op; the writer proceeds unchanged).
228/// - **WIP** (`parked = 0`) → the draft is regenerable crash-scratch, so the write SUPERSEDES it:
229///   `delete_draft` (noting on stderr only if it held a non-trivial return, i.e. `!= default`). The delete
230///   is in-memory on `conn`; the writer's own `s.save()` persists it together with the committed write.
231/// - **parked** (`parked = 1`) → REFUSE with [`CliError::ParkedDraftBlocksWrite`] (fail closed) BEFORE any
232///   committed-row mutation — never silently destroy irreplaceable data.
233///
234/// ★ M-1: callers invoke this RIGHT AFTER `Session::open`, before any committed-row read or write — else
235/// the two writers that early-return on an absent committed row (`answer`, write-back) would exit with a
236/// generic "no inputs" message before the parked-refuse is ever reached (a parked year has no committed
237/// row). Takes `&Connection` (read + a conditional in-memory delete); a parked `Err` propagates via `?`.
238pub fn coherence_clear_or_refuse(conn: &Connection, year: i32) -> Result<(), CliError> {
239    match parked_flag(conn, year)? {
240        None => Ok(()),
241        Some(true) => Err(CliError::ParkedDraftBlocksWrite { year }), // ★ C-1: never clobber the sole copy
242        Some(false) => {
243            if let Some(d) = get_draft_row(conn, year)? {
244                if d.ri != ReturnInputs::default() {
245                    eprintln!(
246                        "note: superseding a work-in-progress draft for {year} with this write."
247                    );
248                }
249            }
250            delete_draft(conn, year)?;
251            Ok(())
252        }
253    }
254}
255
256/// The outcome of a [`commit`] attempt.
257///
258/// - `Committed` — the return screened CLEAN; the committed `return_inputs` row was written and the draft
259///   deleted.
260/// - `Refused(refusal)` — [`screen_inputs`] tripped a fail-closed guard; NOTHING was written (the year is
261///   never poisoned at `resolve`, and the draft is left intact for the user to fix).
262/// - `NoTables` — the year has no full-return tables/params (v1 bundles TY2024 only — I-11); NOTHING was
263///   written.
264pub enum CommitOutcome {
265    Committed,
266    Refused(Refusal),
267    NoTables,
268}
269
270/// Run in-memory `mutate` writes then `save()`, rolling the WHOLE session back to the pre-write image on
271/// ANY failure — a `mutate` error OR a `save` error (P2-d). Previously only a `save` failure restored, so a
272/// mid-write `set`/`delete` error returned early leaving the long-lived in-memory `Session` partially
273/// mutated (and diverged from disk). The snapshot is taken here, before `mutate` touches the conn.
274fn mutate_and_save(
275    sess: &mut Session,
276    mutate: impl FnOnce(&Connection) -> Result<(), CliError>,
277) -> Result<(), CliError> {
278    let snap = sess.snapshot()?;
279    let mutated = mutate(sess.conn());
280    let outcome = mutated.and_then(|()| sess.save());
281    if let Err(e) = outcome {
282        sess.restore(&snap)?; // atomic: never leave an in-memory/disk split or a partial in-memory write
283        return Err(e);
284    }
285    Ok(())
286}
287
288/// Screen `ri`, and ONLY if it passes write the committed row and delete the draft (SPEC §5.7).
289///
290/// The write is all-or-nothing:
291///
292/// - No `table`/`params` for the year → [`CommitOutcome::NoTables`] (the TY2024-only gate, I-11) — writes
293///   nothing.
294/// - [`screen_inputs`] returns `Some(refusal)` → [`CommitOutcome::Refused`] — writes nothing, so a refused
295///   commit never poisons the year at `resolve` and the draft remains for the user to fix.
296/// - Clean → snapshot the in-memory DB, `return_inputs::set` the committed row, `delete_draft`, then
297///   `sess.save()` via `mutate_and_save` (which rolls back on ANY failure) so there is never an
298///   in-memory/disk split (the committed row + draft-deletion are rolled back together, I-7).
299///
300/// Takes `&mut Session` for `save()`; reads through the SAME session's `conn()` — never opens a second
301/// Session (N-1).
302pub fn commit(
303    sess: &mut Session,
304    year: i32,
305    ri: &ReturnInputs,
306    table: Option<&TaxTable>,
307    params: Option<&FullReturnParams>,
308) -> Result<CommitOutcome, CliError> {
309    let (Some(table), Some(params)) = (table, params) else {
310        return Ok(CommitOutcome::NoTables); // I-11: no tables for this year → write nothing
311    };
312    if table.year != year || params.year != year {
313        // ★ I-11 is per-YEAR, not per-call: tables for a DIFFERENT year would `screen_inputs`-pass and
314        // write a committed row for a table-less `year`, poisoning it at resolve. Write nothing.
315        return Ok(CommitOutcome::NoTables);
316    }
317    if let Some(refusal) = screen_inputs(ri, table, params) {
318        return Ok(CommitOutcome::Refused(refusal)); // fail-closed: writes nothing
319    }
320    mutate_and_save(sess, |conn| {
321        crate::return_inputs::set(conn, year, ri)?;
322        delete_draft(conn, year)?;
323        Ok(())
324    })?;
325    Ok(CommitOutcome::Committed)
326}
327
328/// Park the committed return for `year` into its draft (the "switch to tax-profile" toggle — C-1).
329///
330/// Stashes the committed [`ReturnInputs`] row into the draft table with `parked = 1`, then deletes the
331/// committed row, so the year resolves through the [`crate::tax_profile`] again at `resolve.rs` precedence
332/// — the full return is not lost, it is preserved as the sole `parked` copy (D-6) and reinstated by a later
333/// `use full return`. The `tax_profile` itself is never touched; it simply stops being shadowed.
334///
335/// Refuses (writing nothing) when:
336///
337/// - **no committed row** → [`CliError::Usage`] (there is nothing to park).
338/// - **a WIP draft already occupies the slot** (`parked = 0`) → [`CliError::Usage`]. ★ M-4: this refuses
339///   ANY work-in-progress draft, not only one that diverges from the committed row. The store cannot
340///   cheaply tell "divergent" apart, the draft slot is one-per-year, and stashing would clobber the user's
341///   in-progress edit — so refusing a same-valued WIP costs nothing and the conservatism is intentional.
342///
343/// The stash and the delete are ordered stash-FIRST and committed together by a single `sess.save()`: on
344/// save failure `restore(&snap)` rolls BOTH back, so a failed park never loses the committed row's SSNs
345/// (D-6 atomicity, I-7). The delete is the in-session [`crate::return_inputs::delete`] on THIS session's
346/// `conn()` — never the `income clear` command, which would open a second `Session` and deadlock against
347/// the held lock (N-1). Takes `&mut Session` for `save()`.
348pub fn park_to_profile(sess: &mut Session, year: i32) -> Result<(), CliError> {
349    let Some(ri) = crate::return_inputs::get(sess.conn(), year)? else {
350        return Err(CliError::Usage(format!(
351            "no committed return to park for {year}"
352        )));
353    };
354    if parked_flag(sess.conn(), year)? == Some(false) {
355        // ★ clean-state gate (§9 / M-4): a WIP draft owns the one-per-year slot; parking would clobber it.
356        return Err(CliError::Usage(format!(
357            "year {year} has a work-in-progress draft; finish or discard it before switching to the tax-profile"
358        )));
359    }
360    mutate_and_save(sess, |conn| {
361        set_draft_row(conn, year, &ri, true)?; // ★ stash FIRST (parked=1)
362        crate::return_inputs::delete(conn, year)?; // ★ N-1: in-session delete, NOT `income clear`
363        Ok(())
364    }) // ★ atomic (D-6): a failed park (mutate OR save) never loses the committed row
365}
366
367/// The source the input form is CURRENTLY displaying/editing for `year` — the toggle's read side.
368///
369/// Mirrors `resolve.rs`'s precedence: a committed full return always wins over a `tax_profile`, which
370/// wins over nothing at all. This is a display hint for plan 3 (the TUI), not a resolver decision.
371pub enum ActiveSource {
372    FullReturn,
373    TaxProfile,
374    Neither,
375}
376
377/// Which source is active for `year`, mirroring `resolve.rs` precedence (committed full return, then
378/// `tax_profile`, then neither).
379///
380/// ★ M-5: this uses [`crate::return_inputs::exists`] (a cheap `SELECT 1`), so a schema-STALE committed
381/// row still reports `FullReturn` — that is correct, not a bug to "fix" to `get`. The stale row IS the
382/// active source (it is what shadows the `tax_profile` and what `resolve.rs` would refuse to compute
383/// from); reporting `Neither`/`TaxProfile` here would be a false display, hiding the row that is actually
384/// blocking the toggle.
385pub fn active_source(conn: &Connection, year: i32) -> Result<ActiveSource, CliError> {
386    if crate::return_inputs::exists(conn, year)? {
387        return Ok(ActiveSource::FullReturn);
388    }
389    if crate::tax_profile::years(conn)?.contains(&year) {
390        return Ok(ActiveSource::TaxProfile);
391    }
392    Ok(ActiveSource::Neither)
393}
394
395/// Whether a `tax_profile` exists for `year` — the TUI's commit-time shadow warning (§9 create-row
396/// amendment): committing a full return for a year that also has a `tax_profile` leaves the profile
397/// in place but no longer active, so the form warns before writing.
398///
399/// `tax_profile.rs` has no cheaper `exists` probe; `years(conn)?.contains(&year)` is the correct one
400/// (confirmed against current source — it does not deserialize any profile blob).
401pub fn shadows_profile(conn: &Connection, year: i32) -> Result<bool, CliError> {
402    Ok(crate::tax_profile::years(conn)?.contains(&year))
403}
404
405/// Discard the parked draft for `year` — the 'X' path (§9A/M-2), the ONLY deleter of a `parked = 1` row.
406///
407/// The TUI owns the confirmation modal; this fn is the confirmed action. It REFUSES
408/// ([`CliError::Usage`]) unless the year's draft is parked, so it can never be reached to delete a
409/// work-in-progress draft (or a year with no draft at all) behind a "discard parked draft" affordance —
410/// the parked check is the entire safety property of this function.
411///
412/// Runs the delete + save through `mutate_and_save`, which restores the pre-write snapshot on ANY failure
413/// (the delete OR the save; mirrors `park_to_profile`'s atomicity), so a failed discard never leaves an
414/// in-memory/disk split or a partial in-memory delete.
415pub fn discard_parked_draft(sess: &mut Session, year: i32) -> Result<(), CliError> {
416    if parked_flag(sess.conn(), year)? != Some(true) {
417        // never delete a WIP draft (or a non-existent one) behind this affordance
418        return Err(CliError::Usage(format!(
419            "year {year} has no parked draft to discard"
420        )));
421    }
422    mutate_and_save(sess, |conn| {
423        delete_draft(conn, year)?;
424        Ok(())
425    })
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::Session;
432    use btctax_core::tax::return_inputs::ReturnInputs;
433    use btctax_core::tax::types::FilingStatus;
434    use btctax_store::Passphrase;
435    use rusqlite::Connection;
436
437    fn pp() -> Passphrase {
438        Passphrase::new("test-pass".into())
439    }
440
441    /// Shared temp-vault fixture (M-3). MUST return the `TempDir` guard — if it drops, the temp
442    /// dir (and the vault file inside it) is deleted before any later `Session::open`. `Session::create`
443    /// is dropped inside the block so the store single-instance lock is released and `open` can
444    /// re-acquire it (N-1). Later tasks' tests (T4/T6/T7) reuse this helper.
445    fn tmp_vault() -> (tempfile::TempDir, std::path::PathBuf, Passphrase) {
446        let dir = tempfile::tempdir().unwrap();
447        let path = dir.path().join("vault.pgp");
448        {
449            let _ = Session::create(&path, &pp()).unwrap(); // create + drop releases the lock
450        }
451        (dir, path, pp())
452    }
453
454    #[test]
455    fn save_draft_preserves_parked_and_reaches_disk() {
456        let (_dir, path, pp) = tmp_vault();
457        let ri_a = ReturnInputs {
458            filing_status: FilingStatus::Single,
459            ..Default::default()
460        };
461        {
462            let mut sess = Session::open(&path, &pp).unwrap();
463            // seed a PARKED draft directly, then save_draft an edit — parked must survive.
464            set_draft_row(sess.conn(), 2024, &ri_a, true).unwrap();
465            let ri_b = ReturnInputs {
466                filing_status: FilingStatus::Mfj,
467                ..Default::default()
468            };
469            save_draft(&mut sess, 2024, &ri_b).unwrap();
470            assert_eq!(
471                parked_flag(sess.conn(), 2024).unwrap(),
472                Some(true),
473                "NI-1: parked survives an edit"
474            );
475        }
476        // reopen a fresh Session — proves save_draft reached disk (I-7), not just the in-memory conn.
477        let sess2 = Session::open(&path, &pp).unwrap();
478        let row = get_draft_row(sess2.conn(), 2024).unwrap().unwrap();
479        assert_eq!(row.ri.filing_status, FilingStatus::Mfj);
480        assert!(row.parked);
481    }
482
483    #[test]
484    fn save_draft_on_fresh_year_is_unparked() {
485        let (_dir, path, pp) = tmp_vault();
486        let mut sess = Session::open(&path, &pp).unwrap();
487        save_draft(&mut sess, 2024, &ReturnInputs::default()).unwrap();
488        assert_eq!(parked_flag(sess.conn(), 2024).unwrap(), Some(false));
489    }
490
491    /// P2-d: a `mutate` error (not only a `save` error) must roll the in-memory DB back. The closure writes
492    /// a draft row, THEN fails before save; after `mutate_and_save` returns Err, that write must be GONE —
493    /// restored, not left partially applied in the long-lived session. (Kills the restore-only-on-save
494    /// mutant: without the fix the draft write survives the mutate error.)
495    #[test]
496    fn mutate_and_save_rolls_back_the_in_memory_write_on_a_mutate_error() {
497        let (_dir, path, pp) = tmp_vault();
498        let mut sess = Session::open(&path, &pp).unwrap();
499        assert!(
500            !draft_exists(sess.conn(), 2024).unwrap(),
501            "precondition: no draft yet"
502        );
503
504        let ri = ReturnInputs {
505            filing_status: FilingStatus::Single,
506            ..Default::default()
507        };
508        let r = mutate_and_save(&mut sess, |conn| {
509            set_draft_row(conn, 2024, &ri, false)?; // in-memory write ...
510            Err(CliError::Usage("boom".to_string())) // ... then fail BEFORE save
511        });
512        assert!(r.is_err(), "the mutate error must propagate");
513
514        assert!(
515            !draft_exists(sess.conn(), 2024).unwrap(),
516            "P2-d: a mutate error must restore the pre-write image; the draft write must NOT survive"
517        );
518    }
519
520    #[test]
521    fn load_precedence_draft_then_committed_then_fresh() {
522        let conn = Connection::open_in_memory().unwrap();
523        crate::return_inputs::init_table(&conn).unwrap();
524        init_draft_table(&conn).unwrap();
525        // Fresh
526        let (loaded, note) = load(&conn, 2024).unwrap();
527        assert!(matches!(loaded, Loaded::Fresh));
528        assert!(note.is_none(), "no stale discard on a fresh year");
529        // Committed only
530        let cri = ReturnInputs {
531            filing_status: FilingStatus::HoH,
532            ..Default::default()
533        };
534        crate::return_inputs::set(&conn, 2024, &cri).unwrap();
535        let (loaded, note) = load(&conn, 2024).unwrap();
536        assert!(matches!(loaded, Loaded::Committed(r) if r.filing_status == FilingStatus::HoH));
537        assert!(note.is_none(), "no stale discard on the committed path");
538        // Draft shadows committed
539        let dri = ReturnInputs {
540            filing_status: FilingStatus::Mfj,
541            ..Default::default()
542        };
543        set_draft_row(&conn, 2024, &dri, false).unwrap();
544        let (loaded, note) = load(&conn, 2024).unwrap();
545        assert!(
546            matches!(loaded, Loaded::Draft { ri, parked: false } if ri.filing_status == FilingStatus::Mfj)
547        );
548        assert!(
549            note.is_none(),
550            "no stale discard on a version-current draft"
551        );
552    }
553
554    #[test]
555    fn load_discards_stale_wip_but_refuses_stale_parked() {
556        let conn = Connection::open_in_memory().unwrap();
557        init_draft_table(&conn).unwrap();
558        let ri = ReturnInputs {
559            filing_status: FilingStatus::Single,
560            ..Default::default()
561        };
562        let j = serde_json::to_string(&ri).unwrap();
563        // stale WIP (parked=0) at an old version → discarded, falls through to Fresh, row is GONE,
564        // and the discard fact is RETURNED as a StaleNote (I-1: never eprintln!'d from this store fn).
565        conn.execute("INSERT INTO return_inputs_draft(year,inputs_json,schema_version,parked) VALUES(2024,?1,0,0)", [&j]).unwrap();
566        let (loaded, note) = load(&conn, 2024).unwrap();
567        assert!(matches!(loaded, Loaded::Fresh));
568        assert_eq!(
569            note,
570            Some(StaleNote {
571                year: 2024,
572                found: 0,
573                expected: SCHEMA_VERSION
574            }),
575            "the stale-WIP discard returns the note (not an eprintln!)"
576        );
577        assert!(
578            !draft_exists(&conn, 2024).unwrap(),
579            "stale WIP is discarded"
580        );
581        // stale PARKED (parked=1) → REFUSE, row PRESERVED
582        conn.execute("INSERT INTO return_inputs_draft(year,inputs_json,schema_version,parked) VALUES(2025,?1,0,1)", [&j]).unwrap();
583        assert!(matches!(
584            load(&conn, 2025),
585            Err(CliError::StaleParkedDraft {
586                year: 2025,
587                found: 0,
588                ..
589            })
590        ));
591        assert!(
592            draft_exists(&conn, 2025).unwrap(),
593            "stale parked is preserved, not discarded"
594        );
595    }
596
597    /// The canonical screen-clean return: a minimal Single filer that is not a dependent and has answered
598    /// every always-live declaration (mirrors `resolve.rs`'s fixture / the `answer.rs`
599    /// `every_live_question_can_actually_be_answered_and_clears_the_screen` test). No income is needed —
600    /// `screen_inputs` only checks the input-screenable rows, so this passes the screen cleanly.
601    fn clean_screened_ri() -> ReturnInputs {
602        let mut ri = ReturnInputs {
603            filing_status: FilingStatus::Single,
604            header: btctax_core::tax::testonly::not_a_dependent(),
605            ..Default::default()
606        };
607        btctax_core::tax::testonly::answer_all_live_declarations(&mut ri);
608        ri
609    }
610
611    #[test]
612    fn commit_non2024_is_notables_and_writes_nothing() {
613        let (_dir, path, pp) = tmp_vault();
614        let mut sess = Session::open(&path, &pp).unwrap();
615        let ri = ReturnInputs {
616            filing_status: FilingStatus::Single,
617            ..Default::default()
618        };
619        set_draft_row(sess.conn(), 2099, &ri, false).unwrap();
620        // no full-return params for 2099 → NoTables
621        let out = commit(&mut sess, 2099, &ri, None, None).unwrap();
622        assert!(matches!(out, CommitOutcome::NoTables));
623        assert!(
624            !crate::return_inputs::exists(sess.conn(), 2099).unwrap(),
625            "NoTables writes no committed row"
626        );
627        assert!(
628            draft_exists(sess.conn(), 2099).unwrap(),
629            "NoTables leaves the draft"
630        );
631    }
632
633    #[test]
634    fn commit_clean_sets_row_and_deletes_draft_refused_writes_nothing() {
635        use btctax_adapters::{BundledFullReturnTables, BundledTaxTables};
636        use btctax_core::tax::tables::FullReturnTables;
637        use btctax_core::TaxTables;
638        let (_dir, path, pp) = tmp_vault();
639        let tables = BundledTaxTables::load(); // ★ I-B: load() returns Self, NOT Result — no `?`, no `.unwrap()`
640        let fr = BundledFullReturnTables::load();
641        let (t, p) = (
642            tables.table_for(2024).unwrap(),
643            fr.full_return_for(2024).unwrap(),
644        ); // these DO return Option
645        let mut sess = Session::open(&path, &pp).unwrap();
646        // A screen-clean minimal return (all live declarations answered, no income).
647        let clean = clean_screened_ri();
648        set_draft_row(sess.conn(), 2024, &clean, false).unwrap();
649        assert!(matches!(
650            commit(&mut sess, 2024, &clean, Some(t), Some(p)).unwrap(),
651            CommitOutcome::Committed
652        ));
653        assert!(
654            crate::return_inputs::exists(sess.conn(), 2024).unwrap(),
655            "clean commit writes the row"
656        );
657        assert!(
658            !draft_exists(sess.conn(), 2024).unwrap(),
659            "clean commit deletes the draft"
660        );
661        // A refused return (unanswered live declarations) writes nothing.
662        let refused = ReturnInputs {
663            filing_status: FilingStatus::Single,
664            ..Default::default()
665        }; // ~5 live None decls
666        set_draft_row(sess.conn(), 2024, &refused, false).unwrap();
667        assert!(matches!(
668            commit(&mut sess, 2024, &refused, Some(t), Some(p)).unwrap(),
669            CommitOutcome::Refused(_)
670        ));
671        // the committed 2024 row is still the earlier `clean` one; the refused draft remains
672        assert!(
673            crate::return_inputs::exists(sess.conn(), 2024).unwrap(),
674            "a refused commit does not delete the earlier committed row"
675        );
676        assert!(
677            draft_exists(sess.conn(), 2024).unwrap(),
678            "a refused commit leaves the draft"
679        );
680    }
681
682    /// ★ I-3 — `commit` gates I-11 per-YEAR, not per-call: passing the (only) 2024 tables with `year = 2025`
683    /// would `screen_inputs`-pass, but writing a committed row for a table-less year poisons it at resolve.
684    /// The year-consistency guard returns `NoTables` and writes NOTHING.
685    #[test]
686    fn commit_refuses_tables_for_a_different_year_and_writes_nothing() {
687        use btctax_adapters::{BundledFullReturnTables, BundledTaxTables};
688        use btctax_core::tax::tables::FullReturnTables;
689        use btctax_core::TaxTables;
690        let (_dir, path, pp) = tmp_vault();
691        let tables = BundledTaxTables::load(); // ★ I-B: load() returns Self, not Result
692        let fr = BundledFullReturnTables::load();
693        let (t2024, p2024) = (
694            tables.table_for(2024).unwrap(),
695            fr.full_return_for(2024).unwrap(),
696        );
697        let mut sess = Session::open(&path, &pp).unwrap();
698        let clean = clean_screened_ri();
699        // 2024 tables passed with year = 2025 → the year↔table mismatch is caught before any write.
700        let out = commit(&mut sess, 2025, &clean, Some(t2024), Some(p2024)).unwrap();
701        assert!(
702            matches!(out, CommitOutcome::NoTables),
703            "tables for a different year → NoTables, not a committed write"
704        );
705        assert!(
706            !crate::return_inputs::exists(sess.conn(), 2025).unwrap(),
707            "the table-less year is never poisoned with a committed row"
708        );
709    }
710
711    /// ★ §6.2 — an authoritative committed-row write CLEARS a WIP draft but REFUSES a parked one. A WIP
712    /// draft is regenerable crash-scratch, so the write supersedes it; a parked draft is the SOLE copy of a
713    /// screened return (C-1), so it is never silently destroyed — the write is refused, naming both exits.
714    #[test]
715    fn coherence_clears_wip_but_refuses_parked() {
716        let conn = Connection::open_in_memory().unwrap();
717        init_draft_table(&conn).unwrap();
718        let ri = ReturnInputs {
719            filing_status: FilingStatus::Single,
720            ..Default::default()
721        };
722        // WIP draft → cleared
723        set_draft_row(&conn, 2024, &ri, false).unwrap();
724        coherence_clear_or_refuse(&conn, 2024).unwrap();
725        assert!(
726            !draft_exists(&conn, 2024).unwrap(),
727            "coherence clears a WIP draft"
728        );
729        // parked draft → refused, preserved, message names both exits
730        set_draft_row(&conn, 2025, &ri, true).unwrap();
731        let err = coherence_clear_or_refuse(&conn, 2025).unwrap_err();
732        assert!(matches!(
733            err,
734            CliError::ParkedDraftBlocksWrite { year: 2025 }
735        ));
736        let msg = err.to_string();
737        assert!(
738            msg.contains("use full return") && msg.contains("discard parked draft"),
739            "M-d: names both exits"
740        );
741        assert!(
742            draft_exists(&conn, 2025).unwrap(),
743            "a parked draft is never silently destroyed"
744        );
745        // no draft → Ok
746        coherence_clear_or_refuse(&conn, 2030).unwrap();
747    }
748
749    #[test]
750    fn park_stashes_then_deletes_committed_atomically() {
751        let (_dir, path, pp) = tmp_vault();
752        let mut sess = Session::open(&path, &pp).unwrap();
753        let ri = ReturnInputs {
754            filing_status: FilingStatus::Mfj,
755            ..Default::default()
756        };
757        crate::return_inputs::set(sess.conn(), 2024, &ri).unwrap();
758        park_to_profile(&mut sess, 2024).unwrap();
759        // committed row gone; draft holds it with parked=1
760        assert!(
761            !crate::return_inputs::exists(sess.conn(), 2024).unwrap(),
762            "park deletes the committed row"
763        );
764        let d = get_draft_row(sess.conn(), 2024).unwrap().unwrap();
765        assert!(
766            d.parked && d.ri.filing_status == FilingStatus::Mfj,
767            "park stashes the row as parked"
768        );
769        // survives disk (I-7)
770        drop(sess);
771        let s2 = Session::open(&path, &pp).unwrap();
772        assert!(get_draft_row(s2.conn(), 2024).unwrap().unwrap().parked);
773        assert!(
774            !crate::return_inputs::exists(s2.conn(), 2024).unwrap(),
775            "the committed-row DELETE also reached disk, not just the stash"
776        );
777    }
778
779    #[test]
780    fn park_refuses_without_committed_row_and_on_any_wip() {
781        let (_dir, path, pp) = tmp_vault();
782        let mut sess = Session::open(&path, &pp).unwrap();
783        assert!(park_to_profile(&mut sess, 2024).is_err(), "nothing to park");
784        let ri = ReturnInputs {
785            filing_status: FilingStatus::Single,
786            ..Default::default()
787        };
788        crate::return_inputs::set(sess.conn(), 2024, &ri).unwrap();
789        set_draft_row(sess.conn(), 2024, &ri, false).unwrap(); // a WIP draft occupies the slot
790        assert!(
791            park_to_profile(&mut sess, 2024).is_err(),
792            "clean-state gate: won't clobber a WIP draft"
793        );
794        assert!(
795            crate::return_inputs::exists(sess.conn(), 2024).unwrap(),
796            "a refused park leaves the committed row"
797        );
798    }
799
800    /// Mirrors `tax_profile::tests::prof()` (that fixture is private to its own module) — a minimal
801    /// well-formed `TaxProfile` sufficient to make `tax_profile::years` report the year as present.
802    fn sample_profile() -> btctax_core::TaxProfile {
803        use btctax_core::{Carryforward, TaxProfile};
804        use rust_decimal_macros::dec;
805        TaxProfile {
806            filing_status: FilingStatus::Mfj,
807            ordinary_taxable_income: dec!(120000),
808            magi_excluding_crypto: dec!(130000),
809            qualified_dividends_and_other_pref_income: dec!(0),
810            other_net_capital_gain: dec!(0),
811            capital_loss_carryforward_in: Carryforward {
812                short: dec!(0),
813                long: dec!(0),
814            },
815            w2_ss_wages: dec!(0),
816            w2_medicare_wages: dec!(0),
817            schedule_c_expenses: dec!(0),
818        }
819    }
820
821    #[test]
822    fn active_source_follows_resolve_precedence() {
823        let conn = Connection::open_in_memory().unwrap();
824        crate::return_inputs::init_table(&conn).unwrap();
825        crate::tax_profile::init_table(&conn).unwrap();
826        assert!(matches!(
827            active_source(&conn, 2024).unwrap(),
828            ActiveSource::Neither
829        ));
830        crate::tax_profile::set(&conn, 2024, &sample_profile()).unwrap();
831        assert!(matches!(
832            active_source(&conn, 2024).unwrap(),
833            ActiveSource::TaxProfile
834        ));
835        assert!(shadows_profile(&conn, 2024).unwrap());
836        // committed return_inputs wins
837        crate::return_inputs::set(&conn, 2024, &ReturnInputs::default()).unwrap();
838        assert!(matches!(
839            active_source(&conn, 2024).unwrap(),
840            ActiveSource::FullReturn
841        ));
842    }
843
844    #[test]
845    fn discard_parked_draft_only_deletes_a_parked_row() {
846        let (_dir, path, pp) = tmp_vault();
847        let mut sess = Session::open(&path, &pp).unwrap();
848        let ri = ReturnInputs {
849            filing_status: FilingStatus::Single,
850            ..Default::default()
851        };
852        // a WIP draft is NOT discardable via this path
853        set_draft_row(sess.conn(), 2024, &ri, false).unwrap();
854        assert!(
855            discard_parked_draft(&mut sess, 2024).is_err(),
856            "won't delete a WIP behind 'discard parked'"
857        );
858        assert!(draft_exists(sess.conn(), 2024).unwrap());
859        // a parked draft IS discardable
860        set_draft_row(sess.conn(), 2024, &ri, true).unwrap();
861        discard_parked_draft(&mut sess, 2024).unwrap();
862        assert!(!draft_exists(sess.conn(), 2024).unwrap());
863    }
864
865    #[test]
866    fn draft_row_set_get_delete_roundtrip_with_parked() {
867        let conn = Connection::open_in_memory().unwrap();
868        init_draft_table(&conn).unwrap();
869        let ri = ReturnInputs {
870            filing_status: FilingStatus::Mfj,
871            ..Default::default()
872        };
873        // WIP row
874        set_draft_row(&conn, 2024, &ri, false).unwrap();
875        let got = get_draft_row(&conn, 2024).unwrap().unwrap();
876        assert_eq!(got.ri.filing_status, FilingStatus::Mfj);
877        assert_eq!(got.version, SCHEMA_VERSION);
878        assert!(!got.parked);
879        assert_eq!(parked_flag(&conn, 2024).unwrap(), Some(false));
880        // upgrade to parked
881        set_draft_row(&conn, 2024, &ri, true).unwrap();
882        assert!(get_draft_row(&conn, 2024).unwrap().unwrap().parked);
883        // delete
884        assert!(delete_draft(&conn, 2024).unwrap());
885        assert!(get_draft_row(&conn, 2024).unwrap().is_none());
886        assert!(!delete_draft(&conn, 2024).unwrap()); // idempotent
887    }
888}