btctax_cli/lib.rs
1//! btctax-cli: the CLI + reconciliation library that wires the encrypted vault (btctax-store),
2//! ingest (btctax-adapters), and the pure projection (btctax-core) into the Phase-1 command surface
3//! (spec §11). The library is I/O-explicit and deterministic; the binary (`main.rs`) is a thin clap
4//! dispatch. PRIVACY: tests use only temp vaults + synthetic fixtures; no real user file is ever read.
5pub mod bulk_estimated;
6pub mod chokepoint;
7pub mod cli;
8pub mod cmd;
9pub mod config;
10pub mod donation_details;
11pub mod eventref;
12pub mod input_form_store;
13pub mod optimize_attest;
14pub mod price_cache;
15pub mod render;
16pub mod resolve;
17pub mod return_inputs;
18pub mod session;
19pub mod tax_profile;
20pub mod testonly;
21
22pub use cli::Cli;
23// Re-exported at the crate root so the TUI editor (`btctax-tui-edit`) can call it WITHOUT the `cmd::`
24// token its KAT-G1 source gate forbids in non-test code. That gate exists to keep session-lifecycle /
25// lock-holding `cmd::` fns out of the held-session editor; `guard_allocation_vs_tranche` is a PURE
26// `&[LedgerEvent] -> Result` predicate — no `Session`, no lock, no I/O — so the gate's intent is honored,
27// not evaded. Any FUTURE addition here must be equally pure (do NOT re-export a session-opening fn).
28pub use cmd::tranche::guard_allocation_vs_tranche;
29// Re-exported at the crate root mirroring `ATTEST_PHRASE` (below): a plain, distinct consent-phrase
30// constant, not a `cmd::`-scoped session/lock fn, so it belongs beside the other top-level phrase gates.
31pub use cmd::promote::PROMOTE_ACK_PHRASE;
32// Re-exported at the crate root (Defensive Filing Wizard Task 9) alongside `PROMOTE_ACK_PHRASE`: the
33// TUI Promote flow (`btctax-tui-edit`'s `edit/promote_flow.rs`) needs `ProvenanceKind::Purchase` to call
34// `plan_promote` WITHOUT the `cmd::` token its KAT-G1 source gate forbids in non-test code, and
35// `PROVENANCE_TEXT` to show the filer what BG-D5 attestation is being made on their behalf. Both are
36// plain data (a `Copy` enum; a `&'static str`) — no `Session`, no lock, no I/O.
37pub use cmd::promote::{ProvenanceKind, PROVENANCE_TEXT};
38// Re-exported at the crate root so the TUI export path (`btctax-tui::export::do_export`) can call the
39// BG-D8 completeness gate WITHOUT the `cmd::` token its KAT-E10 source gate forbids in non-test code
40// (Approach-B Task 17). Like `guard_allocation_vs_tranche` above, this is a PURE
41// `(&LedgerState, &[LedgerEvent], Option<i32>) -> Result` predicate — no `Session`, no lock, no I/O — so
42// the gate's intent (keep session-lifecycle `cmd::` fns out of the held-session viewer) is honored, not
43// evaded. Any FUTURE addition here must be equally pure (do NOT re-export a session-opening fn).
44pub use cmd::admin::promote_export_gate;
45// Re-exported at the crate root (Defensive Filing Wizard Task 3, ★ arch-n-1) so a future TUI export
46// surface (`btctax-tui-edit`'s `persist.rs`, Task 10) can name `IrsPdfReport` WITHOUT the `cmd::` token
47// its KAT-G1 source gate forbids in non-test code (mirrors `promote_export_gate` above). `IrsPdfReport`
48// is a plain data struct (no `Session`, no lock, no I/O) — the gate's intent is honored, not evaded.
49pub use cmd::admin::IrsPdfReport;
50// Re-exported at the crate root (Defensive Filing Wizard Task 8, ★ C-3) so the TUI Declare flow
51// (`btctax-tui-edit`'s `edit/declare_flow.rs` + `edit/persist.rs`) can drive the DECLARE chokepoint
52// WITHOUT the `cmd::` token its KAT-G1 source gate forbids in non-test code — mirrors
53// `promote_export_gate`/`IrsPdfReport` above. `plan_declare` is a pure `(events, prices, cfg, ...) ->
54// Result` planner (no `Session`, no lock, no I/O); `DeclarePlan`/`Refusal` are plain data types.
55// `apply_declare` DOES touch the mutation surface (`append_decision` + `session.save()`) — it is
56// re-exported here ONLY so `edit/persist.rs`'s `persist_declare_tranche` wrapper can reach it (KAT-G1's
57// `persist_only_tokens` confines the LITERAL `apply_declare(` call token to that one file crate-wide;
58// re-exporting the name itself does not weaken that confinement — the gate scans call sites, not
59// import lists). Any FUTURE addition here must be equally justified (do NOT re-export a second
60// session-opening or unconfined-write fn).
61pub use chokepoint::{apply_declare, plan_declare, DeclarePlan, Refusal};
62// Re-exported at the crate root (Defensive Filing Wizard Task 9, ★ C-3) so the TUI Promote flow
63// (`btctax-tui-edit`'s `edit/promote_flow.rs` + `edit/persist.rs`) can drive the PROMOTE chokepoint
64// WITHOUT the `cmd::` token its KAT-G1 source gate forbids in non-test code — mirrors the
65// `plan_declare`/`DeclarePlan`/`apply_declare` re-export directly above. `plan_promote`/`render_consent`
66// are pure `(events, ...) -> Result` / `(&PromotePlan) -> String` fns (no `Session`, no lock, no I/O);
67// `PromotePlan` is a plain data type; `Refusal` is ALREADY re-exported above (the SAME shared enum both
68// `plan_declare` and `plan_promote` return). `apply_promote` DOES touch the mutation surface — it is
69// re-exported here ONLY so `edit/persist.rs`'s `persist_promote_tranche` wrapper can reach it (KAT-G1's
70// `persist_only_tokens` confines the LITERAL `apply_promote(` call token to that one file crate-wide;
71// re-exporting the name itself does not weaken that confinement — the gate scans call sites, not import
72// lists). Any FUTURE addition here must be equally justified (do NOT re-export a second session-opening
73// or unconfined-write fn).
74pub use chokepoint::{apply_promote, plan_promote, render_consent, PromotePlan};
75// Re-exported at the crate root (Defensive Filing Wizard Task 10, ★ C-3) so the TUI's export step
76// (`btctax-tui-edit`'s `edit/persist.rs`, the ONLY module permitted to name `apply_export(` — its own
77// KAT-G1 mechanized gate confines the LITERAL call token there) can drive the EXPORT chokepoint WITHOUT
78// the `cmd::` token its source gate forbids in non-test code — mirrors the `plan_declare`/`apply_declare`
79// and `plan_promote`/`apply_promote` re-exports above. `plan_export` is a pure `(events, state, ...) ->
80// Result` planner (no `Session`, no lock, no I/O); `ExportPlan` is a plain data type. `apply_export` DOES
81// touch the export write surface (file writes via `export_irs_pdf_from_session`) — it is re-exported here
82// ONLY so `edit/persist.rs`'s `persist_defensive_export` wrapper can reach it; re-exporting the name
83// itself does not weaken KAT-G1's confinement (the gate scans call sites, not import lists). Any FUTURE
84// addition here must be equally justified (do NOT re-export a second session-opening or unconfined-write
85// fn).
86pub use chokepoint::{apply_export, plan_export, ExportOutcome, ExportOutcomes, ExportPlan};
87pub use config::CliConfig;
88pub use session::{
89 BulkFilter, BulkIncomeFilter, BulkIncomePlan, BulkIncomeRow, BulkLinkPlan, BulkLinkRow,
90 BulkReclassifyOutflowPlan, BulkReclassifyOutflowRow, BulkResolvePlan, BulkResolveRow,
91 BulkStiFilter, BulkStiPlan, BulkStiRow, BulkVoidPlan, BulkVoidRow, Frame, MatchAction,
92 MatchProposal, Session,
93};
94
95#[derive(Debug, thiserror::Error)]
96pub enum CliError {
97 #[error(transparent)]
98 Store(#[from] btctax_store::StoreError),
99 #[error(transparent)]
100 Core(#[from] btctax_core::CoreError),
101 #[error(transparent)]
102 Adapter(#[from] btctax_adapters::AdapterError),
103 #[error("sqlite: {0}")]
104 Sqlite(#[from] rusqlite::Error),
105 /// C1: `write_csv_exports` (Task 15) uses `?` on `csv::Writer` ops (→ `csv::Error`); `csv::Error`
106 /// is its own type (NOT covered by `Io(#[from] io::Error)`, whose `From` goes the other way), so it
107 /// needs its own variant or Task 15 will not compile.
108 #[error("csv: {0}")]
109 Csv(#[from] csv::Error),
110 #[error("io: {0}")]
111 Io(#[from] std::io::Error),
112 /// `export-irs-pdf`: an official IRS PDF fill failed — most importantly the geometric read-back
113 /// FAILING CLOSED on a mis-mapped cell, so no wrong tax form is ever written.
114 #[error("IRS form fill: {0}")]
115 FormFill(#[from] btctax_forms::FormsError),
116 /// A user-supplied event reference did not parse as a canonical `EventId` (eventref.rs).
117 #[error("not a valid event reference: {0:?}")]
118 BadEventRef(String),
119 /// A CLI argument was malformed (bad USD/date/enum/wallet spec, or a contradictory flag set).
120 #[error("usage: {0}")]
121 Usage(String),
122 /// M1: a `cli_config` row held an unrecognized value (corrupt DB, future-written value, or manual
123 /// edit gone wrong). Returning an error is safer than silently misreading the stored intent.
124 #[error("unrecognized stored config value: key={key:?} value={value:?}")]
125 BadConfigValue { key: String, value: String },
126 /// P9 §2.6: a stored `return_inputs` row predates the form-question registry (or was written by a newer
127 /// build) and this build does not migrate it. There is no user data yet, so the policy is refuse-and-
128 /// reimport rather than a per-key migration (a version check cannot forget a key). The remedy names all
129 /// THREE commands, in order — `clear` DISCARDS any computed carryover this row's prior reports wrote onto
130 /// it, so the rebuild step is not optional. (Retire this the moment real data exists — FOLLOWUPS, release
131 /// gate.)
132 #[error(
133 "the stored inputs for {year} predate the form-question registry (schema v{found}; this build reads \
134 v{expected}). Run `btctax income clear {year}` — which DISCARDS any carryover this row's prior \
135 reports computed onto it — then `btctax income import` for {year}; then, if this row carried a \
136 computed carryover, `btctax report --tax-year {prior} --write-carryover` to rebuild it.",
137 prior = year - 1
138 )]
139 StaleReturnInputs {
140 year: i32,
141 found: i64,
142 expected: i64,
143 },
144 /// §6.3 / C-1: a PARKED input-form draft is at a schema version this build does not read, and this
145 /// build does not migrate it. Unlike a stale WIP draft (regenerable → discarded), a parked draft may
146 /// hold irreplaceable carryover that exists ONLY in the draft — there is no committed row to re-import
147 /// from — so we REFUSE (fail closed) rather than discard. The remedy therefore is NOT `income import`
148 /// (that recovers a WIP row from committed state, which a parked draft has none of): the message must
149 /// tell the filer the data lives in the draft, must not be discarded, and to re-run on / export from the
150 /// app version that wrote it. (Retire alongside `StaleReturnInputs` the moment migrations exist.)
151 #[error(
152 "year {year}'s parked full return is schema v{found} but this build expects v{expected}; \
153 an upgrade changed the input format. Its data lives only in the draft — do not discard it. \
154 Re-run on the app version that wrote it, or export it there first."
155 )]
156 StaleParkedDraft {
157 year: i32,
158 found: i64,
159 expected: i64,
160 },
161 /// §6.2 draft-coherence: an authoritative committed-row write (`income import` / `income answer` /
162 /// carryover write-back / `income clear`) was attempted for a year whose input-form draft is PARKED.
163 /// A parked draft is the SOLE copy of a screened return (C-1) — clobbering it via the committed row
164 /// would silently destroy irreplaceable data — so the write is REFUSED (fail closed). The message
165 /// names BOTH in-form exits (M-d): re-commit it (`use full return`) or drop it (`discard parked
166 /// draft`, a confirmed delete); a WIP draft, by contrast, is regenerable and is cleared silently.
167 #[error(
168 "year {year} holds a parked full return — in the form, 'use full return' to re-commit it, or \
169 'discard parked draft' (a confirmed delete) to drop it; then re-run this command."
170 )]
171 ParkedDraftBlocksWrite { year: i32 },
172 /// Sub-project 3 attestation gate: an export was attempted while the ledger is pseudo-reconciled
173 /// (a synthetic, non-persisted default contributes to the projection) and NO attestation phrase was
174 /// supplied. Producing a form/data file from a fictional draft requires typing the exact phrase.
175 /// (Supersedes sub-2's interim [I3] blanket refusal.)
176 #[error(
177 "export refused: the ledger is pseudo-reconciled (a synthetic default contributes to the \
178 projection). To export this draft ON PURPOSE, attest the exact phrase {:?} (pass --attest, or \
179 type it at the prompt). Otherwise run `reconcile pseudo off` (or approve + attest the defaults).",
180 ATTEST_PHRASE
181 )]
182 AttestationRequired,
183 /// Sub-project 3 attestation gate: an export was attempted while the ledger is pseudo-reconciled and
184 /// the supplied attestation phrase did NOT match (trimmed, case-sensitive, exact). A wrong phrase is
185 /// FAILED regardless of environment [R0-I1] — no fictional form leaves the machine.
186 #[error(
187 "export refused: the attestation phrase did not match. The ledger is pseudo-reconciled; type the \
188 phrase EXACTLY (trimmed, case-sensitive): {:?}.",
189 ATTEST_PHRASE
190 )]
191 AttestationFailed,
192 /// UX-P4-8: an I/O failure at a user-named path — a `--vault` that is missing/unreadable, or an
193 /// `--out` that collides / cannot be created. Carries the offending PATH and a one-clause remedy
194 /// hint that the bare `io::Error` (surfaced pathlessly through `Store::Io` / `Io`) lacks. Mirrors
195 /// the adapters' `AdapterError::Io { path, source }` so every path-bearing io error reads alike.
196 #[error("io {path}: {source} ({hint})")]
197 PathIo {
198 path: String,
199 hint: String,
200 #[source]
201 source: std::io::Error,
202 },
203}
204
205/// UX-P4-8 hint: shown when a `--vault` cannot be opened (missing/unreadable path).
206pub const VAULT_OPEN_HINT: &str =
207 "check the --vault path, or run `btctax init` to create a new vault";
208
209/// UX-P4-8 hint: shown when an export `--out` directory cannot be created (a colliding file, a
210/// missing parent, or a permission problem).
211pub const EXPORT_OUT_HINT: &str =
212 "choose an --out path that does not already exist as a file and whose parent is writable";
213
214/// Re-wrap a `StoreError` I/O failure with the offending PATH + a one-clause hint (UX-P4-8). ONLY the
215/// pathless `StoreError::Io` is enriched; every other variant (`WrongPassphrase`, `Locked`,
216/// `HalfCreatedVault`, …) passes through unchanged — each already carries its own precise meaning and
217/// must NOT be masked behind a generic path/hint.
218pub fn store_io_with_path(
219 e: btctax_store::StoreError,
220 path: &std::path::Path,
221 hint: &str,
222) -> CliError {
223 match e {
224 btctax_store::StoreError::Io(source) => CliError::PathIo {
225 path: path.display().to_string(),
226 hint: hint.to_string(),
227 source,
228 },
229 other => CliError::Store(other),
230 }
231}
232
233/// Re-wrap a pathless I/O failure with the offending PATH + a one-clause hint (UX-P4-8). Enriches
234/// BOTH shapes an export write can produce: a raw `CliError::Io` (a `write`/`flush` mid-write) AND a
235/// `CliError::Store(StoreError::Io)` (a `mkdir_owner_only`/`open_owner_only` under `out_dir` — e.g. a
236/// SUBPATH collision like `out_dir/lots.csv` already existing as a directory, which `?`-converts
237/// through `From<StoreError>`). A `CliError::Csv` (a serialization error, not a path problem) and
238/// every other variant pass through unchanged.
239pub fn cli_io_with_path(e: CliError, path: &std::path::Path, hint: &str) -> CliError {
240 match e {
241 CliError::Io(source) | CliError::Store(btctax_store::StoreError::Io(source)) => {
242 CliError::PathIo {
243 path: path.display().to_string(),
244 hint: hint.to_string(),
245 source,
246 }
247 }
248 other => other,
249 }
250}
251
252/// The exact phrase a user must affirm to export a form/data file while the ledger is pseudo-reconciled
253/// (sub-project 3). Compared TRIMMED, case-SENSITIVE, exact. The prompt + both error strings are BUILT
254/// from this constant [R0-M1] so there is no drift (a KAT asserts they contain it). `pub` so btctax-tui
255/// shares it [R0-r2-N2].
256pub const ATTEST_PHRASE: &str = "I attest this is true";
257
258/// PURE exact-compare attestation gate — NO I/O, NO TTY read [R0-I2]. The interactive prompt lives in
259/// the caller (the `export-snapshot` main.rs arm / the btctax-tui export modal); this helper only
260/// compares, keeping the library I/O-explicit and the KATs deterministic (no env-dependent branch).
261///
262/// - `attest.map(str::trim) == Some(ATTEST_PHRASE)` → `Ok(())`.
263/// - `Some(_)` non-matching → `Err(AttestationFailed)` (a wrong phrase FAILS regardless of env) [R0-I1].
264/// - `None` → `Err(AttestationRequired)`.
265///
266/// `pub` so btctax-tui shares the exact-compare [R0-r2-N2].
267pub fn require_attestation(attest: Option<&str>) -> Result<(), CliError> {
268 match attest.map(str::trim) {
269 Some(p) if p == ATTEST_PHRASE => Ok(()),
270 Some(_) => Err(CliError::AttestationFailed),
271 None => Err(CliError::AttestationRequired),
272 }
273}