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