Skip to main content

stryke/
perl_fs.rs

1//! Perl-style filesystem helpers (`stat`, `glob`, etc.).
2
3use glob::{MatchOptions, Pattern};
4use rand::Rng;
5use rayon::prelude::*;
6use std::env;
7use std::io::{self, BufRead, Write};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::UNIX_EPOCH;
11
12use crate::pmap_progress::PmapProgress;
13use crate::value::PerlValue;
14
15pub use crate::perl_decode::{
16    decode_utf8_or_latin1, decode_utf8_or_latin1_line, decode_utf8_or_latin1_read_until,
17};
18
19/// Read a file as text for Perl source or slurped data. Unlike [`std::fs::read_to_string`], this
20/// does not reject bytes that are not valid UTF-8 (stock `perl` accepts such files by default).
21pub fn read_file_text_perl_compat(path: impl AsRef<Path>) -> io::Result<String> {
22    let bytes = std::fs::read(path.as_ref())?;
23    Ok(decode_utf8_or_latin1(&bytes))
24}
25
26/// Like [`BufRead::read_line`] but decodes with [`decode_utf8_or_latin1_read_until`] (no U+FFFD).
27pub fn read_line_perl_compat(reader: &mut impl BufRead, buf: &mut String) -> io::Result<usize> {
28    buf.clear();
29    let mut raw = Vec::new();
30    let n = reader.read_until(b'\n', &mut raw)?;
31    if n == 0 {
32        return Ok(0);
33    }
34    buf.push_str(&decode_utf8_or_latin1_read_until(&raw));
35    Ok(n)
36}
37
38/// One line from `reader` (delimiter `\n`), content **without** trailing `\n` / `\r\n` / `\r`,
39/// same as [`BufRead::lines`] but UTF-8 or Latin-1 per line.
40pub fn read_logical_line_perl_compat(reader: &mut impl BufRead) -> io::Result<Option<String>> {
41    let mut buf = Vec::new();
42    let n = reader.read_until(b'\n', &mut buf)?;
43    if n == 0 {
44        return Ok(None);
45    }
46    if buf.ends_with(b"\n") {
47        buf.pop();
48        if buf.ends_with(b"\r") {
49            buf.pop();
50        }
51    }
52    Ok(Some(decode_utf8_or_latin1_line(&buf)))
53}
54
55/// Perl `-t` — true if the handle/path refers to a terminal ([`libc::isatty`] on Unix).
56/// Recognizes `STDIN`/`STDOUT`/`STDERR`, `/dev/stdin` (etc.), `/dev/fd/N`, small numeric fds, or opens a path and tests its fd.
57pub fn filetest_is_tty(path: &str) -> bool {
58    #[cfg(unix)]
59    {
60        use std::os::unix::io::AsRawFd;
61        if let Some(fd) = tty_fd_literal(path) {
62            return unsafe { libc::isatty(fd) != 0 };
63        }
64        if let Ok(f) = std::fs::File::open(path) {
65            return unsafe { libc::isatty(f.as_raw_fd()) != 0 };
66        }
67    }
68    #[cfg(not(unix))]
69    {
70        let _ = path;
71    }
72    false
73}
74
75#[cfg(unix)]
76fn tty_fd_literal(path: &str) -> Option<i32> {
77    match path {
78        "" | "STDIN" | "-" | "/dev/stdin" => Some(0),
79        "STDOUT" | "/dev/stdout" => Some(1),
80        "STDERR" | "/dev/stderr" => Some(2),
81        p if p.starts_with("/dev/fd/") => p.strip_prefix("/dev/fd/").and_then(|s| s.parse().ok()),
82        _ => path.parse::<i32>().ok().filter(|&n| (0..128).contains(&n)),
83    }
84}
85
86/// Check if effective uid/gid has the given access to a file.
87/// `check` is one of 4 (read), 2 (write), 1 (execute).
88#[cfg(unix)]
89pub fn filetest_effective_access(path: &str, check: u32) -> bool {
90    use std::os::unix::fs::MetadataExt;
91    let meta = match std::fs::metadata(path) {
92        Ok(m) => m,
93        Err(_) => return false,
94    };
95    let mode = meta.mode();
96    let euid = unsafe { libc::geteuid() };
97    let egid = unsafe { libc::getegid() };
98    // Root can read/write anything, execute if any x bit set
99    if euid == 0 {
100        return if check == 1 { mode & 0o111 != 0 } else { true };
101    }
102    if meta.uid() == euid {
103        return mode & (check << 6) != 0;
104    }
105    if meta.gid() == egid {
106        return mode & (check << 3) != 0;
107    }
108    mode & check != 0
109}
110
111/// Check if real uid/gid has the given access (uses libc::access).
112#[cfg(unix)]
113pub fn filetest_real_access(path: &str, amode: libc::c_int) -> bool {
114    match std::ffi::CString::new(path) {
115        Ok(c) => unsafe { libc::access(c.as_ptr(), amode) == 0 },
116        Err(_) => false,
117    }
118}
119
120/// Is the file owned by effective uid?
121#[cfg(unix)]
122pub fn filetest_owned_effective(path: &str) -> bool {
123    use std::os::unix::fs::MetadataExt;
124    std::fs::metadata(path)
125        .map(|m| m.uid() == unsafe { libc::geteuid() })
126        .unwrap_or(false)
127}
128
129/// Is the file owned by real uid?
130#[cfg(unix)]
131pub fn filetest_owned_real(path: &str) -> bool {
132    use std::os::unix::fs::MetadataExt;
133    std::fs::metadata(path)
134        .map(|m| m.uid() == unsafe { libc::getuid() })
135        .unwrap_or(false)
136}
137
138/// Is the file a named pipe (FIFO)?
139#[cfg(unix)]
140pub fn filetest_is_pipe(path: &str) -> bool {
141    use std::os::unix::fs::FileTypeExt;
142    std::fs::metadata(path)
143        .map(|m| m.file_type().is_fifo())
144        .unwrap_or(false)
145}
146
147/// Is the file a socket?
148#[cfg(unix)]
149pub fn filetest_is_socket(path: &str) -> bool {
150    use std::os::unix::fs::FileTypeExt;
151    std::fs::metadata(path)
152        .map(|m| m.file_type().is_socket())
153        .unwrap_or(false)
154}
155
156/// Is the file a block device?
157#[cfg(unix)]
158pub fn filetest_is_block_device(path: &str) -> bool {
159    use std::os::unix::fs::FileTypeExt;
160    std::fs::metadata(path)
161        .map(|m| m.file_type().is_block_device())
162        .unwrap_or(false)
163}
164
165/// Is the file a character device?
166#[cfg(unix)]
167pub fn filetest_is_char_device(path: &str) -> bool {
168    use std::os::unix::fs::FileTypeExt;
169    std::fs::metadata(path)
170        .map(|m| m.file_type().is_char_device())
171        .unwrap_or(false)
172}
173
174/// Is setuid bit set?
175#[cfg(unix)]
176pub fn filetest_is_setuid(path: &str) -> bool {
177    use std::os::unix::fs::MetadataExt;
178    std::fs::metadata(path)
179        .map(|m| m.mode() & 0o4000 != 0)
180        .unwrap_or(false)
181}
182
183/// Is setgid bit set?
184#[cfg(unix)]
185pub fn filetest_is_setgid(path: &str) -> bool {
186    use std::os::unix::fs::MetadataExt;
187    std::fs::metadata(path)
188        .map(|m| m.mode() & 0o2000 != 0)
189        .unwrap_or(false)
190}
191
192/// Is sticky bit set?
193#[cfg(unix)]
194pub fn filetest_is_sticky(path: &str) -> bool {
195    use std::os::unix::fs::MetadataExt;
196    std::fs::metadata(path)
197        .map(|m| m.mode() & 0o1000 != 0)
198        .unwrap_or(false)
199}
200
201/// Is the file a text file? (Perl heuristic: read first block, check for high proportion of printable chars)
202pub fn filetest_is_text(path: &str) -> bool {
203    filetest_text_binary(path, true)
204}
205
206/// Is the file a binary file? (opposite of text)
207pub fn filetest_is_binary(path: &str) -> bool {
208    filetest_text_binary(path, false)
209}
210
211fn filetest_text_binary(path: &str, want_text: bool) -> bool {
212    use std::io::Read;
213    let mut f = match std::fs::File::open(path) {
214        Ok(f) => f,
215        Err(_) => return false,
216    };
217    let mut buf = [0u8; 512];
218    let n = match f.read(&mut buf) {
219        Ok(n) => n,
220        Err(_) => return false,
221    };
222    if n == 0 {
223        // Empty files are considered text in Perl
224        return want_text;
225    }
226    let slice = &buf[..n];
227    // Count bytes that are "non-text": NUL and control chars (except \t \n \r \x1b)
228    let non_text = slice
229        .iter()
230        .filter(|&&b| b == 0 || (b < 0x20 && b != b'\t' && b != b'\n' && b != b'\r' && b != 0x1b))
231        .count();
232    let is_text = (non_text as f64 / n as f64) < 0.30;
233    if want_text {
234        is_text
235    } else {
236        !is_text
237    }
238}
239
240/// File age in fractional days since now. `which`: 'M' = mtime, 'A' = atime, 'C' = ctime.
241#[cfg(unix)]
242pub fn filetest_age_days(path: &str, which: char) -> Option<f64> {
243    use std::os::unix::fs::MetadataExt;
244    let meta = std::fs::metadata(path).ok()?;
245    let t = match which {
246        'M' => meta.mtime() as f64,
247        'A' => meta.atime() as f64,
248        _ => meta.ctime() as f64,
249    };
250    let now = std::time::SystemTime::now()
251        .duration_since(std::time::UNIX_EPOCH)
252        .unwrap_or_default()
253        .as_secs_f64();
254    Some((now - t) / 86400.0)
255}
256
257/// 13-element `stat` / `lstat` list (empty vector on failure).
258pub fn stat_path(path: &str, symlink: bool) -> PerlValue {
259    let res = if symlink {
260        std::fs::symlink_metadata(path)
261    } else {
262        std::fs::metadata(path)
263    };
264    match res {
265        Ok(meta) => PerlValue::array(perl_stat_from_metadata(&meta)),
266        Err(_) => PerlValue::array(vec![]),
267    }
268}
269
270pub fn perl_stat_from_metadata(meta: &std::fs::Metadata) -> Vec<PerlValue> {
271    #[cfg(unix)]
272    {
273        use std::os::unix::fs::MetadataExt;
274        vec![
275            PerlValue::integer(meta.dev() as i64),
276            PerlValue::integer(meta.ino() as i64),
277            PerlValue::integer(meta.mode() as i64),
278            PerlValue::integer(meta.nlink() as i64),
279            PerlValue::integer(meta.uid() as i64),
280            PerlValue::integer(meta.gid() as i64),
281            PerlValue::integer(meta.rdev() as i64),
282            PerlValue::integer(meta.len() as i64),
283            PerlValue::integer(meta.atime()),
284            PerlValue::integer(meta.mtime()),
285            PerlValue::integer(meta.ctime()),
286            PerlValue::integer(meta.blksize() as i64),
287            PerlValue::integer(meta.blocks() as i64),
288        ]
289    }
290    #[cfg(not(unix))]
291    {
292        let len = meta.len() as i64;
293        vec![
294            PerlValue::integer(0),
295            PerlValue::integer(0),
296            PerlValue::integer(0),
297            PerlValue::integer(0),
298            PerlValue::integer(0),
299            PerlValue::integer(0),
300            PerlValue::integer(0),
301            PerlValue::integer(len),
302            PerlValue::integer(0),
303            PerlValue::integer(0),
304            PerlValue::integer(0),
305            PerlValue::integer(0),
306            PerlValue::integer(0),
307        ]
308    }
309}
310
311pub fn link_hard(old: &str, new: &str) -> PerlValue {
312    PerlValue::integer(if std::fs::hard_link(old, new).is_ok() {
313        1
314    } else {
315        0
316    })
317}
318
319pub fn link_sym(old: &str, new: &str) -> PerlValue {
320    #[cfg(unix)]
321    {
322        use std::os::unix::fs::symlink;
323        PerlValue::integer(if symlink(old, new).is_ok() { 1 } else { 0 })
324    }
325    #[cfg(not(unix))]
326    {
327        let _ = (old, new);
328        PerlValue::integer(0)
329    }
330}
331
332pub fn read_link(path: &str) -> PerlValue {
333    match std::fs::read_link(path) {
334        Ok(p) => PerlValue::string(p.to_string_lossy().into_owned()),
335        Err(_) => PerlValue::UNDEF,
336    }
337}
338
339/// Absolute path with symlinks resolved (`std::fs::canonicalize`); all path components must exist.
340pub fn realpath_resolved(path: &str) -> io::Result<String> {
341    std::fs::canonicalize(path).map(|p| p.to_string_lossy().into_owned())
342}
343
344/// Normalize `.` / `..` and redundant separators without touching the disk (Perl
345/// `File::Spec->canonpath`-like). Unlike [`std::path::Path::components`] alone, this collapses
346/// `foo/..` in relative paths instead of preserving `..` for symlink safety.
347pub fn canonpath_logical(path: &str) -> String {
348    use std::path::Component;
349    if path.is_empty() {
350        return String::new();
351    }
352    let mut stack: Vec<String> = Vec::new();
353    let mut anchored = false;
354    for c in Path::new(path).components() {
355        match c {
356            Component::Prefix(p) => {
357                stack.push(p.as_os_str().to_string_lossy().into_owned());
358            }
359            Component::RootDir => {
360                anchored = true;
361                stack.clear();
362            }
363            Component::CurDir => {}
364            Component::Normal(s) => {
365                stack.push(s.to_string_lossy().into_owned());
366            }
367            Component::ParentDir => {
368                if anchored {
369                    if !stack.is_empty() {
370                        stack.pop();
371                    }
372                } else if stack.is_empty() || stack.last().is_some_and(|t| t == "..") {
373                    stack.push("..".to_string());
374                } else {
375                    stack.pop();
376                }
377            }
378        }
379    }
380    let body = stack.join("/");
381    if anchored {
382        if body.is_empty() {
383            "/".to_string()
384        } else {
385            format!("/{body}")
386        }
387    } else if body.is_empty() {
388        ".".to_string()
389    } else {
390        body
391    }
392}
393
394/// List file/directory names inside `dir` (non-recursive), sorted.
395/// Returns an empty list if `dir` cannot be read.
396pub fn list_files(dir: &str) -> PerlValue {
397    let mut names: Vec<String> = Vec::new();
398    if let Ok(entries) = std::fs::read_dir(dir) {
399        for entry in entries.flatten() {
400            if let Some(name) = entry.file_name().to_str() {
401                names.push(name.to_string());
402            }
403        }
404    }
405    names.sort();
406    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
407}
408
409/// List only regular file names inside `dir` (non-recursive), sorted.
410/// Excludes directories, symlinks, and special files.
411/// Returns an empty list if `dir` cannot be read.
412pub fn list_filesf(dir: &str) -> PerlValue {
413    let mut names: Vec<String> = Vec::new();
414    if let Ok(entries) = std::fs::read_dir(dir) {
415        for entry in entries.flatten() {
416            if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
417                if let Some(name) = entry.file_name().to_str() {
418                    names.push(name.to_string());
419                }
420            }
421        }
422    }
423    names.sort();
424    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
425}
426
427/// List only regular file paths under `dir` **recursively**, sorted.
428/// Returns relative paths from `dir` (e.g. `"sub/file.txt"`).
429/// Returns an empty list if `dir` cannot be read.
430pub fn list_filesf_recursive(dir: &str) -> PerlValue {
431    let root = std::path::Path::new(dir);
432    let mut paths: Vec<String> = Vec::new();
433    fn walk(base: &std::path::Path, rel: &str, out: &mut Vec<String>) {
434        let Ok(entries) = std::fs::read_dir(base) else {
435            return;
436        };
437        for entry in entries.flatten() {
438            let ft = match entry.file_type() {
439                Ok(ft) => ft,
440                Err(_) => continue,
441            };
442            let name = match entry.file_name().into_string() {
443                Ok(n) => n,
444                Err(_) => continue,
445            };
446            let child_rel = if rel.is_empty() {
447                name.clone()
448            } else {
449                format!("{rel}/{name}")
450            };
451            if ft.is_file() {
452                out.push(child_rel);
453            } else if ft.is_dir() {
454                walk(&base.join(&name), &child_rel, out);
455            }
456        }
457    }
458    walk(root, "", &mut paths);
459    paths.sort();
460    PerlValue::array(paths.into_iter().map(PerlValue::string).collect())
461}
462
463/// List only directory names inside `dir` (non-recursive), sorted.
464/// Returns an empty list if `dir` cannot be read.
465pub fn list_dirs(dir: &str) -> PerlValue {
466    let mut names: Vec<String> = Vec::new();
467    if let Ok(entries) = std::fs::read_dir(dir) {
468        for entry in entries.flatten() {
469            if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
470                if let Some(name) = entry.file_name().to_str() {
471                    names.push(name.to_string());
472                }
473            }
474        }
475    }
476    names.sort();
477    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
478}
479
480/// List subdirectory paths under `dir` **recursively**, sorted.
481/// Returns relative paths from `dir` (e.g. `"sub/nested"`).
482/// Returns an empty list if `dir` cannot be read.
483pub fn list_dirs_recursive(dir: &str) -> PerlValue {
484    let root = std::path::Path::new(dir);
485    let mut paths: Vec<String> = Vec::new();
486    fn walk(base: &std::path::Path, rel: &str, out: &mut Vec<String>) {
487        let Ok(entries) = std::fs::read_dir(base) else {
488            return;
489        };
490        for entry in entries.flatten() {
491            let ft = match entry.file_type() {
492                Ok(ft) => ft,
493                Err(_) => continue,
494            };
495            if !ft.is_dir() {
496                continue;
497            }
498            let name = match entry.file_name().into_string() {
499                Ok(n) => n,
500                Err(_) => continue,
501            };
502            let child_rel = if rel.is_empty() {
503                name.clone()
504            } else {
505                format!("{rel}/{name}")
506            };
507            out.push(child_rel.clone());
508            walk(&base.join(&name), &child_rel, out);
509        }
510    }
511    walk(root, "", &mut paths);
512    paths.sort();
513    PerlValue::array(paths.into_iter().map(PerlValue::string).collect())
514}
515
516/// List only symlink names inside `dir` (non-recursive), sorted.
517/// Returns an empty list if `dir` cannot be read.
518pub fn list_sym_links(dir: &str) -> PerlValue {
519    let mut names: Vec<String> = Vec::new();
520    if let Ok(entries) = std::fs::read_dir(dir) {
521        for entry in entries.flatten() {
522            if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
523                if let Some(name) = entry.file_name().to_str() {
524                    names.push(name.to_string());
525                }
526            }
527        }
528    }
529    names.sort();
530    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
531}
532
533/// List only Unix socket names inside `dir` (non-recursive), sorted.
534/// Returns an empty list if `dir` cannot be read or on non-Unix platforms.
535pub fn list_sockets(dir: &str) -> PerlValue {
536    let mut names: Vec<String> = Vec::new();
537    #[cfg(unix)]
538    {
539        use std::os::unix::fs::FileTypeExt;
540        if let Ok(entries) = std::fs::read_dir(dir) {
541            for entry in entries.flatten() {
542                if entry.file_type().map(|ft| ft.is_socket()).unwrap_or(false) {
543                    if let Some(name) = entry.file_name().to_str() {
544                        names.push(name.to_string());
545                    }
546                }
547            }
548        }
549    }
550    let _ = dir;
551    names.sort();
552    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
553}
554
555/// List only named-pipe (FIFO) names inside `dir` (non-recursive), sorted.
556/// Returns an empty list if `dir` cannot be read or on non-Unix platforms.
557pub fn list_pipes(dir: &str) -> PerlValue {
558    let mut names: Vec<String> = Vec::new();
559    #[cfg(unix)]
560    {
561        use std::os::unix::fs::FileTypeExt;
562        if let Ok(entries) = std::fs::read_dir(dir) {
563            for entry in entries.flatten() {
564                if entry.file_type().map(|ft| ft.is_fifo()).unwrap_or(false) {
565                    if let Some(name) = entry.file_name().to_str() {
566                        names.push(name.to_string());
567                    }
568                }
569            }
570        }
571    }
572    let _ = dir;
573    names.sort();
574    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
575}
576
577/// List only block device names inside `dir` (non-recursive), sorted.
578/// Returns an empty list if `dir` cannot be read or on non-Unix platforms.
579pub fn list_block_devices(dir: &str) -> PerlValue {
580    let mut names: Vec<String> = Vec::new();
581    #[cfg(unix)]
582    {
583        use std::os::unix::fs::FileTypeExt;
584        if let Ok(entries) = std::fs::read_dir(dir) {
585            for entry in entries.flatten() {
586                if entry
587                    .file_type()
588                    .map(|ft| ft.is_block_device())
589                    .unwrap_or(false)
590                {
591                    if let Some(name) = entry.file_name().to_str() {
592                        names.push(name.to_string());
593                    }
594                }
595            }
596        }
597    }
598    let _ = dir;
599    names.sort();
600    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
601}
602
603/// List only character device names inside `dir` (non-recursive), sorted.
604/// Returns an empty list if `dir` cannot be read or on non-Unix platforms.
605pub fn list_char_devices(dir: &str) -> PerlValue {
606    let mut names: Vec<String> = Vec::new();
607    #[cfg(unix)]
608    {
609        use std::os::unix::fs::FileTypeExt;
610        if let Ok(entries) = std::fs::read_dir(dir) {
611            for entry in entries.flatten() {
612                if entry
613                    .file_type()
614                    .map(|ft| ft.is_char_device())
615                    .unwrap_or(false)
616                {
617                    if let Some(name) = entry.file_name().to_str() {
618                        names.push(name.to_string());
619                    }
620                }
621            }
622        }
623    }
624    let _ = dir;
625    names.sort();
626    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
627}
628
629pub fn glob_patterns(patterns: &[String]) -> PerlValue {
630    let mut paths: Vec<String> = Vec::new();
631    for pat in patterns {
632        if let Ok(g) = glob::glob(pat) {
633            for e in g.flatten() {
634                paths.push(normalize_glob_path_display(
635                    e.to_string_lossy().into_owned(),
636                ));
637            }
638        }
639    }
640    paths.sort();
641    paths.dedup();
642    PerlValue::array(paths.into_iter().map(PerlValue::string).collect())
643}
644
645/// Directory prefix of `pat` with no glob metacharacters in any path component.
646fn glob_base_path(pat: &str) -> PathBuf {
647    let p = Path::new(pat);
648    let mut acc = PathBuf::new();
649    for c in p.components() {
650        let s = c.as_os_str().to_string_lossy();
651        if s.contains('*') || s.contains('?') || s.contains('[') {
652            break;
653        }
654        acc.push(c.as_os_str());
655    }
656    if acc.as_os_str().is_empty() {
657        PathBuf::from(".")
658    } else {
659        acc
660    }
661}
662
663fn glob_par_walk(dir: &Path, pattern: &Pattern, options: &MatchOptions) -> Vec<String> {
664    let read = match std::fs::read_dir(dir) {
665        Ok(r) => r,
666        Err(_) => return Vec::new(),
667    };
668    let entries: Vec<_> = read.filter_map(|e| e.ok()).collect();
669    entries
670        .par_iter()
671        .flat_map_iter(|e| {
672            let path = e.path();
673            let mut out = Vec::new();
674            let s = path.to_string_lossy();
675            if pattern.matches_with(s.as_ref(), *options) {
676                out.push(s.into_owned());
677            }
678            if path.is_dir() {
679                out.extend(glob_par_walk(&path, pattern, options));
680            }
681            out.into_iter()
682        })
683        .collect()
684}
685
686/// Parallel recursive glob: same pattern semantics as [`glob_patterns`], but walks the
687/// filesystem with rayon per directory (and parallelizes across patterns).
688pub fn glob_par_patterns(patterns: &[String]) -> PerlValue {
689    glob_par_patterns_inner(patterns, None)
690}
691
692/// Same as [`glob_par_patterns`], with a stderr progress bar (one tick per pattern) when
693/// `progress` is true.
694pub fn glob_par_patterns_with_progress(patterns: &[String], progress: bool) -> PerlValue {
695    if patterns.is_empty() {
696        return PerlValue::array(Vec::new());
697    }
698    let pmap = PmapProgress::new(progress, patterns.len());
699    let v = glob_par_patterns_inner(patterns, Some(&pmap));
700    pmap.finish();
701    v
702}
703
704fn glob_par_patterns_inner(patterns: &[String], progress: Option<&PmapProgress>) -> PerlValue {
705    let options = MatchOptions::new();
706    let out: Vec<String> = patterns
707        .par_iter()
708        .flat_map_iter(|pat| {
709            let rows = (|| {
710                let Ok(pattern) = Pattern::new(pat) else {
711                    return Vec::new();
712                };
713                let base = glob_base_path(pat);
714                if !base.exists() {
715                    return Vec::new();
716                }
717                glob_par_walk(&base, &pattern, &options)
718            })();
719            if let Some(p) = progress {
720                p.tick();
721            }
722            rows
723        })
724        .collect();
725    let mut paths: Vec<String> = out.into_iter().map(normalize_glob_path_display).collect();
726    paths.sort();
727    paths.dedup();
728    PerlValue::array(paths.into_iter().map(PerlValue::string).collect())
729}
730
731/// Stable display form for glob results: relative paths get a `./` prefix when missing.
732fn normalize_glob_path_display(s: String) -> String {
733    let p = Path::new(&s);
734    if p.is_absolute() || s.starts_with("./") || s.starts_with("../") {
735        s
736    } else {
737        format!("./{s}")
738    }
739}
740
741/// `rename OLD, NEW` — 1 on success, 0 on failure (Perl-style).
742pub fn rename_paths(old: &str, new: &str) -> PerlValue {
743    PerlValue::integer(if std::fs::rename(old, new).is_ok() {
744        1
745    } else {
746        0
747    })
748}
749
750#[inline]
751fn is_cross_device_rename(e: &io::Error) -> bool {
752    if e.kind() == io::ErrorKind::CrossesDevices {
753        return true;
754    }
755    #[cfg(unix)]
756    {
757        if e.raw_os_error() == Some(libc::EXDEV) {
758            return true;
759        }
760    }
761    false
762}
763
764fn try_move_path(from: &str, to: &str) -> io::Result<()> {
765    match std::fs::rename(from, to) {
766        Ok(()) => Ok(()),
767        Err(e) => {
768            if !is_cross_device_rename(&e) {
769                return Err(e);
770            }
771            let meta = std::fs::symlink_metadata(from)?;
772            if meta.is_dir() {
773                return Err(io::Error::new(
774                    io::ErrorKind::Unsupported,
775                    "move: cross-device directory move is not supported",
776                ));
777            }
778            if !meta.is_file() && !meta.is_symlink() {
779                return Err(io::Error::new(
780                    io::ErrorKind::Unsupported,
781                    "move: cross-device move supports files and symlinks only",
782                ));
783            }
784            std::fs::copy(from, to)?;
785            std::fs::remove_file(from)?;
786            Ok(())
787        }
788    }
789}
790
791/// `move OLD, NEW` / `mv` — like `rename`, but on cross-device failure copies the file then removes
792/// the source (directories not supported for cross-device).
793pub fn move_path(from: &str, to: &str) -> PerlValue {
794    PerlValue::integer(if try_move_path(from, to).is_ok() {
795        1
796    } else {
797        0
798    })
799}
800
801#[cfg(unix)]
802fn unix_path_executable(path: &Path) -> bool {
803    use std::os::unix::fs::PermissionsExt;
804    std::fs::metadata(path)
805        .ok()
806        .filter(|m| m.is_file())
807        .is_some_and(|m| m.permissions().mode() & 0o111 != 0)
808}
809
810#[cfg(not(unix))]
811fn unix_path_executable(path: &Path) -> bool {
812    path.is_file()
813}
814
815fn display_executable_path(path: &Path) -> Option<String> {
816    if !unix_path_executable(path) {
817        return None;
818    }
819    path.canonicalize()
820        .ok()
821        .map(|p| p.to_string_lossy().into_owned())
822        .or_else(|| Some(path.to_string_lossy().into_owned()))
823}
824
825#[cfg(windows)]
826fn pathext_suffixes() -> Vec<String> {
827    env::var_os("PATHEXT")
828        .map(|s| {
829            env::split_paths(&s)
830                .filter_map(|p| p.to_str().map(str::to_ascii_lowercase))
831                .collect()
832        })
833        .unwrap_or_else(|| vec![".exe".into(), ".cmd".into(), ".bat".into(), ".com".into()])
834}
835
836#[cfg(windows)]
837fn which_in_dir(dir: &Path, program: &str) -> Option<String> {
838    let plain = dir.join(program);
839    if let Some(s) = display_executable_path(&plain) {
840        return Some(s);
841    }
842    if !program.contains('.') {
843        for ext in pathext_suffixes() {
844            let cand = dir.join(format!("{program}{ext}"));
845            if let Some(s) = display_executable_path(&cand) {
846                return Some(s);
847            }
848        }
849    }
850    None
851}
852
853#[cfg(not(windows))]
854fn which_in_dir(dir: &Path, program: &str) -> Option<String> {
855    display_executable_path(&dir.join(program))
856}
857
858/// Resolve `program` using `PATH` (and optional current directory when `include_dot`).
859/// Returns a path string or `None` if not found.
860pub fn which_executable(program: &str, include_dot: bool) -> Option<String> {
861    if program.is_empty() {
862        return None;
863    }
864    if program.contains('/') || (cfg!(windows) && program.contains('\\')) {
865        return display_executable_path(Path::new(program));
866    }
867    let path_os = env::var_os("PATH")?;
868    for dir in env::split_paths(&path_os) {
869        if let Some(s) = which_in_dir(&dir, program) {
870            return Some(s);
871        }
872    }
873    if include_dot {
874        return which_in_dir(Path::new("."), program);
875    }
876    None
877}
878
879/// Read entire file as raw bytes (no text decoding).
880pub fn read_file_bytes(path: &str) -> io::Result<Arc<Vec<u8>>> {
881    Ok(Arc::new(std::fs::read(path)?))
882}
883
884/// Temp file adjacent to `target` for atomic replace (`rename` into place).
885fn adjacent_temp_path(target: &Path) -> PathBuf {
886    let dir = target.parent().unwrap_or_else(|| Path::new("."));
887    let name = target
888        .file_name()
889        .map(|s| s.to_string_lossy().into_owned())
890        .unwrap_or_else(|| "file".to_string());
891    let rnd: u32 = rand::thread_rng().gen();
892    dir.join(format!("{name}.spurt-tmp-{rnd}"))
893}
894
895/// Write bytes to `path`. When `mkdir_parents`, creates parent directories. When `atomic`, writes
896/// to a unique temp file in the same directory then `rename`s into place (best-effort crash safety).
897pub fn spurt_path(path: &str, data: &[u8], mkdir_parents: bool, atomic: bool) -> io::Result<()> {
898    let path = Path::new(path);
899    if mkdir_parents {
900        if let Some(parent) = path.parent() {
901            if !parent.as_os_str().is_empty() {
902                std::fs::create_dir_all(parent)?;
903            }
904        }
905    }
906    if !atomic {
907        return std::fs::write(path, data);
908    }
909    let tmp = adjacent_temp_path(path);
910    {
911        let mut f = std::fs::File::create(&tmp)?;
912        f.write_all(data)?;
913        f.sync_all().ok();
914    }
915    std::fs::rename(&tmp, path)?;
916    Ok(())
917}
918
919/// `copy FROM, TO` — 1 on success, 0 on failure. When `preserve_metadata`, best-effort copy of
920/// access/modification times from the source (after a successful byte copy).
921pub fn copy_file(from: &str, to: &str, preserve_metadata: bool) -> PerlValue {
922    let times = if preserve_metadata {
923        std::fs::metadata(from).ok().map(|src_meta| {
924            let at = src_meta
925                .accessed()
926                .ok()
927                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
928                .map(|d| d.as_secs() as i64)
929                .unwrap_or(0);
930            let mt = src_meta
931                .modified()
932                .ok()
933                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
934                .map(|d| d.as_secs() as i64)
935                .unwrap_or(0);
936            (at, mt)
937        })
938    } else {
939        None
940    };
941    if std::fs::copy(from, to).is_err() {
942        return PerlValue::integer(0);
943    }
944    if let Some((at, mt)) = times {
945        let _ = utime_paths(at, mt, &[to.to_string()]);
946    }
947    PerlValue::integer(1)
948}
949
950/// [`std::path::Path::file_name`] as a string (empty if none).
951pub fn path_basename(path: &str) -> String {
952    Path::new(path)
953        .file_name()
954        .map(|s| s.to_string_lossy().into_owned())
955        .unwrap_or_default()
956}
957
958/// Parent directory string; `"."` when absent; `"/"` for POSIX root-ish paths.
959pub fn path_dirname(path: &str) -> String {
960    if path.is_empty() {
961        return String::new();
962    }
963    let p = Path::new(path);
964    if path == "/" {
965        return "/".to_string();
966    }
967    match p.parent() {
968        None => ".".to_string(),
969        Some(parent) => {
970            let s = parent.to_string_lossy();
971            if s.is_empty() {
972                ".".to_string()
973            } else {
974                s.into_owned()
975            }
976        }
977    }
978}
979
980/// `(base, dir, suffix)` like Perl `File::Basename::fileparse` with a single optional suffix.
981/// When `suffix` is `Some` and `full_base` ends with it, `base` has the suffix removed and `suffix`
982/// is the matched suffix; otherwise `suffix` in the return is empty.
983pub fn fileparse_path(path: &str, suffix: Option<&str>) -> (String, String, String) {
984    let dir = path_dirname(path);
985    let full_base = path_basename(path);
986    let (base, sfx) = if let Some(suf) = suffix.filter(|s| !s.is_empty()) {
987        if full_base.ends_with(suf) && full_base.len() > suf.len() {
988            (
989                full_base[..full_base.len() - suf.len()].to_string(),
990                suf.to_string(),
991            )
992        } else {
993            (full_base.clone(), String::new())
994        }
995    } else {
996        (full_base.clone(), String::new())
997    };
998    (base, dir, sfx)
999}
1000
1001/// `chmod MODE, FILES...` — count of files successfully chmod'd.
1002pub fn chmod_paths(paths: &[String], mode: i64) -> i64 {
1003    #[cfg(unix)]
1004    {
1005        use std::os::unix::fs::PermissionsExt;
1006        let mut count = 0i64;
1007        for path in paths {
1008            if let Ok(meta) = std::fs::metadata(path) {
1009                let mut perms = meta.permissions();
1010                let old = perms.mode();
1011                // Perl passes permission bits (e.g. 0644); preserve st_mode file-type bits.
1012                perms.set_mode((old & !0o777) | (mode as u32 & 0o777));
1013                if std::fs::set_permissions(path, perms).is_ok() {
1014                    count += 1;
1015                }
1016            }
1017        }
1018        count
1019    }
1020    #[cfg(not(unix))]
1021    {
1022        let _ = (paths, mode);
1023        0
1024    }
1025}
1026
1027/// `utime ATIME, MTIME, FILES...` — count of paths successfully updated (Unix `utimes`; 0 on non-Unix).
1028pub fn utime_paths(atime_sec: i64, mtime_sec: i64, paths: &[String]) -> i64 {
1029    #[cfg(unix)]
1030    {
1031        use std::ffi::CString;
1032        let mut count = 0i64;
1033        let tv = [
1034            libc::timeval {
1035                tv_sec: atime_sec as libc::time_t,
1036                tv_usec: 0,
1037            },
1038            libc::timeval {
1039                tv_sec: mtime_sec as libc::time_t,
1040                tv_usec: 0,
1041            },
1042        ];
1043        for path in paths {
1044            let Ok(cs) = CString::new(path.as_str()) else {
1045                continue;
1046            };
1047            if unsafe { libc::utimes(cs.as_ptr(), tv.as_ptr()) } == 0 {
1048                count += 1;
1049            }
1050        }
1051        count
1052    }
1053    #[cfg(not(unix))]
1054    {
1055        let _ = (atime_sec, mtime_sec, paths);
1056        0
1057    }
1058}
1059
1060/// `chown UID, GID, FILES...` — count of files successfully chown'd (Unix only; 0 on non-Unix).
1061pub fn chown_paths(paths: &[String], uid: i64, gid: i64) -> i64 {
1062    #[cfg(unix)]
1063    {
1064        use std::ffi::CString;
1065        let u = if uid < 0 {
1066            (!0u32) as libc::uid_t
1067        } else {
1068            uid as libc::uid_t
1069        };
1070        let g = if gid < 0 {
1071            (!0u32) as libc::gid_t
1072        } else {
1073            gid as libc::gid_t
1074        };
1075        let mut count = 0i64;
1076        for path in paths {
1077            let Ok(c) = CString::new(path.as_str()) else {
1078                continue;
1079            };
1080            let r = unsafe { libc::chown(c.as_ptr(), u, g) };
1081            if r == 0 {
1082                count += 1;
1083            }
1084        }
1085        count
1086    }
1087    #[cfg(not(unix))]
1088    {
1089        let _ = (paths, uid, gid);
1090        0
1091    }
1092}
1093
1094/// `touch FILES...` — create files if they don't exist, update atime/mtime to
1095/// now if they do.  Returns count of files successfully touched.
1096pub fn touch_paths(paths: &[String]) -> i64 {
1097    use std::fs::OpenOptions;
1098    let mut count = 0i64;
1099    for path in paths {
1100        if path.is_empty() {
1101            continue;
1102        }
1103        // Create the file if it doesn't exist (like coreutils touch).
1104        let created = OpenOptions::new()
1105            .create(true)
1106            .append(true)
1107            .open(path)
1108            .is_ok();
1109        if !created {
1110            continue;
1111        }
1112        // Update atime + mtime to now.
1113        #[cfg(unix)]
1114        {
1115            use std::ffi::CString;
1116            if let Ok(cs) = CString::new(path.as_str()) {
1117                // null timeval pointer ⇒ set both times to now
1118                unsafe { libc::utimes(cs.as_ptr(), std::ptr::null()) };
1119            }
1120        }
1121        count += 1;
1122    }
1123    count
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128    use super::*;
1129    use std::collections::HashSet;
1130
1131    #[test]
1132    fn glob_par_matches_sequential_glob_set() {
1133        let base = std::env::temp_dir().join(format!("stryke_glob_par_{}", std::process::id()));
1134        let _ = std::fs::remove_dir_all(&base);
1135        std::fs::create_dir_all(base.join("a")).unwrap();
1136        std::fs::create_dir_all(base.join("b")).unwrap();
1137        std::fs::create_dir_all(base.join("b/nested")).unwrap();
1138        std::fs::File::create(base.join("a/x.log")).unwrap();
1139        std::fs::File::create(base.join("b/y.log")).unwrap();
1140        std::fs::File::create(base.join("b/nested/z.log")).unwrap();
1141        std::fs::File::create(base.join("root.txt")).unwrap();
1142
1143        // Absolute patterns only — never `set_current_dir`; other tests run in parallel.
1144        let pat = format!("{}/**/*.log", base.display());
1145        let a = glob_patterns(std::slice::from_ref(&pat));
1146        let b = glob_par_patterns(std::slice::from_ref(&pat));
1147        let _ = std::fs::remove_dir_all(&base);
1148
1149        let set_a: HashSet<String> = a
1150            .as_array_vec()
1151            .expect("expected array")
1152            .into_iter()
1153            .map(|x| x.to_string())
1154            .collect();
1155        let set_b: HashSet<String> = b
1156            .as_array_vec()
1157            .expect("expected array")
1158            .into_iter()
1159            .map(|x| x.to_string())
1160            .collect();
1161        assert_eq!(set_a, set_b);
1162    }
1163
1164    #[test]
1165    fn glob_par_src_rs_matches_when_src_tree_present() {
1166        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1167        let src = root.join("src");
1168        if !src.is_dir() {
1169            return;
1170        }
1171        let pat = src.join("*.rs").to_string_lossy().into_owned();
1172        let v = glob_par_patterns(&[pat])
1173            .as_array_vec()
1174            .expect("expected array");
1175        assert!(
1176            !v.is_empty(),
1177            "glob_par src/*.rs should find at least one .rs under src/"
1178        );
1179    }
1180
1181    #[test]
1182    fn glob_par_progress_false_same_as_plain() {
1183        let tmp = Path::new(env!("CARGO_MANIFEST_DIR"))
1184            .join("target")
1185            .join(format!("glob_par_prog_false_{}", std::process::id()));
1186        let _ = std::fs::remove_dir_all(&tmp);
1187        std::fs::create_dir_all(&tmp).unwrap();
1188        std::fs::write(tmp.join("probe.rs"), b"// x\n").unwrap();
1189        let pat = tmp.join("*.rs").to_string_lossy().replace('\\', "/");
1190        let a = glob_par_patterns(std::slice::from_ref(&pat));
1191        let b = glob_par_patterns_with_progress(std::slice::from_ref(&pat), false);
1192        let _ = std::fs::remove_dir_all(&tmp);
1193        let va = a.as_array_vec().expect("a");
1194        let vb = b.as_array_vec().expect("b");
1195        assert_eq!(va.len(), vb.len(), "glob_par vs glob_par(..., progress=>0)");
1196        for (x, y) in va.iter().zip(vb.iter()) {
1197            assert_eq!(x.to_string(), y.to_string());
1198        }
1199    }
1200
1201    #[test]
1202    fn read_file_text_perl_compat_maps_invalid_utf8_to_latin1_octets() {
1203        let path = std::env::temp_dir().join(format!("stryke_bad_utf8_{}.txt", std::process::id()));
1204        // Lone continuation bytes — invalid UTF-8 as a whole; per-line Latin-1.
1205        std::fs::write(&path, b"ok\xff\xfe\x80\n").unwrap();
1206        let s = read_file_text_perl_compat(&path).expect("read");
1207        assert!(s.starts_with("ok"));
1208        assert_eq!(&s[2..], "\u{00ff}\u{00fe}\u{0080}\n");
1209        let _ = std::fs::remove_file(&path);
1210    }
1211
1212    #[test]
1213    fn read_logical_line_perl_compat_splits_and_decodes_per_line() {
1214        use std::io::Cursor;
1215        let mut r = Cursor::new(b"a\xff\nb\n");
1216        assert_eq!(
1217            read_logical_line_perl_compat(&mut r).unwrap(),
1218            Some("a\u{00ff}".to_string())
1219        );
1220        assert_eq!(
1221            read_logical_line_perl_compat(&mut r).unwrap(),
1222            Some("b".to_string())
1223        );
1224        assert_eq!(read_logical_line_perl_compat(&mut r).unwrap(), None);
1225    }
1226}