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