pnc-engine 0.2.0

Core engine for PNC: arbitrary-precision RPN evaluation, units, and persistence
Documentation
use crate::stack::DEFAULT_HISTORY_DEPTH;
use crate::value::Value;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DisplayMode {
    #[default]
    Auto,
    Fix,
    Sci,
    Eng,
}

impl<'de> Deserialize<'de> for DisplayMode {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        match s.to_lowercase().as_str() {
            "auto" => Ok(DisplayMode::Auto),
            "fix" => Ok(DisplayMode::Fix),
            "sci" => Ok(DisplayMode::Sci),
            "eng" => Ok(DisplayMode::Eng),
            _ => Ok(DisplayMode::Auto),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Settings {
    #[serde(default = "default_stack_soft_cap")]
    pub stack_soft_cap: usize,
    #[serde(default = "default_rational_digit_cap")]
    pub rational_digit_cap: usize,
    #[serde(default = "default_history_depth")]
    pub history_depth: usize,
    #[serde(default)]
    pub display_mode: DisplayMode,
    #[serde(default = "default_fix_decimals")]
    pub fix_decimals: u8,
    #[serde(default = "default_sci_digits")]
    pub sci_digits: u8,
    #[serde(default = "default_eng_digits")]
    pub eng_digits: u8,
}

impl Default for Settings {
    fn default() -> Self {
        Self {
            stack_soft_cap: default_stack_soft_cap(),
            rational_digit_cap: default_rational_digit_cap(),
            history_depth: default_history_depth(),
            display_mode: DisplayMode::default(),
            fix_decimals: default_fix_decimals(),
            sci_digits: default_sci_digits(),
            eng_digits: default_eng_digits(),
        }
    }
}

fn default_stack_soft_cap() -> usize {
    10_000
}

fn default_rational_digit_cap() -> usize {
    10_000
}

fn default_history_depth() -> usize {
    DEFAULT_HISTORY_DEPTH
}

fn default_fix_decimals() -> u8 {
    2
}

fn default_sci_digits() -> u8 {
    4
}

fn default_eng_digits() -> u8 {
    4
}

pub const CURRENT_SCHEMA_VERSION: u32 = 2;

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct PersistedState {
    #[serde(default = "default_schema_version")]
    pub schema_version: u32,
    #[serde(default, deserialize_with = "deserialize_stack")]
    pub stack: Vec<Value>,
    #[serde(default)]
    pub settings: Settings,
    #[serde(default)]
    pub migrated: bool,
}

fn default_schema_version() -> u32 {
    1
}

impl Default for PersistedState {
    fn default() -> Self {
        Self {
            schema_version: 1,
            stack: Vec::new(),
            settings: Settings::default(),
            migrated: false,
        }
    }
}

impl<'de> Deserialize<'de> for PersistedState {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        #[derive(Deserialize)]
        struct RawV1 {
            #[serde(default = "default_schema_version")]
            schema_version: u32,
            #[serde(default)]
            stack: Vec<toml::Value>,
            #[serde(default)]
            settings: SettingsRaw,
            #[serde(default)]
            migrated: bool,
        }

        #[derive(Deserialize)]
        struct SettingsRaw {
            #[serde(default = "default_stack_soft_cap")]
            stack_soft_cap: usize,
            #[serde(default = "default_rational_digit_cap")]
            rational_digit_cap: usize,
            #[serde(default)]
            history_depth: Option<usize>,
            #[serde(default)]
            undo_depth: Option<usize>,
            #[serde(default)]
            display_mode: Option<DisplayMode>,
            #[serde(default)]
            fix_decimals: Option<u8>,
            #[serde(default)]
            sci_digits: Option<u8>,
            #[serde(default)]
            eng_digits: Option<u8>,
        }

        impl Default for SettingsRaw {
            fn default() -> Self {
                Self {
                    stack_soft_cap: default_stack_soft_cap(),
                    rational_digit_cap: default_rational_digit_cap(),
                    history_depth: None,
                    undo_depth: None,
                    display_mode: None,
                    fix_decimals: None,
                    sci_digits: None,
                    eng_digits: None,
                }
            }
        }

        let raw = RawV1::deserialize(deserializer)?;

        let version = raw.schema_version;
        let is_v1 = version <= 1; // missing or explicit v1

        let mut stack = Vec::new();
        let mut skipped = 0usize;
        for item in raw.stack {
            use serde::de::IntoDeserializer;
            match Value::deserialize(item.into_deserializer()) {
                Ok(v) => stack.push(v),
                Err(e) => {
                    skipped += 1;
                    tracing::debug!(error = %e, "skipped persisted value");
                }
            }
        }
        if skipped > 0 {
            tracing::warn!(
                skipped = skipped,
                "skipped persisted value(s) with unknown kind"
            );
        }

        // Version-gated settings mapping.
        let history_depth = if is_v1 {
            // v1: read undo_depth, map to history_depth.
            raw.settings
                .history_depth
                .or(raw.settings.undo_depth)
                .unwrap_or_else(default_history_depth)
        } else {
            // v2: history_depth only; undo_depth ignored.
            raw.settings
                .history_depth
                .unwrap_or_else(default_history_depth)
        };

        let mut settings = Settings {
            stack_soft_cap: raw.settings.stack_soft_cap,
            rational_digit_cap: raw.settings.rational_digit_cap,
            history_depth,
            display_mode: raw.settings.display_mode.unwrap_or_default(),
            fix_decimals: raw
                .settings
                .fix_decimals
                .unwrap_or_else(default_fix_decimals),
            sci_digits: raw.settings.sci_digits.unwrap_or_else(default_sci_digits),
            eng_digits: raw.settings.eng_digits.unwrap_or_else(default_eng_digits),
        };

        // Clamp history_depth.
        if settings.history_depth > 1000 {
            settings.history_depth = 1000;
        }

        let migrated = raw.migrated || is_v1;

        let schema_version = if is_v1 {
            CURRENT_SCHEMA_VERSION
        } else {
            version
        };

        Ok(PersistedState {
            schema_version,
            stack,
            settings,
            migrated,
        })
    }
}

pub fn state_path() -> PathBuf {
    directories::ProjectDirs::from("", "", "pnc")
        .map(|p| p.data_dir().join("state.toml"))
        .unwrap_or_else(|| PathBuf::from("state.toml"))
}

pub fn load() -> Result<PersistedState, crate::error::PncError> {
    load_from(&state_path())
}

pub fn load_from(path: &std::path::Path) -> Result<PersistedState, crate::error::PncError> {
    if !path.exists() {
        return Ok(PersistedState::default());
    }
    let text = fs::read_to_string(path)?;
    if text.is_empty() {
        return Ok(PersistedState::default());
    }
    // Try to parse as toml::Value first to check schema version type.
    let toml_val: toml::Value =
        toml::from_str(&text).map_err(|e| crate::error::PncError::Parse(e.to_string()))?;

    // Check schema_version field type.
    if let Some(sv) = toml_val.get("schema_version") {
        match sv {
            toml::Value::Integer(n) => {
                let v = *n as u32;
                if v == 0 || v > CURRENT_SCHEMA_VERSION {
                    return Err(crate::error::PncError::UnsupportedSchemaVersion);
                }
            }
            _ => {
                // Non-integer: treat as corrupt.
                return Err(crate::error::PncError::Parse(
                    "corrupt schema_version".to_string(),
                ));
            }
        }
    }

    let state: PersistedState =
        toml::from_str(&text).map_err(|e| crate::error::PncError::Parse(e.to_string()))?;

    // Clamp history_depth.
    let mut state = state;
    if state.settings.history_depth > 1000 {
        state.settings.history_depth = 1000;
    }

    Ok(state)
}

pub fn save(state: &PersistedState) -> Result<(), crate::error::PncError> {
    save_to(state, &state_path())
}

pub fn lock_path() -> std::path::PathBuf {
    // Primary override: full path to the lock file.
    for var in ["PNC_LOCK_PATH", "PNC_LOCK_FILE", "PNC_LOCK"] {
        if let Ok(v) = std::env::var(var)
            && !v.is_empty()
        {
            return std::path::PathBuf::from(v);
        }
    }
    state_path().with_file_name("pnc.lock")
}

pub fn acquire_lock() -> Result<std::fs::File, crate::error::PncError> {
    acquire_lock_at(&lock_path())
}

pub fn acquire_lock_at(path: &std::path::Path) -> Result<std::fs::File, crate::error::PncError> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let file = fs::OpenOptions::new()
        .create(true)
        .write(true)
        .truncate(false)
        .open(path)?;
    use fs2::FileExt;
    file.try_lock_exclusive().map_err(|_| {
        crate::error::PncError::Io(std::io::Error::other(format!(
            "another pnc instance is running (lock: {}); set PNC_LOCK_PATH to override",
            path.display()
        )))
    })?;
    Ok(file)
}

pub fn save_to(
    state: &PersistedState,
    path: &std::path::Path,
) -> Result<(), crate::error::PncError> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let text =
        toml::to_string_pretty(state).map_err(|e| crate::error::PncError::Parse(e.to_string()))?;
    if path.exists() {
        let backup = path.with_extension("bak");
        let _ = fs::copy(path, &backup);
    }
    let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
    fs::create_dir_all(parent)?;

    // Retry the write once on failure before reporting an unrecoverable error.
    // Atomic-ish write without the tempfile crate: write to a sibling temp
    // file, fsync, then rename over the target.
    let mut last_err = None;
    for _ in 0..2 {
        let tmp_path = path.with_file_name(format!(
            "{}.tmp",
            path.file_name()
                .and_then(|s| s.to_str())
                .unwrap_or("state.toml")
        ));
        let write_result = (|| -> std::io::Result<()> {
            use std::io::Write;
            let mut f = fs::OpenOptions::new()
                .create(true)
                .write(true)
                .truncate(true)
                .open(&tmp_path)?;
            f.write_all(text.as_bytes())?;
            f.flush()?;
            f.sync_all()?;
            fs::rename(&tmp_path, path)?;
            Ok(())
        })();
        match write_result {
            Ok(()) => {
                last_err = None;
                break;
            }
            Err(e) => {
                let _ = fs::remove_file(&tmp_path);
                last_err = Some(crate::error::PncError::Io(e));
                continue;
            }
        }
    }
    if let Some(e) = last_err {
        return Err(e);
    }
    // Best-effort fsync of the containing directory.
    if let Some(parent) = path.parent()
        && let Ok(dir) = fs::File::open(parent)
    {
        let _ = dir.sync_all();
    }
    Ok(())
}