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).map(Some).map_err(|e| e.to_string())
89}
90
91pub 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 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 writeln!(out, "Answering full-return questions for tax year {year}:")?;
119
120 for ask in live_questions(&ri) {
121 match ask {
122 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 None => writeln!(out, " please answer y or n")?,
147 }
148 }
149 }
150 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 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 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 #[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 #[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 #[test]
280 fn foreign_questions_are_asked_even_below_the_schedule_b_threshold() {
281 let ids = declaration_ids(&single()); 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 #[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), Ask::Skippable(_) => {} }
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 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 #[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 #[test]
379 fn income_answer_asks_the_class_b_skippables_when_live() {
380 use btctax_core::tax::return_inputs::ScheduleAInputs;
381 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 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 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 #[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 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 #[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 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}