pnc-engine 0.1.1

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

#[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_undo_depth")]
    pub undo_depth: usize,
}

impl Default for Settings {
    fn default() -> Self {
        Self {
            stack_soft_cap: default_stack_soft_cap(),
            rational_digit_cap: default_rational_digit_cap(),
            undo_depth: default_undo_depth(),
        }
    }
}

fn default_stack_soft_cap() -> usize {
    10_000
}

fn default_rational_digit_cap() -> usize {
    10_000
}

fn default_undo_depth() -> usize {
    10
}

#[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,
}

fn default_schema_version() -> u32 {
    1
}

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

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

        let raw = Raw::deserialize(deserializer)?;
        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(_) => {
                    skipped += 1;
                }
            }
        }
        if skipped > 0 {
            eprintln!(
                "warning: skipped {} persisted value(s) with unknown kind",
                skipped
            );
        }
        Ok(PersistedState {
            schema_version: raw.schema_version,
            stack,
            settings: raw.settings,
        })
    }
}

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)?;
    let state: PersistedState =
        toml::from_str(&text).map_err(|e| crate::error::PncError::Parse(e.to_string()))?;
    if state.schema_version > 1 {
        return Err(crate::error::PncError::UnsupportedSchemaVersion);
    }
    Ok(state)
}

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

pub fn lock_path() -> std::path::PathBuf {
    state_path().with_file_name("pnc.lock")
}

pub fn acquire_lock() -> Result<std::fs::File, crate::error::PncError> {
    let path = lock_path();
    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("another pnc instance is running"))
    })?;
    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() {
        if let Ok(dir) = fs::File::open(parent) {
            let _ = dir.sync_all();
        }
    }
    Ok(())
}