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