1use 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
21pub enum Ask {
25 Declaration(&'static FormQuestion),
26 Skippable(&'static SkippableQuestion),
27}
28
29impl Ask {
30 pub fn declaration_id(&self) -> Option<QuestionId> {
32 match self {
33 Ask::Declaration(q) => Some(q.id),
34 Ask::Skippable(_) => None,
35 }
36 }
37 pub fn is_skippable(&self) -> bool {
40 matches!(self, Ask::Skippable(_))
41 }
42}
43
44pub 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 asks.extend(
62 SKIPPABLE_QUESTIONS
63 .iter()
64 .filter(|s| (s.live)(ri))
65 .map(Ask::Skippable),
66 );
67 asks
68}
69
70pub 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
81pub 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
93pub 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 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 writeln!(out, "Answering full-return questions for tax year {year}:")?;
121
122 for ask in live_questions(&ri) {
123 match ask {
124 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 None => writeln!(out, " please answer y or n")?,
150 }
151 }
152 }
153 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 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 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 #[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 #[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 #[test]
286 fn foreign_questions_are_asked_even_below_the_schedule_b_threshold() {
287 let ids = declaration_ids(&single()); 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 #[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), Ask::Skippable(_) => {} }
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 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 | QuestionId::AmtQualifiedDwelling => {
345 r.schedule_a = Some(ScheduleAInputs {
346 mortgage_interest_1098: dec!(9000),
347 ..Default::default()
348 });
349 }
350 QuestionId::AmtCarryoverSameAsRegular => {
351 r.capital_loss_carryforward_in = btctax_core::tax::types::Carryforward {
352 short: dec!(1000),
353 long: dec!(0),
354 };
355 }
356 QuestionId::AmtDepreciationSameAsRegular => {
357 r.schedule_c = Some(btctax_core::tax::return_inputs::ScheduleCInputs {
358 expenses: dec!(5000),
359 ..Default::default()
360 });
361 }
362 _ => {}
363 }
364 r
365 }
366
367 #[test]
374 fn income_answer_asks_every_live_declaration() {
375 for q in FORM_QUESTIONS {
376 let ri = scenario_for(q.id);
377 assert!(
378 (q.live)(&ri),
379 "{:?} must be live in its own scenario (test bug otherwise)",
380 q.id
381 );
382 assert!(
383 live_questions(&ri)
384 .iter()
385 .any(|a| a.declaration_id() == Some(q.id)),
386 "income answer must ask {:?} when it is live — else the screen can refuse for a question \
387 the user can never answer (the near-brick D-8's recovery exists to prevent)",
388 q.id
389 );
390 }
391 }
392
393 #[test]
397 fn income_answer_asks_the_class_b_skippables_when_live() {
398 use btctax_core::tax::return_inputs::ScheduleAInputs;
399 fn reg(id: SkippableId) -> &'static SkippableQuestion {
401 SKIPPABLE_QUESTIONS
402 .iter()
403 .find(|s| s.id == id)
404 .expect("id is a registry entry")
405 }
406 fn has(ri: &ReturnInputs, want: SkippableId) -> bool {
407 live_questions(ri)
408 .iter()
409 .any(|a| matches!(a, Ask::Skippable(s) if s.id == want))
410 }
411 assert!(has(&single(), SkippableId::BlindTaxpayer));
413 assert!(
414 !has(&single(), SkippableId::BlindSpouse),
415 "no spouse ⇒ no spouse-blind"
416 );
417 assert!(
418 !has(&single(), SkippableId::SalesTaxElection),
419 "no Sch A ⇒ no SALT"
420 );
421
422 assert!(has(&with_spouse(single()), SkippableId::BlindSpouse));
423
424 let mut with_a = single();
425 with_a.schedule_a = Some(ScheduleAInputs::default());
426 assert!(has(&with_a, SkippableId::SalesTaxElection));
427
428 let mut ri = with_spouse(single());
430 assert_eq!((reg(SkippableId::BlindTaxpayer).get_bool)(&ri), None);
431 (reg(SkippableId::BlindTaxpayer).set_bool)(&mut ri, true);
432 assert_eq!(ri.header.taxpayer.blind, Some(true));
433 (reg(SkippableId::BlindSpouse).set_bool)(&mut ri, false);
434 assert_eq!(ri.header.spouse.as_ref().unwrap().blind, Some(false));
435 assert!(Ask::Skippable(reg(SkippableId::BlindTaxpayer)).is_skippable());
436 }
437
438 #[test]
441 fn only_the_skippables_are_skippable() {
442 for ask in live_questions(&with_spouse(single())) {
443 assert_eq!(
444 ask.is_skippable(),
445 ask.declaration_id().is_none(),
446 "a declaration must not be skippable; a skippable must not be a declaration"
447 );
448 if let Ask::Skippable(s) = ask {
451 assert!(
452 SKIPPABLE_QUESTIONS.iter().any(|r| r.id == s.id),
453 "a skippable Ask must come from SKIPPABLE_QUESTIONS, got {:?}",
454 s.id
455 );
456 }
457 }
458 }
459
460 #[test]
464 fn a_bare_enter_never_invents_an_answer() {
465 assert_eq!(parse_yes_no("", None), None, "silence is not an answer");
466 assert_eq!(parse_yes_no("", Some(false)), Some(false));
467 assert_eq!(parse_yes_no("", Some(true)), Some(true));
468 assert_eq!(parse_yes_no("y", None), Some(true));
469 assert_eq!(parse_yes_no("N", None), Some(false));
470 assert_eq!(parse_yes_no("Yes", None), Some(true));
471 assert_eq!(
472 parse_yes_no("maybe", None),
473 None,
474 "garbage is not an answer"
475 );
476 assert_eq!(parse_yes_no("maybe", Some(true)), None);
478 }
479
480 #[test]
481 fn a_dob_can_be_skipped_or_given() {
482 assert_eq!(parse_date(" "), Ok(None));
483 assert_eq!(
484 parse_date("1960-01-02"),
485 Ok(Some(time::macros::date!(1960 - 01 - 02)))
486 );
487 assert!(parse_date("Jan 2 1960").is_err());
488 }
489}