Skip to main content

ytil_sys/
file.rs

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