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 /// §4 R3-M6: persist this year's computed charitable + QBI carryover-OUT as next year's
65 /// carryover-IN (a full-return `--tax-year` ReturnInputs year only). `report` is otherwise
66 /// read-only; this flag opts into the vault write.
67 #[arg(long, default_value_t = false)]
68 write_carryover: bool,
69 /// With `--write-carryover`: overwrite a next-year carryover-in that was user-entered
70 /// (`income import`). Without it, a user-entered value is left untouched and the write refuses.
71 #[arg(long, default_value_t = false)]
72 force: bool,
73 },
74 /// Print the LIMITATIONS & supported-forms document: what a v1 full return covers, the credits it
75 /// omits conservatively (your tax is overstated, never understated), what it refuses outright, and
76 /// what it cannot represent. Read this before you file.
77 Limitations,
78 /// Emit a reconciliation decision event.
79 #[command(subcommand)]
80 Reconcile(Reconcile),
81 /// Show or set projection config (TP8 fee treatment / pre-2025 lot method / forward method).
82 Config {
83 #[arg(long, value_enum)]
84 set_fee_treatment: Option<FeeArg>,
85 #[arg(long, value_enum)]
86 set_pre2025_method: Option<MethodLotArg>,
87 #[arg(long, default_value_t = false)]
88 attest_pre2025_method: bool,
89 /// §A.5(a): append a MethodElection decision (the forward standing order). Not a flag
90 /// mutation — this is an event in the ledger. Use --effective-from to set the date
91 /// (default: today / the decision's made-date).
92 #[arg(long, value_enum)]
93 set_forward_method: Option<MethodLotArg>,
94 /// §A.5(a) per-ACCOUNT scope for --set-forward-method (IRS 2025+ per-account rule):
95 /// exchange:PROVIDER:ACCOUNT (the canonical wallet grammar). Omit for a GLOBAL election
96 /// (the existing behavior). Only exchange accounts are electable (a method election is a
97 /// brokerage-account concept; self:LABEL is rejected). The account MUST already exist in the
98 /// vault — an unknown/typo'd account is rejected LOUDLY so it can't create a dead election.
99 #[arg(long)]
100 exchange: Option<String>,
101 /// Effective-from date for --set-forward-method (YYYY-MM-DD). Defaults to made-date.
102 #[arg(long)]
103 effective_from: Option<String>,
104 },
105 /// FR10: export decrypted SQLite + CSV (the NFR2 plaintext exception).
106 ///
107 /// WARNS (does not refuse) on unresolved Hard blockers: any Hard blocker makes every affected
108 /// tax year NOT COMPUTABLE, so the exported Form 8949 / Schedule D / figures are INFORMATIONAL,
109 /// not final. A warning is printed to stderr and the export still succeeds (exit 0). Automation
110 /// that must GATE on unresolved blockers should check `btctax verify` (which exits non-zero),
111 /// since export-snapshot itself stays exit 0.
112 ExportSnapshot {
113 /// Output DIRECTORY receiving the decrypted SQLite DB (snapshot.sqlite) + projection CSVs
114 /// (the NFR2 plaintext exception; created owner-only). ALWAYS writes: lots.csv,
115 /// disposals.csv, removals.csv, income.csv. With --tax-year it ALSO writes form8949.csv,
116 /// schedule_d.csv, form8283.csv, and schedule_se.csv (schedule_se only when there is
117 /// business self-employment income). The `event` column in disposals.csv / removals.csv /
118 /// income.csv is the event-ref that reconcile commands consume (select-lots,
119 /// set-donation-details, reclassify-income, …).
120 ///
121 /// FORMAT (removals.csv header + one sample donation row):
122 /// event,kind,removed_at,lot,sat,basis,fmv_at_transfer,term,acquired_at,claimed_deduction,donee
123 /// import|coinbase|X,donation,2025-03-01,import|coinbase|X#0,25000,120.00,150.00,long,2023-01-05,150.00,Charity Y
124 #[arg(long, verbatim_doc_comment)]
125 out: PathBuf,
126 /// Also emit the per-tax-year Form 8949 + Schedule D CSVs (form8949.csv / schedule_d.csv),
127 /// scoped to this calendar year. Omit to write only the all-years projection CSVs.
128 #[arg(long)]
129 tax_year: Option<i32>,
130 /// Attestation phrase required to export while the ledger is PSEUDO-RECONCILED (a synthetic
131 /// default contributes to the projection). Pass the exact phrase `I attest this is true`
132 /// (trimmed, case-sensitive) to export the fictional draft ON PURPOSE. Omit on a fully-real
133 /// ledger (never gated). Omit on an interactive terminal to be prompted; omit when piped
134 /// (non-TTY) while pseudo-active and the export is refused.
135 #[arg(long)]
136 attest: Option<String>,
137 },
138 /// Fill the OFFICIAL IRS fillable PDFs for a tax year (a whole packet).
139 ///
140 /// Writes (owner-only) into --out, populated from btctax's already-computed projection — no
141 /// capital-gains figure is recomputed:
142 /// - f8949.pdf + schedule_d.pdf — ALWAYS. On the 2025 (1099-DA) revision Bitcoin is filed under
143 /// Box I (short-term) / Box L (long-term) — the digital-asset boxes; on the pre-1099-DA 2024
144 /// and 2017 revisions it is Box C / Box F ("not reported on a 1099-B"). Never the wrong pair
145 /// for the year. More rows than a part's grid holds (11 in 2025, 14 in 2024/2017) paginate
146 /// onto multiple copies, each with its own totals.
147 /// - schedule_se.pdf — when there is business self-employment income and net earnings are ≥ the
148 /// $400 floor. Line 12 (SE tax) = Social Security + regular Medicare ONLY; the 0.9% Additional
149 /// Medicare Tax is a Form 8959 item (flagged on stderr, not put on Schedule SE). Requires a
150 /// stored `tax-profile` for the year (filing status); missing profile ⇒ a NOTE, not a form.
151 /// - form_8283.pdf — when there are BTC donations. Fills the donee/appraiser IDENTITY + per-
152 /// donation property rows (Section A ≤ $5,000 or Section B > $5,000). The property-type box is
153 /// "k Digital assets" on the Rev. 12-2023/2025 forms (2024/2025); the 2017 Rev. 12-2014 form
154 /// has no such box, so BTC uses "j Other" + a printed note. Leaves every OTHER party's
155 /// declaration/signature BLANK — a Section B 8283 is NOT filing-ready without those signed.
156 /// Overflows onto additional copies.
157 /// - form_1040_capgains.pdf — when there is reportable capital/digital-asset activity. Fills the
158 /// capital-gain line (line 7a in 2025 / line 7 in 2024 / line 13 in 2017, when Schedule D is
159 /// active and line 16 ≥ 0; active-and-zero → "-0-"; a net loss leaves it blank — the §1211
160 /// line-21 cap is yours) and, on 2024/2025, the Digital-Asset question (YES iff any disposal,
161 /// income, gift, or donation; never a "No"). The 2017 form has no Digital-Asset question, so an
162 /// income-only 2017 year produces no 1040. 7b checkboxes are untouched.
163 ///
164 /// Every written value is read back GEOMETRICALLY against the blank PDF's own field coordinates and
165 /// the fill FAILS CLOSED on any mis-placement — a wrong tax form is never written. The engine drops
166 /// the forms' XFA layer (else Acrobat opens them blank) and sets NeedAppearances so a viewer
167 /// regenerates the visible values. Schedule D lines 17-22 (28%-rate / unrecaptured-§1250 / QDI
168 /// worksheet, incl. the line-21 loss limit) are OUT OF SCOPE. Rows on an exchange that MAY carry
169 /// 1099-DA broker reporting are flagged on stderr (btctax files them all under Box I/L and says so).
170 ///
171 /// REFUSED for a tax year that has FULL-RETURN inputs (`income import`). These fillers are the
172 /// crypto-slice pipeline: Schedule D carries only the ledger's crypto totals — it has no line 13
173 /// (1099-DIV box-2a capital-gain distributions) and no lines 6/14 (capital-loss carryovers), both
174 /// of which the computed return DOES include in 1040 line 7 — and the 1040 fill covers only the
175 /// capital-gain cluster. For a crypto-only year those forms are complete and correct; for a full
176 /// return they would be complete-LOOKING forms with income missing, so v1 fails closed rather than
177 /// hand you a plausible wrong form. Transcribe the report's figures by hand until the full-return
178 /// fillers ship. See `btctax limitations`.
179 ///
180 /// PSEUDO-RECONCILED ledgers: the same attestation gate as export-snapshot applies, AND every
181 /// page is stamped with a diagonal `DRAFT — ESTIMATE, NOT FOR FILING` watermark.
182 ExportIrsPdf {
183 /// Output DIRECTORY receiving the filled official PDFs (created owner-only): f8949.pdf,
184 /// schedule_d.pdf, and — when applicable — schedule_se.pdf, form_8283.pdf,
185 /// form_1040_capgains.pdf. These contain your unencrypted tax data — write --out OUTSIDE any
186 /// git repo.
187 #[arg(long, verbatim_doc_comment)]
188 out: PathBuf,
189 /// The tax year to fill (this build bundles TY2017, TY2024 and TY2025; other years are
190 /// refused). TY2024/TY2017 are pre-1099-DA revisions: Bitcoin is filed under Box C/F (not Box
191 /// I/L). TY2017 additionally uses the OLD forms — the §B long Schedule SE, Form 8283 Rev.
192 /// 12-2014 ("j Other", no digital-asset box), the 1040 capital gain on line 13, and NO
193 /// Digital-Asset question.
194 #[arg(long)]
195 tax_year: i32,
196 /// Restrict the packet to specific forms (repeat or comma-separate). Default = every
197 /// applicable form (f8949 + schedule-d always; schedule-se when SE income ≥ the $400 floor;
198 /// form-8283 when there are donations; form-1040 when there is reportable digital-asset
199 /// activity). A named form is still skipped when it does not apply.
200 #[arg(long, value_enum, value_delimiter = ',')]
201 forms: Vec<FormArg>,
202 /// Attestation phrase required to export while the ledger is PSEUDO-RECONCILED (a synthetic
203 /// default contributes to the projection). Pass the exact phrase `I attest this is true`
204 /// (trimmed, case-sensitive) to fill the DRAFT-watermarked forms ON PURPOSE. Omit on a
205 /// fully-real ledger (never gated). Omit on an interactive terminal to be prompted; omit when
206 /// piped (non-TTY) while pseudo-active and the export is refused.
207 #[arg(long)]
208 attest: Option<String>,
209 },
210 /// Export the passphrase-protected key.
211 BackupKey {
212 /// File to write the exported key to: an ASCII-armored, passphrase(S2K)-encrypted private
213 /// key, owner-only (mode 0600). Identical format to `init --key-backup`.
214 ///
215 /// FORMAT (structure — NOT a real key):
216 /// -----BEGIN PGP PRIVATE KEY BLOCK-----
217 /// ... base64 armor of the S2K-encrypted secret key ...
218 /// -----END PGP PRIVATE KEY BLOCK-----
219 #[arg(long, verbatim_doc_comment)]
220 out: PathBuf,
221 },
222 /// Lot-specific-identification optimizer (§C — read-only proposal or gated persistence).
223 #[command(subcommand)]
224 Optimize(Optimize),
225 /// Read-only what-if tax planning (task #43): posit a HYPOTHETICAL, NON-persisted transaction and
226 /// see its MARGINAL federal-tax effect on the current-year position. Routes through the same audited
227 /// tax engine as `report --tax-year`; invents no tax authority. Writes NOTHING — no event, no
228 /// side-table row, no vault mutation. Tax decision-support (consequences), not buy/sell/hold advice.
229 #[command(subcommand)]
230 WhatIf(WhatIf),
231 /// Full-return (v1) input surface: import, show, or clear the per-year full-return inputs (W-2s,
232 /// 1099s, deductions, household). Offline; stored in the encrypted vault.
233 #[command(subcommand)]
234 Income(IncomeCmd),
235 /// Set or show the per-tax-year tax profile (filing status, income, MAGI, etc.).
236 TaxProfile {
237 /// The tax year (e.g. 2025).
238 #[arg(long)]
239 year: i32,
240 /// IRS filing status.
241 #[arg(long, value_enum)]
242 filing_status: Option<FilingStatusArg>,
243 /// Ordinary taxable income EXCLUDING all app-computed crypto items (net ST gains,
244 /// mining/staking ordinary income). The engine adds the crypto items on top (B.1 / I5).
245 #[arg(long)]
246 ordinary_taxable_income: Option<String>,
247 /// Modified AGI excluding crypto items, for the §1411 NIIT threshold comparison.
248 ///
249 /// IMPORTANT (§1411 contract): this value MUST already include the taxpayer's qualified
250 /// dividends and non-crypto net capital gains (and any other MAGI add-backs from
251 /// §1411(d)). The engine adds ONLY the crypto AGI delta on top (ambiguity #5 in the
252 /// design). Omitting QD or non-crypto cap gains from this figure understates NIIT.
253 #[arg(
254 long,
255 long_help = "Modified AGI excluding crypto items, for the §1411 NIIT \
256 threshold comparison.\n\nIMPORTANT (§1411 contract): this value MUST already \
257 include the taxpayer's qualified dividends and non-crypto net capital gains (and \
258 any other MAGI add-backs from §1411(d)). The engine adds ONLY the crypto AGI \
259 delta on top (ambiguity #5 in the design). Omitting QD or non-crypto cap gains \
260 from this figure understates NIIT."
261 )]
262 magi_excluding_crypto: Option<String>,
263 /// Qualified dividends + other preferential-rate income sharing the §1(h) 0/15/20 LTCG
264 /// rate stack. Required when setting a profile.
265 #[arg(long)]
266 qualified_dividends: Option<String>,
267 /// Non-crypto net LT-character capital gain already in the profile (optional; defaults
268 /// to 0 when omitted).
269 #[arg(long)]
270 other_net_capital_gain: Option<String>,
271 /// §1212(b) short-term capital loss carryforward into this year (optional; defaults to 0).
272 #[arg(long)]
273 carryforward_short: Option<String>,
274 /// §1212(b) long-term capital loss carryforward into this year (optional; defaults to 0).
275 #[arg(long)]
276 carryforward_long: Option<String>,
277 /// Form W-2 Social Security wages (Box 3 + Box 7 tips; Schedule SE line 8a).
278 /// Reduces the §1401(a) SS cap: ss_cap = max(0, wage_base − w2_ss_wages). Optional;
279 /// defaults to $0. Must not be negative.
280 #[arg(long)]
281 w2_ss_wages: Option<String>,
282 /// Medicare wages (Box 5; Form 8959 line 1).
283 /// Reduces the Additional-Medicare threshold: addl_threshold = max(0, threshold − w2_medicare_wages)
284 /// (§1401(b)(2)(B)/Form 8959 Part II). Optional; defaults to $0. Must not be negative.
285 #[arg(long)]
286 w2_medicare_wages: Option<String>,
287 /// Schedule C deductible business expenses for the year — reduces net SE earnings;
288 /// the income-tax stack above is NOT adjusted (see the advisory).
289 /// Optional; defaults to $0. Must not be negative.
290 #[arg(long)]
291 schedule_c_expenses: Option<String>,
292 /// Show the stored profile for `--year` instead of setting it.
293 #[arg(long, default_value_t = false)]
294 show: bool,
295 /// Store the raw profile even when full-return inputs (`income import`) already exist for the year
296 /// (they take precedence, so the raw profile would otherwise be ignored — D-4 guard).
297 #[arg(long, default_value_t = false)]
298 force: bool,
299 },
300}
301
302/// `optimize` subcommand tree. Task 9 adds `Run`; Task 10 adds `Accept`; Task 11 adds `Consult`.
303#[derive(Subcommand)]
304pub enum Optimize {
305 /// Mode-1 what-if: print the tax-saving lot-selection proposal. NOTHING is filed or bound.
306 Run {
307 /// The tax year to optimize (must be 2025 or later).
308 #[arg(long)]
309 tax_year: i32,
310 },
311 /// Mode-1 gated persistence: recompute the optimum and persist the proposed LotSelection(s),
312 /// gated per disposal (§1.1012-1(j)). A genuinely-contemporaneous pick (made ≤ sale) persists
313 /// freely; an already-executed disposal persists ONLY with a narrow per-disposal `--attest`
314 /// scoped to one `--disposal`; a 2027+ broker-held pick is refused. Revoke via `reconcile void`.
315 Accept {
316 /// The tax year to accept (must be 2025 or later).
317 #[arg(long)]
318 tax_year: i32,
319 /// Restrict to ONE disposal (required to carry `--attest`).
320 #[arg(long)]
321 disposal: Option<String>,
322 /// Narrow contemporaneous-ID attestation for an already-executed disposal. Requires
323 /// `--disposal` (no blanket attestation across all disposals).
324 #[arg(long)]
325 attest: Option<String>,
326 },
327 /// Mode-2 read-only pre-trade what-if (§C.3): tax-min lots + ST/LT split + federal tax + ST→LT
328 /// timing. NOTHING is written — no event, no side-table row. Tax decision-support only;
329 /// not buy/sell/hold advice.
330 Consult {
331 /// Hypothetical sale amount (required). Accepts a satoshi integer OR a BTC decimal, e.g.
332 /// `0.05` or `5000000` (a value with a `.` is BTC; a bare integer is satoshis).
333 #[arg(long)]
334 sell: String,
335 /// Wallet to sell from, e.g. `self:cold` or `exchange:coinbase:default` (required; per-wallet
336 /// pool is mandatory post-2025).
337 #[arg(long)]
338 wallet: Option<String>,
339 /// Sale date for the what-if (YYYY-MM-DD; defaults to today UTC if omitted).
340 #[arg(long)]
341 at: Option<String>,
342 /// Explicit USD proceeds for the hypothetical sale. Required when `--at` is a future date
343 /// with no bundled dataset price and `--fmv` is not used. Mutually exclusive with `--fmv`.
344 #[arg(long, conflicts_with = "fmv")]
345 proceeds: Option<String>,
346 /// Use the bundled daily-close FMV for `--at` instead of an explicit proceeds amount.
347 /// A future date with no dataset price will return a ProceedsRequired error. Mutually
348 /// exclusive with `--proceeds`.
349 #[arg(long, conflicts_with = "proceeds")]
350 fmv: bool,
351 },
352}
353
354/// Full-return (v1) input subcommands (SPEC §4 / recon-04 §6). v1 ships the TOML bulk-import path +
355/// a JSON show; incremental per-field subcommands (`add-w2`, …) are a follow-on.
356#[derive(Subcommand)]
357pub enum IncomeCmd {
358 /// Import full-return inputs from an offline TOML file into the vault for a tax year.
359 Import {
360 /// The tax year (e.g. 2024).
361 #[arg(long)]
362 year: i32,
363 /// Path to the TOML file describing the full-return inputs.
364 #[arg(long)]
365 file: std::path::PathBuf,
366 },
367 /// Show the stored full-return inputs for a tax year (JSON, PII redacted), or nothing if none set.
368 Show {
369 /// The tax year (e.g. 2024).
370 #[arg(long)]
371 year: i32,
372 },
373 /// Remove the stored full-return inputs for a tax year (fall back to a raw `tax-profile`).
374 Clear {
375 /// The tax year (e.g. 2024).
376 #[arg(long)]
377 year: i32,
378 },
379 /// Answer the return's fail-loud questions interactively — the yes/no boxes that have no safe
380 /// default (can someone claim you as a dependent? Schedule B's foreign-account and foreign-trust
381 /// lines) plus the optional dates of birth.
382 ///
383 /// These questions REFUSE the return until they are answered: guessing "no" on your behalf would
384 /// understate your tax and print an unchecked box you never affirmed. This is the only way to answer
385 /// them without editing a TOML file. It never asks for a secret — SSNs and the IP PIN belong to
386 /// `set-pii`, which does not echo what you type.
387 ///
388 /// Requires an existing return for the year (create one with `income import`).
389 Answer {
390 /// The tax year (e.g. 2024).
391 #[arg(long)]
392 year: i32,
393 },
394}
395
396/// `what-if` subcommand tree (task #43). READ-ONLY hypothetical-transaction tax planning: NOTHING is
397/// filed, appended, or persisted. Mirrors the `optimize consult` shape, plus an ad-hoc `TaxProfile`
398/// (so you can plan without `tax-profile set`).
399#[derive(Subcommand)]
400pub enum WhatIf {
401 /// Posit a hypothetical, NON-persisted SALE and see its MARGINAL federal tax: the lots it would
402 /// consume, the ST/LT split, which §1(h) LTCG bracket (0/15/20) it lands in + room to the next
403 /// breakpoint, the exact marginal tax (with-hypothetical minus baseline — the sale's OWN effect,
404 /// not the whole-year figure), the effective rate, the §1212(b) carryforward carried to next year,
405 /// this year's ordinary offset, and the §1411 NIIT delta. A net loss surfaces the carryforward
406 /// disclosure (its value is NOT this-year tax). Writes NOTHING.
407 Sell {
408 /// Hypothetical sale amount (required). Accepts a satoshi integer OR a BTC decimal, e.g.
409 /// `0.05` or `5000000` (a value with a `.` is BTC; a bare integer is satoshis).
410 #[arg(long)]
411 sell: String,
412 /// Wallet to sell from, e.g. `self:cold` or `exchange:coinbase:default` (required; the
413 /// per-wallet pool is mandatory post-2025).
414 #[arg(long)]
415 wallet: Option<String>,
416 /// Sale date for the what-if (YYYY-MM-DD; defaults to today UTC if omitted).
417 #[arg(long)]
418 at: Option<String>,
419 /// USD price per WHOLE BTC for the hypothetical sale (proceeds = price × sat / 1e8). Omit to
420 /// use the bundled daily-close FMV for `--at`; REQUIRED for a future/off-dataset `--at` with no
421 /// bundled price (else the what-if returns a ProceedsRequired error).
422 #[arg(long)]
423 price: Option<String>,
424 /// Lot-selection method for the hypothetical sale: fifo|lifo|hifo. Omit to consume by the
425 /// STANDING method (the account's in-force election / the default), exactly as a real disposal
426 /// on that date would.
427 #[arg(long, value_enum)]
428 method: Option<MethodLotArg>,
429 /// AD-HOC filing status (single|mfj|mfs|hoh|qss). Supplying this (with `--income`) builds a
430 /// NON-persisted profile for the plan instead of the stored `tax-profile`. Omit ALL ad-hoc
431 /// flags to use the stored profile for the sale year.
432 #[arg(long, value_enum)]
433 filing_status: Option<FilingStatusArg>,
434 /// AD-HOC ordinary taxable income EXCLUDING crypto (the base the crypto stacks on). Required
435 /// when building an ad-hoc profile.
436 #[arg(long)]
437 income: Option<String>,
438 /// AD-HOC modified AGI excluding crypto, for the §1411 NIIT threshold. DEFAULTS TO `--income`
439 /// when omitted (never $0 — a $0 MAGI would silently suppress every NIIT disclosure); a printed
440 /// caveat notes the assumption. Supply the true MAGI (incl. QD + non-crypto cap gains) to avoid
441 /// understating NIIT.
442 #[arg(long)]
443 magi: Option<String>,
444 /// AD-HOC §1212(b) LONG-TERM capital-loss carryforward INTO the sale year (optional; defaults
445 /// to $0). The dominant BTC case; short-term carryforward-in is out of scope for the ad-hoc
446 /// profile (set a stored `tax-profile` for that).
447 #[arg(long)]
448 carryforward_in: Option<String>,
449 },
450 /// Posit a hypothetical, NON-persisted HARVEST and find the MAX BTC to sell such that a target holds
451 /// on the ENTIRE prefix [0, N]: `--target zero-ltcg` (sell all that fits in the §1(h) 0% bracket),
452 /// `fifteen-ltcg` (stay at/under 15%), `gain=$X` (realize at most $X of gain WITH this sale), or
453 /// `tax=$X` (add at most $X of marginal federal tax; `tax=$0` is the flagship "zero-tax harvest").
454 /// Uses the STANDING lot method's consumption order (never re-optimized). Discloses the §1212(b)
455 /// carryforward burn, the §1411 NIIT kink (a 0%/15% answer can still cost +3.8%), and the plateau
456 /// notes. The answer is ALWAYS engine-verified. Writes NOTHING.
457 Harvest {
458 /// The harvest target: `zero-ltcg` | `fifteen-ltcg` | `gain=$X` | `tax=$X` (X >= 0). `$` and
459 /// commas are optional (e.g. `gain=25000`, `tax=$0`, `gain=$1,000`).
460 #[arg(long)]
461 target: String,
462 /// Wallet to harvest from, e.g. `self:cold` or `exchange:coinbase:default` (required; the
463 /// per-wallet pool is mandatory post-2025).
464 #[arg(long)]
465 wallet: Option<String>,
466 /// Harvest date for the what-if (YYYY-MM-DD; defaults to today UTC if omitted).
467 #[arg(long)]
468 at: Option<String>,
469 /// USD price per WHOLE BTC. Omit to use the bundled daily-close FMV for `--at`; REQUIRED for a
470 /// future/off-dataset `--at` with no bundled price.
471 #[arg(long)]
472 price: Option<String>,
473 /// AD-HOC filing status (single|mfj|mfs|hoh|qss). Supplying this (with `--income`) builds a
474 /// NON-persisted profile for the plan instead of the stored `tax-profile`. Omit ALL ad-hoc
475 /// flags to use the stored profile for the harvest year.
476 #[arg(long, value_enum)]
477 filing_status: Option<FilingStatusArg>,
478 /// AD-HOC ordinary taxable income EXCLUDING crypto (the base the crypto stacks on). Required
479 /// when building an ad-hoc profile.
480 #[arg(long)]
481 income: Option<String>,
482 /// AD-HOC modified AGI excluding crypto, for the §1411 NIIT threshold. DEFAULTS TO `--income`
483 /// when omitted (never $0 — a $0 MAGI would silently suppress every NIIT disclosure); a printed
484 /// caveat notes the assumption.
485 #[arg(long)]
486 magi: Option<String>,
487 /// AD-HOC §1212(b) LONG-TERM capital-loss carryforward INTO the harvest year (optional; defaults
488 /// to $0) — expands the harvestable-gain room (gains are absorbed before touching the pref stack).
489 #[arg(long)]
490 carryforward_in: Option<String>,
491 },
492}
493
494#[derive(Subcommand)]
495pub enum Reconcile {
496 /// Confirm a self-transfer (TransferLink).
497 LinkTransfer {
498 out: String,
499 #[arg(long, conflicts_with = "to_wallet")]
500 to_event: Option<String>,
501 #[arg(long)]
502 to_wallet: Option<String>,
503 },
504 /// Classify an inbound TransferIn as income.
505 ClassifyInboundIncome {
506 in_ref: String,
507 #[arg(long)]
508 kind: String,
509 #[arg(long)]
510 fmv: Option<String>,
511 #[arg(long)]
512 business: bool,
513 },
514 /// Classify an inbound TransferIn as a received gift.
515 ClassifyInboundGift {
516 in_ref: String,
517 #[arg(long)]
518 fmv_at_gift: String,
519 #[arg(long)]
520 donor_basis: Option<String>,
521 #[arg(long)]
522 donor_acquired: Option<String>,
523 },
524 /// Classify an inbound TransferIn as an inbound self-transfer ("my own coins" returning) —
525 /// non-taxable, creates a fresh lot. `--basis` defaults to $0 (conservative; fires the honest
526 /// zero-basis advisory when omitted); `--acquired` defaults to 1 year + 1 day before receipt
527 /// (assumed long-term for a cold-storage deposit; discloses an advisory so you can correct it).
528 ClassifyInboundSelfTransfer {
529 in_ref: String,
530 #[arg(long)]
531 basis: Option<String>,
532 #[arg(long)]
533 acquired: Option<String>,
534 },
535 /// Reclassify a pending TransferOut.
536 ReclassifyOutflow {
537 out: String,
538 #[arg(long, value_enum)]
539 as_kind: OutKindArg,
540 #[arg(long)]
541 amount: String,
542 #[arg(long)]
543 fee: Option<String>,
544 #[arg(long)]
545 appraisal: bool,
546 /// Free-form donee identifier (e.g. "Alice", "Charity X"). Carried through to
547 /// removals.csv and Form 8283; does not affect tax math.
548 #[arg(long)]
549 donee: Option<String>,
550 },
551 /// Set a manual FMV on an event.
552 SetFmv {
553 event: String,
554 #[arg(long)]
555 fmv: String,
556 },
557 /// Void a revocable decision.
558 Void { target: String },
559 /// Resolve an Unclassified row from a JSON imported payload.
560 ClassifyRaw {
561 target: String,
562 /// A JSON-encoded imported EventPayload (serde externally-tagged: `{"Variant":{...}}`) to
563 /// resolve the Unclassified target as. Must be an IMPORTED variant — Acquire, Income,
564 /// Dispose, TransferOut, TransferIn, or Unclassified. USD fields (usd_cost, fee_usd, …) are
565 /// decimal STRINGS; `sat` is an integer.
566 ///
567 /// FORMAT (Acquire example):
568 /// {"Acquire":{"sat":2000000,"usd_cost":"1680.00","fee_usd":"5.00","basis_source":"ExchangeProvided"}}
569 #[arg(long, verbatim_doc_comment)]
570 payload_json: String,
571 },
572 /// Accept an import conflict.
573 AcceptConflict { conflict: String },
574 /// Reject an import conflict.
575 RejectConflict { conflict: String },
576 /// Path-B safe-harbor allocate (from the actual pre-2025 position).
577 SafeHarborAllocate {
578 #[arg(long, value_enum, default_value_t = MethodArg::Actual)]
579 method: MethodArg,
580 #[arg(long)]
581 attest: bool,
582 },
583 /// Attest an existing allocation as timely.
584 SafeHarborAttest,
585 /// §A.4 Specific-ID: pick the exact lots a disposal consumes.
586 SelectLots {
587 disposal: String,
588 /// One lot pick per --from flag (repeatable). Each PICK is
589 /// `<origin_event_id>#<split_sequence>:<sat>`. The origin_event_id + split come from the
590 /// `lot` column of disposals.csv or the `origin_event`/`split` columns of lots.csv
591 /// (export-snapshot). The total sat across the picks must equal the disposal's principal
592 /// (validated in the fold).
593 ///
594 /// FORMAT (two picks):
595 /// --from import|coinbase|X#0:25000 --from import|river|Y#1:5000
596 #[arg(long = "from", required = true, verbatim_doc_comment)]
597 from: Vec<String>,
598 },
599 /// §A.4 Batch import LotSelections from a CSV (disposal_ref,origin_event_id,split_sequence,sat).
600 ImportSelections {
601 /// CSV of lot picks imported as LotSelection decisions (§A.4). The header is REQUIRED and
602 /// validated loudly; rows sharing a disposal_ref are grouped into a single decision.
603 /// disposal_ref is the disposal event's ref (disposals.csv `event` column); origin_event_id
604 /// is the lot's origin (lots.csv `origin_event` column).
605 ///
606 /// FORMAT (header + one sample row):
607 /// disposal_ref,origin_event_id,split_sequence,sat
608 /// import|gemini|trade|T-2.O-2,import|coinbase|X,0,1000000
609 #[arg(verbatim_doc_comment)]
610 csv: PathBuf,
611 },
612 /// SE-completion Chunk C: flip `business` (and optionally `kind`) on an already-imported Income event.
613 ///
614 /// Corrects the `business: false` hard-code that River (and other adapters) emit at ingest time,
615 /// enabling SE-tax treatment for professional miners / stakers. The engine validates that the target
616 /// event exists and is an Income event — a missing or non-Income target fires a Hard DecisionConflict
617 /// blocker (decision excluded). For TransferIn rows use `classify-inbound-income` instead.
618 ///
619 /// DecisionConflict is Hard — to re-decide, `void` the prior decision first, then re-issue.
620 ReclassifyIncome {
621 /// The Income event reference (from `report` or `income_recognized.csv` 'event' column).
622 income_event: String,
623 /// Whether this income is from a trade or business (true → SE-tax eligible).
624 /// Must be supplied explicitly: `--business true` or `--business false`.
625 #[arg(long, required = true, action = clap::ArgAction::Set)]
626 business: bool,
627 /// Optional income kind correction: mining|staking|interest|airdrop|reward.
628 /// Omit to keep the original kind (only flip `business`).
629 #[arg(long)]
630 kind: Option<String>,
631 },
632 /// Store Form 8283 Section-B donation + appraiser details for a donation event.
633 /// The event ref is the TransferOut EventId from the removals.csv 'event' column.
634 SetDonationDetails {
635 /// TransferOut event reference for the donation (from removals.csv 'event' column).
636 out_event_ref: String,
637 /// Donee organization name (Part IV; required).
638 #[arg(long, required = true)]
639 donee_name: String,
640 /// Donee mailing address (Part IV; optional).
641 #[arg(long)]
642 donee_address: Option<String>,
643 /// Donee EIN (Part IV; required for Section-B completeness).
644 #[arg(long)]
645 donee_ein: Option<String>,
646 /// Qualified appraiser name (Part III; required).
647 #[arg(long, required = true)]
648 appraiser_name: String,
649 /// Appraiser mailing address (Part III; optional).
650 #[arg(long)]
651 appraiser_address: Option<String>,
652 /// Appraiser TIN/SSN/EIN (Part III §6695A; satisfies the TIN-or-PTIN requirement).
653 #[arg(long)]
654 appraiser_tin: Option<String>,
655 /// Appraiser PTIN (Part III §6695A; satisfies the TIN-or-PTIN requirement).
656 #[arg(long)]
657 appraiser_ptin: Option<String>,
658 /// Appraiser qualifications declaration (§170(f)(11)(E)).
659 #[arg(long)]
660 appraiser_qualifications: Option<String>,
661 /// Date the qualified appraisal was made (YYYY-MM-DD).
662 #[arg(long)]
663 appraisal_date: Option<String>,
664 /// FMV determination method override (overrides the section-derived default on the
665 /// Form 8283 carrier row; resolves the Section-A fmv_method deferral when supplied).
666 #[arg(long)]
667 fmv_method: Option<String>,
668 },
669 /// Show stored Form 8283 donation details for a donation event.
670 ShowDonationDetails {
671 /// TransferOut event reference for the donation (from removals.csv 'event' column).
672 out_event_ref: String,
673 },
674 /// Bulk-confirm self-transfers: link every PENDING outbound transfer in a time frame to one
675 /// destination wallet (non-taxable). Shows a preview + requires --yes (or interactive y/N).
676 BulkLinkTransfer {
677 /// Destination wallet every selected outflow links to.
678 #[arg(long)]
679 to_wallet: String,
680 /// Restrict to a single tax year (mutually exclusive with --from/--to).
681 #[arg(long, conflicts_with_all = ["from", "to"])]
682 year: Option<i32>,
683 /// Range start (YYYY-MM-DD; requires --to).
684 #[arg(long, requires = "to")]
685 from: Option<String>,
686 /// Range end (YYYY-MM-DD, inclusive; requires --from).
687 #[arg(long, requires = "from")]
688 to: Option<String>,
689 /// Only outflows FROM this source wallet.
690 #[arg(long)]
691 from_wallet: Option<String>,
692 /// Print the preview and exit without writing.
693 #[arg(long)]
694 dry_run: bool,
695 /// Skip the interactive confirmation (non-interactive apply).
696 #[arg(long)]
697 yes: bool,
698 },
699 /// Bulk-classify unknown-basis inbound deposits as self-transfer-ins ("my own coins"): apply
700 /// Cycle A's `SelfTransferMine` ($0 conservative basis, non-taxable) to MANY pending inbounds in a
701 /// time frame at once. Shows a preview surfacing the total USD given $0 basis (the over-tax
702 /// exposure) + requires --yes (or interactive y/N). Each is a voidable decision; for a deposit
703 /// whose real cost you can substantiate, classify it single-item with `classify-inbound-self-transfer --basis`.
704 BulkClassifyInboundSelfTransfer {
705 /// Restrict to a single tax year (mutually exclusive with --from/--to).
706 #[arg(long, conflicts_with_all = ["from", "to"])]
707 year: Option<i32>,
708 /// Range start (YYYY-MM-DD; requires --to).
709 #[arg(long, requires = "to")]
710 from: Option<String>,
711 /// Range end (YYYY-MM-DD, inclusive; requires --from).
712 #[arg(long, requires = "from")]
713 to: Option<String>,
714 /// Only inbounds received INTO this wallet.
715 #[arg(long)]
716 wallet: Option<String>,
717 /// Print the preview and exit without writing.
718 #[arg(long)]
719 dry_run: bool,
720 /// Skip the interactive confirmation (non-interactive apply).
721 #[arg(long)]
722 yes: bool,
723 },
724 /// Bulk-classify unknown-basis inbound deposits as INCOME (mining|staking|interest|airdrop|reward):
725 /// recognize MANY pending inbounds as ordinary income at their auto-FMV (the daily-close market
726 /// value at receipt) in one confirmed batch, with a UNIFORM `--kind` + `--business` flag. Shows a
727 /// preview surfacing the total income recognized + the count of inbounds EXCLUDED because no price
728 /// was available for their date (those stay pending — an income row with no FMV would year-gate).
729 /// Each is a voidable decision; for a single deposit use `classify-inbound-income`.
730 BulkClassifyInboundIncome {
731 /// Income kind for the whole batch: mining|staking|interest|airdrop|reward.
732 #[arg(long)]
733 kind: String,
734 /// Whether this income is from a trade or business (true → SE-tax eligible).
735 #[arg(long)]
736 business: bool,
737 /// Restrict to a single tax year (mutually exclusive with --from/--to).
738 #[arg(long, conflicts_with_all = ["from", "to"])]
739 year: Option<i32>,
740 /// Range start (YYYY-MM-DD; requires --to).
741 #[arg(long, requires = "to")]
742 from: Option<String>,
743 /// Range end (YYYY-MM-DD, inclusive; requires --from).
744 #[arg(long, requires = "from")]
745 to: Option<String>,
746 /// Only inbounds received INTO this wallet.
747 #[arg(long)]
748 wallet: Option<String>,
749 /// Print the preview and exit without writing.
750 #[arg(long)]
751 dry_run: bool,
752 /// Skip the interactive confirmation (non-interactive apply).
753 #[arg(long)]
754 yes: bool,
755 },
756 /// Bulk-reclassify unknown pending OUTFLOWS as dispositions (Sell|Spend): reclassify MANY pending
757 /// `TransferOut`s as a `Dispose` in one confirmed batch, with the daily-close market value at the
758 /// outflow date as the ESTIMATED proceeds. Shows a preview surfacing the total ESTIMATED proceeds
759 /// AND the total ESTIMATED gain (sum(fmv) - sum(basis)) + the count of outflows EXCLUDED because no price
760 /// was available for their date (those stay pending — a Sell with fabricated proceeds would be a
761 /// SILENT misreport). `--kind` is UNIFORM and accepts ONLY sell|spend (gift/donate are out of
762 /// scope). Each is a voidable decision; for a single outflow use `reclassify-outflow`.
763 BulkReclassifyOutflow {
764 /// Disposition kind for the whole batch: sell|spend (gift/donate rejected — out of scope).
765 #[arg(long)]
766 kind: String,
767 /// Restrict to a single tax year (mutually exclusive with --from/--to).
768 #[arg(long, conflicts_with_all = ["from", "to"])]
769 year: Option<i32>,
770 /// Range start (YYYY-MM-DD; requires --to).
771 #[arg(long, requires = "to")]
772 from: Option<String>,
773 /// Range end (YYYY-MM-DD, inclusive; requires --from).
774 #[arg(long, requires = "from")]
775 to: Option<String>,
776 /// Only outflows from this SOURCE wallet.
777 #[arg(long)]
778 wallet: Option<String>,
779 /// Print the preview and exit without writing.
780 #[arg(long)]
781 dry_run: bool,
782 /// Skip the interactive confirmation (non-interactive apply).
783 #[arg(long)]
784 yes: bool,
785 },
786 /// Bulk-resolve import conflicts: ACCEPT (adopt each new payload) or REJECT (keep each current
787 /// payload) MANY flagged `ImportConflict`s in one confirmed batch. Shows a `current → new` preview,
788 /// then requires --yes (or interactive y/N). Exactly one of --accept / --reject is required. Each
789 /// resolution is NON-REVOCABLE (`SupersedeImport`/`RejectImport` cannot be voided); to resolve a
790 /// conflict differently, exclude it and use single-item `accept-conflict`/`reject-conflict`.
791 #[command(group(clap::ArgGroup::new("resolve_action").required(true).args(["accept", "reject"])))]
792 BulkResolveConflict {
793 /// Accept every listed conflict (adopt each new payload onto its target).
794 #[arg(long)]
795 accept: bool,
796 /// Reject every listed conflict (keep each target's current payload).
797 #[arg(long)]
798 reject: bool,
799 /// Print the preview and exit without writing.
800 #[arg(long)]
801 dry_run: bool,
802 /// Skip the interactive confirmation (non-interactive apply).
803 #[arg(long)]
804 yes: bool,
805 },
806 /// Bulk-void MANY revocable reconcile decisions in one confirmed batch (bulk-void). Shows a preview
807 /// of every voidable decision (the SHARED `voidable_decisions` predicate — effective safe-harbor
808 /// allocations are OMITTED, #7), then requires --yes (or interactive y/N). Each void is
809 /// NON-REVOCABLE (a `VoidDecisionEvent` cannot itself be voided — re-apply the original decision to
810 /// restore). Voiding a `LotSelection` also re-exposes its disposal to the default method and clears
811 /// its optimizer attestation.
812 BulkVoid {
813 /// Print the preview and exit without writing.
814 #[arg(long)]
815 dry_run: bool,
816 /// Skip the interactive confirmation (non-interactive apply).
817 #[arg(long)]
818 yes: bool,
819 },
820 /// Match unreconciled inbound + outbound legs as self-transfers (self-transfer-passthrough C3).
821 /// With no --in/--out: PREVIEW the proposed pairs (read-only). With --in and --out: confirm ONE
822 /// pair (DROP for a same-wallet passthrough, RELOCATE for a cross-wallet transfer). NEVER automatic.
823 MatchSelfTransfers {
824 /// Confirm this in-leg (TransferIn eventref); requires --out.
825 #[arg(long = "in", requires = "out_ref")]
826 in_ref: Option<String>,
827 /// Confirm this out-leg (TransferOut eventref); requires --in.
828 #[arg(long = "out", requires = "in_ref")]
829 out_ref: Option<String>,
830 /// Override the suggested action (else the proposal's topology-derived action is used).
831 #[arg(long, value_enum)]
832 action: Option<SelfTransferActionArg>,
833 /// Print the preview and exit without writing (conflicts with --in/--out).
834 #[arg(long, conflicts_with_all = ["in_ref", "out_ref"])]
835 dry_run: bool,
836 },
837 /// Pseudo-reconcile MODE (sub-project 2): fill deliberately-fictional default decisions at
838 /// projection time (NEVER persisted) to clear the Hard classification blockers — a loudly-flagged
839 /// `[PSEUDO]` on-screen estimate you correct toward truth. `on`/`off` toggle the mode; `approve`
840 /// promotes chosen defaults to real (attested) decisions.
841 #[command(subcommand)]
842 Pseudo(Pseudo),
843}
844
845/// `reconcile pseudo <action>` — the pseudo-reconcile mode sub-verbs (sub-project 2).
846#[derive(Subcommand)]
847pub enum Pseudo {
848 /// Turn pseudo-reconcile mode ON. Projection now synthesizes non-persisted default decisions for
849 /// unresolved unknown-basis inbounds (self-transfer $0), unclassified rows, and import conflicts
850 /// (accept-first); every synthetic contribution is flagged `[PSEUDO]` on screen and BLOCKS export.
851 On,
852 /// Turn pseudo-reconcile mode OFF. Projection reverts to real-only instantly and totally (no
853 /// fictional events were ever written). Already-approved decisions REMAIN (they are real now).
854 Off,
855 /// Promote pseudo default decisions to REAL (attested) decisions in bulk. Shows a preview + requires
856 /// `--yes` (or `--dry-run` to preview only). Optional filters restrict which defaults are approved.
857 Approve {
858 /// Only approve defaults of this TYPE: `self-transfer` (unknown-basis inbound → $0 self-transfer),
859 /// `raw` (unclassified row placeholder), `conflict` (import conflict accept-first), or `fmv`
860 /// (native income FMV synthesized from the daily close). Omit = all.
861 #[arg(long, value_enum)]
862 kind: Option<PseudoKindArg>,
863 /// Only approve defaults whose target event is in this wallet (e.g. `exchange:coinbase:main`).
864 #[arg(long)]
865 wallet: Option<String>,
866 /// Only approve defaults whose target event falls in this tax year.
867 #[arg(long)]
868 year: Option<i32>,
869 /// Print the preview and exit without writing.
870 #[arg(long)]
871 dry_run: bool,
872 /// Skip the interactive confirmation (non-interactive apply).
873 #[arg(long)]
874 yes: bool,
875 },
876}
877
878/// The pseudo-default TYPE filter for `reconcile pseudo approve --kind`.
879#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
880pub enum PseudoKindArg {
881 /// Unknown-basis inbound defaulted to a $0-basis self-transfer-in.
882 SelfTransfer,
883 /// Unclassified row defaulted to a zero-value placeholder (ClassifyRaw).
884 Raw,
885 /// Import conflict defaulted to accept-first (SupersedeImport).
886 Conflict,
887 /// Native income with a missing FMV defaulted to the daily-close value (ManualFmv).
888 Fmv,
889}
890
891#[derive(Copy, Clone, ValueEnum)]
892pub enum SelfTransferActionArg {
893 /// Same-wallet passthrough → SelfTransferPassthrough (both legs skipped, non-taxable).
894 Drop,
895 /// Cross-wallet transfer → TransferLink (relocate the lots to the destination wallet).
896 Relocate,
897}
898
899/// One official form in the `export-irs-pdf` packet (the `--forms` opt-in filter).
900#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
901pub enum FormArg {
902 /// Form 8949 (per-disposition capital-gains rows).
903 F8949,
904 /// Schedule D (aggregated capital-gains totals).
905 ScheduleD,
906 /// Schedule SE (self-employment tax).
907 ScheduleSe,
908 /// Form 8283 (noncash charitable contributions).
909 Form8283,
910 /// Form 1040 (capital-gains cells + the digital-asset question).
911 Form1040,
912}
913
914#[derive(Copy, Clone, ValueEnum)]
915pub enum FilingStatusArg {
916 Single,
917 Mfj,
918 Mfs,
919 Hoh,
920 Qss,
921}
922
923impl From<FilingStatusArg> for FilingStatus {
924 fn from(a: FilingStatusArg) -> Self {
925 match a {
926 FilingStatusArg::Single => FilingStatus::Single,
927 FilingStatusArg::Mfj => FilingStatus::Mfj,
928 FilingStatusArg::Mfs => FilingStatus::Mfs,
929 FilingStatusArg::Hoh => FilingStatus::HoH,
930 FilingStatusArg::Qss => FilingStatus::Qss,
931 }
932 }
933}
934
935#[derive(Copy, Clone, ValueEnum)]
936pub enum FeeArg {
937 C,
938 B,
939}
940
941#[derive(Copy, Clone, ValueEnum)]
942pub enum MethodLotArg {
943 Fifo,
944 Lifo,
945 Hifo,
946}
947
948#[derive(Copy, Clone, ValueEnum)]
949pub enum OutKindArg {
950 Sell,
951 Spend,
952 Gift,
953 Donate,
954}
955
956impl From<MethodLotArg> for LotMethod {
957 fn from(a: MethodLotArg) -> Self {
958 match a {
959 MethodLotArg::Fifo => LotMethod::Fifo,
960 MethodLotArg::Lifo => LotMethod::Lifo,
961 MethodLotArg::Hifo => LotMethod::Hifo,
962 }
963 }
964}
965
966#[derive(Copy, Clone, ValueEnum)]
967pub enum MethodArg {
968 Actual,
969 ProRata,
970}
971
972#[cfg(test)]
973mod tests {
974 use super::*;
975 use clap::CommandFactory;
976
977 /// Render the LONG help (`--help`) of a subcommand identified by its path, recursing into
978 /// nested subcommands (e.g. `["reconcile", "import-selections"]`). Mirrors what a user sees at
979 /// `btctax <path...> --help`, which includes each argument's verbatim long-help.
980 fn long_help_of(path: &[&str]) -> String {
981 let mut cmd = Cli::command();
982 for name in path {
983 cmd = cmd
984 .find_subcommand(name)
985 .unwrap_or_else(|| panic!("subcommand {name:?} exists"))
986 .clone();
987 }
988 cmd.render_long_help().to_string()
989 }
990
991 // Requirement 3, `--help` half: each file/format-taking arg's long-help carries its FORMAT +
992 // a text EXAMPLE. Tokens are comma/brace-joined (no spaces) so help-wrapping can never break
993 // them (verified against the real binary output). This is the single source of truth that
994 // clap_mangen also renders into the per-subcommand man page (Task 2).
995
996 #[test]
997 fn help_documents_key_backup_format() {
998 let h = long_help_of(&["init"]);
999 assert!(
1000 h.contains("-----BEGIN PGP PRIVATE KEY BLOCK-----"),
1001 "init --key-backup help must document the ASCII-armored key format:\n{h}"
1002 );
1003 }
1004
1005 #[test]
1006 fn help_documents_backup_key_format() {
1007 let h = long_help_of(&["backup-key"]);
1008 assert!(
1009 h.contains("-----BEGIN PGP PRIVATE KEY BLOCK-----"),
1010 "backup-key --out help must document the ASCII-armored key format:\n{h}"
1011 );
1012 }
1013
1014 #[test]
1015 fn help_documents_export_snapshot_format() {
1016 let h = long_help_of(&["export-snapshot"]);
1017 // The exact removals.csv header read from the render.rs writer.
1018 assert!(
1019 h.contains("event,kind,removed_at,lot,sat,basis,fmv_at_transfer"),
1020 "export-snapshot --out help must document the projection CSV headers:\n{h}"
1021 );
1022 }
1023
1024 #[test]
1025 fn help_documents_import_selections_format() {
1026 let h = long_help_of(&["reconcile", "import-selections"]);
1027 assert!(
1028 h.contains("disposal_ref,origin_event_id,split_sequence,sat"),
1029 "import-selections help must document the required CSV header:\n{h}"
1030 );
1031 }
1032
1033 #[test]
1034 fn help_documents_classify_raw_format() {
1035 let h = long_help_of(&["reconcile", "classify-raw"]);
1036 // The exact externally-tagged serde shape (Usd = decimal string, sat = integer).
1037 assert!(
1038 h.contains(r#"{"Acquire":{"sat":2000000,"usd_cost":"1680.00","fee_usd":"5.00","basis_source":"ExchangeProvided"}}"#),
1039 "classify-raw --payload-json help must document the JSON payload shape:\n{h}"
1040 );
1041 }
1042
1043 #[test]
1044 fn help_documents_select_lots_format() {
1045 let h = long_help_of(&["reconcile", "select-lots"]);
1046 assert!(
1047 h.contains("import|coinbase|X#0:25000"),
1048 "select-lots --from help must document the <event>#<split>:<sat> pick format:\n{h}"
1049 );
1050 }
1051}