Skip to main content

soma_tauri_shell/
persistence.rs

1//! Generic app-data path resolution and atomic JSON persistence helpers.
2//!
3//! This module owns *mechanics* only: given a path and a serializable value,
4//! write it atomically; given a path, read and parse a JSON value. It has no
5//! knowledge of any particular product's settings shape or file name — the
6//! caller supplies both.
7
8use std::{
9    fs, io,
10    path::{Path, PathBuf},
11};
12
13use serde::{de::DeserializeOwned, Serialize};
14use tauri::{AppHandle, Manager};
15
16use crate::command::CommandResult;
17
18/// Resolve `file_name` inside the app's config directory (e.g.
19/// `~/.config/<app>/settings.json` on Linux). Does not create the directory;
20/// callers that write should create parents first (see [`write_json_atomic`]).
21pub fn app_data_path(app: &AppHandle, file_name: &str) -> CommandResult<PathBuf> {
22    app.path()
23        .app_config_dir()
24        .map(|dir| dir.join(file_name))
25        .map_err(|err| format!("failed to resolve app config directory: {err}"))
26}
27
28/// Read and parse `path` as JSON. A missing file is not an error — it
29/// returns `T::default()`, matching the common "no settings saved yet" case.
30pub fn read_json_or_default<T: DeserializeOwned + Default>(path: &Path) -> CommandResult<T> {
31    let contents = match fs::read_to_string(path) {
32        Ok(contents) => contents,
33        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(T::default()),
34        Err(err) => {
35            return Err(format!("failed to read {}: {err}", path.display()));
36        }
37    };
38    parse_json(&contents, path)
39}
40
41/// Parse `contents` as JSON, producing a message that names `path` on
42/// failure. Split out from [`read_json_or_default`] so parse failures can be
43/// tested without touching the filesystem.
44pub fn parse_json<T: DeserializeOwned>(contents: &str, path: &Path) -> CommandResult<T> {
45    serde_json::from_str(contents)
46        .map_err(|err| format!("failed to parse {}: {err}", path.display()))
47}
48
49/// Serialize `value` to pretty JSON and write it to `path` atomically,
50/// creating parent directories as needed.
51pub fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> CommandResult<()> {
52    if let Some(parent) = path.parent() {
53        fs::create_dir_all(parent)
54            .map_err(|err| format!("failed to create directory {}: {err}", parent.display()))?;
55    }
56    let body =
57        serde_json::to_string_pretty(value).map_err(|err| format!("failed to serialize: {err}"))?;
58    atomic_write(path, body.as_bytes())
59}
60
61/// Write `data` to `path` atomically: write to a per-write unique temp file,
62/// fsync, then rename onto the target.
63///
64/// The temp name carries a UUID so two concurrent writers of the same `path`
65/// do not collide on a fixed `<path>.tmp`. If any step fails the temp file is
66/// best-effort removed so unique temps don't accumulate on error.
67///
68/// On Unix, the temp file is created with mode `0o600` atomically via
69/// `OpenOptions::mode`, so it is never world-readable even momentarily. On
70/// Windows no explicit permission change is applied; rely on the directory
71/// ACL to restrict access.
72pub fn atomic_write(path: &Path, data: &[u8]) -> CommandResult<()> {
73    let tmp = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
74    let write = || -> CommandResult<()> {
75        {
76            let mut opts = fs::OpenOptions::new();
77            opts.write(true).create(true).truncate(true);
78
79            #[cfg(unix)]
80            {
81                use std::os::unix::fs::OpenOptionsExt;
82                opts.mode(0o600);
83            }
84
85            let mut file = opts
86                .open(&tmp)
87                .map_err(|err| format!("failed to open {}: {err}", tmp.display()))?;
88
89            use std::io::Write;
90            file.write_all(data)
91                .map_err(|err| format!("failed to write {}: {err}", tmp.display()))?;
92            file.sync_all()
93                .map_err(|err| format!("failed to sync {}: {err}", tmp.display()))?;
94        }
95        fs::rename(&tmp, path)
96            .map_err(|err| format!("failed to rename into {}: {err}", path.display()))
97    };
98    write().inspect_err(|_| {
99        let _ = fs::remove_file(&tmp);
100    })
101}
102
103/// Read an environment variable, returning `None` for a missing or
104/// whitespace-only value. The returned value is *not* trimmed — callers that
105/// need a trimmed value should trim it themselves, matching how this
106/// distinguishes "unset" from "set to something".
107pub fn env_var_or_none(key: &str) -> Option<String> {
108    std::env::var(key)
109        .ok()
110        .filter(|value| !value.trim().is_empty())
111}
112
113#[cfg(test)]
114#[path = "persistence_tests.rs"]
115mod tests;