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