use crate::CliError;
use btctax_core::TaxProfile;
use rusqlite::{Connection, OptionalExtension};
use std::collections::BTreeMap;
pub fn init_table(conn: &Connection) -> Result<(), CliError> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tax_profile \
(year INTEGER PRIMARY KEY, profile_json TEXT NOT NULL);",
)?;
Ok(())
}
pub fn get(conn: &Connection, year: i32) -> Result<Option<TaxProfile>, CliError> {
init_table(conn)?;
let json: Option<String> = conn
.query_row(
"SELECT profile_json FROM tax_profile WHERE year=?1",
[year],
|r| r.get(0),
)
.optional()?;
match json {
None => Ok(None),
Some(j) => Ok(Some(serde_json::from_str(&j).map_err(|e| {
CliError::BadConfigValue {
key: format!("tax_profile[{year}]"),
value: format!("invalid JSON: {e}"),
}
})?)),
}
}
pub fn set(conn: &Connection, year: i32, p: &TaxProfile) -> Result<(), CliError> {
init_table(conn)?;
let j = serde_json::to_string(p).map_err(|e| CliError::BadConfigValue {
key: format!("tax_profile[{year}]"),
value: e.to_string(),
})?;
conn.execute(
"INSERT INTO tax_profile(year,profile_json) VALUES(?1,?2) \
ON CONFLICT(year) DO UPDATE SET profile_json=excluded.profile_json",
rusqlite::params![year, j],
)?;
Ok(())
}
pub fn years(conn: &Connection) -> Result<Vec<i32>, CliError> {
init_table(conn)?;
let mut stmt = conn.prepare("SELECT year FROM tax_profile ORDER BY year")?;
let rows = stmt.query_map([], |r| r.get::<_, i32>(0))?;
Ok(rows.collect::<Result<Vec<_>, _>>()?)
}
pub fn all(conn: &Connection) -> Result<BTreeMap<i32, TaxProfile>, CliError> {
init_table(conn)?;
let mut stmt = conn.prepare("SELECT year, profile_json FROM tax_profile ORDER BY year")?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i32>(0)?, r.get::<_, String>(1)?)))?;
let mut out = BTreeMap::new();
for row in rows {
let (y, j) = row?;
out.insert(
y,
serde_json::from_str(&j).map_err(|e| CliError::BadConfigValue {
key: format!("tax_profile[{y}]"),
value: e.to_string(),
})?,
);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use btctax_core::{Carryforward, FilingStatus, TaxProfile};
use rust_decimal_macros::dec;
fn mem() -> rusqlite::Connection {
let c = rusqlite::Connection::open_in_memory().unwrap();
init_table(&c).unwrap();
c
}
fn prof() -> TaxProfile {
TaxProfile {
filing_status: FilingStatus::Mfj,
ordinary_taxable_income: dec!(120000),
magi_excluding_crypto: dec!(130000),
qualified_dividends_and_other_pref_income: dec!(0),
other_net_capital_gain: dec!(0),
capital_loss_carryforward_in: Carryforward {
short: dec!(0),
long: dec!(0),
},
w2_ss_wages: dec!(0),
w2_medicare_wages: dec!(0),
schedule_c_expenses: dec!(0),
}
}
#[test]
fn set_then_get_round_trips() {
let c = mem();
set(&c, 2025, &prof()).unwrap();
assert_eq!(get(&c, 2025).unwrap().unwrap(), prof());
assert_eq!(get(&c, 2024).unwrap(), None);
}
#[test]
fn get_on_tableless_vault_is_ok_none() {
let c = rusqlite::Connection::open_in_memory().unwrap(); assert_eq!(get(&c, 2025).unwrap(), None);
}
#[test]
fn bad_json_is_a_typed_error_not_a_panic() {
let c = mem();
c.execute(
"INSERT INTO tax_profile(year,profile_json) VALUES(2025,'not json')",
[],
)
.unwrap();
assert!(matches!(
get(&c, 2025).unwrap_err(),
CliError::BadConfigValue { .. }
));
}
#[test]
fn all_returns_sorted_by_year() {
let c = mem();
set(&c, 2026, &prof()).unwrap();
set(&c, 2025, &prof()).unwrap();
assert_eq!(
all(&c).unwrap().keys().copied().collect::<Vec<_>>(),
vec![2025, 2026]
);
}
}