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}