Skip to main content

ytil_sys/
file.rs

1use std::collections::VecDeque;
2use std::fs::DirEntry;
3use std::os::unix::fs::PermissionsExt as _;
4use std::path::Path;
5use std::path::PathBuf;
6use std::process::Command;
7use std::process::Stdio;
8
9use chrono::Utc;
10use rootcause::prelude::ResultExt as _;
11use rootcause::report;
12use serde::Serialize;
13use ytil_cmd::CmdExt as _;
14
15/// Raw filesystem / MIME classification result returned by [`exec_file_cmd`].
16#[derive(Clone, Serialize)]
17pub enum FileCmdOutput {
18    /// Path identified as a binary file.
19    BinaryFile(String),
20    /// Path identified as a text (plain / CSV) file.
21    TextFile(String),
22    /// Path identified as a directory.
23    Directory(String),
24    /// Path that does not exist.
25    NotFound(String),
26    /// Path whose type could not be determined.
27    Unknown(String),
28}
29
30/// Execute the system `file -I` command for `path` and classify the MIME output
31/// into a [`FileCmdOutput`].
32///
33/// Used to distinguish:
34/// - directories
35/// - text files
36/// - binary files
37/// - missing paths
38/// - unknown types
39///
40/// # Errors
41/// - launching or waiting on the `file` command fails
42/// - the command exits with non-success
43/// - standard output cannot be decoded as valid UTF-8
44pub fn exec_file_cmd(path: &str) -> rootcause::Result<FileCmdOutput> {
45    let stdout_bytes = Command::new("file").args(["-I", path]).exec()?.stdout;
46    let stdout = std::str::from_utf8(&stdout_bytes)?.to_lowercase();
47    if stdout.contains(" inode/directory;") {
48        return Ok(FileCmdOutput::Directory(path.to_owned()));
49    }
50    if stdout.contains(" text/") || stdout.contains(" application/json") {
51        return Ok(FileCmdOutput::TextFile(path.to_owned()));
52    }
53    if stdout.contains(" application/") {
54        return Ok(FileCmdOutput::BinaryFile(path.to_owned()));
55    }
56    if stdout.contains(" no such file or directory") {
57        return Ok(FileCmdOutput::NotFound(path.to_owned()));
58    }
59    Ok(FileCmdOutput::Unknown(path.to_owned()))
60}
61
62/// Creates a symbolic link from the target to the link path, removing any existing file at the link location.
63///
64/// # Errors
65/// - A filesystem operation (open/read/write/remove) fails.
66/// - Creating the symlink fails.
67/// - The existing link cannot be removed.
68pub fn ln_sf<P: AsRef<Path>>(target: &P, link: &P) -> rootcause::Result<()> {
69    // Remove atomically without check-then-remove TOCTOU race, ignoring NotFound
70    match std::fs::remove_file(link.as_ref()) {
71        Ok(()) => {}
72        Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => {}
73        Err(e) => {
74            Err(e)
75                .context("error removing existing link")
76                .attach_with(|| format!("link={}", link.as_ref().display()))?;
77        }
78    }
79    std::os::unix::fs::symlink(target.as_ref(), link.as_ref())
80        .context("error creating symlink")
81        .attach_with(|| format!("target={} link={}", target.as_ref().display(), link.as_ref().display()))?;
82    Ok(())
83}
84
85/// Creates symbolic links for all files in the target directory to the link directory.
86///
87/// # Errors
88/// - A filesystem operation (open/read/write/remove) fails.
89/// - Creating an individual symlink fails.
90/// - Traversing `target_dir` fails.
91pub fn ln_sf_files_in_dir<P: AsRef<std::path::Path>>(target_dir: P, link_dir: P) -> rootcause::Result<()> {
92    for target in std::fs::read_dir(&target_dir)
93        .context("error reading directory")
94        .attach_with(|| format!("path={}", target_dir.as_ref().display()))?
95    {
96        let target = target.context("error getting target entry")?.path();
97        if target.is_file() {
98            let target_name = target
99                .file_name()
100                .ok_or_else(|| report!("error missing filename for target"))
101                .attach_with(|| format!("path={}", target.display()))?;
102            let link = link_dir.as_ref().join(target_name);
103            ln_sf(&target, &link)
104                .context("error linking file from directory")
105                .attach_with(|| format!("target={} link={}", target.display(), link.display()))?;
106        }
107    }
108    Ok(())
109}
110
111/// Copies the given content to the system clipboard using the `pbcopy` command (macOS only).
112///
113/// # Errors
114/// - The clipboard program cannot be spawned.
115/// - The clipboard program exits with failure.
116pub fn cp_to_system_clipboard(content: &mut &[u8]) -> rootcause::Result<()> {
117    let cmd = "pbcopy";
118
119    let mut pbcopy_child = ytil_cmd::silent_cmd(cmd)
120        .stdin(Stdio::piped())
121        .spawn()
122        .context("error spawning cmd")
123        .attach_with(|| format!("cmd={cmd:?}"))?;
124
125    std::io::copy(
126        content,
127        pbcopy_child
128            .stdin
129            .as_mut()
130            .ok_or_else(|| report!("error getting cmd child stdin"))
131            .attach_with(|| format!("cmd={cmd:?}"))?,
132    )
133    .context("error copying content to stdin")
134    .attach_with(|| format!("cmd={cmd:?}"))?;
135
136    if !pbcopy_child
137        .wait()
138        .context("error waiting for cmd")
139        .attach_with(|| format!("cmd={cmd:?}"))?
140        .success()
141    {
142        Err(report!("error copying to system clipboard"))
143            .attach_with(|| format!("cmd={cmd:?} content={content:#?}"))?;
144    }
145
146    Ok(())
147}
148
149/// Sets executable permissions (755) on the specified filepath.
150///
151/// # Errors
152/// - A filesystem operation (open/read/write/remove) fails.
153/// - File metadata cannot be read.
154/// - Permissions cannot be updated.
155pub fn chmod_x<P: AsRef<Path>>(path: P) -> rootcause::Result<()> {
156    let mut perms = std::fs::metadata(&path)
157        .context("error reading metadata")
158        .attach_with(|| format!("path={}", path.as_ref().display()))?
159        .permissions();
160
161    perms.set_mode(0o755);
162
163    std::fs::set_permissions(&path, perms)
164        .context("error setting permissions")
165        .attach_with(|| format!("path={}", path.as_ref().display()))?;
166
167    Ok(())
168}
169
170/// Sets executable permissions on all files in the specified directory.
171///
172/// # Errors
173/// - A filesystem operation (open/read/write/remove) fails.
174/// - A chmod operation fails.
175/// - Directory traversal fails.
176pub fn chmod_x_files_in_dir<P: AsRef<Path>>(dir: P) -> rootcause::Result<()> {
177    for target_res in std::fs::read_dir(&dir)
178        .context("error reading directory")
179        .attach_with(|| format!("path={}", dir.as_ref().display()))?
180    {
181        let target = target_res.context("error getting directory entry")?.path();
182        if target.is_file() {
183            chmod_x(&target)
184                .context("error setting file permissions in directory")
185                .attach_with(|| format!("path={}", target.display()))?;
186        }
187    }
188    Ok(())
189}
190
191/// Atomically copies a file from `from` to `to`.
192///
193/// The content is first written to a uniquely named temporary sibling (with
194/// PID and timestamp) and then moved into place with [`std::fs::rename`]. This
195/// minimizes the window where readers could observe a partially written file.
196///
197/// # Errors
198/// - A filesystem operation (open/read/write/remove) fails.
199/// - `from` does not exist (error from `std::fs::copy`).
200/// - The atomic rename fails.
201/// - The destination's parent directory or file name cannot be resolved.
202/// - The temporary copy fails.
203pub fn atomic_cp(from: &Path, to: &Path) -> rootcause::Result<()> {
204    // Removed explicit existence check to avoid TOCTOU race - let std::fs::copy
205    // report the error if the source doesn't exist
206    let tmp_name = format!(
207        "{}.tmp-{}-{}",
208        to.file_name()
209            .ok_or_else(|| report!("error getting file name"))
210            .attach_with(|| format!("path={}", to.display()))?
211            .to_string_lossy(),
212        std::process::id(),
213        Utc::now().to_rfc3339()
214    );
215    let tmp_path = to
216        .parent()
217        .ok_or_else(|| report!("error missing parent directory"))
218        .attach_with(|| format!("path={}", to.display()))?
219        .join(tmp_name);
220
221    std::fs::copy(from, &tmp_path)
222        .context("error copying file to temp")
223        .attach_with(|| format!("from={} temp={}", from.display(), tmp_path.display()))?;
224    std::fs::rename(&tmp_path, to)
225        .context("error renaming file")
226        .attach_with(|| format!("from={} to={}", tmp_path.display(), to.display()))?;
227
228    Ok(())
229}
230
231/// Recursively find files matching a predicate (breadth-first)
232///
233/// Performs a breadth-first traversal starting at `dir`, skipping directories for which
234/// `skip_dir_fn` returns true, and collecting file paths for which `matching_file_fn` returns true.
235///
236/// # Errors
237/// - Filesystem I/O error during traversal.
238pub fn find_matching_recursively_in_dir(
239    dir: &Path,
240    matching_file_fn: impl Fn(&DirEntry) -> bool,
241    skip_dir_fn: impl Fn(&DirEntry) -> bool,
242) -> rootcause::Result<Vec<PathBuf>> {
243    let mut manifests = Vec::new();
244    let mut queue = VecDeque::from([dir.to_path_buf()]);
245
246    while let Some(dir) = queue.pop_front() {
247        for entry in std::fs::read_dir(&dir)
248            .context("error reading directory")
249            .attach_with(|| format!("path={}", dir.display()))?
250        {
251            let entry = entry.context("error getting entry")?;
252            let path = entry.path();
253            let file_type = entry
254                .file_type()
255                .context("error getting file type")
256                .attach_with(|| format!("entry={}", path.display()))?;
257
258            if file_type.is_file() {
259                if matching_file_fn(&entry) {
260                    manifests.push(path);
261                }
262                continue;
263            }
264
265            if skip_dir_fn(&entry) {
266                continue;
267            }
268            queue.push_back(path);
269        }
270    }
271
272    Ok(manifests)
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn atomic_cp_copies_file_contents() {
281        let dir = tempfile::tempdir().unwrap();
282        let src = dir.path().join("src.txt");
283        let dst = dir.path().join("dst.txt");
284        std::fs::write(&src, b"hello").unwrap();
285
286        let res = atomic_cp(&src, &dst);
287
288        assert2::assert!(let Ok(()) = res);
289        assert_eq!(std::fs::read(&dst).unwrap(), b"hello");
290    }
291
292    #[test]
293    fn atomic_cp_errors_when_missing_source() {
294        let dir = tempfile::tempdir().unwrap();
295        let src = dir.path().join("missing.txt");
296        let dst = dir.path().join("dst.txt");
297
298        let res = atomic_cp(&src, &dst);
299
300        assert2::assert!(let Err(err) = res);
301        // Error now comes from std::fs::copy wrapped with context
302        assert!(err.to_string().contains("error copying file to temp"));
303    }
304
305    #[test]
306    fn find_matching_recursively_in_dir_returns_the_expected_paths() {
307        let dir = tempfile::tempdir().unwrap();
308        // layout: a/, a/b/, c.txt, a/b/d.txt
309        std::fs::create_dir(dir.path().join("a")).unwrap();
310        std::fs::create_dir(dir.path().join("a/b")).unwrap();
311        std::fs::write(dir.path().join("c.txt"), b"c").unwrap();
312        std::fs::write(dir.path().join("a/b/d.txt"), b"d").unwrap();
313
314        let res = find_matching_recursively_in_dir(
315            dir.path(),
316            |e| e.path().extension().and_then(|s| s.to_str()) == Some("txt"),
317            |_| false,
318        );
319        assert2::assert!(let Ok(mut found) = res);
320        found.sort();
321
322        let mut expected = vec![dir.path().join("c.txt"), dir.path().join("a/b/d.txt")];
323        expected.sort();
324        assert_eq!(found, expected);
325    }
326}