Skip to main content

cuqueclicker_lib/platform/
native.rs

1//! Native platform impl: filesystem persistence + OS file lock.
2//!
3//! Save lives at `$XDG_CONFIG_HOME/cuqueclicker/save.json` (or the platform
4//! equivalent under `$HOME`/`$APPDATA`/`$USERPROFILE`). The single-instance
5//! lock is `std::fs::File::try_lock` on a sibling `.lock` file. The lock is
6//! tied to the file descriptor — process exit, clean or crash, releases it.
7//! The `.lock` file on disk carries no state of its own.
8
9use anyhow::Result;
10use std::env;
11use std::fs::{self, File, OpenOptions, TryLockError};
12use std::io;
13use std::path::PathBuf;
14
15use super::Capabilities;
16use crate::game::state::GameState;
17
18/// Native is the canonical surface — every game-side affordance maps to
19/// something the OS can actually do.
20pub const CAPABILITIES: Capabilities = Capabilities { can_quit: true };
21
22fn save_path() -> Option<PathBuf> {
23    let base = env::var_os("XDG_CONFIG_HOME")
24        .map(PathBuf::from)
25        .or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
26        .or_else(|| env::var_os("APPDATA").map(PathBuf::from))
27        .or_else(|| env::var_os("USERPROFILE").map(|h| PathBuf::from(h).join(".config")))?;
28    Some(base.join("cuqueclicker").join("save.json"))
29}
30
31fn lock_path() -> Option<PathBuf> {
32    save_path().map(|p| p.with_extension("json.lock"))
33}
34
35/// Filesystem-backed persistence. Stateless — the save path is derived
36/// from environment variables on every call.
37pub struct Persistence;
38
39impl Default for Persistence {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45impl Persistence {
46    pub fn new() -> Self {
47        Self
48    }
49
50    /// Best-effort load. Returns `GameState::default()` if no save exists,
51    /// the file is unreadable, or it fails to deserialize. Always runs the
52    /// loaded state through `migrate()` to bring older save schemas current.
53    pub fn load(&self) -> GameState {
54        if let Some(path) = save_path()
55            && let Ok(data) = fs::read_to_string(&path)
56            && let Ok(state) = serde_json::from_str::<GameState>(&data)
57        {
58            return state.migrate();
59        }
60        GameState::default()
61    }
62
63    /// Atomic write via tmp-rename. On failure (no save dir, disk full)
64    /// returns the IO error — callers typically `let _ =` this since save
65    /// failures shouldn't crash the game.
66    pub fn save(&self, state: &GameState) -> Result<()> {
67        if let Some(path) = save_path() {
68            if let Some(parent) = path.parent() {
69                fs::create_dir_all(parent)?;
70            }
71            let tmp = path.with_extension("json.tmp");
72            let data = serde_json::to_string_pretty(state)?;
73            fs::write(&tmp, data)?;
74            fs::rename(&tmp, &path)?;
75        }
76        Ok(())
77    }
78}
79
80/// Holds an exclusive OS-level lock on the save directory for the lifetime
81/// of the process. `std::fs::File::try_lock` is stdlib (since Rust 1.89):
82/// flock on Unix, LockFileEx on Windows.
83pub struct InstanceLock {
84    _file: File,
85}
86
87impl InstanceLock {
88    /// Acquire the lock or fail with `io::ErrorKind::WouldBlock` if another
89    /// instance holds it. Other IO failures (no save dir, permission denied)
90    /// surface their underlying error verbatim.
91    pub fn try_acquire() -> io::Result<Self> {
92        let Some(path) = lock_path() else {
93            return Err(io::Error::new(
94                io::ErrorKind::NotFound,
95                "no XDG_CONFIG_HOME / HOME / APPDATA / USERPROFILE set; cannot locate save dir",
96            ));
97        };
98        if let Some(parent) = path.parent() {
99            fs::create_dir_all(parent)?;
100        }
101        let file = OpenOptions::new()
102            .write(true)
103            .create(true)
104            .truncate(false)
105            .open(&path)?;
106        file.try_lock().map_err(|e| match e {
107            TryLockError::WouldBlock => io::Error::new(
108                io::ErrorKind::WouldBlock,
109                "save lock held by another process",
110            ),
111            TryLockError::Error(io_err) => io_err,
112        })?;
113        Ok(InstanceLock { _file: file })
114    }
115}