Skip to main content

btctax_cli/cmd/
admin.rs

1//! `config`, `export-snapshot` (FR10), `backup-key` — administrative commands. Config surfaces the TP8
2//! (c)/(b) treatment + the pre-2025 lot method; export/backup arrive in Task 15.
3use crate::cli::FormArg;
4use crate::config::{set_fee_treatment, set_pre2025_method as config_set_pre2025_method};
5use crate::render::write_csv_exports;
6use crate::{require_attestation, CliConfig, CliError, Session};
7use btctax_adapters::BundledTaxTables;
8use btctax_core::{
9    compute_se_tax, se_net_income, FeeTreatment, LedgerEvent, LotMethod, ScheduleDPart, Severity,
10    TaxTables, Usd, DIGITAL_ASSET_8949_FIRST_YEAR,
11};
12use btctax_forms::Form1040Inputs;
13use btctax_store::{fsperms, Passphrase};
14use std::path::{Path, PathBuf};
15
16/// Outcome of the CLI `export_snapshot` wrapper: the written snapshot path plus the count of
17/// UNRESOLVED Hard blockers (`severity() == Hard`) in the projection. Any Hard blocker gates EVERY
18/// tax year (`compute_tax_year` short-circuits on the projection-wide first Hard blocker), so
19/// `unresolved_hard > 0` means every exported Form 8949 / Schedule D / projection CSV is
20/// INFORMATIONAL, not final — the `ExportSnapshot` main.rs arm warns on stderr accordingly. A
21/// fully-resolved ledger yields `0` and no warning. Advisory blockers (incl. `PseudoReconcileActive`,
22/// `SelfTransferInboundZeroBasis`) never count.
23#[derive(Debug, Clone)]
24pub struct ExportReport {
25    pub path: PathBuf,
26    pub unresolved_hard: usize,
27    /// Approach-B experimental disclosure (`design/approach-b-experimental-notice`, fix round 1
28    /// Important #4): `btctax_core::experimental::uses_approach_b(events)` on the projected events —
29    /// `true` iff a live (non-voided) DeclareTranche/PromoteTranche is on file. `export-snapshot` writes
30    /// the same `form_8275.txt`/`basis_methodology.txt` disclosure files `export-irs-pdf` does (this is
31    /// the CSV/preparer-handoff path), so it gets the SAME stderr notice (main.rs). Interface-only — the
32    /// notice is never written to `out_dir`.
33    pub experimental_notice_active: bool,
34}
35
36pub fn show_config(vault_path: &Path, pp: &Passphrase) -> Result<CliConfig, CliError> {
37    Session::open(vault_path, pp)?.config()
38}
39
40/// Persist a new TP8 fee treatment (None = leave unchanged), then return the resulting config.
41pub fn set_config(
42    vault_path: &Path,
43    pp: &Passphrase,
44    fee_treatment: Option<FeeTreatment>,
45) -> Result<CliConfig, CliError> {
46    let mut session = Session::open(vault_path, pp)?;
47    if let Some(t) = fee_treatment {
48        set_fee_treatment(session.conn(), t)?;
49        session.save()?;
50    }
51    session.config()
52}
53
54/// Persist the pre-2025 lot identification method and attestation flag, then return the resulting config.
55pub fn set_pre2025_method(
56    vault_path: &Path,
57    pp: &Passphrase,
58    m: LotMethod,
59    attested: bool,
60) -> Result<CliConfig, CliError> {
61    let mut session = Session::open(vault_path, pp)?;
62    config_set_pre2025_method(session.conn(), m, attested)?;
63    session.save()?;
64    session.config()
65}
66
67/// **BG-D8 (Task 14) — the export COMPLETENESS gate.** REFUSES the export (writing ZERO bytes: called
68/// FIRST in each export fn, before any `mkdir_out`/file write) when a promoted-basis DISPOSAL leg is filed
69/// in the exported range but its Form 8275 disclosure is absent or INCOMPLETE (an empty/scaffold-only Part
70/// II). Reg §1.6662-4(f) makes a disclosure adequate only on a COMPLETED Form 8275; a promoted leg filed
71/// without one is inadequate disclosure — a HARD refusal, never a warning.
72///
73/// Mirrors the pseudo-active attestation slot (`if state.pseudo_active() { require_attestation(...)? }`):
74/// a real refuse-before-bytes gate. It is deliberately NOT the always-written `basis_methodology.txt`
75/// pattern (which unconditionally writes and can never refuse) — a refused export leaves `out_dir`
76/// untouched.
77///
78/// `year: Some(y)` scopes the check to `y` (the per-year PDF packets). `year: None` — the non-year-scoped
79/// CSV/snapshot export — means "ANY year with a promoted filed disposal leg in the exported range" (N-3),
80/// so an all-years dump can never smuggle out an inadequately-disclosed promoted position either.
81///
82/// The refusing state is only reachable via a hand-crafted raw-vault write (an empty `part_ii_narrative`):
83/// the T10 `promote-tranche` verb refuses an empty narrative at record time (BG-D7), so a CLI-recorded
84/// promote is complete by construction — this gate is the type-level backstop for the corner it cannot.
85pub fn promote_export_gate(
86    state: &btctax_core::state::LedgerState,
87    events: &[LedgerEvent],
88    year: Option<i32>,
89) -> Result<(), CliError> {
90    // The year(s) to check: the requested one, or — for the whole-range CSV/snapshot dump — every year in
91    // which a promoted disposal leg files. ★ Task 3 (arch-m-2/DFW-D11): the `None` arm's enumeration is
92    // single-sourced from `chokepoint::promoted_filing_years` — the SAME 8275-completeness set, never
93    // duplicated here (and NOT the fold-diff export set, which is strictly larger — see `flagged_years`).
94    let years: Vec<i32> = match year {
95        Some(y) => vec![y],
96        None => crate::chokepoint::promoted_filing_years(state)
97            .into_iter()
98            .collect(),
99    };
100    for y in years {
101        // `disclosure_8275` is `Some` iff a promoted DISPOSAL leg files in `y`; refuse when its Part II is
102        // empty/incomplete (the `incomplete` flag T13 exposes for exactly this gate).
103        if let Some(disc) = btctax_core::tax::form8275::disclosure_8275(events, state, y) {
104            if disc.incomplete {
105                return Err(CliError::Usage(format!(
106                    "refusing to export a packet with a promoted-basis leg but no complete Form 8275 \
107                     disclosure for {y}: Reg \u{00a7}1.6662-4(f) makes disclosure adequate only on a \
108                     COMPLETED Form 8275, and this promoted disposal has an empty Part II narrative. \
109                     Record the Part II explanation (re-run `btctax reconcile promote-tranche … \
110                     --part-ii-file <path>`) before exporting."
111                )));
112            }
113        }
114    }
115    Ok(())
116}
117
118/// FR10 / NFR2 exception: decrypted SQLite image (via the store) + the projected ledger as CSV.
119/// When `tax_year` is `Some(y)`, the per-tax-year Form 8949 + Schedule D CSVs are also written,
120/// year-scoped to `y` (P2-B); when `None`, only the all-years CSVs are written.
121///
122/// Sub-project 3 attestation gate: when the projection is pseudo-active (a synthetic default
123/// contributes), producing any form/data file requires the exact `ATTEST_PHRASE` in `attest`
124/// (trimmed, case-sensitive). Checked FIRST — before any bytes are written — so a refused export
125/// leaves `out_dir` untouched. A fully-real (not-pseudo-active) ledger ignores `attest` entirely.
126pub fn export_snapshot(
127    vault_path: &Path,
128    pp: &Passphrase,
129    out_dir: &Path,
130    tax_year: Option<i32>,
131    attest: Option<&str>,
132) -> Result<ExportReport, CliError> {
133    let session = Session::open(vault_path, pp)?;
134    // Two refuse-before-bytes gates, checked FIRST (before the vault snapshot / CSV writes) so a refused
135    // export leaves out_dir untouched:
136    //  1. BG-D8 completeness gate — a promoted-basis leg filed without its complete Form 8275 is a HARD
137    //     refusal (Reg §1.6662-4(f)). `year: None` (a whole-range dump) means "ANY year with a promoted
138    //     filed leg" (N-3), so the all-years CSV cannot smuggle an inadequately-disclosed position either.
139    //  2. Attestation gate — no fictional snapshot/8949/Schedule D leaves the machine unguarded when a
140    //     synthetic default contributes and the attestation is missing/wrong.
141    let (events, state, _cfg) = session.load_events_and_project()?;
142    promote_export_gate(&state, &events, tax_year)?;
143    if state.pseudo_active() {
144        require_attestation(attest)?;
145    }
146    // UX-P4-8: name the --out path (and hint) when the export directory cannot be created (a
147    // colliding file / missing parent / permission problem), instead of a bare `io: File exists`.
148    let sqlite = session
149        .vault()
150        .export_snapshot(out_dir)
151        .map_err(|e| crate::store_io_with_path(e, out_dir, crate::EXPORT_OUT_HINT))?; // writes out_dir/snapshot.sqlite
152                                                                                      // P2-D: standalone Schedule SE §1401 figure for the year-scoped export. Needs the year's filing
153                                                                                      // status (profile) + the year's ss_wage_base (bundled table); `None` when either is absent or
154                                                                                      // there is no business SE income. The "present but no table" note is a text-report concern
155                                                                                      // (render_schedule_se) — the CSV carries the computed figure only.
156    let se_result = match tax_year {
157        Some(y) => {
158            let tables = BundledTaxTables::load();
159            // Resolve (ReturnInputs-derived → stored → …); an uncomputable/refused profile just omits the
160            // SE figure — the export (a data snapshot) still proceeds, never emitting a wrong number.
161            let profile = match session.resolve_screened(&state, y, &tables)? {
162                crate::resolve::ProfileOutcome::Ready { profile, .. } => profile,
163                crate::resolve::ProfileOutcome::Uncomputable { .. } => None,
164            };
165            profile.and_then(|p| {
166                tables.table_for(y).and_then(|t| {
167                    compute_se_tax(
168                        &state,
169                        y,
170                        p.filing_status,
171                        t,
172                        p.w2_ss_wages,
173                        p.w2_medicare_wages,
174                        p.schedule_c_expenses,
175                    )
176                })
177            })
178        }
179        None => None,
180    };
181    let donation_details = session.donation_details()?;
182    // UX-P4-8: name the --out path for any I/O failure writing under out_dir — a `mkdir_owner_only`/
183    // `open_owner_only` failure (a SUBPATH collision such as `out_dir/lots.csv` being a directory)
184    // arrives as `CliError::Store(StoreError::Io)`, a mid-write `flush`/`writeln` as `CliError::Io`;
185    // `cli_io_with_path` enriches BOTH. A `csv::Error` (serialization, not a path problem) passes
186    // through.
187    write_csv_exports(
188        out_dir,
189        &state,
190        tax_year,
191        se_result.as_ref(),
192        &donation_details,
193    )
194    .map_err(|e| crate::cli_io_with_path(e, out_dir, crate::EXPORT_OUT_HINT))?;
195    // BG-D8: emit the Form 8275 disclosure by its OWN name alongside the year-scoped packet (mirrors the
196    // basis_methodology.txt emit inside write_csv_exports). The gate above already guaranteed every
197    // promoted leg in the exported range carries a complete Part II.
198    match tax_year {
199        Some(y) => {
200            crate::render::write_form_8275_txt(out_dir, &state, &events, y)
201                .map_err(|e| crate::cli_io_with_path(e, out_dir, crate::EXPORT_OUT_HINT))?;
202        }
203        None => {
204            // Task 16 / M2: the all-years dump emits promoted rows (lots/disposals.csv) for EVERY
205            // promoted year in range, so it must co-emit the 8275 for every one of them too — not just
206            // whichever year a `Some(y)` caller happened to name. Year-suffixed filenames (never the
207            // bare `form_8275.txt`): a real vault can have promoted disposal legs in more than one tax
208            // year, and the bare name would let a second year silently overwrite the first's disclosure.
209            let mut promoted_years: std::collections::BTreeSet<i32> =
210                std::collections::BTreeSet::new();
211            for d in &state.disposals {
212                if d.legs
213                    .iter()
214                    .any(|l| state.promoted_origins.contains(&l.lot_id.origin_event_id))
215                {
216                    promoted_years.insert(d.disposed_at.year());
217                }
218            }
219            for y in promoted_years {
220                crate::render::write_form_8275_txt_named(
221                    out_dir,
222                    &state,
223                    &events,
224                    y,
225                    &format!("form_8275_{y}.txt"),
226                )
227                .map_err(|e| crate::cli_io_with_path(e, out_dir, crate::EXPORT_OUT_HINT))?;
228            }
229        }
230    }
231    // [R0-I1] Count UNRESOLVED Hard blockers only. Any Hard blocker gates every year, so the count
232    // alone (no per-year `compute_tax_year` call, no profile/tables dependency) drives the main.rs
233    // stderr "INFORMATIONAL, not final" disclosure. Advisory blockers never count.
234    let unresolved_hard = state
235        .blockers
236        .iter()
237        .filter(|b| b.kind.severity() == Severity::Hard)
238        .count();
239    Ok(ExportReport {
240        path: sqlite,
241        unresolved_hard,
242        experimental_notice_active: btctax_core::experimental::uses_approach_b(&events),
243    })
244}
245
246/// Probe: would an export be gated? `true` when the projection is pseudo-active (a synthetic default
247/// contributes). Used by the `export-snapshot` CLI arm to decide whether to PROMPT for the attestation
248/// phrase; the authoritative gate lives inside `export_snapshot` itself. Kept in the library so main.rs
249/// stays a thin dispatch (no session-open / projection business logic in the binary).
250pub fn export_pseudo_active(vault_path: &Path, pp: &Passphrase) -> Result<bool, CliError> {
251    let (state, _cfg) = Session::open(vault_path, pp)?.project()?;
252    Ok(state.pseudo_active())
253}
254
255/// Outcome of `export_irs_pdf`: the written PDF paths, the unresolved-Hard-blocker count (same
256/// INFORMATIONAL disclosure as `export-snapshot`), whether the fill was watermarked (pseudo-active),
257/// the count of rows that MIGHT belong on a separate broker-reported 8949 (the [I5] advisory — the
258/// separate boxes are year-aware: A/B/D/E from a 1099-B pre-TY2025, G/H/J/K from a 1099-DA from
259/// TY2025), and the SP2 packet (Schedule SE + Form 8283 + Form 1040 cap-gains) with the advisories
260/// each one drives.
261#[derive(Debug, Clone)]
262pub struct IrsPdfReport {
263    pub f8949_path: Option<PathBuf>,
264    pub schedule_d_path: Option<PathBuf>,
265    pub tax_year: i32,
266    pub unresolved_hard: usize,
267    pub broker_reported_rows: usize,
268    pub watermarked: bool,
269    /// Schedule SE — written only when SE income ≥ the $400 floor (and selected).
270    pub schedule_se_path: Option<PathBuf>,
271    /// SE tax was computed but net earnings were below the $400 floor → SE not owed, form skipped.
272    pub se_below_floor: bool,
273    /// `Some(addl)` when the §1401(b)(2) Additional Medicare Tax is nonzero — a Form 8959 item, NOT on
274    /// Schedule SE (a loud advisory).
275    pub se_addl_medicare: Option<Usd>,
276    /// SE-eligible business income exists but no profile/table was available (`compute_se_tax` → None
277    /// for a reason other than "no SE income") → a NOTE, not a silent skip (the `se_net_income`
278    /// discriminator).
279    pub se_income_without_profile: bool,
280    /// Form 8283 — written only when there are donations (and selected).
281    pub form_8283_path: Option<PathBuf>,
282    /// Any Form 8283 row needs manual review (incomplete appraiser/donee declaration) → escalate.
283    pub form_8283_needs_review: bool,
284    /// The Form 8283 section actually written (`Some(true)` = Section B, `Some(false)` = Section A).
285    pub form_8283_section_b: Option<bool>,
286    /// Form 1040 cap-gains — written only when there is reportable digital-asset activity (and selected).
287    pub form_1040_path: Option<PathBuf>,
288    /// Line 7a received a value on the written 1040.
289    pub form_1040_filled_7a: bool,
290    /// The 1040 was skipped for a NET LOSS on line 7a (the §1211 line-21 cap is the filer's).
291    pub form_1040_loss: bool,
292    /// Form 8275 (Disclosure Statement) — Task 16: written only on the crypto-slice path, only when a
293    /// promoted-basis disposal leg files in `tax_year` (and selected). Always `None` on the full-return
294    /// path — its 8275 is inside `full_return_paths` instead (sequence-prefixed, e.g. `92_f8275.pdf`).
295    pub form_8275_path: Option<PathBuf>,
296    /// ★ The FULL-RETURN packet's files, in Attachment Sequence order (empty on the crypto-slice path).
297    /// The two paths write NON-OVERLAPPING names, so no two runs can be collated into a chimera return.
298    pub full_return_paths: Vec<PathBuf>,
299    /// The full-return packet's manifest (the filer's stapling order).
300    pub full_return_manifest: Option<PathBuf>,
301    /// UX-P4-5: `true` when a `--forms` SLICE was passed on a full-return year and therefore IGNORED
302    /// (the whole jointly-computed packet writes; honoring a slice of it is tax-unsound). The caller
303    /// warns on stderr. Always `false` on the crypto-slice path (there `--forms` is honored).
304    pub forms_ignored_full_return: bool,
305    /// Approach-B experimental disclosure (`design/approach-b-experimental-notice`):
306    /// `btctax_core::experimental::uses_approach_b(events)` on the projected events — `true` iff a live
307    /// (non-voided) DeclareTranche/PromoteTranche is on file. Drives main.rs's stderr notice ONLY — the
308    /// notice is interface-only and is never written to `out_dir`.
309    pub experimental_notice_active: bool,
310}
311
312/// The **[I5]** broker-reporting advisory line, year-aware — or `None` when no disposition may have
313/// been broker-reported (`broker_reported_rows == 0`).
314///
315/// The "separate 8949 / not-reported box" pairing depends on the form revision, exactly as the box
316/// assignment does (mirrors [`btctax_core::DIGITAL_ASSET_8949_FIRST_YEAR`]): pre-TY2025 an exchange
317/// disposal may have been reported on a **1099-B**, belongs on a separate 8949 under **Box A/B (ST) /
318/// D/E (LT)**, and this export files every row under **Box C/F**; from TY2025 it is the **1099-DA**,
319/// **Box G/H/J/K**, and every row files under **Box I/L**. Emitting the 2025 pairing on a pre-2025
320/// export would steer the filer to boxes that do not exist on that revision — hence the year gate.
321pub fn broker_reporting_advisory(tax_year: i32, broker_reported_rows: usize) -> Option<String> {
322    if broker_reported_rows == 0 {
323        return None;
324    }
325    let (broker_form, separate_boxes, filed_boxes) = if tax_year >= DIGITAL_ASSET_8949_FIRST_YEAR {
326        ("1099-DA", "Box G/H/J/K", "Box I/L")
327    } else {
328        ("1099-B", "Box A/B (ST) / D/E (LT)", "Box C/F")
329    };
330    Some(format!(
331        "⚠ [I5] {broker_reported_rows} disposition(s) occurred on an exchange that MAY have issued \
332         {broker_form} broker basis reporting — those would belong on a SEPARATE Form 8949 under \
333         {separate_boxes}. This export files EVERY Bitcoin row under {filed_boxes} (not-reported \
334         default) and says so; reclassify by hand if you received a {broker_form}."
335    ))
336}
337
338/// Whether a form is included: the packet is every applicable form unless `--forms` opts in to a subset.
339fn wants(selected: &[FormArg], f: FormArg) -> bool {
340    selected.is_empty() || selected.contains(&f)
341}
342
343/// A Schedule D part is "active" (worth reporting) iff it has any proceeds/cost/gain.
344fn sd_part_active(p: &ScheduleDPart) -> bool {
345    !p.proceeds.is_zero() || !p.cost_basis.is_zero() || !p.gain.is_zero()
346}
347
348/// `export-irs-pdf`: fill the OFFICIAL IRS PDFs for `tax_year` and write them (owner-only) to
349/// `out_dir`. THIN OPENER (★ arch-C-1, Defensive Filing Wizard Task 3): opens its OWN `Session` and
350/// projects ONCE, then delegates everything else to [`export_irs_pdf_from_session`] — the `&Session`
351/// inner a future TUI (which already holds the vault's `VaultLock`) can call directly, without a SECOND
352/// `Session::open` (which would deadlock the editor, `session.rs:662`).
353pub fn export_irs_pdf(
354    vault_path: &Path,
355    pp: &Passphrase,
356    out_dir: &Path,
357    tax_year: i32,
358    forms: &[FormArg],
359    attest: Option<&str>,
360) -> Result<IrsPdfReport, CliError> {
361    let session = Session::open(vault_path, pp)?;
362    let (events, state, _cfg) = session.load_events_and_project()?;
363    export_irs_pdf_from_session(&session, &state, &events, out_dir, tax_year, forms, attest)
364}
365
366/// The `&Session` inner of `export_irs_pdf` (★ arch-C-1): fill the OFFICIAL IRS PDFs for `tax_year` over
367/// an ALREADY-OPEN `session` + an ALREADY-PROJECTED `state`/`events` — no `Session::open`, no re-project.
368/// The packet is Form 8949 + Schedule D (always applicable) plus — when applicable and selected —
369/// Schedule SE (SE income ≥ $400), Form 8283 (donations), and Form 1040 cap-gains (reportable
370/// digital-asset activity). The form data is REUSED from the projection
371/// (`form_8949`/`schedule_d`/`form_8283`/`compute_se_tax`) — nothing capital-gains is recomputed; the
372/// SE §1401 figure is computed here from the year's stored `TaxProfile`. Same pseudo-active attestation
373/// gate as `export-snapshot`: checked FIRST, so a refused export leaves `out_dir` untouched; a pseudo
374/// fill is additionally DRAFT-watermarked.
375///
376/// ★ arch-m-new-1/n-new-1: the full-vs-slice `return_inputs::exists` dispatch lives ONCE, HERE — both
377/// the thin `export_irs_pdf` opener AND the chokepoint's `apply_export` (`chokepoint/mod.rs`) route
378/// through this ONE fn, so the dispatch is never duplicated.
379pub(crate) fn export_irs_pdf_from_session(
380    session: &Session,
381    state: &btctax_core::state::LedgerState,
382    events: &[LedgerEvent],
383    out_dir: &Path,
384    tax_year: i32,
385    forms: &[FormArg],
386    attest: Option<&str>,
387) -> Result<IrsPdfReport, CliError> {
388    // ★ THE DISPATCH (P6.5). Exactly one function decides which pipeline runs, and the two write
389    // NON-OVERLAPPING filenames, so artifacts from two runs can never be collated into a chimera
390    // return: the full packet writes `f1040.pdf`, `f1040s1.pdf`, … + a manifest; the crypto slice
391    // writes `form_1040_capgains.pdf`, `schedule_d.pdf`, `f8949.pdf`, ….
392    //
393    // This REPLACES the P5-C1 refusal (`CryptoSliceExportForFullReturnYear`). That guard existed only
394    // because the slice's Schedule D carries the crypto totals alone — no line 13 for 1099-DIV box-2a
395    // capital-gain distributions, no lines 6/14 for capital-loss carryovers — so on a full-return year
396    // it was a complete-LOOKING form with income missing. The full pipeline fills all of them, plus
397    // every attachment the forms cite, so the reason for the refusal is gone. Deleting it downgrades a
398    // type-level impossibility to a branch, which is why the branch is HERE, alone, and pinned by KATs
399    // in BOTH directions.
400    if crate::return_inputs::exists(session.conn(), tax_year)? {
401        // The full-return pipeline runs the BG-D8 gate itself (checked first there too).
402        let mut report = export_full_return(session, state, events, out_dir, tax_year, attest)?;
403        // UX-P4-5: a --forms slice cannot be honored on a full-return year (the 14-form packet is
404        // jointly computed; a slice of it is tax-unsound). The packet still writes in full; flag the
405        // ignored slice so the caller warns.
406        report.forms_ignored_full_return = !forms.is_empty();
407        return Ok(report);
408    }
409
410    // BG-D8 completeness gate (crypto-slice path) — a promoted-basis leg without its complete Form 8275
411    // is a HARD refusal, checked FIRST (before the pseudo watermark check and any byte written).
412    promote_export_gate(state, events, Some(tax_year))?;
413
414    // Attestation gate — no fictional tax form leaves the machine unguarded, and a refusal
415    // writes no bytes. (A fully-real ledger ignores `attest`.)
416    let watermarked = state.pseudo_active();
417    if watermarked {
418        require_attestation(attest)?;
419    }
420
421    // Reuse the projection's capital-gains data verbatim (no recompute).
422    let rows = btctax_core::form_8949(state, tax_year);
423    let totals = btctax_core::schedule_d(state, tax_year);
424
425    // Form 8275 (Disclosure Statement) — Task 16: `Some` iff a promoted-basis disposal leg files in
426    // `tax_year` (the same `disclosure_8275` scoping `promote_export_gate` above already used to confirm
427    // completeness).
428    let printed_8275 = btctax_core::tax::form8275::disclosure_8275(events, state, tax_year)
429        .map(|d| btctax_core::tax::printed::printed_8275(&d));
430    // Task 16 / ADD-2 (mirrors `export_full_return`'s pre-check below): v1 does not paginate Form 8275 —
431    // refuse HERE, before `mkdir_out`, so an overflowing year (> 6 promoted disposal legs) names the year
432    // + a concrete remedy and writes ZERO bytes, instead of a bare `FormsError::Overflow` display after
433    // other files (`basis_methodology.txt`, `form_8275.txt`) already exist on disk.
434    if let Some(p) = &printed_8275 {
435        let cap = btctax_forms::Form8275Map::for_year(tax_year)?.rows.len();
436        if p.part_i.len() > cap {
437            return Err(CliError::Usage(format!(
438                "cannot export {tax_year}: {n} promoted disposal leg(s) each need a Form 8275 Part I \
439                 row, but this revision holds only {cap} — Form 8275 cannot yet paginate beyond {cap} \
440                 rows. File the 8275 manually for {tax_year}, or reduce the number of promoted disposal \
441                 legs filed in {tax_year} (e.g. void one of the promotes) and re-export.",
442                n = p.part_i.len(),
443            )));
444        }
445    }
446
447    // T-f8275-part-ii-overflow round 2 finding 2: refuse an OVERFLOWING Part II narrative HERE too,
448    // before `mkdir_out` — same reasoning as the Part I row check just above. Without this, the
449    // narrative's overflow was only discovered mid-write, deep inside `fill_form_8275_slice` (called
450    // AFTER `basis_methodology.txt`, `form_8275.txt`, and possibly `f8949.pdf`/`schedule_d.pdf` were
451    // already on disk) — leaving an estimated-basis 8949 filed with no 8275 PDF behind it, exactly the
452    // §6662(d) exposure this whole disclosure feature exists to close.
453    if let Some(p) = &printed_8275 {
454        if let btctax_forms::PartIiCapacity::Overflow(overflow) =
455            btctax_forms::part_ii_capacity_check(&p.part_ii, tax_year)?
456        {
457            return Err(CliError::Usage(part_ii_overflow_message(
458                tax_year, &overflow,
459            )));
460        }
461    }
462
463    // A pseudo-active fill DRAFT-watermarks every page before it hits disk.
464    let stamp = |bytes: Vec<u8>| -> Result<Vec<u8>, CliError> {
465        Ok(if watermarked {
466            btctax_forms::stamp_draft_watermark(&bytes)?
467        } else {
468            bytes
469        })
470    };
471
472    // ★ whole-branch tax M-2: refuse an UNSUPPORTED year HERE, before `mkdir_out` — mirroring the Form
473    // 8275 pre-check directly above (and `export_full_return`'s own pre-write table lookup). Without it,
474    // a year outside `btctax_forms::SUPPORTED_YEARS` created `out_dir/` and wrote
475    // `basis_methodology.txt` + `form_8275.txt` BEFORE `fill_form_8949` raised `UnsupportedYear`,
476    // leaving a HALF-POPULATED packet directory beside the reported failure — a filer could mail a
477    // directory holding a methodology disclosure with no forms behind it. The error is byte-identical to
478    // the one `fill_form_8949` used to raise (`CliError::FormFill(FormsError::UnsupportedYear(year))`),
479    // so the single-year CLI `export-irs-pdf` path is unchanged apart from writing ZERO bytes.
480    if !btctax_forms::SUPPORTED_YEARS.contains(&tax_year) {
481        return Err(CliError::FormFill(
482            btctax_forms::FormsError::UnsupportedYear(tax_year),
483        ));
484    }
485
486    mkdir_out(out_dir)?;
487
488    // I-3 (D-4): the MANDATORY conservative-filing methodology disclosure rides the PDF packet too, not
489    // just the CSV paths — a filer mailing the flagship filing-ready artifact must get the i8949-required
490    // basis explanation whenever a $0-basis tranche row is present. Writes nothing for a no-tranche year.
491    crate::render::write_basis_methodology_txt(out_dir, state, tax_year)?;
492    // BG-D8: the Form 8275 disclosure rides the packet by its OWN name. The gate above guaranteed a
493    // promoted leg reaching here has a complete Part II. Writes nothing for a no-promoted-leg year.
494    crate::render::write_form_8275_txt(out_dir, state, events, tax_year)?;
495    // Approach-B experimental disclosure (`design/approach-b-experimental-notice`): an INTERFACE-only
496    // signal for main.rs's stderr notice — deliberately NEVER written to `out_dir` (the export directory
497    // is what the filer mails/hands to a preparer; the notice belongs on stderr/TUI/NOTICE only).
498    let experimental_notice_active = btctax_core::experimental::uses_approach_b(events);
499
500    // ── Form 8949 + Schedule D (always applicable). ──
501    let f8949_path = if wants(forms, FormArg::F8949) {
502        let bytes = stamp(btctax_forms::fill_form_8949(&rows, tax_year)?)?;
503        let path = out_dir.join("f8949.pdf");
504        write_bytes_owner_only(&path, &bytes)?;
505        Some(path)
506    } else {
507        None
508    };
509    let schedule_d_path = if wants(forms, FormArg::ScheduleD) {
510        let bytes = stamp(btctax_forms::fill_schedule_d(&totals, tax_year)?)?;
511        let path = out_dir.join("schedule_d.pdf");
512        write_bytes_owner_only(&path, &bytes)?;
513        Some(path)
514    } else {
515        None
516    };
517
518    // ── Schedule SE (self-employment tax). Compute the §1401 figure from the year's TaxProfile. ──
519    let se_computed = {
520        let tables = BundledTaxTables::load();
521        let profile = match session.resolve_screened(state, tax_year, &tables)? {
522            crate::resolve::ProfileOutcome::Ready { profile, .. } => profile,
523            crate::resolve::ProfileOutcome::Uncomputable { .. } => None, // export proceeds; SE omitted
524        };
525        profile.and_then(|p| {
526            tables.table_for(tax_year).and_then(|t| {
527                compute_se_tax(
528                    state,
529                    tax_year,
530                    p.filing_status,
531                    t,
532                    p.w2_ss_wages,
533                    p.w2_medicare_wages,
534                    p.schedule_c_expenses,
535                )
536                .map(|se| (se, p.w2_ss_wages, t.ss_wage_base))
537            })
538        })
539    };
540    // Discriminator: SE income present but `compute_se_tax` returned None (no profile / no table) → a
541    // NOTE, not a silent skip (mirrors the render layer; never a fabricated form).
542    let se_income_without_profile =
543        se_computed.is_none() && !se_net_income(state, tax_year).is_zero();
544    let mut schedule_se_path = None;
545    let mut se_below_floor = false;
546    let mut se_addl_medicare = None;
547    if wants(forms, FormArg::ScheduleSe) {
548        if let Some((se, w2_ss_wages, ss_wage_base)) = se_computed {
549            if !se.addl.is_zero() {
550                se_addl_medicare = Some(se.addl);
551            }
552            match btctax_forms::fill_schedule_se(&se, w2_ss_wages, ss_wage_base, tax_year)? {
553                Some(bytes) => {
554                    let bytes = stamp(bytes)?;
555                    let path = out_dir.join("schedule_se.pdf");
556                    write_bytes_owner_only(&path, &bytes)?;
557                    schedule_se_path = Some(path);
558                }
559                None => se_below_floor = true, // below the $400 floor — SE not owed.
560            }
561        }
562    }
563
564    // ── Form 8283 (noncash charitable contributions). ──
565    let mut form_8283_path = None;
566    let mut form_8283_needs_review = false;
567    let mut form_8283_section_b = None;
568    if wants(forms, FormArg::Form8283) {
569        let details = session.donation_details()?;
570        let rows_8283 = btctax_core::form_8283(state, tax_year, &details);
571        if let Some(bytes) = btctax_forms::fill_form_8283(&rows_8283, tax_year)? {
572            form_8283_needs_review = rows_8283.iter().any(|r| r.needs_review);
573            form_8283_section_b = rows_8283
574                .iter()
575                .find_map(|r| r.section)
576                .map(|s| s == btctax_core::Form8283Section::B);
577            let bytes = stamp(bytes)?;
578            let path = out_dir.join("form_8283.pdf");
579            write_bytes_owner_only(&path, &bytes)?;
580            form_8283_path = Some(path);
581        }
582    }
583
584    // ── Form 1040 cap-gains cells + the digital-asset question. ──
585    let mut form_1040_path = None;
586    let mut form_1040_filled_7a = false;
587    let mut form_1040_loss = false;
588    if wants(forms, FormArg::Form1040) {
589        let da_yes = !rows.is_empty()
590            || state
591                .income_recognized
592                .iter()
593                .any(|i| i.recognized_at.year() == tax_year)
594            || state
595                .removals
596                .iter()
597                .any(|r| r.removed_at.year() == tax_year);
598        let inputs = Form1040Inputs {
599            da_yes,
600            schedule_d_active: sd_part_active(&totals.st) || sd_part_active(&totals.lt),
601            schedule_d_line16: totals.st.gain + totals.lt.gain,
602        };
603        if let Some(fill) = btctax_forms::fill_form_1040_capgains(&inputs, tax_year)? {
604            form_1040_filled_7a = fill.filled_7a;
605            form_1040_loss = fill.loss;
606            let bytes = stamp(fill.pdf)?;
607            let path = out_dir.join("form_1040_capgains.pdf");
608            write_bytes_owner_only(&path, &bytes)?;
609            form_1040_path = Some(path);
610        }
611    }
612
613    // ── Form 8275 (Disclosure Statement) — the OFFICIAL PDF, crypto-slice fill (Task 16). Rides beside
614    // the `write_form_8275_txt` content emitted above; no filer identity (mirrors the Form 8283
615    // crypto-slice fill). `promote_export_gate` already guaranteed a complete Part II, and the overflow
616    // pre-check above already guaranteed the Part I rows fit this revision's capacity. ──
617    // BG-D8 (whole-branch tax M-1): the Form 8275 is the MANDATORY disclosure that must travel WITH the
618    // promoted 8949 position — so it rides UNCONDITIONALLY whenever a promoted disposal leg is filed,
619    // NOT behind `wants(forms, Form8275)`. Otherwise `--forms f8949` would export the estimate position
620    // without its official disclosure PDF (Reg §1.6662-4(f) makes disclosure adequate only on a COMPLETED
621    // Form 8275). This mirrors the always-emitted `form_8275.txt` and the unconditional overflow refusal:
622    // the disclosure is never a `--forms`-narrowable slice. (`printed_8275` is `Some` iff a promoted
623    // disposal leg files this year; a non-promoted export writes no 8275.)
624    let mut form_8275_path = None;
625    if let Some(p) = &printed_8275 {
626        if let Some(bytes) = btctax_forms::fill_form_8275_slice(p, tax_year)? {
627            let bytes = stamp(bytes)?;
628            let path = out_dir.join("form_8275.pdf");
629            write_bytes_owner_only(&path, &bytes)?;
630            form_8275_path = Some(path);
631        }
632    }
633
634    let unresolved_hard = state
635        .blockers
636        .iter()
637        .filter(|b| b.kind.severity() == Severity::Hard)
638        .count();
639    Ok(IrsPdfReport {
640        full_return_paths: Vec::new(),
641        full_return_manifest: None,
642        forms_ignored_full_return: false, // crypto-slice path honors --forms
643        f8949_path,
644        schedule_d_path,
645        tax_year,
646        unresolved_hard,
647        broker_reported_rows: btctax_forms::rows_possibly_broker_reported(&rows),
648        watermarked,
649        schedule_se_path,
650        se_below_floor,
651        se_addl_medicare,
652        se_income_without_profile,
653        form_8283_path,
654        form_8283_needs_review,
655        form_8283_section_b,
656        form_1040_path,
657        form_1040_filled_7a,
658        form_1040_loss,
659        form_8275_path,
660        experimental_notice_active,
661    })
662}
663
664/// The Form 8275 Part II narrative overflow refusal (T-f8275-part-ii-overflow round 2 finding 2) —
665/// shared by BOTH export paths (`export_irs_pdf_from_session` + `export_full_return`) so the wording
666/// never drifts between them. The narrative is FIXED once recorded (the vault is append-only —
667/// `plan_promote` refuses to re-promote an already-promoted tranche), so "shorten it and re-run
668/// promote-tranche" is not an available remedy at export time; the honest remedy is void-and-redo (a
669/// known follow-up: `design/f8275-part-ii-overflow/FOLLOWUPS.md`).
670fn part_ii_overflow_message(tax_year: i32, overflow: &btctax_forms::PartIiOverflow) -> String {
671    format!(
672        "cannot export {tax_year}: the Form 8275 Part II narrative needs about {rows} single-line \
673         fields but only {cap} are available (Part II's own line 1 + Part IV's continuation lines) at \
674         8pt \u{2014} roughly the first {chars} characters of it would fit. The narrative is fixed once \
675         recorded (the vault is append-only), so shortening it now means voiding the promote(s) whose \
676         narrative is too long and re-recording with a shorter --part-ii-file. File the 8275 manually \
677         for {tax_year} instead, or void and re-record, then re-export.",
678        rows = overflow.rows_needed,
679        cap = overflow.capacity,
680        chars = overflow.chars_fit,
681    )
682}
683
684/// Write `bytes` to `path` with owner-only (0o600) permissions, matching the CSV export path.
685fn write_bytes_owner_only(path: &Path, bytes: &[u8]) -> Result<(), CliError> {
686    use std::io::Write;
687    let mut f = fsperms::open_owner_only(path)?;
688    f.write_all(bytes)?;
689    f.flush()?;
690    Ok(())
691}
692
693/// UX-P4-8: create the export `--out` directory, NAMING the path + a one-clause hint when it cannot be
694/// created (a colliding file, a missing parent, a permission problem) — instead of the bare
695/// `io: File exists (os error 17)` the pathless `StoreError::Io` produces. The single choke point for
696/// every directory-producing export (`export-snapshot` via `write_csv_exports`, `export-irs-pdf`,
697/// `export-full-return`).
698fn mkdir_out(out_dir: &Path) -> Result<(), CliError> {
699    fsperms::mkdir_owner_only(out_dir)
700        .map_err(|e| crate::store_io_with_path(e, out_dir, crate::EXPORT_OUT_HINT))
701}
702
703/// §8: export the passphrase-protected key (escape hatch; HIGH-security write).
704pub fn backup_key(vault_path: &Path, pp: &Passphrase, out_path: &Path) -> Result<(), CliError> {
705    Session::open(vault_path, pp)?
706        .vault()
707        .backup_key(out_path)
708        // UX-P4-8: name the --out path (and hint) when the key file cannot be written (a colliding
709        // directory, a missing parent, a permission problem), not a bare `io: …`.
710        .map_err(|e| crate::store_io_with_path(e, out_path, crate::EXPORT_OUT_HINT))?;
711    Ok(())
712}
713
714/// ★ **The full-return export** (P6.3b / P6.5) — the whole filable packet, not the crypto slice.
715///
716/// Runs the same fail-closed screens the report runs (a return the report will not compute is a return
717/// the exporter must not print), assembles the printed packet in CORE, and fills it ALL-OR-NOTHING: if
718/// any member form refuses, zero bytes reach the disk. A 1040 whose line 2b cites a Schedule B that is
719/// not attached is a wrong return, so partial emission would be a fail-open.
720///
721/// **The packet exports CLEAN** (no DRAFT watermark, no attestation) — the user's §9 decision, folded
722/// into the SPEC. The one exception is PSEUDO-reconciled figures, which are FICTIONAL and can never be
723/// filed: those are watermarked regardless, and that gate composes with (and dominates) everything else.
724fn export_full_return(
725    session: &Session,
726    state: &btctax_core::state::LedgerState,
727    events: &[LedgerEvent],
728    out_dir: &Path,
729    tax_year: i32,
730    attest: Option<&str>,
731) -> Result<IrsPdfReport, CliError> {
732    use btctax_adapters::{BundledFullReturnTables, BundledTaxTables};
733    use btctax_core::tax::tables::{FullReturnTables, TaxTables};
734    use std::fmt::Write as _;
735
736    // BG-D8 completeness gate — checked FIRST (before the tables lookup, the fail-closed screens, and any
737    // byte written): a promoted-basis leg without its complete Form 8275 is a HARD refusal.
738    promote_export_gate(state, events, Some(tax_year))?;
739
740    let tables = BundledTaxTables::load();
741    let fr_tables = BundledFullReturnTables::load();
742    let (Some(params), Some(table)) = (
743        fr_tables.full_return_for(tax_year),
744        tables.table_for(tax_year),
745    ) else {
746        return Err(CliError::Usage(format!(
747            "no full-return tables for {tax_year} — the full-return packet needs a supported tax year \
748             (TY2024)"
749        )));
750    };
751
752    let ri = crate::return_inputs::get(session.conn(), tax_year)?
753        .ok_or_else(|| CliError::Usage(format!("no return_inputs stored for {tax_year}")))?;
754
755    // Fail-closed screens, in the same order the report runs them. A refusal writes NO bytes.
756    let refuse = |r: btctax_core::tax::return_refuse::Refusal| {
757        CliError::Usage(format!(
758            "the {tax_year} return is not computable [{:?}]: {} — no forms were written",
759            r.reason, r.detail
760        ))
761    };
762    if let Some(r) = btctax_core::tax::return_refuse::screen_inputs(&ri, table, params) {
763        return Err(refuse(r));
764    }
765    if let Some(r) =
766        btctax_core::tax::return_1040::screen_compute_dependent(&ri, state, tax_year, params)
767    {
768        return Err(refuse(r));
769    }
770    let ar = btctax_core::tax::return_1040::assemble_absolute(&ri, state, params, table, tax_year);
771    if let Some(r) = btctax_core::tax::return_1040::screen_absolute(&ri, &ar, params) {
772        return Err(refuse(r));
773    }
774
775    // Pseudo figures are FICTIONAL: they are watermarked no matter what, and the attestation gate for
776    // them is unchanged. A clean (real-ledger) packet needs no attestation — SPEC §9 as amended.
777    let watermarked = state.pseudo_active();
778    if watermarked {
779        require_attestation(attest)?;
780    }
781
782    let details = session.donation_details()?;
783    let printed = btctax_core::tax::packet::assemble_printed_return(
784        &ri, state, &details, &ar, table, tax_year, events,
785    )
786    .map_err(|e| {
787        // `HeaderError`'s Display carries the right remedy per variant (a malformed SSN, an unanswered
788        // declaration, or an MFJ return with no spouse) — no longer always "fix the identity" (P9 §3.2).
789        CliError::Usage(format!("the {tax_year} return cannot be printed: {e}"))
790    })?;
791
792    // Task 16 / ADD-2: Form 8275 v1 does not paginate (unlike Form 8283's `overflow::merge_copies`) — a
793    // promoted year with more than the revision's Part I row capacity (6 rows) cannot be filled at all.
794    // Refuse HERE, before the whole-packet fill, so the failure names the year + a concrete remedy
795    // instead of surfacing as a bare `FormsError::Overflow` display deep inside an all-or-nothing fill.
796    if let Some(f8275) = &printed.forms.f8275 {
797        let cap = btctax_forms::Form8275Map::for_year(tax_year)?.rows.len();
798        if f8275.part_i.len() > cap {
799            return Err(CliError::Usage(format!(
800                "cannot export {tax_year}: {n} promoted disposal leg(s) each need a Form 8275 Part I \
801                 row, but this revision holds only {cap} — Form 8275 cannot yet paginate beyond {cap} \
802                 rows. File the 8275 manually for {tax_year}, or reduce the number of promoted disposal \
803                 legs filed in {tax_year} (e.g. void one of the promotes) and re-export.",
804                n = f8275.part_i.len(),
805            )));
806        }
807    }
808
809    // T-f8275-part-ii-overflow round 2 finding 2: pre-flight for the SAME nicely-worded refusal as the
810    // crypto-slice path above ("do it uniformly"). This path was already BYTE-safe without it — the
811    // ALL-OR-NOTHING `fill_full_return` below runs before `mkdir_out`, so an overflowing narrative
812    // already refused with zero bytes written — but without this check it would surface as this
813    // crate's generic `FormsError::Overflow` Display via `CliError::FormFill`, not a message naming the
814    // year, the character budget, and a remedy.
815    if let Some(f8275) = &printed.forms.f8275 {
816        if let btctax_forms::PartIiCapacity::Overflow(overflow) =
817            btctax_forms::part_ii_capacity_check(&f8275.part_ii, tax_year)?
818        {
819            return Err(CliError::Usage(part_ii_overflow_message(
820                tax_year, &overflow,
821            )));
822        }
823    }
824
825    // ★ ALL-OR-NOTHING: every form fills BEFORE anything is written.
826    let packet = btctax_forms::fill_full_return(&printed, tax_year)?;
827
828    mkdir_out(out_dir)?;
829    // I-3 (D-4): the MANDATORY methodology disclosure rides the full-return packet too (see export_irs_pdf).
830    crate::render::write_basis_methodology_txt(out_dir, state, tax_year)?;
831    // BG-D8: the Form 8275 disclosure rides the full-return packet by its OWN name (gate above guaranteed
832    // a complete Part II). Writes nothing for a no-promoted-leg year.
833    crate::render::write_form_8275_txt(out_dir, state, events, tax_year)?;
834    // Approach-B experimental disclosure (`design/approach-b-experimental-notice`): an INTERFACE-only
835    // signal for main.rs's stderr notice — deliberately NEVER written to `out_dir` (the export directory
836    // is what the filer mails/hands to a preparer; the notice belongs on stderr/TUI/NOTICE only).
837    let experimental_notice_active = btctax_core::experimental::uses_approach_b(events);
838    let mut manifest = String::from("# btctax full-return packet — staple in this order\n");
839    let mut paths: Vec<PathBuf> = Vec::new();
840    for form in &packet {
841        let bytes = if watermarked {
842            btctax_forms::stamp_draft_watermark(&form.bytes)?
843        } else {
844            form.bytes.clone()
845        };
846        // ★ The packet's filenames are SEQUENCE-PREFIXED (`00_f1040.pdf`, `12A_f8949.pdf`, …). Two
847        // reasons, and the first is a correctness guarantee: the crypto slice writes bare stems
848        // (`f8949.pdf`, `schedule_d.pdf`, `schedule_se.pdf`) and THREE of them collided with the
849        // packet's — so a full-return run and a slice run into one directory could silently interleave
850        // a cents Schedule SE with a whole-dollar 1040: the chimera return the dispatch mitigation
851        // exists to prevent (Fable P6 r1 I7). Second, the prefix IS the stapling order.
852        let path = out_dir.join(format!(
853            "{}_{}.pdf",
854            form.attachment_sequence.unwrap_or("00"),
855            form.name
856        ));
857        write_bytes_owner_only(&path, &bytes)?;
858        let seq = form.attachment_sequence.unwrap_or("—");
859        let _ = writeln!(
860            manifest,
861            "{seq:>4}  {}",
862            path.file_name().unwrap_or_default().to_string_lossy()
863        );
864        paths.push(path);
865    }
866    let manifest_path = out_dir.join("manifest.txt");
867    write_bytes_owner_only(&manifest_path, manifest.as_bytes())?;
868
869    let unresolved_hard = state
870        .blockers
871        .iter()
872        .filter(|b| b.kind.severity() == Severity::Hard)
873        .count();
874    Ok(IrsPdfReport {
875        watermarked,
876        tax_year,
877        unresolved_hard,
878        broker_reported_rows: 0,
879        full_return_paths: paths,
880        full_return_manifest: Some(manifest_path),
881        forms_ignored_full_return: false, // set by the dispatch (which has `forms`), not here
882        // The crypto-slice PATHS are absent (the two pipelines are disjoint), but the 8283's loud
883        // escalations are NOT slice-specific: the packet can contain a Section-B 8283 whose appraiser
884        // declaration is unsigned, and silencing that guard on the one path that announces a "clean,
885        // filable" packet would be a fail-open (Fable P6 r1 I8b).
886        f8949_path: None,
887        schedule_d_path: None,
888        schedule_se_path: None,
889        se_below_floor: false,
890        se_addl_medicare: None,
891        se_income_without_profile: false,
892        form_8283_path: None,
893        form_8283_needs_review: printed
894            .forms
895            .f8283
896            .as_ref()
897            .is_some_and(|r| r.rows().iter().any(|row| row.needs_review)),
898        form_8283_section_b: printed.forms.f8283.as_ref().map(|r| {
899            r.rows()
900                .iter()
901                .any(|row| row.section == Some(btctax_core::Form8283Section::B))
902        }),
903        form_1040_path: None,
904        form_1040_filled_7a: false,
905        form_1040_loss: false,
906        // The full-return path's 8275 (when present) is inside `full_return_paths` — sequence-prefixed
907        // (`92_f8275.pdf`), not this crypto-slice-only bare-named field.
908        form_8275_path: None,
909        experimental_notice_active,
910    })
911}
912
913#[cfg(test)]
914mod tests {
915    use super::*;
916
917    /// [I5] r2/NEW-IMPORTANT-1: the broker-reporting advisory is YEAR-AWARE. On the TY2025+
918    /// digital-asset revision it must cite the 1099-DA and the digital-asset boxes (G/H/J/K separate,
919    /// I/L filed) — never the securities boxes.
920    #[test]
921    fn broker_advisory_ty2025_cites_1099da_and_digital_asset_boxes() {
922        let msg = broker_reporting_advisory(2025, 3).expect("3 broker rows → an advisory");
923        assert!(msg.contains("1099-DA"), "TY2025 cites the 1099-DA: {msg}");
924        assert!(msg.contains("Box G/H/J/K"), "separate 8949 boxes: {msg}");
925        assert!(msg.contains("Box I/L"), "filed-under boxes: {msg}");
926        assert!(msg.contains('3'), "the row count: {msg}");
927        // The securities-era pairing must NOT leak onto a 2025 export.
928        assert!(
929            !msg.contains("1099-B"),
930            "no pre-2025 1099-B on TY2025: {msg}"
931        );
932        assert!(
933            !msg.contains("Box C/F"),
934            "no securities C/F on TY2025: {msg}"
935        );
936    }
937
938    /// [I5] r2/NEW-IMPORTANT-1: pre-TY2025 (the securities-box revisions — TY2024/TY2017 are shipped
939    /// export years) the advisory must cite the 1099-B and the securities boxes (A/B, D/E separate,
940    /// C/F filed) — boxes G–L do not exist on those form revisions.
941    #[test]
942    fn broker_advisory_pre_2025_cites_1099b_and_securities_boxes() {
943        let msg = broker_reporting_advisory(2024, 1).expect("1 broker row → an advisory");
944        assert!(msg.contains("1099-B"), "pre-2025 cites the 1099-B: {msg}");
945        assert!(msg.contains("Box A/B"), "separate ST securities box: {msg}");
946        assert!(msg.contains("D/E"), "separate LT securities box: {msg}");
947        assert!(
948            msg.contains("Box C/F"),
949            "filed-under securities boxes: {msg}"
950        );
951        // The digital-asset-era pairing must NOT leak onto a pre-2025 export.
952        assert!(!msg.contains("1099-DA"), "no 1099-DA pre-2025: {msg}");
953        assert!(!msg.contains("G/H/J/K"), "no digital boxes pre-2025: {msg}");
954        assert!(
955            !msg.contains("Box I/L"),
956            "no digital filed-boxes pre-2025: {msg}"
957        );
958    }
959
960    /// [I5]: no exchange disposition → no advisory, in either era.
961    #[test]
962    fn broker_advisory_is_none_without_broker_rows() {
963        assert!(broker_reporting_advisory(2025, 0).is_none());
964        assert!(broker_reporting_advisory(2024, 0).is_none());
965    }
966
967    /// UX-P4-8 (fold I2): `mkdir_out` — the shared export-`--out` directory creator — names the
968    /// offending PATH and the remedy HINT when the directory cannot be created (here: an `--out`
969    /// that collides with a plain file), instead of a bare `io: File exists`.
970    #[test]
971    fn mkdir_out_collision_names_path_and_hint() {
972        let dir = tempfile::tempdir().unwrap();
973        let collide = dir.path().join("collide");
974        std::fs::write(&collide, b"i am a file, not a directory").unwrap();
975        let err = mkdir_out(&collide).expect_err("a file collision must error");
976        match &err {
977            CliError::PathIo { path, hint, .. } => {
978                assert!(path.contains("collide"), "names the path: {path}");
979                assert_eq!(hint, crate::EXPORT_OUT_HINT, "carries the export-out hint");
980            }
981            other => panic!("expected PathIo, got {other:?}"),
982        }
983        let msg = err.to_string();
984        assert!(msg.contains("collide"), "Display names the path: {msg}");
985        // Literal (not self-referential to the const, which `contains("")` would trivially satisfy if
986        // the const were emptied) — pins the hint's CONTENT (fold r2-N2).
987        assert!(
988            msg.contains("does not already exist as a file"),
989            "Display carries the hint content: {msg}"
990        );
991    }
992}