Skip to main content

btctax_cli/
config.rs

1//! The CLI's persisted projection-config knob (TP8 self-transfer fee treatment + lot method), stored
2//! in a `cli_config(key,value)` table inside the vault DB. It is a projection *input parameter*, not
3//! ledger state (NFR6): the event log remains the sole source of truth; this only selects a swappable
4//! rule. TP8 default is (c), USER-MANDATED — never default to (b).
5use crate::CliError;
6use btctax_core::{FeeTreatment, LotMethod, ProjectionConfig};
7use rusqlite::{Connection, OptionalExtension};
8
9/// Session-wide projection configuration loaded from the vault's `cli_config` table.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct CliConfig {
12    pub fee_treatment: FeeTreatment,
13    pub pre2025_method: LotMethod,
14    pub pre2025_method_attested: bool,
15}
16
17impl Default for CliConfig {
18    fn default() -> Self {
19        // DO NOT change: TP8 default is (c). Spec §2/TP8 + user memory forbid flipping it to (b).
20        CliConfig {
21            fee_treatment: FeeTreatment::TreatmentC,
22            pre2025_method: LotMethod::Fifo,
23            pre2025_method_attested: false,
24        }
25    }
26}
27
28impl CliConfig {
29    /// Convert to the core projection type for use in `project()`.
30    pub fn to_projection(self) -> ProjectionConfig {
31        ProjectionConfig {
32            self_transfer_fee: self.fee_treatment,
33            pre2025_method: self.pre2025_method,
34            pre2025_method_attested: self.pre2025_method_attested,
35        }
36    }
37}
38
39/// Create the `cli_config` key-value side-table if it does not exist.
40/// M2: `CREATE TABLE IF NOT EXISTS` makes this idempotent — safe to call on any vault (old, new,
41/// or restored from snapshot). Called by `Session::create`; also called at the top of `read_config`
42/// as a defensive ensure-table-then-read guard.
43pub fn init_config_table(conn: &Connection) -> Result<(), CliError> {
44    conn.execute_batch(
45        "CREATE TABLE IF NOT EXISTS cli_config (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
46    )?;
47    Ok(())
48}
49
50fn get(conn: &Connection, key: &str) -> Result<Option<String>, CliError> {
51    Ok(conn
52        .query_row("SELECT value FROM cli_config WHERE key=?1", [key], |r| {
53            r.get::<_, String>(0)
54        })
55        .optional()?)
56}
57
58fn lot_method_tag(m: LotMethod) -> &'static str {
59    match m {
60        LotMethod::Fifo => "fifo",
61        LotMethod::Lifo => "lifo",
62        LotMethod::Hifo => "hifo",
63    }
64}
65
66/// Read the persisted config, falling back to the (c)+FIFO default for any *unset* key (so a freshly
67/// created vault, or a future-added key, reads as the safe default).
68///
69/// M2 (robust to older vaults): calls `init_config_table` first so that a vault created before this
70/// table existed never fails with "no such table". The CREATE IF NOT EXISTS is a no-op when the table
71/// already exists.
72///
73/// M1 (no silent misread): returns `CliError::BadConfigValue` for any stored value that is not a
74/// recognized enum tag. A corrupt DB or a value written by a future version of the tool will surface
75/// as an error rather than being silently re-interpreted as the default.
76pub fn read_config(conn: &Connection) -> Result<CliConfig, CliError> {
77    // M2: ensure-table-then-read so that older/restored vaults don't get "no such table".
78    init_config_table(conn)?;
79
80    let mut cfg = CliConfig::default();
81    if let Some(v) = get(conn, "fee_treatment")? {
82        cfg.fee_treatment = match v.as_str() {
83            "c" => FeeTreatment::TreatmentC,
84            "b" => FeeTreatment::TreatmentB,
85            _ => {
86                // M1: surface corrupt or future-written values instead of silently misreading them.
87                return Err(CliError::BadConfigValue {
88                    key: "fee_treatment".into(),
89                    value: v,
90                });
91            }
92        };
93    }
94    if let Some(v) = get(conn, "pre2025_method")? {
95        cfg.pre2025_method = match v.as_str() {
96            "fifo" => LotMethod::Fifo,
97            "lifo" => LotMethod::Lifo,
98            "hifo" => LotMethod::Hifo,
99            _ => {
100                return Err(CliError::BadConfigValue {
101                    key: "pre2025_method".into(),
102                    value: v,
103                })
104            }
105        };
106    }
107    if let Some(v) = get(conn, "pre2025_method_attested")? {
108        cfg.pre2025_method_attested = match v.as_str() {
109            "true" => true,
110            "false" => false,
111            _ => {
112                return Err(CliError::BadConfigValue {
113                    key: "pre2025_method_attested".into(),
114                    value: v,
115                })
116            }
117        };
118    }
119    Ok(cfg)
120}
121
122/// Persist the pre-2025 lot identification method and its attestation flag.
123pub fn set_pre2025_method(conn: &Connection, m: LotMethod, attested: bool) -> Result<(), CliError> {
124    conn.execute(
125        "INSERT INTO cli_config(key,value) VALUES('pre2025_method',?1)
126         ON CONFLICT(key) DO UPDATE SET value=excluded.value",
127        [lot_method_tag(m)],
128    )?;
129    conn.execute(
130        "INSERT INTO cli_config(key,value) VALUES('pre2025_method_attested',?1)
131         ON CONFLICT(key) DO UPDATE SET value=excluded.value",
132        [if attested { "true" } else { "false" }],
133    )?;
134    Ok(())
135}
136
137/// Persist the TP8 fee treatment. Both (c) and (b) are writable; (b) is opt-in only.
138/// The application enforces (c) as the default — callers must explicitly pass TreatmentB to opt in.
139pub fn set_fee_treatment(conn: &Connection, t: FeeTreatment) -> Result<(), CliError> {
140    let v = match t {
141        FeeTreatment::TreatmentC => "c",
142        FeeTreatment::TreatmentB => "b",
143    };
144    conn.execute(
145        "INSERT INTO cli_config(key,value) VALUES('fee_treatment',?1)
146         ON CONFLICT(key) DO UPDATE SET value=excluded.value",
147        [v],
148    )?;
149    Ok(())
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use btctax_core::{FeeTreatment, LotMethod};
156
157    fn mem() -> rusqlite::Connection {
158        let c = rusqlite::Connection::open_in_memory().unwrap();
159        init_config_table(&c).unwrap();
160        c
161    }
162
163    #[test]
164    fn default_is_treatment_c_user_mandated() {
165        let c = mem();
166        assert_eq!(
167            read_config(&c).unwrap().fee_treatment,
168            FeeTreatment::TreatmentC
169        );
170    }
171
172    #[test]
173    fn set_then_read_b_opt_in_round_trips() {
174        let c = mem();
175        set_fee_treatment(&c, FeeTreatment::TreatmentB).unwrap();
176        assert_eq!(
177            read_config(&c).unwrap().fee_treatment,
178            FeeTreatment::TreatmentB
179        );
180        // and back to the mandated default
181        set_fee_treatment(&c, FeeTreatment::TreatmentC).unwrap();
182        assert_eq!(
183            read_config(&c).unwrap().fee_treatment,
184            FeeTreatment::TreatmentC
185        );
186    }
187
188    #[test]
189    fn default_pre2025_method_is_fifo_unattested() {
190        let c = mem();
191        let cfg = read_config(&c).unwrap();
192        assert!(!matches!(cfg.pre2025_method, LotMethod::Hifo));
193        assert_eq!(cfg.pre2025_method, LotMethod::Fifo);
194        assert!(!cfg.pre2025_method_attested);
195        assert_eq!(cfg.to_projection().pre2025_method, LotMethod::Fifo);
196        // KAT (Task 1): to_projection carries attested=false (false→false)
197        assert!(!cfg.to_projection().pre2025_method_attested);
198    }
199    #[test]
200    fn set_pre2025_method_round_trips_with_attestation() {
201        let c = mem();
202        set_pre2025_method(&c, LotMethod::Hifo, true).unwrap();
203        let cfg = read_config(&c).unwrap();
204        assert_eq!(cfg.pre2025_method, LotMethod::Hifo);
205        assert!(cfg.pre2025_method_attested);
206        assert_eq!(cfg.to_projection().pre2025_method, LotMethod::Hifo);
207        // KAT (Task 1): to_projection carries attested=true (true→true)
208        assert!(cfg.to_projection().pre2025_method_attested);
209    }
210    #[test]
211    fn bad_pre2025_method_value_is_an_error() {
212        let c = mem();
213        c.execute(
214            "INSERT INTO cli_config(key,value) VALUES('pre2025_method','zzz')",
215            [],
216        )
217        .unwrap();
218        assert!(matches!(read_config(&c).unwrap_err(),
219            CliError::BadConfigValue { ref key, .. } if key == "pre2025_method"));
220    }
221
222    // M2: read_config must not fail with "no such table" on a vault that was created
223    // before the cli_config table was added (e.g. an older/restored vault).
224    #[test]
225    fn read_config_on_table_less_vault_returns_default() {
226        let c = rusqlite::Connection::open_in_memory().unwrap();
227        // Deliberately do NOT call init_config_table — simulate an older vault.
228        let cfg = read_config(&c).unwrap(); // must not error
229        assert_eq!(cfg.fee_treatment, FeeTreatment::TreatmentC);
230    }
231
232    // M1: an unrecognized stored value must surface as an error, not a silent default.
233    #[test]
234    fn bad_stored_value_is_an_error_not_silent_default() {
235        let c = mem();
236        // Manually insert a corrupt / future value.
237        c.execute(
238            "INSERT INTO cli_config(key,value) VALUES('fee_treatment','z')",
239            [],
240        )
241        .unwrap();
242        let err = read_config(&c).unwrap_err();
243        assert!(
244            matches!(err, CliError::BadConfigValue { ref key, .. } if key == "fee_treatment"),
245            "expected BadConfigValue, got: {err}"
246        );
247    }
248}