Skip to main content

btctax_cli/
cli.rs

1//! The clap-4 command surface for `btctax`, extracted into the library so tooling
2//! (the `xtask` man-page generator) can obtain the `Command` via `Cli::command()`
3//! (`clap::CommandFactory`). The binary (`main.rs`) is a thin dispatch over these types.
4//!
5//! FILE-FORMAT DOCS — SINGLE SOURCE OF TRUTH: the long-help (`///` doc-comments with
6//! `#[arg(verbatim_doc_comment)]`) on the file/format-taking args below is rendered BOTH
7//! into `--help` (clap) AND into the per-subcommand man page (clap_mangen), zero drift.
8//! Formats were read from the writers, never from stale comments: export CSVs from
9//! `render.rs`, the classify-raw serde shape from `btctax-core::EventPayload`, the key
10//! armor from `btctax-store::Vault::backup_key`, the selections header from
11//! `cmd::reconcile::import_selections`, the lot pick from `eventref::parse_lot_pick`.
12use btctax_core::{FilingStatus, LotMethod};
13use clap::{Parser, Subcommand, ValueEnum};
14use std::path::PathBuf;
15
16#[derive(Parser)]
17#[command(name = "btctax", about = "Offline US Bitcoin tax ledger (Phase 1)")]
18pub struct Cli {
19    /// Path to the encrypted vault (vault.pgp).
20    #[arg(long, global = true, default_value = "vault.pgp")]
21    pub vault: PathBuf,
22    #[command(subcommand)]
23    pub command: Command,
24}
25
26#[derive(Subcommand)]
27pub enum Command {
28    /// Create the encrypted vault + force a key backup.
29    Init {
30        /// File to write the forced key backup to: an ASCII-armored, passphrase(S2K)-encrypted
31        /// private key, owner-only (mode 0600). Identical format to `backup-key --out`. Store it
32        /// offline — it is the only way to recover the vault if you lose `vault.key`.
33        ///
34        /// FORMAT (structure — NOT a real key):
35        ///   -----BEGIN PGP PRIVATE KEY BLOCK-----
36        ///   ... base64 armor of the S2K-encrypted secret key ...
37        ///   -----END PGP PRIVATE KEY BLOCK-----
38        #[arg(long, verbatim_doc_comment)]
39        key_backup: PathBuf,
40        /// Clear an interrupted/half-created init (orphan `vault.key`, no encrypted store) and start fresh.
41        #[arg(long, default_value_t = false)]
42        repair: bool,
43    },
44    /// Import one or more export files (auto-groups Swan).
45    Import { files: Vec<PathBuf> },
46    /// FR9 integrity check (non-zero exit on hard blockers).
47    Verify,
48    /// Show holdings + realized disposals/removals/income. With --tax-year: standalone TaxResult.
49    #[command(alias = "show")]
50    Report {
51        /// Filter realized disposals/removals/income to a specific calendar year (display path).
52        #[arg(long)]
53        year: Option<i32>,
54        /// Compute the crypto-attributable federal tax for the given tax year (B.5 / Task 9).
55        /// Requires a stored tax profile (`tax-profile --year Y ...`) and the bundled TY table.
56        /// Independent of --year; the two flags are not aliased.
57        #[arg(long)]
58        tax_year: Option<i32>,
59        /// Cumulative prior-year TAXABLE gifts (post-annual-exclusion Form 709 amounts), not
60        /// gross gifts. Used for the §2505 lifetime-exclusion consumption advisory. Defaults to
61        /// $0 when omitted (the advisory discloses this assumption). Must not be negative.
62        #[arg(long)]
63        prior_taxable_gifts: Option<String>,
64    },
65    /// Emit a reconciliation decision event.
66    #[command(subcommand)]
67    Reconcile(Reconcile),
68    /// Show or set projection config (TP8 fee treatment / pre-2025 lot method / forward method).
69    Config {
70        #[arg(long, value_enum)]
71        set_fee_treatment: Option<FeeArg>,
72        #[arg(long, value_enum)]
73        set_pre2025_method: Option<MethodLotArg>,
74        #[arg(long, default_value_t = false)]
75        attest_pre2025_method: bool,
76        /// §A.5(a): append a MethodElection decision (the forward standing order). Not a flag
77        /// mutation — this is an event in the ledger. Use --effective-from to set the date
78        /// (default: today / the decision's made-date).
79        #[arg(long, value_enum)]
80        set_forward_method: Option<MethodLotArg>,
81        /// §A.5(a) per-ACCOUNT scope for --set-forward-method (IRS 2025+ per-account rule):
82        /// exchange:PROVIDER:ACCOUNT (the canonical wallet grammar). Omit for a GLOBAL election
83        /// (the existing behavior). Only exchange accounts are electable (a method election is a
84        /// brokerage-account concept; self:LABEL is rejected). The account MUST already exist in the
85        /// vault — an unknown/typo'd account is rejected LOUDLY so it can't create a dead election.
86        #[arg(long)]
87        exchange: Option<String>,
88        /// Effective-from date for --set-forward-method (YYYY-MM-DD). Defaults to made-date.
89        #[arg(long)]
90        effective_from: Option<String>,
91    },
92    /// FR10: export decrypted SQLite + CSV (the NFR2 plaintext exception).
93    ExportSnapshot {
94        /// Output DIRECTORY receiving the decrypted SQLite DB (snapshot.sqlite) + projection CSVs
95        /// (the NFR2 plaintext exception; created owner-only). ALWAYS writes: lots.csv,
96        /// disposals.csv, removals.csv, income.csv. With --tax-year it ALSO writes form8949.csv,
97        /// schedule_d.csv, form8283.csv, and schedule_se.csv (schedule_se only when there is
98        /// business self-employment income). The `event` column in disposals.csv / removals.csv /
99        /// income.csv is the event-ref that reconcile commands consume (select-lots,
100        /// set-donation-details, reclassify-income, …).
101        ///
102        /// FORMAT (removals.csv header + one sample donation row):
103        ///   event,kind,removed_at,lot,sat,basis,fmv_at_transfer,term,acquired_at,claimed_deduction,donee
104        ///   import|coinbase|X,donation,2025-03-01,import|coinbase|X#0,25000,120.00,150.00,long,2023-01-05,150.00,Charity Y
105        #[arg(long, verbatim_doc_comment)]
106        out: PathBuf,
107        /// Also emit the per-tax-year Form 8949 + Schedule D CSVs (form8949.csv / schedule_d.csv),
108        /// scoped to this calendar year. Omit to write only the all-years projection CSVs.
109        #[arg(long)]
110        tax_year: Option<i32>,
111        /// Attestation phrase required to export while the ledger is PSEUDO-RECONCILED (a synthetic
112        /// default contributes to the projection). Pass the exact phrase `I attest this is true`
113        /// (trimmed, case-sensitive) to export the fictional draft ON PURPOSE. Omit on a fully-real
114        /// ledger (never gated). Omit on an interactive terminal to be prompted; omit when piped
115        /// (non-TTY) while pseudo-active and the export is refused.
116        #[arg(long)]
117        attest: Option<String>,
118    },
119    /// Export the passphrase-protected key.
120    BackupKey {
121        /// File to write the exported key to: an ASCII-armored, passphrase(S2K)-encrypted private
122        /// key, owner-only (mode 0600). Identical format to `init --key-backup`.
123        ///
124        /// FORMAT (structure — NOT a real key):
125        ///   -----BEGIN PGP PRIVATE KEY BLOCK-----
126        ///   ... base64 armor of the S2K-encrypted secret key ...
127        ///   -----END PGP PRIVATE KEY BLOCK-----
128        #[arg(long, verbatim_doc_comment)]
129        out: PathBuf,
130    },
131    /// Lot-specific-identification optimizer (§C — read-only proposal or gated persistence).
132    #[command(subcommand)]
133    Optimize(Optimize),
134    /// Set or show the per-tax-year tax profile (filing status, income, MAGI, etc.).
135    TaxProfile {
136        /// The tax year (e.g. 2025).
137        #[arg(long)]
138        year: i32,
139        /// IRS filing status.
140        #[arg(long, value_enum)]
141        filing_status: Option<FilingStatusArg>,
142        /// Ordinary taxable income EXCLUDING all app-computed crypto items (net ST gains,
143        /// mining/staking ordinary income). The engine adds the crypto items on top (B.1 / I5).
144        #[arg(long)]
145        ordinary_taxable_income: Option<String>,
146        /// Modified AGI excluding crypto items, for the §1411 NIIT threshold comparison.
147        ///
148        /// IMPORTANT (§1411 contract): this value MUST already include the taxpayer's qualified
149        /// dividends and non-crypto net capital gains (and any other MAGI add-backs from
150        /// §1411(d)). The engine adds ONLY the crypto AGI delta on top (ambiguity #5 in the
151        /// design). Omitting QD or non-crypto cap gains from this figure understates NIIT.
152        #[arg(
153            long,
154            long_help = "Modified AGI excluding crypto items, for the §1411 NIIT \
155            threshold comparison.\n\nIMPORTANT (§1411 contract): this value MUST already \
156            include the taxpayer's qualified dividends and non-crypto net capital gains (and \
157            any other MAGI add-backs from §1411(d)). The engine adds ONLY the crypto AGI \
158            delta on top (ambiguity #5 in the design). Omitting QD or non-crypto cap gains \
159            from this figure understates NIIT."
160        )]
161        magi_excluding_crypto: Option<String>,
162        /// Qualified dividends + other preferential-rate income sharing the §1(h) 0/15/20 LTCG
163        /// rate stack. Required when setting a profile.
164        #[arg(long)]
165        qualified_dividends: Option<String>,
166        /// Non-crypto net LT-character capital gain already in the profile (optional; defaults
167        /// to 0 when omitted).
168        #[arg(long)]
169        other_net_capital_gain: Option<String>,
170        /// §1212(b) short-term capital loss carryforward into this year (optional; defaults to 0).
171        #[arg(long)]
172        carryforward_short: Option<String>,
173        /// §1212(b) long-term capital loss carryforward into this year (optional; defaults to 0).
174        #[arg(long)]
175        carryforward_long: Option<String>,
176        /// Form W-2 Social Security wages (Box 3 + Box 7 tips; Schedule SE line 8a).
177        /// Reduces the §1401(a) SS cap: ss_cap = max(0, wage_base − w2_ss_wages). Optional;
178        /// defaults to $0. Must not be negative.
179        #[arg(long)]
180        w2_ss_wages: Option<String>,
181        /// Medicare wages (Box 5; Form 8959 line 1).
182        /// Reduces the Additional-Medicare threshold: addl_threshold = max(0, threshold − w2_medicare_wages)
183        /// (§1401(b)(2)(B)/Form 8959 Part II). Optional; defaults to $0. Must not be negative.
184        #[arg(long)]
185        w2_medicare_wages: Option<String>,
186        /// Schedule C deductible business expenses for the year — reduces net SE earnings;
187        /// the income-tax stack above is NOT adjusted (see the advisory).
188        /// Optional; defaults to $0. Must not be negative.
189        #[arg(long)]
190        schedule_c_expenses: Option<String>,
191        /// Show the stored profile for `--year` instead of setting it.
192        #[arg(long, default_value_t = false)]
193        show: bool,
194    },
195}
196
197/// `optimize` subcommand tree.  Task 9 adds `Run`; Task 10 adds `Accept`; Task 11 adds `Consult`.
198#[derive(Subcommand)]
199pub enum Optimize {
200    /// Mode-1 what-if: print the tax-saving lot-selection proposal. NOTHING is filed or bound.
201    Run {
202        /// The tax year to optimize (must be 2025 or later).
203        #[arg(long)]
204        tax_year: i32,
205    },
206    /// Mode-1 gated persistence: recompute the optimum and persist the proposed LotSelection(s),
207    /// gated per disposal (§1.1012-1(j)). A genuinely-contemporaneous pick (made ≤ sale) persists
208    /// freely; an already-executed disposal persists ONLY with a narrow per-disposal `--attest`
209    /// scoped to one `--disposal`; a 2027+ broker-held pick is refused. Revoke via `reconcile void`.
210    Accept {
211        /// The tax year to accept (must be 2025 or later).
212        #[arg(long)]
213        tax_year: i32,
214        /// Restrict to ONE disposal (required to carry `--attest`).
215        #[arg(long)]
216        disposal: Option<String>,
217        /// Narrow contemporaneous-ID attestation for an already-executed disposal. Requires
218        /// `--disposal` (no blanket attestation across all disposals).
219        #[arg(long)]
220        attest: Option<String>,
221    },
222    /// Mode-2 read-only pre-trade what-if (§C.3): tax-min lots + ST/LT split + federal tax + ST→LT
223    /// timing. NOTHING is written — no event, no side-table row. Tax decision-support only;
224    /// not buy/sell/hold advice.
225    Consult {
226        /// Hypothetical sale amount in satoshis (required).
227        #[arg(long)]
228        sell: String,
229        /// Wallet to sell from, e.g. `self:cold` or `exchange:coinbase:default` (required; per-wallet
230        /// pool is mandatory post-2025).
231        #[arg(long)]
232        wallet: Option<String>,
233        /// Sale date for the what-if (YYYY-MM-DD; defaults to today UTC if omitted).
234        #[arg(long)]
235        at: Option<String>,
236        /// Explicit USD proceeds for the hypothetical sale. Required when `--at` is a future date
237        /// with no bundled dataset price and `--fmv` is not used. Mutually exclusive with `--fmv`.
238        #[arg(long, conflicts_with = "fmv")]
239        proceeds: Option<String>,
240        /// Use the bundled daily-close FMV for `--at` instead of an explicit proceeds amount.
241        /// A future date with no dataset price will return a ProceedsRequired error. Mutually
242        /// exclusive with `--proceeds`.
243        #[arg(long, conflicts_with = "proceeds")]
244        fmv: bool,
245    },
246}
247
248#[derive(Subcommand)]
249pub enum Reconcile {
250    /// Confirm a self-transfer (TransferLink).
251    LinkTransfer {
252        out: String,
253        #[arg(long, conflicts_with = "to_wallet")]
254        to_event: Option<String>,
255        #[arg(long)]
256        to_wallet: Option<String>,
257    },
258    /// Classify an inbound TransferIn as income.
259    ClassifyInboundIncome {
260        in_ref: String,
261        #[arg(long)]
262        kind: String,
263        #[arg(long)]
264        fmv: Option<String>,
265        #[arg(long)]
266        business: bool,
267    },
268    /// Classify an inbound TransferIn as a received gift.
269    ClassifyInboundGift {
270        in_ref: String,
271        #[arg(long)]
272        fmv_at_gift: String,
273        #[arg(long)]
274        donor_basis: Option<String>,
275        #[arg(long)]
276        donor_acquired: Option<String>,
277    },
278    /// Classify an inbound TransferIn as an inbound self-transfer ("my own coins" returning) —
279    /// non-taxable, creates a fresh lot. `--basis` defaults to $0 (conservative; fires the honest
280    /// zero-basis advisory when omitted); `--acquired` defaults to the receipt date (short-term).
281    ClassifyInboundSelfTransfer {
282        in_ref: String,
283        #[arg(long)]
284        basis: Option<String>,
285        #[arg(long)]
286        acquired: Option<String>,
287    },
288    /// Reclassify a pending TransferOut.
289    ReclassifyOutflow {
290        out: String,
291        #[arg(long, value_enum)]
292        as_kind: OutKindArg,
293        #[arg(long)]
294        amount: String,
295        #[arg(long)]
296        fee: Option<String>,
297        #[arg(long)]
298        appraisal: bool,
299        /// Free-form donee identifier (e.g. "Alice", "Charity X"). Carried through to
300        /// removals.csv and Form 8283; does not affect tax math.
301        #[arg(long)]
302        donee: Option<String>,
303    },
304    /// Set a manual FMV on an event.
305    SetFmv {
306        event: String,
307        #[arg(long)]
308        fmv: String,
309    },
310    /// Void a revocable decision.
311    Void { target: String },
312    /// Resolve an Unclassified row from a JSON imported payload.
313    ClassifyRaw {
314        target: String,
315        /// A JSON-encoded imported EventPayload (serde externally-tagged: `{"Variant":{...}}`) to
316        /// resolve the Unclassified target as. Must be an IMPORTED variant — Acquire, Income,
317        /// Dispose, TransferOut, TransferIn, or Unclassified. USD fields (usd_cost, fee_usd, …) are
318        /// decimal STRINGS; `sat` is an integer.
319        ///
320        /// FORMAT (Acquire example):
321        ///   {"Acquire":{"sat":2000000,"usd_cost":"1680.00","fee_usd":"5.00","basis_source":"ExchangeProvided"}}
322        #[arg(long, verbatim_doc_comment)]
323        payload_json: String,
324    },
325    /// Accept an import conflict.
326    AcceptConflict { conflict: String },
327    /// Reject an import conflict.
328    RejectConflict { conflict: String },
329    /// Path-B safe-harbor allocate (from the actual pre-2025 position).
330    SafeHarborAllocate {
331        #[arg(long, value_enum, default_value_t = MethodArg::Actual)]
332        method: MethodArg,
333        #[arg(long)]
334        attest: bool,
335    },
336    /// Attest an existing allocation as timely.
337    SafeHarborAttest,
338    /// §A.4 Specific-ID: pick the exact lots a disposal consumes.
339    SelectLots {
340        disposal: String,
341        /// One lot pick per --from flag (repeatable). Each PICK is
342        /// `<origin_event_id>#<split_sequence>:<sat>`. The origin_event_id + split come from the
343        /// `lot` column of disposals.csv or the `origin_event`/`split` columns of lots.csv
344        /// (export-snapshot). The total sat across the picks must equal the disposal's principal
345        /// (validated in the fold).
346        ///
347        /// FORMAT (two picks):
348        ///   --from import|coinbase|X#0:25000 --from import|river|Y#1:5000
349        #[arg(long = "from", required = true, verbatim_doc_comment)]
350        from: Vec<String>,
351    },
352    /// §A.4 Batch import LotSelections from a CSV (disposal_ref,origin_event_id,split_sequence,sat).
353    ImportSelections {
354        /// CSV of lot picks imported as LotSelection decisions (§A.4). The header is REQUIRED and
355        /// validated loudly; rows sharing a disposal_ref are grouped into a single decision.
356        /// disposal_ref is the disposal event's ref (disposals.csv `event` column); origin_event_id
357        /// is the lot's origin (lots.csv `origin_event` column).
358        ///
359        /// FORMAT (header + one sample row):
360        ///   disposal_ref,origin_event_id,split_sequence,sat
361        ///   import|gemini|trade|T-2.O-2,import|coinbase|X,0,1000000
362        #[arg(verbatim_doc_comment)]
363        csv: PathBuf,
364    },
365    /// SE-completion Chunk C: flip `business` (and optionally `kind`) on an already-imported Income event.
366    ///
367    /// Corrects the `business: false` hard-code that River (and other adapters) emit at ingest time,
368    /// enabling SE-tax treatment for professional miners / stakers. The engine validates that the target
369    /// event exists and is an Income event — a missing or non-Income target fires a Hard DecisionConflict
370    /// blocker (decision excluded). For TransferIn rows use `classify-inbound-income` instead.
371    ///
372    /// DecisionConflict is Hard — to re-decide, `void` the prior decision first, then re-issue.
373    ReclassifyIncome {
374        /// The Income event reference (from `report` or `income_recognized.csv` 'event' column).
375        income_event: String,
376        /// Whether this income is from a trade or business (true → SE-tax eligible).
377        /// Must be supplied explicitly: `--business true` or `--business false`.
378        #[arg(long, required = true, action = clap::ArgAction::Set)]
379        business: bool,
380        /// Optional income kind correction: mining|staking|interest|airdrop|reward.
381        /// Omit to keep the original kind (only flip `business`).
382        #[arg(long)]
383        kind: Option<String>,
384    },
385    /// Store Form 8283 Section-B donation + appraiser details for a donation event.
386    /// The event ref is the TransferOut EventId from the removals.csv 'event' column.
387    SetDonationDetails {
388        /// TransferOut event reference for the donation (from removals.csv 'event' column).
389        out_event_ref: String,
390        /// Donee organization name (Part IV; required).
391        #[arg(long, required = true)]
392        donee_name: String,
393        /// Donee mailing address (Part IV; optional).
394        #[arg(long)]
395        donee_address: Option<String>,
396        /// Donee EIN (Part IV; required for Section-B completeness).
397        #[arg(long)]
398        donee_ein: Option<String>,
399        /// Qualified appraiser name (Part III; required).
400        #[arg(long, required = true)]
401        appraiser_name: String,
402        /// Appraiser mailing address (Part III; optional).
403        #[arg(long)]
404        appraiser_address: Option<String>,
405        /// Appraiser TIN/SSN/EIN (Part III §6695A; satisfies the TIN-or-PTIN requirement).
406        #[arg(long)]
407        appraiser_tin: Option<String>,
408        /// Appraiser PTIN (Part III §6695A; satisfies the TIN-or-PTIN requirement).
409        #[arg(long)]
410        appraiser_ptin: Option<String>,
411        /// Appraiser qualifications declaration (§170(f)(11)(E)).
412        #[arg(long)]
413        appraiser_qualifications: Option<String>,
414        /// Date the qualified appraisal was made (YYYY-MM-DD).
415        #[arg(long)]
416        appraisal_date: Option<String>,
417        /// FMV determination method override (overrides the section-derived default on the
418        /// Form 8283 carrier row; resolves the Section-A fmv_method deferral when supplied).
419        #[arg(long)]
420        fmv_method: Option<String>,
421    },
422    /// Show stored Form 8283 donation details for a donation event.
423    ShowDonationDetails {
424        /// TransferOut event reference for the donation (from removals.csv 'event' column).
425        out_event_ref: String,
426    },
427    /// Bulk-confirm self-transfers: link every PENDING outbound transfer in a time frame to one
428    /// destination wallet (non-taxable). Shows a preview + requires --yes (or interactive y/N).
429    BulkLinkTransfer {
430        /// Destination wallet every selected outflow links to.
431        #[arg(long)]
432        to_wallet: String,
433        /// Restrict to a single tax year (mutually exclusive with --from/--to).
434        #[arg(long, conflicts_with_all = ["from", "to"])]
435        year: Option<i32>,
436        /// Range start (YYYY-MM-DD; requires --to).
437        #[arg(long, requires = "to")]
438        from: Option<String>,
439        /// Range end (YYYY-MM-DD, inclusive; requires --from).
440        #[arg(long, requires = "from")]
441        to: Option<String>,
442        /// Only outflows FROM this source wallet.
443        #[arg(long)]
444        from_wallet: Option<String>,
445        /// Print the preview and exit without writing.
446        #[arg(long)]
447        dry_run: bool,
448        /// Skip the interactive confirmation (non-interactive apply).
449        #[arg(long)]
450        yes: bool,
451    },
452    /// Bulk-classify unknown-basis inbound deposits as self-transfer-ins ("my own coins"): apply
453    /// Cycle A's `SelfTransferMine` ($0 conservative basis, non-taxable) to MANY pending inbounds in a
454    /// time frame at once. Shows a preview surfacing the total USD given $0 basis (the over-tax
455    /// exposure) + requires --yes (or interactive y/N). Each is a voidable decision; for a deposit
456    /// whose real cost you can substantiate, classify it single-item with `classify-inbound-self-transfer --basis`.
457    BulkClassifyInboundSelfTransfer {
458        /// Restrict to a single tax year (mutually exclusive with --from/--to).
459        #[arg(long, conflicts_with_all = ["from", "to"])]
460        year: Option<i32>,
461        /// Range start (YYYY-MM-DD; requires --to).
462        #[arg(long, requires = "to")]
463        from: Option<String>,
464        /// Range end (YYYY-MM-DD, inclusive; requires --from).
465        #[arg(long, requires = "from")]
466        to: Option<String>,
467        /// Only inbounds received INTO this wallet.
468        #[arg(long)]
469        wallet: Option<String>,
470        /// Print the preview and exit without writing.
471        #[arg(long)]
472        dry_run: bool,
473        /// Skip the interactive confirmation (non-interactive apply).
474        #[arg(long)]
475        yes: bool,
476    },
477    /// Bulk-classify unknown-basis inbound deposits as INCOME (mining|staking|interest|airdrop|reward):
478    /// recognize MANY pending inbounds as ordinary income at their auto-FMV (the daily-close market
479    /// value at receipt) in one confirmed batch, with a UNIFORM `--kind` + `--business` flag. Shows a
480    /// preview surfacing the total income recognized + the count of inbounds EXCLUDED because no price
481    /// was available for their date (those stay pending — an income row with no FMV would year-gate).
482    /// Each is a voidable decision; for a single deposit use `classify-inbound-income`.
483    BulkClassifyInboundIncome {
484        /// Income kind for the whole batch: mining|staking|interest|airdrop|reward.
485        #[arg(long)]
486        kind: String,
487        /// Whether this income is from a trade or business (true → SE-tax eligible).
488        #[arg(long)]
489        business: bool,
490        /// Restrict to a single tax year (mutually exclusive with --from/--to).
491        #[arg(long, conflicts_with_all = ["from", "to"])]
492        year: Option<i32>,
493        /// Range start (YYYY-MM-DD; requires --to).
494        #[arg(long, requires = "to")]
495        from: Option<String>,
496        /// Range end (YYYY-MM-DD, inclusive; requires --from).
497        #[arg(long, requires = "from")]
498        to: Option<String>,
499        /// Only inbounds received INTO this wallet.
500        #[arg(long)]
501        wallet: Option<String>,
502        /// Print the preview and exit without writing.
503        #[arg(long)]
504        dry_run: bool,
505        /// Skip the interactive confirmation (non-interactive apply).
506        #[arg(long)]
507        yes: bool,
508    },
509    /// Bulk-reclassify unknown pending OUTFLOWS as dispositions (Sell|Spend): reclassify MANY pending
510    /// `TransferOut`s as a `Dispose` in one confirmed batch, with the daily-close market value at the
511    /// outflow date as the ESTIMATED proceeds. Shows a preview surfacing the total ESTIMATED proceeds
512    /// AND the total ESTIMATED gain (sum(fmv) - sum(basis)) + the count of outflows EXCLUDED because no price
513    /// was available for their date (those stay pending — a Sell with fabricated proceeds would be a
514    /// SILENT misreport). `--kind` is UNIFORM and accepts ONLY sell|spend (gift/donate are out of
515    /// scope). Each is a voidable decision; for a single outflow use `reclassify-outflow`.
516    BulkReclassifyOutflow {
517        /// Disposition kind for the whole batch: sell|spend (gift/donate rejected — out of scope).
518        #[arg(long)]
519        kind: String,
520        /// Restrict to a single tax year (mutually exclusive with --from/--to).
521        #[arg(long, conflicts_with_all = ["from", "to"])]
522        year: Option<i32>,
523        /// Range start (YYYY-MM-DD; requires --to).
524        #[arg(long, requires = "to")]
525        from: Option<String>,
526        /// Range end (YYYY-MM-DD, inclusive; requires --from).
527        #[arg(long, requires = "from")]
528        to: Option<String>,
529        /// Only outflows from this SOURCE wallet.
530        #[arg(long)]
531        wallet: Option<String>,
532        /// Print the preview and exit without writing.
533        #[arg(long)]
534        dry_run: bool,
535        /// Skip the interactive confirmation (non-interactive apply).
536        #[arg(long)]
537        yes: bool,
538    },
539    /// Bulk-resolve import conflicts: ACCEPT (adopt each new payload) or REJECT (keep each current
540    /// payload) MANY flagged `ImportConflict`s in one confirmed batch. Shows a `current → new` preview,
541    /// then requires --yes (or interactive y/N). Exactly one of --accept / --reject is required. Each
542    /// resolution is NON-REVOCABLE (`SupersedeImport`/`RejectImport` cannot be voided); to resolve a
543    /// conflict differently, exclude it and use single-item `accept-conflict`/`reject-conflict`.
544    #[command(group(clap::ArgGroup::new("resolve_action").required(true).args(["accept", "reject"])))]
545    BulkResolveConflict {
546        /// Accept every listed conflict (adopt each new payload onto its target).
547        #[arg(long)]
548        accept: bool,
549        /// Reject every listed conflict (keep each target's current payload).
550        #[arg(long)]
551        reject: bool,
552        /// Print the preview and exit without writing.
553        #[arg(long)]
554        dry_run: bool,
555        /// Skip the interactive confirmation (non-interactive apply).
556        #[arg(long)]
557        yes: bool,
558    },
559    /// Bulk-void MANY revocable reconcile decisions in one confirmed batch (bulk-void). Shows a preview
560    /// of every voidable decision (the SHARED `voidable_decisions` predicate — effective safe-harbor
561    /// allocations are OMITTED, #7), then requires --yes (or interactive y/N). Each void is
562    /// NON-REVOCABLE (a `VoidDecisionEvent` cannot itself be voided — re-apply the original decision to
563    /// restore). Voiding a `LotSelection` also re-exposes its disposal to the default method and clears
564    /// its optimizer attestation.
565    BulkVoid {
566        /// Print the preview and exit without writing.
567        #[arg(long)]
568        dry_run: bool,
569        /// Skip the interactive confirmation (non-interactive apply).
570        #[arg(long)]
571        yes: bool,
572    },
573    /// Match unreconciled inbound + outbound legs as self-transfers (self-transfer-passthrough C3).
574    /// With no --in/--out: PREVIEW the proposed pairs (read-only). With --in and --out: confirm ONE
575    /// pair (DROP for a same-wallet passthrough, RELOCATE for a cross-wallet transfer). NEVER automatic.
576    MatchSelfTransfers {
577        /// Confirm this in-leg (TransferIn eventref); requires --out.
578        #[arg(long = "in", requires = "out_ref")]
579        in_ref: Option<String>,
580        /// Confirm this out-leg (TransferOut eventref); requires --in.
581        #[arg(long = "out", requires = "in_ref")]
582        out_ref: Option<String>,
583        /// Override the suggested action (else the proposal's topology-derived action is used).
584        #[arg(long, value_enum)]
585        action: Option<SelfTransferActionArg>,
586        /// Print the preview and exit without writing (conflicts with --in/--out).
587        #[arg(long, conflicts_with_all = ["in_ref", "out_ref"])]
588        dry_run: bool,
589    },
590    /// Pseudo-reconcile MODE (sub-project 2): fill deliberately-fictional default decisions at
591    /// projection time (NEVER persisted) to clear the Hard classification blockers — a loudly-flagged
592    /// `[PSEUDO]` on-screen estimate you correct toward truth. `on`/`off` toggle the mode; `approve`
593    /// promotes chosen defaults to real (attested) decisions.
594    #[command(subcommand)]
595    Pseudo(Pseudo),
596}
597
598/// `reconcile pseudo <action>` — the pseudo-reconcile mode sub-verbs (sub-project 2).
599#[derive(Subcommand)]
600pub enum Pseudo {
601    /// Turn pseudo-reconcile mode ON. Projection now synthesizes non-persisted default decisions for
602    /// unresolved unknown-basis inbounds (self-transfer $0), unclassified rows, and import conflicts
603    /// (accept-first); every synthetic contribution is flagged `[PSEUDO]` on screen and BLOCKS export.
604    On,
605    /// Turn pseudo-reconcile mode OFF. Projection reverts to real-only instantly and totally (no
606    /// fictional events were ever written). Already-approved decisions REMAIN (they are real now).
607    Off,
608    /// Promote pseudo default decisions to REAL (attested) decisions in bulk. Shows a preview + requires
609    /// `--yes` (or `--dry-run` to preview only). Optional filters restrict which defaults are approved.
610    Approve {
611        /// Only approve defaults of this TYPE: `self-transfer` (unknown-basis inbound → $0 self-transfer),
612        /// `raw` (unclassified row placeholder), or `conflict` (import conflict accept-first). Omit = all.
613        #[arg(long, value_enum)]
614        kind: Option<PseudoKindArg>,
615        /// Only approve defaults whose target event is in this wallet (e.g. `exchange:coinbase:main`).
616        #[arg(long)]
617        wallet: Option<String>,
618        /// Only approve defaults whose target event falls in this tax year.
619        #[arg(long)]
620        year: Option<i32>,
621        /// Print the preview and exit without writing.
622        #[arg(long)]
623        dry_run: bool,
624        /// Skip the interactive confirmation (non-interactive apply).
625        #[arg(long)]
626        yes: bool,
627    },
628}
629
630/// The pseudo-default TYPE filter for `reconcile pseudo approve --kind`.
631#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
632pub enum PseudoKindArg {
633    /// Unknown-basis inbound defaulted to a $0-basis self-transfer-in.
634    SelfTransfer,
635    /// Unclassified row defaulted to a zero-value placeholder (ClassifyRaw).
636    Raw,
637    /// Import conflict defaulted to accept-first (SupersedeImport).
638    Conflict,
639}
640
641#[derive(Copy, Clone, ValueEnum)]
642pub enum SelfTransferActionArg {
643    /// Same-wallet passthrough → SelfTransferPassthrough (both legs skipped, non-taxable).
644    Drop,
645    /// Cross-wallet transfer → TransferLink (relocate the lots to the destination wallet).
646    Relocate,
647}
648
649#[derive(Copy, Clone, ValueEnum)]
650pub enum FilingStatusArg {
651    Single,
652    Mfj,
653    Mfs,
654    Hoh,
655    Qss,
656}
657
658impl From<FilingStatusArg> for FilingStatus {
659    fn from(a: FilingStatusArg) -> Self {
660        match a {
661            FilingStatusArg::Single => FilingStatus::Single,
662            FilingStatusArg::Mfj => FilingStatus::Mfj,
663            FilingStatusArg::Mfs => FilingStatus::Mfs,
664            FilingStatusArg::Hoh => FilingStatus::HoH,
665            FilingStatusArg::Qss => FilingStatus::Qss,
666        }
667    }
668}
669
670#[derive(Copy, Clone, ValueEnum)]
671pub enum FeeArg {
672    C,
673    B,
674}
675
676#[derive(Copy, Clone, ValueEnum)]
677pub enum MethodLotArg {
678    Fifo,
679    Lifo,
680    Hifo,
681}
682
683#[derive(Copy, Clone, ValueEnum)]
684pub enum OutKindArg {
685    Sell,
686    Spend,
687    Gift,
688    Donate,
689}
690
691impl From<MethodLotArg> for LotMethod {
692    fn from(a: MethodLotArg) -> Self {
693        match a {
694            MethodLotArg::Fifo => LotMethod::Fifo,
695            MethodLotArg::Lifo => LotMethod::Lifo,
696            MethodLotArg::Hifo => LotMethod::Hifo,
697        }
698    }
699}
700
701#[derive(Copy, Clone, ValueEnum)]
702pub enum MethodArg {
703    Actual,
704    ProRata,
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use clap::CommandFactory;
711
712    /// Render the LONG help (`--help`) of a subcommand identified by its path, recursing into
713    /// nested subcommands (e.g. `["reconcile", "import-selections"]`). Mirrors what a user sees at
714    /// `btctax <path...> --help`, which includes each argument's verbatim long-help.
715    fn long_help_of(path: &[&str]) -> String {
716        let mut cmd = Cli::command();
717        for name in path {
718            cmd = cmd
719                .find_subcommand(name)
720                .unwrap_or_else(|| panic!("subcommand {name:?} exists"))
721                .clone();
722        }
723        cmd.render_long_help().to_string()
724    }
725
726    // Requirement 3, `--help` half: each file/format-taking arg's long-help carries its FORMAT +
727    // a text EXAMPLE. Tokens are comma/brace-joined (no spaces) so help-wrapping can never break
728    // them (verified against the real binary output). This is the single source of truth that
729    // clap_mangen also renders into the per-subcommand man page (Task 2).
730
731    #[test]
732    fn help_documents_key_backup_format() {
733        let h = long_help_of(&["init"]);
734        assert!(
735            h.contains("-----BEGIN PGP PRIVATE KEY BLOCK-----"),
736            "init --key-backup help must document the ASCII-armored key format:\n{h}"
737        );
738    }
739
740    #[test]
741    fn help_documents_backup_key_format() {
742        let h = long_help_of(&["backup-key"]);
743        assert!(
744            h.contains("-----BEGIN PGP PRIVATE KEY BLOCK-----"),
745            "backup-key --out help must document the ASCII-armored key format:\n{h}"
746        );
747    }
748
749    #[test]
750    fn help_documents_export_snapshot_format() {
751        let h = long_help_of(&["export-snapshot"]);
752        // The exact removals.csv header read from the render.rs writer.
753        assert!(
754            h.contains("event,kind,removed_at,lot,sat,basis,fmv_at_transfer"),
755            "export-snapshot --out help must document the projection CSV headers:\n{h}"
756        );
757    }
758
759    #[test]
760    fn help_documents_import_selections_format() {
761        let h = long_help_of(&["reconcile", "import-selections"]);
762        assert!(
763            h.contains("disposal_ref,origin_event_id,split_sequence,sat"),
764            "import-selections help must document the required CSV header:\n{h}"
765        );
766    }
767
768    #[test]
769    fn help_documents_classify_raw_format() {
770        let h = long_help_of(&["reconcile", "classify-raw"]);
771        // The exact externally-tagged serde shape (Usd = decimal string, sat = integer).
772        assert!(
773            h.contains(r#"{"Acquire":{"sat":2000000,"usd_cost":"1680.00","fee_usd":"5.00","basis_source":"ExchangeProvided"}}"#),
774            "classify-raw --payload-json help must document the JSON payload shape:\n{h}"
775        );
776    }
777
778    #[test]
779    fn help_documents_select_lots_format() {
780        let h = long_help_of(&["reconcile", "select-lots"]);
781        assert!(
782            h.contains("import|coinbase|X#0:25000"),
783            "select-lots --from help must document the <event>#<split>:<sat> pick format:\n{h}"
784        );
785    }
786}