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