Skip to main content

app_json_settings/core/
save.rs

1use std::fs::{self, File, OpenOptions};
2use std::io::{self, Write};
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use crate::Result;
8
9#[cfg(windows)]
10use std::os::windows::ffi::OsStrExt;
11
12static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
13
14#[cfg(windows)]
15#[allow(non_snake_case)]
16#[link(name = "kernel32")]
17unsafe extern "system" {
18    fn MoveFileExW(existing_file_name: *const u16, new_file_name: *const u16, flags: u32) -> i32;
19}
20
21/// Strategy used when saving a settings file.
22#[derive(Debug, Clone, Copy, Eq, PartialEq)]
23pub enum SaveMode {
24    /// Write the new JSON to a temporary file, flush it, and replace the target.
25    ///
26    /// This is the default mode. It avoids exposing a partially written final
27    /// settings file if the process stops during the write step.
28    Atomic,
29
30    /// Write JSON directly to the final path.
31    ///
32    /// This mode matches the v2.2.0 behavior and is useful for unusual
33    /// filesystems, debugging, or applications that intentionally want direct
34    /// overwrite semantics.
35    Direct,
36}
37
38pub fn save_to_path(path: &Path, content: &str, mode: SaveMode) -> Result<()> {
39    if let Some(parent) = non_empty_parent(path) {
40        fs::create_dir_all(parent)?;
41    }
42
43    match mode {
44        SaveMode::Atomic => save_atomic(path, content),
45        SaveMode::Direct => save_direct(path, content),
46    }
47}
48
49fn save_direct(path: &Path, content: &str) -> Result<()> {
50    fs::write(path, content)?;
51    Ok(())
52}
53
54fn save_atomic(path: &Path, content: &str) -> Result<()> {
55    let (temp_path, mut temp_file) = create_temp_file(path)?;
56
57    let result = (|| -> io::Result<()> {
58        temp_file.write_all(content.as_bytes())?;
59        temp_file.sync_all()?;
60        drop(temp_file);
61
62        replace_file(&temp_path, path)?;
63        sync_parent_dir(path);
64        Ok(())
65    })();
66
67    if result.is_err() {
68        let _ = fs::remove_file(&temp_path);
69    }
70
71    result?;
72    Ok(())
73}
74
75fn create_temp_file(target: &Path) -> io::Result<(PathBuf, File)> {
76    let parent = non_empty_parent(target).unwrap_or_else(|| Path::new("."));
77    let target_name = target
78        .file_name()
79        .map(|name| name.to_string_lossy())
80        .unwrap_or_else(|| "settings.json".into());
81
82    for attempt in 0..1000_u16 {
83        let counter = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
84        let nanos = SystemTime::now()
85            .duration_since(UNIX_EPOCH)
86            .map(|duration| duration.as_nanos())
87            .unwrap_or(0);
88        let temp_name = format!(
89            ".{target_name}.tmp.{}.{}.{}.{}",
90            std::process::id(),
91            nanos,
92            counter,
93            attempt
94        );
95        let temp_path = parent.join(temp_name);
96
97        match OpenOptions::new()
98            .write(true)
99            .create_new(true)
100            .open(&temp_path)
101        {
102            Ok(file) => return Ok((temp_path, file)),
103            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
104            Err(error) => return Err(error),
105        }
106    }
107
108    Err(io::Error::new(
109        io::ErrorKind::AlreadyExists,
110        "could not create a unique temporary settings file",
111    ))
112}
113
114#[cfg(unix)]
115fn replace_file(temp_path: &Path, target_path: &Path) -> io::Result<()> {
116    fs::rename(temp_path, target_path)
117}
118
119#[cfg(windows)]
120fn replace_file(temp_path: &Path, target_path: &Path) -> io::Result<()> {
121    const MOVEFILE_REPLACE_EXISTING: u32 = 0x0000_0001;
122    const MOVEFILE_WRITE_THROUGH: u32 = 0x0000_0008;
123
124    let old_path = wide_null_terminated(temp_path);
125    let new_path = wide_null_terminated(target_path);
126
127    // SAFETY: Both pointers are valid, null-terminated UTF-16 buffers that live
128    // for the duration of the call. The flags request an in-place replacement
129    // of the destination by a temporary file created in the same directory.
130    let ok = unsafe {
131        MoveFileExW(
132            old_path.as_ptr(),
133            new_path.as_ptr(),
134            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
135        )
136    };
137
138    if ok == 0 {
139        Err(io::Error::last_os_error())
140    } else {
141        Ok(())
142    }
143}
144
145#[cfg(windows)]
146fn wide_null_terminated(path: &Path) -> Vec<u16> {
147    path.as_os_str().encode_wide().chain([0]).collect()
148}
149
150#[cfg(not(any(unix, windows)))]
151fn replace_file(temp_path: &Path, target_path: &Path) -> io::Result<()> {
152    if target_path.exists() {
153        return Err(io::Error::new(
154            io::ErrorKind::Unsupported,
155            "atomic replacement is not implemented for this target; use SaveMode::Direct",
156        ));
157    }
158
159    fs::rename(temp_path, target_path)
160}
161
162fn non_empty_parent(path: &Path) -> Option<&Path> {
163    path.parent()
164        .filter(|parent| !parent.as_os_str().is_empty())
165}
166
167fn sync_parent_dir(path: &Path) {
168    if let Some(parent) = non_empty_parent(path) {
169        if let Ok(dir) = File::open(parent) {
170            let _ = dir.sync_all();
171        }
172    }
173}