Skip to main content

jt_consoleutils/
fs_utils.rs

1use std::path::Path;
2
3/// Check whether two paths refer to the same file on disk.
4/// Returns false if either path doesn't exist or can't be canonicalized.
5pub fn same_file(a: &Path, b: &Path) -> bool {
6   match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
7      (Ok(ca), Ok(cb)) => ca == cb,
8      _ => false
9   }
10}
11
12/// Check whether two files have identical content.
13/// Returns false if either file doesn't exist or can't be read, or if their
14/// sizes differ (avoids reading content when lengths don't match).
15pub fn same_content(a: &Path, b: &Path) -> bool {
16   let (Ok(meta_a), Ok(meta_b)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
17      return false;
18   };
19   if meta_a.len() != meta_b.len() {
20      return false;
21   }
22   let (Ok(bytes_a), Ok(bytes_b)) = (std::fs::read(a), std::fs::read(b)) else {
23      return false;
24   };
25   bytes_a == bytes_b
26}
27
28/// Sets executable permission bits (+x) on Unix; no-op on Windows.
29pub fn make_executable(path: &Path) -> std::io::Result<()> {
30   #[cfg(unix)]
31   {
32      use std::os::unix::fs::PermissionsExt;
33      let mut perms = std::fs::metadata(path)?.permissions();
34      perms.set_mode(perms.mode() | 0o111);
35      std::fs::set_permissions(path, perms)?;
36   }
37   #[cfg(not(unix))]
38   {
39      let _ = path;
40   }
41   Ok(())
42}
43
44/// Remove a path that is a symlink, handling the platform difference between
45/// symlinks to directories (Windows: `remove_dir`) and symlinks to files
46/// (Unix: `remove_file`). Returns `Ok(false)` if `path` is not a symlink.
47pub fn remove_symlink_dir_like(path: &Path) -> Result<bool, std::io::Error> {
48   if !path.is_symlink() {
49      return Ok(false);
50   }
51
52   #[cfg(windows)]
53   std::fs::remove_dir(path)?;
54
55   #[cfg(unix)]
56   std::fs::remove_file(path)?;
57
58   Ok(true)
59}
60
61#[cfg(test)]
62mod tests {
63   use std::fs;
64
65   use tempfile::TempDir;
66
67   use super::*;
68
69   // -------------------------------------------------------------------------
70   // same_file
71   // -------------------------------------------------------------------------
72
73   #[test]
74   fn same_file_returns_true_for_identical_paths() {
75      // Given
76      let dir = TempDir::new().unwrap();
77      let path = dir.path().join("file.txt");
78      fs::write(&path, b"hello").unwrap();
79
80      // When / Then
81      assert!(same_file(&path, &path));
82   }
83
84   #[test]
85   fn same_file_returns_false_for_different_files() {
86      // Given
87      let dir = TempDir::new().unwrap();
88      let a = dir.path().join("a.txt");
89      let b = dir.path().join("b.txt");
90      fs::write(&a, b"hello").unwrap();
91      fs::write(&b, b"hello").unwrap();
92
93      // When / Then
94      assert!(!same_file(&a, &b));
95   }
96
97   #[test]
98   fn same_file_returns_false_when_path_does_not_exist() {
99      // Given
100      let dir = TempDir::new().unwrap();
101      let missing = dir.path().join("missing.txt");
102      let existing = dir.path().join("existing.txt");
103      fs::write(&existing, b"hi").unwrap();
104
105      // When / Then
106      assert!(!same_file(&missing, &existing));
107   }
108
109   // -------------------------------------------------------------------------
110   // same_content
111   // -------------------------------------------------------------------------
112
113   #[test]
114   fn same_content_returns_true_for_identical_bytes() {
115      // Given
116      let dir = TempDir::new().unwrap();
117      let a = dir.path().join("a.txt");
118      let b = dir.path().join("b.txt");
119      fs::write(&a, b"hello world").unwrap();
120      fs::write(&b, b"hello world").unwrap();
121
122      // When / Then
123      assert!(same_content(&a, &b));
124   }
125
126   #[test]
127   fn same_content_returns_false_for_different_bytes() {
128      // Given
129      let dir = TempDir::new().unwrap();
130      let a = dir.path().join("a.txt");
131      let b = dir.path().join("b.txt");
132      fs::write(&a, b"hello").unwrap();
133      fs::write(&b, b"world").unwrap();
134
135      // When / Then
136      assert!(!same_content(&a, &b));
137   }
138
139   #[test]
140   fn same_content_returns_false_for_different_sizes() {
141      // Given
142      let dir = TempDir::new().unwrap();
143      let a = dir.path().join("a.txt");
144      let b = dir.path().join("b.txt");
145      fs::write(&a, b"hi").unwrap();
146      fs::write(&b, b"hello").unwrap();
147
148      // When / Then
149      assert!(!same_content(&a, &b));
150   }
151
152   #[test]
153   fn same_content_returns_false_when_file_missing() {
154      // Given
155      let dir = TempDir::new().unwrap();
156      let a = dir.path().join("a.txt");
157      let missing = dir.path().join("missing.txt");
158      fs::write(&a, b"data").unwrap();
159
160      // When / Then
161      assert!(!same_content(&a, &missing));
162   }
163
164   // -------------------------------------------------------------------------
165   // make_executable
166   // -------------------------------------------------------------------------
167
168   #[test]
169   fn make_executable_does_not_error_on_existing_file() {
170      // Given
171      let dir = TempDir::new().unwrap();
172      let path = dir.path().join("script.sh");
173      fs::write(&path, b"#!/bin/sh\n").unwrap();
174
175      // When / Then
176      assert!(make_executable(&path).is_ok());
177   }
178
179   #[cfg(unix)]
180   #[test]
181   fn make_executable_sets_exec_bit_on_unix() {
182      // Given
183      use std::os::unix::fs::PermissionsExt;
184      let dir = TempDir::new().unwrap();
185      let path = dir.path().join("script.sh");
186      fs::write(&path, b"#!/bin/sh\n").unwrap();
187
188      // Ensure no exec bit initially
189      let mut perms = fs::metadata(&path).unwrap().permissions();
190      perms.set_mode(0o600);
191      fs::set_permissions(&path, perms).unwrap();
192
193      // When
194      make_executable(&path).unwrap();
195
196      // Then
197      let mode = fs::metadata(&path).unwrap().permissions().mode();
198      assert!(mode & 0o111 != 0, "exec bit should be set");
199   }
200
201   // -------------------------------------------------------------------------
202   // remove_symlink_dir_like
203   // -------------------------------------------------------------------------
204
205   #[cfg(unix)]
206   #[test]
207   fn remove_symlink_dir_like_removes_symlink_on_unix() {
208      // Given
209      let dir = TempDir::new().unwrap();
210      let target = dir.path().join("target.txt");
211      let link = dir.path().join("link");
212      fs::write(&target, b"data").unwrap();
213      std::os::unix::fs::symlink(&target, &link).unwrap();
214      assert!(link.is_symlink());
215
216      // When
217      let result = remove_symlink_dir_like(&link).unwrap();
218
219      // Then
220      assert!(result);
221      assert!(!link.exists() && !link.is_symlink());
222   }
223
224   #[test]
225   fn remove_symlink_dir_like_returns_false_for_non_symlink() {
226      // Given
227      let dir = TempDir::new().unwrap();
228      let path = dir.path().join("plain.txt");
229      fs::write(&path, b"data").unwrap();
230
231      // When
232      let result = remove_symlink_dir_like(&path).unwrap();
233
234      // Then
235      assert!(!result);
236   }
237}