agentmux_srv\registry/
atomic.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Atomic file write + rename helpers. All registry mutations route
5//! through these so readers never observe a half-written file.
6
7use std::io::Write;
8use std::path::Path;
9
10/// Write `bytes` to `path` atomically: sibling temp file → fsync →
11/// rename over the target. Rename is atomic on every supported
12/// filesystem when source and destination live on the same volume,
13/// which is guaranteed here (sibling temp).
14pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
15    let parent = path.parent().ok_or_else(|| {
16        std::io::Error::new(
17            std::io::ErrorKind::InvalidInput,
18            "write_atomic: path has no parent",
19        )
20    })?;
21    std::fs::create_dir_all(parent)?;
22    let mut tmp = tempfile::Builder::new()
23        .prefix(".registry-tmp-")
24        .suffix(".json")
25        .tempfile_in(parent)?;
26    tmp.as_file_mut().write_all(bytes)?;
27    tmp.as_file_mut().sync_all()?;
28    tmp.persist(path).map_err(|e| e.error)?;
29    Ok(())
30}
31
32/// Atomic rename. Used for retire/unretire (same-directory tree).
33pub fn rename_atomic(from: &Path, to: &Path) -> std::io::Result<()> {
34    if let Some(parent) = to.parent() {
35        std::fs::create_dir_all(parent)?;
36    }
37    std::fs::rename(from, to)
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43
44    #[test]
45    fn write_atomic_creates_parent_and_target() {
46        let tmp = tempfile::tempdir().unwrap();
47        let target = tmp.path().join("nested").join("file.json");
48        write_atomic(&target, b"hello").unwrap();
49        assert_eq!(std::fs::read(&target).unwrap(), b"hello");
50    }
51
52    #[test]
53    fn write_atomic_overwrites() {
54        let tmp = tempfile::tempdir().unwrap();
55        let target = tmp.path().join("a.json");
56        write_atomic(&target, b"v1").unwrap();
57        write_atomic(&target, b"v2-longer").unwrap();
58        assert_eq!(std::fs::read(&target).unwrap(), b"v2-longer");
59    }
60
61    #[test]
62    fn rename_atomic_moves_file() {
63        let tmp = tempfile::tempdir().unwrap();
64        let from = tmp.path().join("from.json");
65        let to = tmp.path().join("retired").join("from.json");
66        std::fs::write(&from, b"x").unwrap();
67        rename_atomic(&from, &to).unwrap();
68        assert!(!from.exists());
69        assert_eq!(std::fs::read(&to).unwrap(), b"x");
70    }
71
72    #[test]
73    fn rename_atomic_does_not_create_when_missing() {
74        let tmp = tempfile::tempdir().unwrap();
75        let from = tmp.path().join("missing.json");
76        let to = tmp.path().join("retired.json");
77        let err = rename_atomic(&from, &to).unwrap_err();
78        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
79    }
80}