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    /// Pseudo-reconcile mode flag (sub-project 2). Default `false`; toggled by `reconcile pseudo on|off`.
16    /// A projection *input parameter*, not ledger state (NFR6) — stored in `cli_config`, mirrors
17    /// `fee_treatment`/`pre2025_method`.
18    pub pseudo_reconcile: bool,
19}
20
21impl Default for CliConfig {
22    fn default() -> Self {
23        // DO NOT change: TP8 default is (c). Spec §2/TP8 + user memory forbid flipping it to (b).
24        CliConfig {
25            fee_treatment: FeeTreatment::TreatmentC,
26            // Realistic no-election default: HIFO is the most commonly elected method and minimizes the
27            // estimated gain (§reconcile-defaults). Stays `attested: false` below so the user is still
28            // prompted to affirm HIFO per exchange (HIFO needs specific-ID/records).
29            pre2025_method: LotMethod::Hifo,
30            pre2025_method_attested: false,
31            pseudo_reconcile: false,
32        }
33    }
34}
35
36impl CliConfig {
37    /// Convert to the core projection type for use in `project()`.
38    pub fn to_projection(self) -> ProjectionConfig {
39        ProjectionConfig {
40            self_transfer_fee: self.fee_treatment,
41            pre2025_method: self.pre2025_method,
42            pre2025_method_attested: self.pre2025_method_attested,
43            pseudo_reconcile: self.pseudo_reconcile,
44        }
45    }
46}
47
48/// Create the `cli_config` key-value side-table if it does not exist.
49/// M2: `CREATE TABLE IF NOT EXISTS` makes this idempotent — safe to call on any vault (old, new,
50/// or restored from snapshot). Called by `Session::create`; also called at the top of `read_config`
51/// as a defensive ensure-table-then-read guard.
52pub fn init_config_table(conn: &Connection) -> Result<(), CliError> {
53    conn.execute_batch(
54        "CREATE TABLE IF NOT EXISTS cli_config (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
55    )?;
56    Ok(())
57}
58
59fn get(conn: &Connection, key: &str) -> Result<Option<String>, CliError> {
60    Ok(conn
61        .query_row("SELECT value FROM cli_config WHERE key=?1", [key], |r| {
62            r.get::<_, String>(0)
63        })
64        .optional()?)
65}
66
67fn lot_method_tag(m: LotMethod) -> &'static str {
68    match m {
69        LotMethod::Fifo => "fifo",
70        LotMethod::Lifo => "lifo",
71        LotMethod::Hifo => "hifo",
72    }
73}
74
75/// Read the persisted config, falling back to the (c)+FIFO default for any *unset* key (so a freshly
76/// created vault, or a future-added key, reads as the safe default).
77///
78/// M2 (robust to older vaults): calls `init_config_table` first so that a vault created before this
79/// table existed never fails with "no such table". The CREATE IF NOT EXISTS is a no-op when the table
80/// already exists.
81///
82/// M1 (no silent misread): returns `CliError::BadConfigValue` for any stored value that is not a
83/// recognized enum tag. A corrupt DB or a value written by a future version of the tool will surface
84/// as an error rather than being silently re-interpreted as the default.
85pub fn read_config(conn: &Connection) -> Result<CliConfig, CliError> {
86    // M2: ensure-table-then-read so that older/restored vaults don't get "no such table".
87    init_config_table(conn)?;
88
89    let mut cfg = CliConfig::default();
90    if let Some(v) = get(conn, "fee_treatment")? {
91        cfg.fee_treatment = match v.as_str() {
92            "c" => FeeTreatment::TreatmentC,
93            "b" => FeeTreatment::TreatmentB,
94            _ => {
95                // M1: surface corrupt or future-written values instead of silently misreading them.
96                return Err(CliError::BadConfigValue {
97                    key: "fee_treatment".into(),
98                    value: v,
99                });
100            }
101        };
102    }
103    if let Some(v) = get(conn, "pre2025_method")? {
104        cfg.pre2025_method = match v.as_str() {
105            "fifo" => LotMethod::Fifo,
106            "lifo" => LotMethod::Lifo,
107            "hifo" => LotMethod::Hifo,
108            _ => {
109                return Err(CliError::BadConfigValue {
110                    key: "pre2025_method".into(),
111                    value: v,
112                })
113            }
114        };
115    }
116    if let Some(v) = get(conn, "pre2025_method_attested")? {
117        cfg.pre2025_method_attested = match v.as_str() {
118            "true" => true,
119            "false" => false,
120            _ => {
121                return Err(CliError::BadConfigValue {
122                    key: "pre2025_method_attested".into(),
123                    value: v,
124                })
125            }
126        };
127    }
128    if let Some(v) = get(conn, "pseudo_reconcile")? {
129        cfg.pseudo_reconcile = match v.as_str() {
130            "true" => true,
131            "false" => false,
132            _ => {
133                return Err(CliError::BadConfigValue {
134                    key: "pseudo_reconcile".into(),
135                    value: v,
136                })
137            }
138        };
139    }
140    Ok(cfg)
141}
142
143/// Persist the pseudo-reconcile mode flag (sub-project 2). `reconcile pseudo on|off`.
144pub fn set_pseudo_reconcile(conn: &Connection, on: bool) -> Result<(), CliError> {
145    conn.execute(
146        "INSERT INTO cli_config(key,value) VALUES('pseudo_reconcile',?1)
147         ON CONFLICT(key) DO UPDATE SET value=excluded.value",
148        [if on { "true" } else { "false" }],
149    )?;
150    Ok(())
151}
152
153/// Persist the pre-2025 lot identification method and its attestation flag.
154pub fn set_pre2025_method(conn: &Connection, m: LotMethod, attested: bool) -> Result<(), CliError> {
155    conn.execute(
156        "INSERT INTO cli_config(key,value) VALUES('pre2025_method',?1)
157         ON CONFLICT(key) DO UPDATE SET value=excluded.value",
158        [lot_method_tag(m)],
159    )?;
160    conn.execute(
161        "INSERT INTO cli_config(key,value) VALUES('pre2025_method_attested',?1)
162         ON CONFLICT(key) DO UPDATE SET value=excluded.value",
163        [if attested { "true" } else { "false" }],
164    )?;
165    Ok(())
166}
167
168/// Persist the TP8 fee treatment. Both (c) and (b) are writable; (b) is opt-in only.
169/// The application enforces (c) as the default — callers must explicitly pass TreatmentB to opt in.
170pub fn set_fee_treatment(conn: &Connection, t: FeeTreatment) -> Result<(), CliError> {
171    let v = match t {
172        FeeTreatment::TreatmentC => "c",
173        FeeTreatment::TreatmentB => "b",
174    };
175    conn.execute(
176        "INSERT INTO cli_config(key,value) VALUES('fee_treatment',?1)
177         ON CONFLICT(key) DO UPDATE SET value=excluded.value",
178        [v],
179    )?;
180    Ok(())
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use btctax_core::{FeeTreatment, LotMethod};
187
188    fn mem() -> rusqlite::Connection {
189        let c = rusqlite::Connection::open_in_memory().unwrap();
190        init_config_table(&c).unwrap();
191        c
192    }
193
194    #[test]
195    fn default_is_treatment_c_user_mandated() {
196        let c = mem();
197        assert_eq!(
198            read_config(&c).unwrap().fee_treatment,
199            FeeTreatment::TreatmentC
200        );
201    }
202
203    #[test]
204    fn set_then_read_b_opt_in_round_trips() {
205        let c = mem();
206        set_fee_treatment(&c, FeeTreatment::TreatmentB).unwrap();
207        assert_eq!(
208            read_config(&c).unwrap().fee_treatment,
209            FeeTreatment::TreatmentB
210        );
211        // and back to the mandated default
212        set_fee_treatment(&c, FeeTreatment::TreatmentC).unwrap();
213        assert_eq!(
214            read_config(&c).unwrap().fee_treatment,
215            FeeTreatment::TreatmentC
216        );
217    }
218
219    #[test]
220    fn default_pre2025_method_is_hifo_unattested() {
221        // [reconcile-defaults] The realistic no-election default is HIFO, still UNATTESTED (the user is
222        // prompted to affirm HIFO per exchange). Flipped from FIFO.
223        let c = mem();
224        let cfg = read_config(&c).unwrap();
225        assert!(!matches!(cfg.pre2025_method, LotMethod::Fifo));
226        assert_eq!(cfg.pre2025_method, LotMethod::Hifo);
227        assert!(!cfg.pre2025_method_attested);
228        assert_eq!(cfg.to_projection().pre2025_method, LotMethod::Hifo);
229        // KAT (Task 1): to_projection carries attested=false (false→false)
230        assert!(!cfg.to_projection().pre2025_method_attested);
231    }
232    #[test]
233    fn set_pre2025_method_round_trips_with_attestation() {
234        let c = mem();
235        set_pre2025_method(&c, LotMethod::Hifo, true).unwrap();
236        let cfg = read_config(&c).unwrap();
237        assert_eq!(cfg.pre2025_method, LotMethod::Hifo);
238        assert!(cfg.pre2025_method_attested);
239        assert_eq!(cfg.to_projection().pre2025_method, LotMethod::Hifo);
240        // KAT (Task 1): to_projection carries attested=true (true→true)
241        assert!(cfg.to_projection().pre2025_method_attested);
242    }
243    #[test]
244    fn bad_pre2025_method_value_is_an_error() {
245        let c = mem();
246        c.execute(
247            "INSERT INTO cli_config(key,value) VALUES('pre2025_method','zzz')",
248            [],
249        )
250        .unwrap();
251        assert!(matches!(read_config(&c).unwrap_err(),
252            CliError::BadConfigValue { ref key, .. } if key == "pre2025_method"));
253    }
254
255    // M2: read_config must not fail with "no such table" on a vault that was created
256    // before the cli_config table was added (e.g. an older/restored vault).
257    #[test]
258    fn read_config_on_table_less_vault_returns_default() {
259        let c = rusqlite::Connection::open_in_memory().unwrap();
260        // Deliberately do NOT call init_config_table — simulate an older vault.
261        let cfg = read_config(&c).unwrap(); // must not error
262        assert_eq!(cfg.fee_treatment, FeeTreatment::TreatmentC);
263    }
264
265    // [T1 pseudo] mode flag defaults false, round-trips on/off, and maps into ProjectionConfig.
266    #[test]
267    fn pseudo_reconcile_defaults_false_and_round_trips() {
268        let c = mem();
269        // Default (unset key) → false [N1].
270        assert!(!read_config(&c).unwrap().pseudo_reconcile);
271        assert!(!read_config(&c).unwrap().to_projection().pseudo_reconcile);
272        // on
273        set_pseudo_reconcile(&c, true).unwrap();
274        assert!(read_config(&c).unwrap().pseudo_reconcile);
275        assert!(read_config(&c).unwrap().to_projection().pseudo_reconcile);
276        // off
277        set_pseudo_reconcile(&c, false).unwrap();
278        assert!(!read_config(&c).unwrap().pseudo_reconcile);
279    }
280
281    #[test]
282    fn bad_pseudo_reconcile_value_is_an_error() {
283        let c = mem();
284        c.execute(
285            "INSERT INTO cli_config(key,value) VALUES('pseudo_reconcile','maybe')",
286            [],
287        )
288        .unwrap();
289        assert!(matches!(read_config(&c).unwrap_err(),
290            CliError::BadConfigValue { ref key, .. } if key == "pseudo_reconcile"));
291    }
292
293    // M1: an unrecognized stored value must surface as an error, not a silent default.
294    #[test]
295    fn bad_stored_value_is_an_error_not_silent_default() {
296        let c = mem();
297        // Manually insert a corrupt / future value.
298        c.execute(
299            "INSERT INTO cli_config(key,value) VALUES('fee_treatment','z')",
300            [],
301        )
302        .unwrap();
303        let err = read_config(&c).unwrap_err();
304        assert!(
305            matches!(err, CliError::BadConfigValue { ref key, .. } if key == "fee_treatment"),
306            "expected BadConfigValue, got: {err}"
307        );
308    }
309}