use crate::{return_inputs, CliError, Session};
use btctax_core::tax::questions::{
FormQuestion, QuestionId, SkippableKind, SkippableQuestion, FORM_QUESTIONS, SKIPPABLE_QUESTIONS,
};
use btctax_core::tax::return_inputs::ReturnInputs;
use btctax_store::Passphrase;
use std::io::Write;
use std::path::Path;
pub enum Ask {
Declaration(&'static FormQuestion),
Skippable(&'static SkippableQuestion),
}
impl Ask {
pub fn declaration_id(&self) -> Option<QuestionId> {
match self {
Ask::Declaration(q) => Some(q.id),
Ask::Skippable(_) => None,
}
}
pub fn is_skippable(&self) -> bool {
matches!(self, Ask::Skippable(_))
}
}
pub fn live_questions(ri: &ReturnInputs) -> Vec<Ask> {
let mut asks: Vec<Ask> = FORM_QUESTIONS
.iter()
.filter(|q| (q.live)(ri))
.map(Ask::Declaration)
.collect();
asks.extend(
SKIPPABLE_QUESTIONS
.iter()
.filter(|s| (s.live)(ri))
.map(Ask::Skippable),
);
asks
}
pub fn parse_yes_no(line: &str, default: Option<bool>) -> Option<bool> {
match line.trim().to_ascii_lowercase().as_str() {
"y" | "yes" => Some(true),
"n" | "no" => Some(false),
"" => default,
_ => None,
}
}
pub fn parse_date(line: &str) -> Result<Option<time::Date>, String> {
let t = line.trim();
if t.is_empty() {
return Ok(None);
}
let fmt = time::macros::format_description!("[year]-[month]-[day]");
time::Date::parse(t, fmt)
.map(Some)
.map_err(|e| e.to_string())
}
pub fn answer_return_inputs(
vault: &Path,
pp: &Passphrase,
year: i32,
input: &mut impl std::io::BufRead,
out: &mut impl Write,
) -> Result<(), CliError> {
let mut s = Session::open(vault, pp)?;
crate::input_form_store::coherence_clear_or_refuse(s.conn(), year)?;
let Some(mut ri) = return_inputs::get(s.conn(), year)? else {
return Err(CliError::Usage(format!(
"no full-return inputs for tax year {year} — `income answer` fills in the questions on an \
EXISTING return; create one first with `btctax income import --year {year} --file <toml>`"
)));
};
writeln!(out, "Answering full-return questions for tax year {year}:")?;
for ask in live_questions(&ri) {
match ask {
Ask::Declaration(q) => {
let cur = (q.get)(&ri);
loop {
let shown = match cur {
Some(true) => "y/n, currently y",
Some(false) => "y/n, currently n",
None => "y/n",
};
write!(out, "{} [{}]: ", q.prompt, shown)?;
out.flush()?;
let mut line = String::new();
if input.read_line(&mut line)? == 0 {
return Err(CliError::Usage(
"input ended before every question was answered — nothing was stored"
.into(),
));
}
match parse_yes_no(&line, cur) {
Some(v) => {
(q.set)(&mut ri, v);
break;
}
None => writeln!(out, " please answer y or n")?,
}
}
}
Ask::Skippable(sk) => match sk.kind {
SkippableKind::Date => {
let cur = (sk.get_date)(&ri);
loop {
let shown = cur.map_or_else(|| "none".to_string(), |d| d.to_string());
write!(out, "{} [{}; Enter to skip]: ", sk.prompt, shown)?;
out.flush()?;
let mut line = String::new();
if input.read_line(&mut line)? == 0 {
return Err(CliError::Usage(
"input ended before every question was answered — nothing was stored".into(),
));
}
match parse_date(&line) {
Ok(None) => break,
Ok(Some(d)) => {
(sk.set_date)(&mut ri, d);
break;
}
Err(e) => writeln!(out, " not a date (YYYY-MM-DD): {e}")?,
}
}
}
SkippableKind::YesNo => {
let cur = (sk.get_bool)(&ri);
loop {
let shown = match cur {
Some(true) => "y/n, currently y",
Some(false) => "y/n, currently n",
None => "y/n",
};
write!(out, "{} [{}; Enter to skip]: ", sk.prompt, shown)?;
out.flush()?;
let mut line = String::new();
if input.read_line(&mut line)? == 0 {
return Err(CliError::Usage(
"input ended before every question was answered — nothing was stored".into(),
));
}
if line.trim().is_empty() {
break;
}
match parse_yes_no(line.trim(), None) {
Some(v) => {
(sk.set_bool)(&mut ri, v);
break;
}
None => writeln!(out, " please answer y or n, or Enter to skip")?,
}
}
}
},
}
}
return_inputs::set(s.conn(), year, &ri)?;
s.save()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use btctax_core::tax::questions::SkippableId;
use btctax_core::tax::return_inputs::{Form1099Int, Person};
use btctax_core::FilingStatus;
use rust_decimal_macros::dec;
fn single() -> ReturnInputs {
ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
}
}
fn with_spouse(mut ri: ReturnInputs) -> ReturnInputs {
ri.header.spouse = Some(Person {
first_name: "Pat".into(),
last_name: "Doe".into(),
ssn: "987654321".into(),
..Default::default()
});
ri
}
fn declaration_ids(ri: &ReturnInputs) -> Vec<QuestionId> {
live_questions(ri)
.iter()
.filter_map(Ask::declaration_id)
.collect()
}
fn has_spouse_dob(ri: &ReturnInputs) -> bool {
live_questions(ri)
.iter()
.any(|a| matches!(a, Ask::Skippable(s) if s.id == SkippableId::DobSpouse))
}
#[test]
fn a_single_filer_is_asked_the_always_live_declarations_and_no_spouse_question() {
assert_eq!(
declaration_ids(&single()),
vec![
QuestionId::DependentTaxpayer,
QuestionId::ForeignAccounts,
QuestionId::ForeignTrust,
QuestionId::HsaActivity,
QuestionId::DualStatusAlien,
]
);
assert!(!has_spouse_dob(&single()), "no spouse ⇒ no spouse DOB");
}
#[test]
fn spouse_questions_appear_exactly_when_a_spouse_does() {
assert!(declaration_ids(&with_spouse(single())).contains(&QuestionId::DependentSpouse));
assert!(has_spouse_dob(&with_spouse(single())));
assert!(!declaration_ids(&single()).contains(&QuestionId::DependentSpouse));
assert!(!has_spouse_dob(&single()));
}
#[test]
fn foreign_questions_are_asked_even_below_the_schedule_b_threshold() {
let ids = declaration_ids(&single()); assert!(ids.contains(&QuestionId::ForeignAccounts));
assert!(ids.contains(&QuestionId::ForeignTrust));
}
#[test]
fn mfs_is_asked_whether_the_spouse_itemizes() {
let mut mfs = single();
mfs.filing_status = FilingStatus::Mfs;
assert!(declaration_ids(&mfs).contains(&QuestionId::MfsSpouseItemizes));
assert!(!declaration_ids(&single()).contains(&QuestionId::MfsSpouseItemizes));
}
#[test]
fn every_live_question_can_actually_be_answered_and_clears_the_screen() {
let mut ri = with_spouse(single());
ri.filing_status = FilingStatus::Mfj;
ri.int_1099.push(Form1099Int {
box1_interest: dec!(2000),
..Default::default()
});
use btctax_adapters::{BundledFullReturnTables, BundledTaxTables};
use btctax_core::tax::return_refuse::screen_inputs;
use btctax_core::tax::tables::FullReturnTables;
use btctax_core::TaxTables;
let fr = BundledFullReturnTables::load();
let tt = BundledTaxTables::load();
let params = fr.full_return_for(2024).expect("TY2024 params are bundled");
let table = tt.table_for(2024).expect("TY2024 table is bundled");
assert!(
screen_inputs(&ri, table, params).is_some(),
"an all-unanswered return must refuse — else this test proves nothing"
);
for ask in live_questions(&ri) {
match ask {
Ask::Declaration(q) => (q.set)(&mut ri, false), Ask::Skippable(_) => {} }
}
assert!(
screen_inputs(&ri, table, params).is_none(),
"answering every LIVE declaration must clear the screen — if it does not, `answer` cannot \
rescue a bricked year and the whole command is a dead end"
);
}
fn scenario_for(id: QuestionId) -> ReturnInputs {
use btctax_core::tax::return_inputs::ScheduleAInputs;
let mut r = single();
match id {
QuestionId::DependentSpouse => r.filing_status = FilingStatus::Mfj,
QuestionId::MfsSpouseItemizes => r.filing_status = FilingStatus::Mfs,
QuestionId::MortgageAllUsedToBuyBuildImprove => {
r.schedule_a = Some(ScheduleAInputs {
mortgage_interest_1098: dec!(9000),
..Default::default()
});
}
_ => {}
}
r
}
#[test]
fn income_answer_asks_every_live_declaration() {
for q in FORM_QUESTIONS {
let ri = scenario_for(q.id);
assert!(
(q.live)(&ri),
"{:?} must be live in its own scenario (test bug otherwise)",
q.id
);
assert!(
live_questions(&ri)
.iter()
.any(|a| a.declaration_id() == Some(q.id)),
"income answer must ask {:?} when it is live — else the screen can refuse for a question \
the user can never answer (the near-brick D-8's recovery exists to prevent)",
q.id
);
}
}
#[test]
fn income_answer_asks_the_class_b_skippables_when_live() {
use btctax_core::tax::return_inputs::ScheduleAInputs;
fn reg(id: SkippableId) -> &'static SkippableQuestion {
SKIPPABLE_QUESTIONS
.iter()
.find(|s| s.id == id)
.expect("id is a registry entry")
}
fn has(ri: &ReturnInputs, want: SkippableId) -> bool {
live_questions(ri)
.iter()
.any(|a| matches!(a, Ask::Skippable(s) if s.id == want))
}
assert!(has(&single(), SkippableId::BlindTaxpayer));
assert!(
!has(&single(), SkippableId::BlindSpouse),
"no spouse ⇒ no spouse-blind"
);
assert!(
!has(&single(), SkippableId::SalesTaxElection),
"no Sch A ⇒ no SALT"
);
assert!(has(&with_spouse(single()), SkippableId::BlindSpouse));
let mut with_a = single();
with_a.schedule_a = Some(ScheduleAInputs::default());
assert!(has(&with_a, SkippableId::SalesTaxElection));
let mut ri = with_spouse(single());
assert_eq!((reg(SkippableId::BlindTaxpayer).get_bool)(&ri), None);
(reg(SkippableId::BlindTaxpayer).set_bool)(&mut ri, true);
assert_eq!(ri.header.taxpayer.blind, Some(true));
(reg(SkippableId::BlindSpouse).set_bool)(&mut ri, false);
assert_eq!(ri.header.spouse.as_ref().unwrap().blind, Some(false));
assert!(Ask::Skippable(reg(SkippableId::BlindTaxpayer)).is_skippable());
}
#[test]
fn only_the_skippables_are_skippable() {
for ask in live_questions(&with_spouse(single())) {
assert_eq!(
ask.is_skippable(),
ask.declaration_id().is_none(),
"a declaration must not be skippable; a skippable must not be a declaration"
);
if let Ask::Skippable(s) = ask {
assert!(
SKIPPABLE_QUESTIONS.iter().any(|r| r.id == s.id),
"a skippable Ask must come from SKIPPABLE_QUESTIONS, got {:?}",
s.id
);
}
}
}
#[test]
fn a_bare_enter_never_invents_an_answer() {
assert_eq!(parse_yes_no("", None), None, "silence is not an answer");
assert_eq!(parse_yes_no("", Some(false)), Some(false));
assert_eq!(parse_yes_no("", Some(true)), Some(true));
assert_eq!(parse_yes_no("y", None), Some(true));
assert_eq!(parse_yes_no("N", None), Some(false));
assert_eq!(parse_yes_no("Yes", None), Some(true));
assert_eq!(
parse_yes_no("maybe", None),
None,
"garbage is not an answer"
);
assert_eq!(parse_yes_no("maybe", Some(true)), None);
}
#[test]
fn a_dob_can_be_skipped_or_given() {
assert_eq!(parse_date(" "), Ok(None));
assert_eq!(
parse_date("1960-01-02"),
Ok(Some(time::macros::date!(1960 - 01 - 02)))
);
assert!(parse_date("Jan 2 1960").is_err());
}
}