Skip to main content

btctax_cli/cmd/
answer.rs

1//! `income answer` (D-8) — the ONLY in-app path to the fail-loud tri-states and the dates of birth.
2//!
3//! **Why it must exist.** The D-8 migration's recovery story was "just re-import one TOML line" — which
4//! assumes the user still HAS the TOML. The spec tells them to delete it (plaintext hygiene), `income
5//! show` emits masked JSON and so cannot regenerate it, and `set-pii` prompts for secrets only. Without
6//! `answer`, a TOML-less user faces a permanently-refusing year and no way to answer a single boolean: a
7//! wall, landing hardest on the people who did exactly what the spec told them to.
8//!
9//! **What it deliberately does NOT own: secrets.** SSNs and the IP PIN belong to `set-pii`, which is
10//! no-echo. `answer` is an ordinary echoing prompt — routing a secret through it would print a crown jewel
11//! into terminal scrollback.
12use crate::{return_inputs, CliError, Session};
13use btctax_core::tax::questions::{
14    FormQuestion, QuestionId, SkippableKind, SkippableQuestion, FORM_QUESTIONS, SKIPPABLE_QUESTIONS,
15};
16use btctax_core::tax::return_inputs::ReturnInputs;
17use btctax_store::Passphrase;
18use std::io::Write;
19use std::path::Path;
20
21/// One thing `income answer` asks: a MANDATORY declaration (from the [`FORM_QUESTIONS`] registry — a bare
22/// Enter with nothing on file is refused, never accepted as an answer) or a SKIPPABLE prompt (from the
23/// core [`SKIPPABLE_QUESTIONS`] registry — a bare Enter leaves `None`, forgoing the benefit lawfully).
24pub enum Ask {
25    Declaration(&'static FormQuestion),
26    Skippable(&'static SkippableQuestion),
27}
28
29impl Ask {
30    /// The registry `QuestionId` if this is a declaration — for tests that assert WHICH questions are live.
31    pub fn declaration_id(&self) -> Option<QuestionId> {
32        match self {
33            Ask::Declaration(q) => Some(q.id),
34            Ask::Skippable(_) => None,
35        }
36    }
37    /// Whether a bare Enter with nothing on file is a legitimate outcome. True for skippables (DOBs), false
38    /// for declarations — silence on a declaration is exactly what D-8 forbids.
39    pub fn is_skippable(&self) -> bool {
40        matches!(self, Ask::Skippable(_))
41    }
42}
43
44/// ★ EXACTLY the questions this return needs — the MANDATORY declarations DERIVED from the registry (so the
45/// prompt scope IS the refusal scope, by identity: the no-brick property is now true by construction, not by
46/// a second hand-written list that can drift, r1 M-1), then the skippable DOBs.
47///
48/// **Every live declaration is asked in ONE pass** — including ones already answered, whose current value
49/// is offered as the default. A blob the screen refuses cannot be stored, so answering only *some*
50/// declarations would leave the return refused and unstorable; asking everything at once prevents that
51/// deadlock. The spouse DOB prompt is gated on `header.spouse.is_some()` (r3 I-7).
52pub fn live_questions(ri: &ReturnInputs) -> Vec<Ask> {
53    let mut asks: Vec<Ask> = FORM_QUESTIONS
54        .iter()
55        .filter(|q| (q.live)(ri))
56        .map(Ask::Declaration)
57        .collect();
58    // ★ P9 §2.2 class-(B) skippables — DERIVED from the core [`SKIPPABLE_QUESTIONS`] registry (the DOBs, the
59    // blindness pair, and the §164(b)(5) sales-tax election), each gated by its own `live` predicate so the
60    // prompt scope tracks the WRITE scope (a `set` on an absent spouse / Schedule A is silently discarded).
61    asks.extend(
62        SKIPPABLE_QUESTIONS
63            .iter()
64            .filter(|s| (s.live)(ri))
65            .map(Ask::Skippable),
66    );
67    asks
68}
69
70/// Parse one yes/no reply. `""` (a bare Enter) means "keep `default`", and is only an ANSWER when there
71/// already is one — otherwise the caller must re-ask, because silence is exactly what D-8 forbids.
72pub fn parse_yes_no(line: &str, default: Option<bool>) -> Option<bool> {
73    match line.trim().to_ascii_lowercase().as_str() {
74        "y" | "yes" => Some(true),
75        "n" | "no" => Some(false),
76        "" => default,
77        _ => None,
78    }
79}
80
81/// Parse one date reply. `Ok(None)` = the user SKIPPED (a bare Enter) — a legitimate outcome for a DOB.
82pub fn parse_date(line: &str) -> Result<Option<time::Date>, String> {
83    let t = line.trim();
84    if t.is_empty() {
85        return Ok(None);
86    }
87    let fmt = time::macros::format_description!("[year]-[month]-[day]");
88    time::Date::parse(t, fmt)
89        .map(Some)
90        .map_err(|e| e.to_string())
91}
92
93/// `income answer --year N` — ask every live question, then store.
94///
95/// **Refuses on a year with no row**: only `income import` creates one. Answering questions about a return
96/// that does not exist would materialize a near-empty blob, which then takes PRECEDENCE over the user's
97/// `tax-profile` (the resolver ranks `ReturnInputs` first) — silently replacing a working profile with an
98/// empty return. A missing row is a mistake to report, not a shape to invent.
99pub fn answer_return_inputs(
100    vault: &Path,
101    pp: &Passphrase,
102    year: i32,
103    input: &mut impl std::io::BufRead,
104    out: &mut impl Write,
105) -> Result<(), CliError> {
106    let mut s = Session::open(vault, pp)?;
107    // ★ §6.2 (M-1): reconcile the draft BEFORE the committed-row read below — that read early-returns a
108    // generic "no inputs" message on an absent row, and a PARKED year has no committed row, so running
109    // coherence first is what surfaces the parked-refuse remedy instead of the generic message.
110    crate::input_form_store::coherence_clear_or_refuse(s.conn(), year)?;
111    let Some(mut ri) = return_inputs::get(s.conn(), year)? else {
112        return Err(CliError::Usage(format!(
113            "no full-return inputs for tax year {year} — `income answer` fills in the questions on an \
114             EXISTING return; create one first with `btctax income import --year {year} --file <toml>`"
115        )));
116    };
117
118    // ★ r3 NIT-2 — the questions say "in this tax year" but the registry prompts are `&'static str` and
119    // cannot interpolate the year; a one-line banner anchors them so the filer need not hold it in their head.
120    writeln!(out, "Answering full-return questions for tax year {year}:")?;
121
122    for ask in live_questions(&ri) {
123        match ask {
124            // A MANDATORY declaration — silence with nothing on file is refused, never accepted (D-8).
125            Ask::Declaration(q) => {
126                let cur = (q.get)(&ri);
127                loop {
128                    let shown = match cur {
129                        Some(true) => "y/n, currently y",
130                        Some(false) => "y/n, currently n",
131                        None => "y/n",
132                    };
133                    write!(out, "{} [{}]: ", q.prompt, shown)?;
134                    out.flush()?;
135                    let mut line = String::new();
136                    if input.read_line(&mut line)? == 0 {
137                        return Err(CliError::Usage(
138                            "input ended before every question was answered — nothing was stored"
139                                .into(),
140                        ));
141                    }
142                    match parse_yes_no(&line, cur) {
143                        Some(v) => {
144                            (q.set)(&mut ri, v);
145                            break;
146                        }
147                        // ★ No default and no answer ⇒ ASK AGAIN. Accepting silence here would reintroduce
148                        // D-8 through the front door.
149                        None => writeln!(out, "  please answer y or n")?,
150                    }
151                }
152            }
153            // A SKIPPABLE prompt — a bare Enter KEEPS whatever is on file (which may be `None`, forgoing the
154            // benefit; the matching advisory then tells the filer). Two value shapes, branched by `kind()`.
155            Ask::Skippable(sk) => match sk.kind {
156                SkippableKind::Date => {
157                    let cur = (sk.get_date)(&ri);
158                    loop {
159                        let shown = cur.map_or_else(|| "none".to_string(), |d| d.to_string());
160                        write!(out, "{} [{}; Enter to skip]: ", sk.prompt, shown)?;
161                        out.flush()?;
162                        let mut line = String::new();
163                        if input.read_line(&mut line)? == 0 {
164                            return Err(CliError::Usage(
165                                "input ended before every question was answered — nothing was stored".into(),
166                            ));
167                        }
168                        match parse_date(&line) {
169                            Ok(None) => break,
170                            Ok(Some(d)) => {
171                                (sk.set_date)(&mut ri, d);
172                                break;
173                            }
174                            Err(e) => writeln!(out, "  not a date (YYYY-MM-DD): {e}")?,
175                        }
176                    }
177                }
178                SkippableKind::YesNo => {
179                    let cur = (sk.get_bool)(&ri);
180                    loop {
181                        let shown = match cur {
182                            Some(true) => "y/n, currently y",
183                            Some(false) => "y/n, currently n",
184                            None => "y/n",
185                        };
186                        write!(out, "{} [{}; Enter to skip]: ", sk.prompt, shown)?;
187                        out.flush()?;
188                        let mut line = String::new();
189                        if input.read_line(&mut line)? == 0 {
190                            return Err(CliError::Usage(
191                                "input ended before every question was answered — nothing was stored".into(),
192                            ));
193                        }
194                        // ★ A bare Enter KEEPS whatever is on file (may be `None` ⇒ skip); only y/n sets a
195                        // value; garbage re-asks. Silence is a legitimate outcome here — unlike a declaration.
196                        if line.trim().is_empty() {
197                            break;
198                        }
199                        match parse_yes_no(line.trim(), None) {
200                            Some(v) => {
201                                (sk.set_bool)(&mut ri, v);
202                                break;
203                            }
204                            None => writeln!(out, "  please answer y or n, or Enter to skip")?,
205                        }
206                    }
207                }
208            },
209        }
210    }
211
212    return_inputs::set(s.conn(), year, &ri)?;
213    s.save()?;
214    Ok(())
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use btctax_core::tax::questions::SkippableId;
221    use btctax_core::tax::return_inputs::{Form1099Int, Person};
222    use btctax_core::FilingStatus;
223    use rust_decimal_macros::dec;
224
225    fn single() -> ReturnInputs {
226        ReturnInputs {
227            filing_status: FilingStatus::Single,
228            ..Default::default()
229        }
230    }
231    fn with_spouse(mut ri: ReturnInputs) -> ReturnInputs {
232        ri.header.spouse = Some(Person {
233            first_name: "Pat".into(),
234            last_name: "Doe".into(),
235            ssn: "987654321".into(),
236            ..Default::default()
237        });
238        ri
239    }
240
241    /// The registry `QuestionId`s asked for a return, in order.
242    fn declaration_ids(ri: &ReturnInputs) -> Vec<QuestionId> {
243        live_questions(ri)
244            .iter()
245            .filter_map(Ask::declaration_id)
246            .collect()
247    }
248    fn has_spouse_dob(ri: &ReturnInputs) -> bool {
249        live_questions(ri)
250            .iter()
251            .any(|a| matches!(a, Ask::Skippable(s) if s.id == SkippableId::DobSpouse))
252    }
253
254    /// A Single filer is asked the FIVE always-live declarations (dependent-taxpayer, both foreign
255    /// questions, HSA activity, dual-status) and — skippably — a DOB. Nothing about a spouse who does not
256    /// exist, and (post-§2.9) the foreign questions appear even with no Schedule B.
257    #[test]
258    fn a_single_filer_is_asked_the_always_live_declarations_and_no_spouse_question() {
259        assert_eq!(
260            declaration_ids(&single()),
261            vec![
262                QuestionId::DependentTaxpayer,
263                QuestionId::ForeignAccounts,
264                QuestionId::ForeignTrust,
265                QuestionId::HsaActivity,
266                QuestionId::DualStatusAlien,
267            ]
268        );
269        assert!(!has_spouse_dob(&single()), "no spouse ⇒ no spouse DOB");
270    }
271
272    /// ★ The prompt scope must track the REFUSAL scope. A spouse question asked of a spouse-less return is
273    /// the prompt-level twin of the refusal-level bug D-8 fixed.
274    #[test]
275    fn spouse_questions_appear_exactly_when_a_spouse_does() {
276        assert!(declaration_ids(&with_spouse(single())).contains(&QuestionId::DependentSpouse));
277        assert!(has_spouse_dob(&with_spouse(single())));
278        assert!(!declaration_ids(&single()).contains(&QuestionId::DependentSpouse));
279        assert!(!has_spouse_dob(&single()));
280    }
281
282    /// ★ §2.9 — the foreign-account/-trust questions are asked on EVERY return, INCLUDING below the
283    /// Schedule B threshold. Scoping them by `schedule_b_files` was the circular-liveness bug: that
284    /// predicate reads `foreign_accounts` itself, so a never-asked account silently omitted Schedule B.
285    #[test]
286    fn foreign_questions_are_asked_even_below_the_schedule_b_threshold() {
287        let ids = declaration_ids(&single()); // no interest at all — well below $1,500
288        assert!(ids.contains(&QuestionId::ForeignAccounts));
289        assert!(ids.contains(&QuestionId::ForeignTrust));
290    }
291
292    #[test]
293    fn mfs_is_asked_whether_the_spouse_itemizes() {
294        let mut mfs = single();
295        mfs.filing_status = FilingStatus::Mfs;
296        assert!(declaration_ids(&mfs).contains(&QuestionId::MfsSpouseItemizes));
297        assert!(!declaration_ids(&single()).contains(&QuestionId::MfsSpouseItemizes));
298    }
299
300    /// ★ Every question the SCREEN can refuse for must be ASKABLE — otherwise `answer` cannot clear the
301    /// refusal it exists to clear, and the year stays bricked. This is the property that ties the two
302    /// scopes together; it is the whole point of the command.
303    #[test]
304    fn every_live_question_can_actually_be_answered_and_clears_the_screen() {
305        let mut ri = with_spouse(single());
306        ri.filing_status = FilingStatus::Mfj;
307        ri.int_1099.push(Form1099Int {
308            box1_interest: dec!(2000),
309            ..Default::default()
310        });
311        use btctax_adapters::{BundledFullReturnTables, BundledTaxTables};
312        use btctax_core::tax::return_refuse::screen_inputs;
313        use btctax_core::tax::tables::FullReturnTables;
314        use btctax_core::TaxTables;
315        let fr = BundledFullReturnTables::load();
316        let tt = BundledTaxTables::load();
317        let params = fr.full_return_for(2024).expect("TY2024 params are bundled");
318        let table = tt.table_for(2024).expect("TY2024 table is bundled");
319
320        assert!(
321            screen_inputs(&ri, table, params).is_some(),
322            "an all-unanswered return must refuse — else this test proves nothing"
323        );
324        for ask in live_questions(&ri) {
325            match ask {
326                Ask::Declaration(q) => (q.set)(&mut ri, false), // answer "no"
327                Ask::Skippable(_) => {}                         // skippable by design
328            }
329        }
330        assert!(
331            screen_inputs(&ri, table, params).is_none(),
332            "answering every LIVE declaration must clear the screen — if it does not, `answer` cannot \
333             rescue a bricked year and the whole command is a dead end"
334        );
335    }
336
337    /// A return set up so `id` is LIVE (nothing answered yet).
338    fn scenario_for(id: QuestionId) -> ReturnInputs {
339        use btctax_core::tax::return_inputs::ScheduleAInputs;
340        let mut r = single();
341        match id {
342            QuestionId::DependentSpouse => r.filing_status = FilingStatus::Mfj,
343            QuestionId::MfsSpouseItemizes => r.filing_status = FilingStatus::Mfs,
344            QuestionId::MortgageAllUsedToBuyBuildImprove => {
345                r.schedule_a = Some(ScheduleAInputs {
346                    mortgage_interest_1098: dec!(9000),
347                    ..Default::default()
348                });
349            }
350            _ => {}
351        }
352        r
353    }
354
355    /// ★ THE no-brick property, registry-DERIVED (§3.5 assertion 3 / r4 I-3 / IMPL r1 I-1). For EVERY
356    /// registry entry: on a return where it is live, `income answer` must ASK it. Held by identity today,
357    /// but the spec mandated this assertion by name after it went red three revisions running — and a
358    /// hand-written per-question test silently omitted the mortgage question (its liveness is the one
359    /// non-trivial predicate). Deriving it means a dropped or mis-filtered entry — for ANY question, incl.
360    /// the ones steps 6–12 keep adding to this file — fails a named test.
361    #[test]
362    fn income_answer_asks_every_live_declaration() {
363        for q in FORM_QUESTIONS {
364            let ri = scenario_for(q.id);
365            assert!(
366                (q.live)(&ri),
367                "{:?} must be live in its own scenario (test bug otherwise)",
368                q.id
369            );
370            assert!(
371                live_questions(&ri)
372                    .iter()
373                    .any(|a| a.declaration_id() == Some(q.id)),
374                "income answer must ask {:?} when it is live — else the screen can refuse for a question \
375                 the user can never answer (the near-brick D-8's recovery exists to prevent)",
376                q.id
377            );
378        }
379    }
380
381    /// ★ P9 §2.2 step 7 — the class-(B) SKIPPABLE bool prompts: blindness (taxpayer always; spouse only
382    /// with a spouse `Person`) and the §164(b)(5) sales-tax election (only with a Schedule A). Skippable ⇒
383    /// a bare Enter leaves `None`, and the forgone-benefit advisory fires (the owner mandate).
384    #[test]
385    fn income_answer_asks_the_class_b_skippables_when_live() {
386        use btctax_core::tax::return_inputs::ScheduleAInputs;
387        // The core registry entry for `id` — the source of truth `income answer` now derives from.
388        fn reg(id: SkippableId) -> &'static SkippableQuestion {
389            SKIPPABLE_QUESTIONS
390                .iter()
391                .find(|s| s.id == id)
392                .expect("id is a registry entry")
393        }
394        fn has(ri: &ReturnInputs, want: SkippableId) -> bool {
395            live_questions(ri)
396                .iter()
397                .any(|a| matches!(a, Ask::Skippable(s) if s.id == want))
398        }
399        // Taxpayer blindness is always live; spouse-blind and SALT only when their gate is met.
400        assert!(has(&single(), SkippableId::BlindTaxpayer));
401        assert!(
402            !has(&single(), SkippableId::BlindSpouse),
403            "no spouse ⇒ no spouse-blind"
404        );
405        assert!(
406            !has(&single(), SkippableId::SalesTaxElection),
407            "no Sch A ⇒ no SALT"
408        );
409
410        assert!(has(&with_spouse(single()), SkippableId::BlindSpouse));
411
412        let mut with_a = single();
413        with_a.schedule_a = Some(ScheduleAInputs::default());
414        assert!(has(&with_a, SkippableId::SalesTaxElection));
415
416        // A bool skippable roundtrips through the CORE registry accessors and is genuinely skippable.
417        let mut ri = with_spouse(single());
418        assert_eq!((reg(SkippableId::BlindTaxpayer).get_bool)(&ri), None);
419        (reg(SkippableId::BlindTaxpayer).set_bool)(&mut ri, true);
420        assert_eq!(ri.header.taxpayer.blind, Some(true));
421        (reg(SkippableId::BlindSpouse).set_bool)(&mut ri, false);
422        assert_eq!(ri.header.spouse.as_ref().unwrap().blind, Some(false));
423        assert!(Ask::Skippable(reg(SkippableId::BlindTaxpayer)).is_skippable());
424    }
425
426    /// The mandatory declarations are not skippable; the DOBs are. (Anchored to the enum shape, not a value:
427    /// every `Skippable` is skippable, every `Declaration` is not.)
428    #[test]
429    fn only_the_skippables_are_skippable() {
430        for ask in live_questions(&with_spouse(single())) {
431            assert_eq!(
432                ask.is_skippable(),
433                ask.declaration_id().is_none(),
434                "a declaration must not be skippable; a skippable must not be a declaration"
435            );
436            // ★ Every skippable `Ask` is a genuine entry of the CORE registry (the source of truth
437            // post-move) — the prompt scope IS the `SKIPPABLE_QUESTIONS` scope, by derivation.
438            if let Ask::Skippable(s) = ask {
439                assert!(
440                    SKIPPABLE_QUESTIONS.iter().any(|r| r.id == s.id),
441                    "a skippable Ask must come from SKIPPABLE_QUESTIONS, got {:?}",
442                    s.id
443                );
444            }
445        }
446    }
447
448    /// ★ A bare Enter is an ANSWER only when there is already an answer to keep. With nothing on file it
449    /// must NOT resolve — accepting silence is exactly the defect D-8 removed, walking back in through the
450    /// prompt.
451    #[test]
452    fn a_bare_enter_never_invents_an_answer() {
453        assert_eq!(parse_yes_no("", None), None, "silence is not an answer");
454        assert_eq!(parse_yes_no("", Some(false)), Some(false));
455        assert_eq!(parse_yes_no("", Some(true)), Some(true));
456        assert_eq!(parse_yes_no("y", None), Some(true));
457        assert_eq!(parse_yes_no("N", None), Some(false));
458        assert_eq!(parse_yes_no("Yes", None), Some(true));
459        assert_eq!(
460            parse_yes_no("maybe", None),
461            None,
462            "garbage is not an answer"
463        );
464        // ...and garbage must not silently take the stored default either.
465        assert_eq!(parse_yes_no("maybe", Some(true)), None);
466    }
467
468    #[test]
469    fn a_dob_can_be_skipped_or_given() {
470        assert_eq!(parse_date("  "), Ok(None));
471        assert_eq!(
472            parse_date("1960-01-02"),
473            Ok(Some(time::macros::date!(1960 - 01 - 02)))
474        );
475        assert!(parse_date("Jan 2 1960").is_err());
476    }
477}