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
9pub fn display_path(path: &Path) -> String {
13 path.to_string_lossy().to_string()
14}
15
16pub 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
40pub 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
58pub 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
173pub 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
198pub 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
262pub 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
295pub 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
310pub fn replace_file(from: &Path, to: &Path) -> io::Result<()> {
317 replace_file_impl(from, to)
318}
319
320pub 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 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#[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}