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