diff --git a/Cargo.lock b/Cargo.lock index 10253a5..1748189 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -296,6 +296,7 @@ dependencies = [ "ratatui", "serde", "serde_json", + "tempfile", "uuid", ] @@ -430,6 +431,12 @@ dependencies = [ "regex", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -607,9 +614,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" dependencies = [ "once_cell", "wasm-bindgen", @@ -652,9 +659,9 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "line-clipping" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ "bitflags 2.11.0", ] @@ -734,9 +741,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -769,9 +776,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-derive" @@ -1329,6 +1336,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -1473,9 +1493,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" @@ -1508,9 +1528,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "atomic", "getrandom 0.4.2", @@ -1560,9 +1580,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" dependencies = [ "cfg-if", "once_cell", @@ -1573,9 +1593,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1583,9 +1603,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" dependencies = [ "bumpalo", "proc-macro2", @@ -1596,9 +1616,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" dependencies = [ "unicode-ident", ] diff --git a/Cargo.toml b/Cargo.toml index 8105ee7..8890f35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,6 @@ ratatui = "0.30.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" uuid = { version = "1.22.0", features = ["v4", "serde"] } + +[dev-dependencies] +tempfile = "3" diff --git a/src/core/store/fs.rs b/src/core/store/fs.rs index 3009535..43e4990 100644 --- a/src/core/store/fs.rs +++ b/src/core/store/fs.rs @@ -39,10 +39,76 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { let temp_path = parent.join(format!(".tmp-{}", Uuid::new_v4())); fs::write(&temp_path, bytes)?; - if path.exists() { - // TODO: Replace this with a fully atomic cross-platform strategy if Windows overwrite behavior matters. - fs::remove_file(path)?; + // On Unix, rename(2) atomically overwrites the target — no race window. + // On Windows, rename fails if the target exists, so we remove first. + // This leaves a small crash window on Windows; a future improvement could + // use ReplaceFileW for full atomicity. + #[cfg(unix)] + { + if let Err(e) = fs::rename(&temp_path, path) { + let _ = fs::remove_file(&temp_path); + return Err(e); + } } - fs::rename(temp_path, path) + #[cfg(windows)] + { + if let Err(e) = fs::remove_file(path) { + if e.kind() != io::ErrorKind::NotFound { + let _ = fs::remove_file(&temp_path); + return Err(e); + } + } + if let Err(e) = fs::rename(&temp_path, path) { + let _ = fs::remove_file(&temp_path); + return Err(e); + } + } + + #[cfg(not(any(unix, windows)))] + { + if let Err(e) = fs::rename(&temp_path, path) { + let _ = fs::remove_file(&temp_path); + return Err(e); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn write_atomic_creates_new_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("new_file.json"); + + write_atomic(&path, b"hello world").unwrap(); + + assert_eq!(fs::read_to_string(&path).unwrap(), "hello world"); + } + + #[test] + fn write_atomic_overwrites_existing_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("existing.json"); + + fs::write(&path, b"old content").unwrap(); + write_atomic(&path, b"new content").unwrap(); + + assert_eq!(fs::read_to_string(&path).unwrap(), "new content"); + } + + #[test] + fn write_atomic_creates_parent_dirs() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("a").join("b").join("file.json"); + + write_atomic(&path, b"nested").unwrap(); + + assert_eq!(fs::read_to_string(&path).unwrap(), "nested"); + } }