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 color_eyre::eyre::Context as _;
11use color_eyre::eyre::bail;
12use color_eyre::eyre::eyre;
13use serde::Serialize;
14use ytil_cmd::CmdExt as _;
15
16/// Raw filesystem / MIME classification result returned by [`exec_file_cmd`].
17#[derive(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) -> color_eyre::Result<FileCmdOutput> {
46    let stdout_bytes = Command::new("sh")
47        .arg("-c")
48        .arg(format!("file {path} -I"))
49        .exec()?
50        .stdout;
51    let stdout = std::str::from_utf8(&stdout_bytes)?.to_lowercase();
52    if stdout.contains(" inode/directory;") {
53        return Ok(FileCmdOutput::Directory(path.to_owned()));
54    }
55    if stdout.contains(" text/") || stdout.contains(" application/json") {
56        return Ok(FileCmdOutput::TextFile(path.to_owned()));
57    }
58    if stdout.contains(" application/") {
59        return Ok(FileCmdOutput::BinaryFile(path.to_owned()));
60    }
61    if stdout.contains(" no such file or directory") {
62        return Ok(FileCmdOutput::NotFound(path.to_owned()));
63    }
64    Ok(FileCmdOutput::Unknown(path.to_owned()))
65}
66
67/// Creates a symbolic link from the target to the link path, removing any existing file at the link location.
68///
69/// # Errors
70/// - A filesystem operation (open/read/write/remove) fails.
71/// - Creating the symlink fails.
72/// - The existing link cannot be removed.
73pub fn ln_sf<P: AsRef<Path>>(target: &P, link: &P) -> color_eyre::Result<()> {
74    if link
75        .as_ref()
76        .try_exists()
77        .wrap_err_with(|| eyre!("error checking if link exists | link={}", link.as_ref().display()))?
78    {
79        std::fs::remove_file(link.as_ref())
80            .wrap_err_with(|| eyre!("error removing existing link | link={}", link.as_ref().display()))?;
81    }
82    std::os::unix::fs::symlink(target.as_ref(), link.as_ref()).wrap_err_with(|| {
83        eyre!(
84            "error creating symlink for target={} link={}",
85            target.as_ref().display(),
86            link.as_ref().display()
87        )
88    })?;
89    Ok(())
90}
91
92/// Creates symbolic links for all files in the target directory to the link directory.
93///
94/// # Errors
95/// - A filesystem operation (open/read/write/remove) fails.
96/// - Creating an individual symlink fails.
97/// - Traversing `target_dir` fails.
98pub fn ln_sf_files_in_dir<P: AsRef<std::path::Path>>(target_dir: P, link_dir: P) -> color_eyre::Result<()> {
99    for target in std::fs::read_dir(&target_dir)
100        .wrap_err_with(|| eyre!("error reading directory | path={}", target_dir.as_ref().display()))?
101    {
102        let target = target.wrap_err_with(|| eyre!("error getting target entry"))?.path();
103        if target.is_file() {
104            let target_name = target
105                .file_name()
106                .ok_or_else(|| eyre!("error missing filename for target | path={}", target.display()))?;
107            let link = link_dir.as_ref().join(target_name);
108            ln_sf(&target, &link).wrap_err_with(|| {
109                eyre!(
110                    "error creating symlink | target={} link={}",
111                    target.display(),
112                    link.display()
113                )
114            })?;
115        }
116    }
117    Ok(())
118}
119
120/// Copies the given content to the system clipboard using the `pbcopy` command.
121///
122/// # Errors
123/// - The clipboard program cannot be spawned.
124/// - The clipboard program exits with failure.
125pub fn cp_to_system_clipboard(content: &mut &[u8]) -> color_eyre::Result<()> {
126    let cmd = "pbcopy";
127
128    let mut pbcopy_child = ytil_cmd::silent_cmd(cmd)
129        .stdin(Stdio::piped())
130        .spawn()
131        .wrap_err_with(|| eyre!("error spawning cmd | cmd={cmd:?}"))?;
132
133    std::io::copy(
134        content,
135        pbcopy_child
136            .stdin
137            .as_mut()
138            .ok_or_else(|| eyre!("error getting cmd child stdin | cmd={cmd:?}"))?,
139    )
140    .wrap_err_with(|| eyre!("error copying content to stdin | cmd={cmd:?}"))?;
141
142    if !pbcopy_child
143        .wait()
144        .wrap_err_with(|| eyre!("error waiting for cmd | cmd={cmd:?}"))?
145        .success()
146    {
147        bail!("error copying to system clipboard | cmd={cmd:?} content={content:#?}");
148    }
149
150    Ok(())
151}
152
153/// Sets executable permissions (755) on the specified filepath.
154///
155/// # Errors
156/// - A filesystem operation (open/read/write/remove) fails.
157/// - File metadata cannot be read.
158/// - Permissions cannot be updated.
159pub fn chmod_x<P: AsRef<Path>>(path: P) -> color_eyre::Result<()> {
160    let mut perms = std::fs::metadata(&path)
161        .wrap_err_with(|| eyre!("error reading metadata | path={}", path.as_ref().display()))?
162        .permissions();
163
164    perms.set_mode(0o755);
165
166    std::fs::set_permissions(&path, perms)
167        .wrap_err_with(|| eyre!("error setting permissions | path={}", path.as_ref().display()))?;
168
169    Ok(())
170}
171
172/// Sets executable permissions on all files in the specified directory.
173///
174/// # Errors
175/// - A filesystem operation (open/read/write/remove) fails.
176/// - A chmod operation fails.
177/// - Directory traversal fails.
178pub fn chmod_x_files_in_dir<P: AsRef<Path>>(dir: P) -> color_eyre::Result<()> {
179    for target_res in
180        std::fs::read_dir(&dir).wrap_err_with(|| eyre!("error reading directory | path={}", dir.as_ref().display()))?
181    {
182        let target = target_res
183            .wrap_err_with(|| eyre!("error getting directory entry"))?
184            .path();
185        if target.is_file() {
186            chmod_x(&target).wrap_err_with(|| eyre!("error setting permissions | path={}", target.display()))?;
187        }
188    }
189    Ok(())
190}
191
192/// Atomically copies a file from `from` to `to`.
193///
194/// The content is first written to a uniquely named temporary sibling (with
195/// PID and timestamp) and then moved into place with [`std::fs::rename`]. This
196/// minimizes the window where readers could observe a partially written file.
197///
198/// # Errors
199/// - A filesystem operation (open/read/write/remove) fails.
200/// - `from` Does not exist.
201/// - The atomic rename fails.
202/// - The destination's parent directory or file name cannot be resolved.
203/// - The temporary copy fails.
204pub fn atomic_cp(from: &Path, to: &Path) -> color_eyre::Result<()> {
205    if !from.exists() {
206        return Err(eyre!("error missing source file | path={}", from.display()));
207    }
208
209    let tmp_name = format!(
210        "{}.tmp-{}-{}",
211        to.file_name()
212            .ok_or_else(|| eyre!("error getting file name | path={}", to.display()))?
213            .to_string_lossy(),
214        std::process::id(),
215        Utc::now().to_rfc3339()
216    );
217    let tmp_path = to
218        .parent()
219        .ok_or_else(|| eyre!("error missing parent directory | path={}", to.display()))?
220        .join(tmp_name);
221
222    std::fs::copy(from, &tmp_path).with_context(|| {
223        format!(
224            "error copying file to temp | from={} temp={}",
225            from.display(),
226            tmp_path.display()
227        )
228    })?;
229    std::fs::rename(&tmp_path, to)
230        .with_context(|| format!("error renaming file | from={} to={}", tmp_path.display(), to.display()))?;
231
232    Ok(())
233}
234
235/// Recursively find files matching a predicate (breadth-first)
236///
237/// Performs a breadth-first traversal starting at `dir`, skipping directories for which
238/// `skip_dir_fn` returns true, and collecting file paths for which `matching_file_fn` returns true.
239///
240/// # Errors
241/// - A directory cannot be read.
242/// - File type metadata for an entry cannot be determined.
243/// - Any underlying filesystem I/O error occurs during traversal.
244///
245/// # Performance
246/// Uses an in-memory queue (BFS). For extremely deep trees consider a streaming iterator variant;
247/// current implementation favors simplicity over incremental output.
248///
249/// # Future Work
250/// - Provide an iterator adapter (`impl Iterator<Item = PathBuf>`), avoiding collecting all results.
251/// - Optional parallel traversal behind a feature flag for large repositories.
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) -> color_eyre::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
262            std::fs::read_dir(&dir).wrap_err_with(|| eyre!("error reading directory | path={}", dir.display()))?
263        {
264            let entry = entry.wrap_err_with(|| eyre!("error getting entry"))?;
265            let path = entry.path();
266            let file_type = entry
267                .file_type()
268                .wrap_err_with(|| eyre!("error getting file type | entry={}", path.display()))?;
269
270            if file_type.is_file() {
271                if matching_file_fn(&entry) {
272                    manifests.push(path);
273                }
274                continue;
275            }
276
277            if skip_dir_fn(&entry) {
278                continue;
279            }
280            queue.push_back(path);
281        }
282    }
283
284    Ok(manifests)
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn atomic_cp_copies_file_contents() {
293        let dir = tempfile::tempdir().unwrap();
294        let src = dir.path().join("src.txt");
295        let dst = dir.path().join("dst.txt");
296        std::fs::write(&src, b"hello").unwrap();
297
298        let res = atomic_cp(&src, &dst);
299
300        assert2::let_assert!(Ok(()) = res);
301        assert_eq!(std::fs::read(&dst).unwrap(), b"hello");
302    }
303
304    #[test]
305    fn atomic_cp_errors_when_missing_source() {
306        let dir = tempfile::tempdir().unwrap();
307        let src = dir.path().join("missing.txt");
308        let dst = dir.path().join("dst.txt");
309
310        let res = atomic_cp(&src, &dst);
311
312        assert2::let_assert!(Err(err) = res);
313        assert!(err.to_string().contains("error missing source file"));
314    }
315
316    #[test]
317    fn find_matching_recursively_in_dir_returns_the_expected_paths() {
318        let dir = tempfile::tempdir().unwrap();
319        // layout: a/, a/b/, c.txt, a/b/d.txt
320        std::fs::create_dir(dir.path().join("a")).unwrap();
321        std::fs::create_dir(dir.path().join("a/b")).unwrap();
322        std::fs::write(dir.path().join("c.txt"), b"c").unwrap();
323        std::fs::write(dir.path().join("a/b/d.txt"), b"d").unwrap();
324
325        let res = find_matching_recursively_in_dir(
326            dir.path(),
327            |e| e.path().extension().and_then(|s| s.to_str()) == Some("txt"),
328            |_| false,
329        );
330        assert2::let_assert!(Ok(mut found) = res);
331        found.sort();
332
333        let mut expected = vec![dir.path().join("c.txt"), dir.path().join("a/b/d.txt")];
334        expected.sort();
335        assert_eq!(found, expected);
336    }
337}