Skip to main content

nils_common/
fs.rs

1use std::fs::{self, File, OpenOptions};
2use std::io::{self, Read, Write};
3#[cfg(unix)]
4use std::os::unix::fs::PermissionsExt;
5use std::path::{Component, Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7use thiserror::Error;
8
9/// Render `path` using `to_string_lossy` for use in CLI JSON envelopes and
10/// log messages. Lossy on purpose — non-UTF8 bytes are replaced with U+FFFD
11/// so the result is always serializable.
12pub fn display_path(path: &Path) -> String {
13    path.to_string_lossy().to_string()
14}
15
16/// Lexically normalize `path`: drop `.` components, collapse `..` components by
17/// popping the previous segment, and preserve any root or prefix (Windows
18/// drive / UNC). The result is purely syntactic — the filesystem is not
19/// consulted.
20///
21/// Behavior matches the per-crate `normalize_absolute_path` / `normalize_path`
22/// helpers that previously lived in `agent-scope-lock`, `web-evidence`, and
23/// `agent-workflow-primitives::test_first_evidence`.
24pub fn normalize_path(path: &Path) -> PathBuf {
25    let mut normalized = PathBuf::new();
26    for component in path.components() {
27        match component {
28            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
29            Component::RootDir => normalized.push(component.as_os_str()),
30            Component::CurDir => {}
31            Component::ParentDir => {
32                normalized.pop();
33            }
34            Component::Normal(part) => normalized.push(part),
35        }
36    }
37    normalized
38}
39
40/// The user's home directory from `HOME`, treating an unset or empty value as
41/// absent. Reads the environment on every call so subprocess tests can
42/// override it.
43///
44/// Consolidates the per-crate `home_dir` helpers that previously lived in
45/// `agent-workflow-primitives`, `image-processing`, and `zsh-kit`. Empty-value
46/// filtering is now uniform; previously only `zsh-kit` and
47/// `provider_runtime::paths` filtered empty `HOME` values. Callers that need a
48/// platform fallback (for example `USERPROFILE` on Windows) or a
49/// crate-specific error type layer it on top of this helper.
50pub fn home_dir() -> Option<PathBuf> {
51    let raw = std::env::var_os("HOME")?;
52    if raw.is_empty() {
53        return None;
54    }
55    Some(PathBuf::from(raw))
56}
57
58/// Expand a leading `~` (the literal `~` or a `~/` prefix) using [`home_dir`].
59///
60/// The path is returned unchanged when it has no `~` prefix or when no home
61/// directory is available. `~user` forms are not expanded.
62///
63/// Behavior matches the per-crate `expand_user` / `expand_home` helpers that
64/// previously lived in `agent-workflow-primitives::repo_retro`,
65/// `image-processing`, and `zsh-kit`.
66pub fn expand_home(path: &Path) -> PathBuf {
67    let text = path.to_string_lossy();
68    if text == "~" {
69        return home_dir().unwrap_or_else(|| path.to_path_buf());
70    }
71    if let Some(rest) = text.strip_prefix("~/")
72        && let Some(home) = home_dir()
73    {
74        return home.join(rest);
75    }
76    path.to_path_buf()
77}
78
79pub const SECRET_FILE_MODE: u32 = 0o600;
80const MAX_TEMP_PATH_ATTEMPTS: u32 = 10;
81
82#[derive(Debug, Error)]
83pub enum AtomicWriteError {
84    #[error("failed to create parent directory {path}: {source}")]
85    CreateParentDir {
86        path: PathBuf,
87        #[source]
88        source: io::Error,
89    },
90    #[error("failed to create temporary file {path}: {source}")]
91    CreateTempFile {
92        path: PathBuf,
93        #[source]
94        source: io::Error,
95    },
96    #[error("failed to create unique temporary file for {target} after {attempts} attempts")]
97    TempPathExhausted { target: PathBuf, attempts: u32 },
98    #[error("failed to write temporary file {path}: {source}")]
99    WriteTempFile {
100        path: PathBuf,
101        #[source]
102        source: io::Error,
103    },
104    #[error("failed to set permissions on {path}: {source}")]
105    SetPermissions {
106        path: PathBuf,
107        #[source]
108        source: io::Error,
109    },
110    #[error("failed to replace {to} from {from}: {source}")]
111    ReplaceFile {
112        from: PathBuf,
113        to: PathBuf,
114        #[source]
115        source: io::Error,
116    },
117}
118
119#[derive(Debug, Error)]
120pub enum TimestampError {
121    #[error("failed to create parent directory {path}: {source}")]
122    CreateParentDir {
123        path: PathBuf,
124        #[source]
125        source: io::Error,
126    },
127    #[error("failed to write timestamp file {path}: {source}")]
128    WriteFile {
129        path: PathBuf,
130        #[source]
131        source: io::Error,
132    },
133    #[error("failed to remove timestamp file {path}: {source}")]
134    RemoveFile {
135        path: PathBuf,
136        #[source]
137        source: io::Error,
138    },
139}
140
141#[derive(Debug, Error)]
142pub enum WriteTextError {
143    #[error("failed to create parent directory {path}: {source}")]
144    CreateParentDir {
145        path: PathBuf,
146        #[source]
147        source: io::Error,
148    },
149    #[error("failed to write file {path}: {source}")]
150    WriteFile {
151        path: PathBuf,
152        #[source]
153        source: io::Error,
154    },
155}
156
157#[derive(Debug, Error)]
158pub enum FileHashError {
159    #[error("failed to open file for hashing {path}: {source}")]
160    OpenFile {
161        path: PathBuf,
162        #[source]
163        source: io::Error,
164    },
165    #[error("failed to read file for hashing {path}: {source}")]
166    ReadFile {
167        path: PathBuf,
168        #[source]
169        source: io::Error,
170    },
171}
172
173/// Compute a lowercase SHA-256 digest for a file.
174pub fn sha256_file(path: &Path) -> Result<String, FileHashError> {
175    let mut file = File::open(path).map_err(|source| FileHashError::OpenFile {
176        path: path.to_path_buf(),
177        source,
178    })?;
179    let mut hasher = Sha256::new();
180    let mut buf = [0u8; 8192];
181
182    loop {
183        let read = file
184            .read(&mut buf)
185            .map_err(|source| FileHashError::ReadFile {
186                path: path.to_path_buf(),
187                source,
188            })?;
189        if read == 0 {
190            break;
191        }
192        hasher.update(&buf[..read]);
193    }
194
195    Ok(hex_encode(&hasher.finalize()))
196}
197
198/// Write bytes to `path` using a temp file + replace.
199///
200/// The helper creates parent directories when needed and applies `mode` on Unix.
201pub fn write_atomic(path: &Path, contents: &[u8], mode: u32) -> Result<(), AtomicWriteError> {
202    if let Some(parent) = path.parent() {
203        fs::create_dir_all(parent).map_err(|source| AtomicWriteError::CreateParentDir {
204            path: parent.to_path_buf(),
205            source,
206        })?;
207    }
208
209    let mut attempt = 0u32;
210    loop {
211        let tmp_path = temp_path(path, attempt);
212        match OpenOptions::new()
213            .write(true)
214            .create_new(true)
215            .open(&tmp_path)
216        {
217            Ok(mut file) => {
218                file.write_all(contents)
219                    .map_err(|source| AtomicWriteError::WriteTempFile {
220                        path: tmp_path.clone(),
221                        source,
222                    })?;
223                let _ = file.flush();
224                set_permissions(&tmp_path, mode).map_err(|source| {
225                    AtomicWriteError::SetPermissions {
226                        path: tmp_path.clone(),
227                        source,
228                    }
229                })?;
230                drop(file);
231
232                replace_file(&tmp_path, path).map_err(|source| AtomicWriteError::ReplaceFile {
233                    from: tmp_path.clone(),
234                    to: path.to_path_buf(),
235                    source,
236                })?;
237                set_permissions(path, mode).map_err(|source| AtomicWriteError::SetPermissions {
238                    path: path.to_path_buf(),
239                    source,
240                })?;
241                return Ok(());
242            }
243            Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
244                attempt += 1;
245                if attempt > MAX_TEMP_PATH_ATTEMPTS {
246                    return Err(AtomicWriteError::TempPathExhausted {
247                        target: path.to_path_buf(),
248                        attempts: attempt,
249                    });
250                }
251            }
252            Err(source) => {
253                return Err(AtomicWriteError::CreateTempFile {
254                    path: tmp_path,
255                    source,
256                });
257            }
258        }
259    }
260}
261
262/// Persist a timestamp line.
263///
264/// Behavior:
265/// - `Some(value)`: trims at first newline and writes if non-empty.
266/// - `None` or empty value: removes the file, ignoring `NotFound`.
267pub fn write_timestamp(path: &Path, iso: Option<&str>) -> Result<(), TimestampError> {
268    if let Some(raw) = iso {
269        let trimmed = raw.split(&['\n', '\r'][..]).next().unwrap_or("");
270        if !trimmed.is_empty() {
271            if let Some(parent) = path.parent() {
272                fs::create_dir_all(parent).map_err(|source| TimestampError::CreateParentDir {
273                    path: parent.to_path_buf(),
274                    source,
275                })?;
276            }
277            fs::write(path, trimmed).map_err(|source| TimestampError::WriteFile {
278                path: path.to_path_buf(),
279                source,
280            })?;
281            return Ok(());
282        }
283    }
284
285    match fs::remove_file(path) {
286        Ok(()) => Ok(()),
287        Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()),
288        Err(source) => Err(TimestampError::RemoveFile {
289            path: path.to_path_buf(),
290            source,
291        }),
292    }
293}
294
295/// Write UTF-8 text to `path`, creating parent directories when needed.
296pub fn write_text(path: &Path, text: &str) -> Result<(), WriteTextError> {
297    if let Some(parent) = path.parent() {
298        fs::create_dir_all(parent).map_err(|source| WriteTextError::CreateParentDir {
299            path: parent.to_path_buf(),
300            source,
301        })?;
302    }
303
304    fs::write(path, text).map_err(|source| WriteTextError::WriteFile {
305        path: path.to_path_buf(),
306        source,
307    })
308}
309
310/// Replace `to` by renaming `from` to `to`.
311///
312/// Notes:
313/// - On Unix, `rename` overwrites atomically when `from` and `to` are on the same filesystem.
314/// - On Windows, `rename` fails when `to` exists. We fall back to remove + rename, which is not
315///   atomic but matches the expected overwrite behavior for temp-file workflows.
316pub fn replace_file(from: &Path, to: &Path) -> io::Result<()> {
317    replace_file_impl(from, to)
318}
319
320/// Alias for `replace_file` (kept for readability at call sites).
321pub fn rename_overwrite(from: &Path, to: &Path) -> io::Result<()> {
322    replace_file(from, to)
323}
324
325#[cfg(unix)]
326fn replace_file_impl(from: &Path, to: &Path) -> io::Result<()> {
327    fs::rename(from, to)
328}
329
330#[cfg(windows)]
331fn replace_file_impl(from: &Path, to: &Path) -> io::Result<()> {
332    match fs::rename(from, to) {
333        Ok(()) => Ok(()),
334        Err(err) => {
335            // Be conservative: do not delete `to` unless we can confirm `from` exists.
336            if !from.exists() {
337                return Err(err);
338            }
339
340            if !to.exists() {
341                return Err(err);
342            }
343
344            match fs::remove_file(to) {
345                Ok(()) => {}
346                Err(remove_err) if remove_err.kind() == io::ErrorKind::NotFound => {}
347                Err(remove_err) => {
348                    return Err(io::Error::new(
349                        io::ErrorKind::Other,
350                        format!("rename failed: {err} (remove failed: {remove_err})"),
351                    ));
352                }
353            }
354
355            fs::rename(from, to).map_err(|err2| {
356                io::Error::new(
357                    io::ErrorKind::Other,
358                    format!("rename failed: {err} ({err2})"),
359                )
360            })
361        }
362    }
363}
364
365#[cfg(not(any(unix, windows)))]
366fn replace_file_impl(from: &Path, to: &Path) -> io::Result<()> {
367    fs::rename(from, to)
368}
369
370#[cfg(unix)]
371fn set_permissions(path: &Path, mode: u32) -> io::Result<()> {
372    let perm = fs::Permissions::from_mode(mode);
373    fs::set_permissions(path, perm)
374}
375
376#[cfg(not(unix))]
377fn set_permissions(_path: &Path, _mode: u32) -> io::Result<()> {
378    Ok(())
379}
380
381// Atomic-write helper, off the render path: produces a unique tempfile
382// name for the write-temp-then-rename pattern used by `atomic_write`.
383// The Resolved Decision #9 determinism gate covers the render pipeline
384// (nils-agent-runtime's `src/render/`), which never touches this helper
385// — `agent-runtime render` writes through `std::fs::write` directly.
386// Allowing `SystemTime::now()` exactly here keeps the gate green
387// without weakening the rule for any new render-path code.
388#[allow(clippy::disallowed_methods)]
389fn temp_path(path: &Path, attempt: u32) -> PathBuf {
390    let filename = path
391        .file_name()
392        .and_then(|name| name.to_str())
393        .unwrap_or("tmp");
394    let pid = std::process::id();
395    let nanos = SystemTime::now()
396        .duration_since(UNIX_EPOCH)
397        .map(|duration| duration.as_nanos())
398        .unwrap_or(0);
399    let tmp_name = format!(".{filename}.tmp-{pid}-{nanos}-{attempt}");
400    path.with_file_name(tmp_name)
401}
402
403fn hex_encode(bytes: &[u8]) -> String {
404    const HEX: &[u8; 16] = b"0123456789abcdef";
405
406    let mut out = String::with_capacity(bytes.len() * 2);
407    for byte in bytes {
408        out.push(HEX[(byte >> 4) as usize] as char);
409        out.push(HEX[(byte & 0x0f) as usize] as char);
410    }
411    out
412}
413
414struct Sha256 {
415    state: [u32; 8],
416    buffer: [u8; 64],
417    buffer_len: usize,
418    total_len: u64,
419}
420
421impl Sha256 {
422    fn new() -> Self {
423        Self {
424            state: [
425                0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
426                0x5be0cd19,
427            ],
428            buffer: [0u8; 64],
429            buffer_len: 0,
430            total_len: 0,
431        }
432    }
433
434    fn update(&mut self, mut data: &[u8]) {
435        self.total_len = self.total_len.wrapping_add(data.len() as u64);
436
437        if self.buffer_len > 0 {
438            let need = 64 - self.buffer_len;
439            let take = need.min(data.len());
440            self.buffer[self.buffer_len..self.buffer_len + take].copy_from_slice(&data[..take]);
441            self.buffer_len += take;
442            data = &data[take..];
443
444            if self.buffer_len == 64 {
445                let block = self.buffer;
446                self.compress(&block);
447                self.buffer_len = 0;
448            }
449        }
450
451        while data.len() >= 64 {
452            let block: [u8; 64] = data[..64].try_into().expect("64-byte block");
453            self.compress(&block);
454            data = &data[64..];
455        }
456
457        if !data.is_empty() {
458            self.buffer[..data.len()].copy_from_slice(data);
459            self.buffer_len = data.len();
460        }
461    }
462
463    fn finalize(mut self) -> [u8; 32] {
464        let bit_len = self.total_len.wrapping_mul(8);
465
466        self.buffer[self.buffer_len] = 0x80;
467        self.buffer_len += 1;
468
469        if self.buffer_len > 56 {
470            self.buffer[self.buffer_len..].fill(0);
471            let block = self.buffer;
472            self.compress(&block);
473            self.buffer = [0u8; 64];
474            self.buffer_len = 0;
475        }
476
477        self.buffer[self.buffer_len..56].fill(0);
478        self.buffer[56..64].copy_from_slice(&bit_len.to_be_bytes());
479        let block = self.buffer;
480        self.compress(&block);
481
482        let mut out = [0u8; 32];
483        for (index, chunk) in out.chunks_exact_mut(4).enumerate() {
484            chunk.copy_from_slice(&self.state[index].to_be_bytes());
485        }
486        out
487    }
488
489    fn compress(&mut self, block: &[u8; 64]) {
490        let mut schedule = [0u32; 64];
491        for (index, word) in schedule.iter_mut().take(16).enumerate() {
492            let offset = index * 4;
493            *word = u32::from_be_bytes([
494                block[offset],
495                block[offset + 1],
496                block[offset + 2],
497                block[offset + 3],
498            ]);
499        }
500
501        for index in 16..64 {
502            let s0 = schedule[index - 15].rotate_right(7)
503                ^ schedule[index - 15].rotate_right(18)
504                ^ (schedule[index - 15] >> 3);
505            let s1 = schedule[index - 2].rotate_right(17)
506                ^ schedule[index - 2].rotate_right(19)
507                ^ (schedule[index - 2] >> 10);
508            schedule[index] = schedule[index - 16]
509                .wrapping_add(s0)
510                .wrapping_add(schedule[index - 7])
511                .wrapping_add(s1);
512        }
513
514        let mut a = self.state[0];
515        let mut b = self.state[1];
516        let mut c = self.state[2];
517        let mut d = self.state[3];
518        let mut e = self.state[4];
519        let mut f = self.state[5];
520        let mut g = self.state[6];
521        let mut h = self.state[7];
522
523        for index in 0..64 {
524            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
525            let choice = (e & f) ^ ((!e) & g);
526            let t1 = h
527                .wrapping_add(s1)
528                .wrapping_add(choice)
529                .wrapping_add(ROUND_CONSTANTS[index])
530                .wrapping_add(schedule[index]);
531            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
532            let majority = (a & b) ^ (a & c) ^ (b & c);
533            let t2 = s0.wrapping_add(majority);
534
535            h = g;
536            g = f;
537            f = e;
538            e = d.wrapping_add(t1);
539            d = c;
540            c = b;
541            b = a;
542            a = t1.wrapping_add(t2);
543        }
544
545        self.state[0] = self.state[0].wrapping_add(a);
546        self.state[1] = self.state[1].wrapping_add(b);
547        self.state[2] = self.state[2].wrapping_add(c);
548        self.state[3] = self.state[3].wrapping_add(d);
549        self.state[4] = self.state[4].wrapping_add(e);
550        self.state[5] = self.state[5].wrapping_add(f);
551        self.state[6] = self.state[6].wrapping_add(g);
552        self.state[7] = self.state[7].wrapping_add(h);
553    }
554}
555
556const ROUND_CONSTANTS: [u32; 64] = [
557    0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
558    0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
559    0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
560    0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
561    0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
562    0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
563    0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
564    0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
565];
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use nils_test_support::{EnvGuard, GlobalStateLock};
571    use tempfile::TempDir;
572
573    #[test]
574    fn display_path_renders_unicode_paths_losslessly() {
575        assert_eq!(display_path(Path::new("/tmp/repo")), "/tmp/repo");
576        assert_eq!(display_path(Path::new("")), "");
577    }
578
579    #[test]
580    fn home_dir_reads_home_and_filters_unset_and_empty_values() {
581        let lock = GlobalStateLock::new();
582        {
583            let _guard = EnvGuard::set(&lock, "HOME", "/tmp/test-home");
584            assert_eq!(home_dir(), Some(PathBuf::from("/tmp/test-home")));
585        }
586        {
587            let _guard = EnvGuard::set(&lock, "HOME", "");
588            assert_eq!(home_dir(), None);
589        }
590        {
591            let _guard = EnvGuard::remove(&lock, "HOME");
592            assert_eq!(home_dir(), None);
593        }
594    }
595
596    #[test]
597    fn expand_home_expands_tilde_and_tilde_slash_prefixes_only() {
598        let lock = GlobalStateLock::new();
599        let _guard = EnvGuard::set(&lock, "HOME", "/tmp/test-home");
600
601        assert_eq!(expand_home(Path::new("~")), PathBuf::from("/tmp/test-home"));
602        assert_eq!(
603            expand_home(Path::new("~/x/y")),
604            PathBuf::from("/tmp/test-home/x/y")
605        );
606        assert_eq!(
607            expand_home(Path::new("relative/path")),
608            PathBuf::from("relative/path")
609        );
610        assert_eq!(
611            expand_home(Path::new("/abs/path")),
612            PathBuf::from("/abs/path")
613        );
614        assert_eq!(expand_home(Path::new("~user/x")), PathBuf::from("~user/x"));
615    }
616
617    #[test]
618    fn expand_home_returns_path_unchanged_without_home() {
619        let lock = GlobalStateLock::new();
620        let _guard = EnvGuard::remove(&lock, "HOME");
621
622        assert_eq!(expand_home(Path::new("~")), PathBuf::from("~"));
623        assert_eq!(expand_home(Path::new("~/x")), PathBuf::from("~/x"));
624    }
625
626    #[test]
627    fn normalize_path_drops_curdir_components() {
628        assert_eq!(
629            normalize_path(Path::new("/a/./b/./c")),
630            PathBuf::from("/a/b/c")
631        );
632    }
633
634    #[test]
635    fn normalize_path_collapses_parent_components() {
636        assert_eq!(
637            normalize_path(Path::new("/a/b/../c")),
638            PathBuf::from("/a/c")
639        );
640    }
641
642    #[test]
643    fn normalize_path_preserves_root_when_collapsing_past_start() {
644        assert_eq!(normalize_path(Path::new("/../a")), PathBuf::from("/a"));
645    }
646
647    #[test]
648    fn normalize_path_preserves_relative_paths() {
649        assert_eq!(normalize_path(Path::new("a/b/c")), PathBuf::from("a/b/c"));
650    }
651
652    #[test]
653    fn fs_replace_file_overwrites_existing_destination() {
654        let dir = TempDir::new().expect("tempdir");
655        let from = dir.path().join("from.tmp");
656        let to = dir.path().join("to.txt");
657
658        fs::write(&from, "new").expect("write from");
659        fs::write(&to, "old").expect("write to");
660
661        replace_file(&from, &to).expect("replace_file");
662
663        assert!(!from.exists(), "from should be moved away");
664        assert_eq!(fs::read_to_string(&to).expect("read to"), "new");
665    }
666
667    #[test]
668    fn fs_sha256_file_matches_known_hash() {
669        let dir = TempDir::new().expect("tempdir");
670        let path = dir.path().join("blob.txt");
671        fs::write(&path, b"hello\n").expect("write file");
672
673        let digest = sha256_file(&path).expect("sha256");
674
675        assert_eq!(
676            digest,
677            "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
678        );
679    }
680
681    #[test]
682    fn fs_sha256_file_returns_structured_open_error() {
683        let dir = TempDir::new().expect("tempdir");
684        let missing = dir.path().join("missing.txt");
685
686        let err = sha256_file(&missing).expect_err("missing file should fail");
687
688        match err {
689            FileHashError::OpenFile { path, .. } => assert_eq!(path, missing),
690            other => panic!("unexpected error variant: {other:?}"),
691        }
692    }
693
694    #[test]
695    fn fs_write_atomic_creates_parent_and_writes_contents() {
696        let dir = TempDir::new().expect("tempdir");
697        let path = dir.path().join("nested").join("secret.json");
698
699        write_atomic(&path, br#"{"ok":true}"#, SECRET_FILE_MODE).expect("write_atomic");
700
701        assert_eq!(
702            fs::read_to_string(&path).expect("read content"),
703            r#"{"ok":true}"#
704        );
705
706        #[cfg(unix)]
707        {
708            use std::os::unix::fs::PermissionsExt;
709            let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
710            assert_eq!(mode, 0o600);
711        }
712    }
713
714    #[test]
715    fn fs_write_atomic_returns_structured_parent_error() {
716        let dir = TempDir::new().expect("tempdir");
717        let parent_file = dir.path().join("not-a-directory");
718        let target = parent_file.join("secret.json");
719        fs::write(&parent_file, "block parent dir creation").expect("seed file");
720
721        let err = write_atomic(&target, b"{}", SECRET_FILE_MODE)
722            .expect_err("parent dir creation should fail");
723
724        match err {
725            AtomicWriteError::CreateParentDir { path, .. } => assert_eq!(path, parent_file),
726            other => panic!("unexpected error variant: {other:?}"),
727        }
728    }
729
730    #[test]
731    fn fs_write_timestamp_trims_newlines_and_writes_value() {
732        let dir = TempDir::new().expect("tempdir");
733        let path = dir.path().join("stamp.txt");
734
735        write_timestamp(&path, Some("2025-01-20T00:00:00Z\n")).expect("write timestamp");
736
737        assert_eq!(
738            fs::read_to_string(&path).expect("read timestamp"),
739            "2025-01-20T00:00:00Z"
740        );
741    }
742
743    #[test]
744    fn fs_write_timestamp_creates_parent_for_write_path() {
745        let dir = TempDir::new().expect("tempdir");
746        let path = dir.path().join("nested").join("stamp.txt");
747
748        write_timestamp(&path, Some("2025-01-20T00:00:00Z")).expect("write timestamp");
749
750        assert_eq!(
751            fs::read_to_string(&path).expect("read timestamp"),
752            "2025-01-20T00:00:00Z"
753        );
754    }
755
756    #[test]
757    fn fs_write_timestamp_removes_file_when_value_missing_or_empty() {
758        let dir = TempDir::new().expect("tempdir");
759        let path = dir.path().join("stamp.txt");
760        fs::write(&path, "present").expect("seed timestamp");
761
762        write_timestamp(&path, None).expect("timestamp none");
763        assert!(!path.exists(), "expected timestamp file removed");
764
765        fs::write(&path, "present").expect("seed timestamp");
766        write_timestamp(&path, Some("\n")).expect("timestamp empty");
767        assert!(!path.exists(), "expected timestamp file removed");
768    }
769
770    #[test]
771    fn fs_write_timestamp_ignores_missing_remove_target() {
772        let dir = TempDir::new().expect("tempdir");
773        let missing = dir.path().join("missing.timestamp");
774
775        write_timestamp(&missing, None).expect("missing remove should not fail");
776    }
777
778    #[test]
779    fn fs_write_timestamp_remove_path_does_not_create_parent_dir() {
780        let dir = TempDir::new().expect("tempdir");
781        let parent = dir.path().join("missing").join("cache");
782        let missing = parent.join("auth.json.timestamp");
783
784        write_timestamp(&missing, None).expect("missing remove should not fail");
785
786        assert!(
787            !parent.exists(),
788            "remove path should not create parent directories"
789        );
790    }
791
792    #[test]
793    fn fs_write_text_creates_parent_and_writes_contents() {
794        let dir = TempDir::new().expect("tempdir");
795        let path = dir.path().join("nested").join("note.md");
796
797        write_text(&path, "hello").expect("write_text");
798
799        assert_eq!(fs::read_to_string(&path).expect("read text"), "hello");
800    }
801
802    #[test]
803    fn fs_write_text_returns_structured_parent_error() {
804        let dir = TempDir::new().expect("tempdir");
805        let parent_file = dir.path().join("not-a-directory");
806        let target = parent_file.join("note.md");
807        fs::write(&parent_file, "block parent dir creation").expect("seed file");
808
809        let err = write_text(&target, "hello").expect_err("parent dir creation should fail");
810
811        match err {
812            WriteTextError::CreateParentDir { path, .. } => assert_eq!(path, parent_file),
813            other => panic!("unexpected error variant: {other:?}"),
814        }
815    }
816}