Skip to main content

pnc_engine/
persist.rs

1use crate::stack::DEFAULT_HISTORY_DEPTH;
2use crate::value::Value;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
8#[serde(rename_all = "lowercase")]
9pub enum DisplayMode {
10    #[default]
11    Auto,
12    Fix,
13    Sci,
14    Eng,
15}
16
17impl<'de> Deserialize<'de> for DisplayMode {
18    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
19    where
20        D: serde::Deserializer<'de>,
21    {
22        let s = String::deserialize(deserializer)?;
23        match s.to_lowercase().as_str() {
24            "auto" => Ok(DisplayMode::Auto),
25            "fix" => Ok(DisplayMode::Fix),
26            "sci" => Ok(DisplayMode::Sci),
27            "eng" => Ok(DisplayMode::Eng),
28            _ => Ok(DisplayMode::Auto),
29        }
30    }
31}
32
33/// Persisted calculator settings.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct Settings {
36    #[serde(default = "default_stack_soft_cap")]
37    pub stack_soft_cap: usize,
38    #[serde(default = "default_rational_digit_cap")]
39    pub rational_digit_cap: usize,
40    #[serde(default = "default_history_depth")]
41    pub history_depth: usize,
42    #[serde(default)]
43    pub display_mode: DisplayMode,
44    #[serde(default = "default_fix_decimals")]
45    pub fix_decimals: u8,
46    #[serde(default = "default_sci_digits")]
47    pub sci_digits: u8,
48    #[serde(default = "default_eng_digits")]
49    pub eng_digits: u8,
50}
51
52impl Default for Settings {
53    fn default() -> Self {
54        Self {
55            stack_soft_cap: default_stack_soft_cap(),
56            rational_digit_cap: default_rational_digit_cap(),
57            history_depth: default_history_depth(),
58            display_mode: DisplayMode::default(),
59            fix_decimals: default_fix_decimals(),
60            sci_digits: default_sci_digits(),
61            eng_digits: default_eng_digits(),
62        }
63    }
64}
65
66fn default_stack_soft_cap() -> usize {
67    10_000
68}
69
70fn default_rational_digit_cap() -> usize {
71    10_000
72}
73
74fn default_history_depth() -> usize {
75    DEFAULT_HISTORY_DEPTH
76}
77
78fn default_fix_decimals() -> u8 {
79    2
80}
81
82fn default_sci_digits() -> u8 {
83    4
84}
85
86fn default_eng_digits() -> u8 {
87    4
88}
89
90pub const CURRENT_SCHEMA_VERSION: u32 = 2;
91
92/// Top-level persisted state (stack + settings + schema version).
93#[derive(Debug, Clone, PartialEq, Serialize)]
94pub struct PersistedState {
95    #[serde(default = "default_schema_version")]
96    pub schema_version: u32,
97    #[serde(default, deserialize_with = "deserialize_stack")]
98    pub stack: Vec<Value>,
99    #[serde(default)]
100    pub settings: Settings,
101    #[serde(default)]
102    pub migrated: bool,
103}
104
105fn default_schema_version() -> u32 {
106    1
107}
108
109impl Default for PersistedState {
110    fn default() -> Self {
111        Self {
112            schema_version: 1,
113            stack: Vec::new(),
114            settings: Settings::default(),
115            migrated: false,
116        }
117    }
118}
119
120impl<'de> Deserialize<'de> for PersistedState {
121    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
122    where
123        D: serde::Deserializer<'de>,
124    {
125        #[derive(Deserialize)]
126        struct RawV1 {
127            #[serde(default = "default_schema_version")]
128            schema_version: u32,
129            #[serde(default)]
130            stack: Vec<toml::Value>,
131            #[serde(default)]
132            settings: SettingsRaw,
133            #[serde(default)]
134            migrated: bool,
135        }
136
137        #[derive(Deserialize)]
138        struct SettingsRaw {
139            #[serde(default = "default_stack_soft_cap")]
140            stack_soft_cap: usize,
141            #[serde(default = "default_rational_digit_cap")]
142            rational_digit_cap: usize,
143            #[serde(default)]
144            history_depth: Option<usize>,
145            #[serde(default)]
146            undo_depth: Option<usize>,
147            #[serde(default)]
148            display_mode: Option<DisplayMode>,
149            #[serde(default)]
150            fix_decimals: Option<u8>,
151            #[serde(default)]
152            sci_digits: Option<u8>,
153            #[serde(default)]
154            eng_digits: Option<u8>,
155        }
156
157        impl Default for SettingsRaw {
158            fn default() -> Self {
159                Self {
160                    stack_soft_cap: default_stack_soft_cap(),
161                    rational_digit_cap: default_rational_digit_cap(),
162                    history_depth: None,
163                    undo_depth: None,
164                    display_mode: None,
165                    fix_decimals: None,
166                    sci_digits: None,
167                    eng_digits: None,
168                }
169            }
170        }
171
172        let raw = RawV1::deserialize(deserializer)?;
173
174        let version = raw.schema_version;
175        let is_v1 = version <= 1; // missing or explicit v1
176
177        let mut stack = Vec::new();
178        let mut skipped = 0usize;
179        for item in raw.stack {
180            use serde::de::IntoDeserializer;
181            match Value::deserialize(item.into_deserializer()) {
182                Ok(v) => stack.push(v),
183                Err(e) => {
184                    skipped += 1;
185                    tracing::debug!(error = %e, "skipped persisted value");
186                }
187            }
188        }
189        if skipped > 0 {
190            tracing::warn!(
191                skipped = skipped,
192                "skipped persisted value(s) with unknown kind"
193            );
194        }
195
196        // Version-gated settings mapping.
197        let history_depth = if is_v1 {
198            // v1: read undo_depth, map to history_depth.
199            raw.settings
200                .history_depth
201                .or(raw.settings.undo_depth)
202                .unwrap_or_else(default_history_depth)
203        } else {
204            // v2: history_depth only; undo_depth ignored.
205            raw.settings
206                .history_depth
207                .unwrap_or_else(default_history_depth)
208        };
209
210        let mut settings = Settings {
211            stack_soft_cap: raw.settings.stack_soft_cap,
212            rational_digit_cap: raw.settings.rational_digit_cap,
213            history_depth,
214            display_mode: raw.settings.display_mode.unwrap_or_default(),
215            fix_decimals: raw
216                .settings
217                .fix_decimals
218                .unwrap_or_else(default_fix_decimals),
219            sci_digits: raw.settings.sci_digits.unwrap_or_else(default_sci_digits),
220            eng_digits: raw.settings.eng_digits.unwrap_or_else(default_eng_digits),
221        };
222
223        // Clamp history_depth.
224        if settings.history_depth > 1000 {
225            settings.history_depth = 1000;
226        }
227
228        let migrated = raw.migrated || is_v1;
229
230        let schema_version = if is_v1 {
231            CURRENT_SCHEMA_VERSION
232        } else {
233            version
234        };
235
236        Ok(PersistedState {
237            schema_version,
238            stack,
239            settings,
240            migrated,
241        })
242    }
243}
244
245pub fn state_path() -> PathBuf {
246    directories::ProjectDirs::from("", "", "pnc")
247        .map(|p| p.data_dir().join("state.toml"))
248        .unwrap_or_else(|| PathBuf::from("state.toml"))
249}
250
251pub fn load() -> Result<PersistedState, crate::error::PncError> {
252    load_from(&state_path())
253}
254
255pub fn load_from(path: &std::path::Path) -> Result<PersistedState, crate::error::PncError> {
256    if !path.exists() {
257        return Ok(PersistedState::default());
258    }
259    let text = fs::read_to_string(path)?;
260    if text.is_empty() {
261        return Ok(PersistedState::default());
262    }
263    // Try to parse as toml::Value first to check schema version type.
264    let toml_val: toml::Value =
265        toml::from_str(&text).map_err(|e| crate::error::PncError::Parse(e.to_string()))?;
266
267    // Check schema_version field type.
268    if let Some(sv) = toml_val.get("schema_version") {
269        match sv {
270            toml::Value::Integer(n) => {
271                let v = *n as u32;
272                if v == 0 || v > CURRENT_SCHEMA_VERSION {
273                    return Err(crate::error::PncError::UnsupportedSchemaVersion);
274                }
275            }
276            _ => {
277                // Non-integer: treat as corrupt.
278                return Err(crate::error::PncError::Parse(
279                    "corrupt schema_version".to_string(),
280                ));
281            }
282        }
283    }
284
285    let state: PersistedState =
286        toml::from_str(&text).map_err(|e| crate::error::PncError::Parse(e.to_string()))?;
287
288    // Clamp history_depth.
289    let mut state = state;
290    if state.settings.history_depth > 1000 {
291        state.settings.history_depth = 1000;
292    }
293
294    Ok(state)
295}
296
297pub fn save(state: &PersistedState) -> Result<(), crate::error::PncError> {
298    save_to(state, &state_path())
299}
300
301pub fn lock_path() -> std::path::PathBuf {
302    // Primary override: full path to the lock file.
303    for var in ["PNC_LOCK_PATH", "PNC_LOCK_FILE", "PNC_LOCK"] {
304        if let Ok(v) = std::env::var(var)
305            && !v.is_empty()
306        {
307            return std::path::PathBuf::from(v);
308        }
309    }
310    state_path().with_file_name("pnc.lock")
311}
312
313pub fn acquire_lock() -> Result<std::fs::File, crate::error::PncError> {
314    acquire_lock_at(&lock_path())
315}
316
317pub fn acquire_lock_at(path: &std::path::Path) -> Result<std::fs::File, crate::error::PncError> {
318    if let Some(parent) = path.parent() {
319        fs::create_dir_all(parent)?;
320    }
321    let file = fs::OpenOptions::new()
322        .create(true)
323        .write(true)
324        .truncate(false)
325        .open(path)?;
326    use fs2::FileExt;
327    file.try_lock_exclusive().map_err(|_| {
328        crate::error::PncError::Io(std::io::Error::other(format!(
329            "another pnc instance is running (lock: {}); set PNC_LOCK_PATH to override",
330            path.display()
331        )))
332    })?;
333    Ok(file)
334}
335
336pub fn save_to(
337    state: &PersistedState,
338    path: &std::path::Path,
339) -> Result<(), crate::error::PncError> {
340    if let Some(parent) = path.parent() {
341        fs::create_dir_all(parent)?;
342    }
343    let text =
344        toml::to_string_pretty(state).map_err(|e| crate::error::PncError::Parse(e.to_string()))?;
345    if path.exists() {
346        let backup = path.with_extension("bak");
347        let _ = fs::copy(path, &backup);
348    }
349    let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
350    fs::create_dir_all(parent)?;
351
352    // Retry the write once on failure before reporting an unrecoverable error.
353    // Atomic-ish write without the tempfile crate: write to a sibling temp
354    // file, fsync, then rename over the target.
355    let mut last_err = None;
356    for _ in 0..2 {
357        let tmp_path = path.with_file_name(format!(
358            "{}.tmp",
359            path.file_name()
360                .and_then(|s| s.to_str())
361                .unwrap_or("state.toml")
362        ));
363        let write_result = (|| -> std::io::Result<()> {
364            use std::io::Write;
365            let mut f = fs::OpenOptions::new()
366                .create(true)
367                .write(true)
368                .truncate(true)
369                .open(&tmp_path)?;
370            f.write_all(text.as_bytes())?;
371            f.flush()?;
372            f.sync_all()?;
373            fs::rename(&tmp_path, path)?;
374            Ok(())
375        })();
376        match write_result {
377            Ok(()) => {
378                last_err = None;
379                break;
380            }
381            Err(e) => {
382                let _ = fs::remove_file(&tmp_path);
383                last_err = Some(crate::error::PncError::Io(e));
384                continue;
385            }
386        }
387    }
388    if let Some(e) = last_err {
389        return Err(e);
390    }
391    // Best-effort fsync of the containing directory.
392    if let Some(parent) = path.parent()
393        && let Ok(dir) = fs::File::open(parent)
394    {
395        let _ = dir.sync_all();
396    }
397    Ok(())
398}