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 executable file names inside `dir` (non-recursive), sorted.
604/// Returns an empty list if `dir` cannot be read.
605pub fn list_executables(dir: &str) -> PerlValue {
606    let mut names: Vec<String> = Vec::new();
607    #[cfg(unix)]
608    {
609        if let Ok(entries) = std::fs::read_dir(dir) {
610            for entry in entries.flatten() {
611                if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false)
612                    && unix_path_executable(&entry.path())
613                {
614                    if let Some(name) = entry.file_name().to_str() {
615                        names.push(name.to_string());
616                    }
617                }
618            }
619        }
620    }
621    #[cfg(not(unix))]
622    {
623        if let Ok(entries) = std::fs::read_dir(dir) {
624            for entry in entries.flatten() {
625                if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
626                    let p = entry.path();
627                    if let Some(ext) = p.extension() {
628                        if ext == "exe" || ext == "bat" || ext == "cmd" {
629                            if let Some(name) = entry.file_name().to_str() {
630                                names.push(name.to_string());
631                            }
632                        }
633                    }
634                }
635            }
636        }
637    }
638    let _ = dir;
639    names.sort();
640    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
641}
642
643/// List only character device names inside `dir` (non-recursive), sorted.
644/// Returns an empty list if `dir` cannot be read or on non-Unix platforms.
645pub fn list_char_devices(dir: &str) -> PerlValue {
646    let mut names: Vec<String> = Vec::new();
647    #[cfg(unix)]
648    {
649        use std::os::unix::fs::FileTypeExt;
650        if let Ok(entries) = std::fs::read_dir(dir) {
651            for entry in entries.flatten() {
652                if entry
653                    .file_type()
654                    .map(|ft| ft.is_char_device())
655                    .unwrap_or(false)
656                {
657                    if let Some(name) = entry.file_name().to_str() {
658                        names.push(name.to_string());
659                    }
660                }
661            }
662        }
663    }
664    let _ = dir;
665    names.sort();
666    PerlValue::array(names.into_iter().map(PerlValue::string).collect())
667}
668
669pub fn glob_patterns(patterns: &[String]) -> PerlValue {
670    let mut paths: Vec<String> = Vec::new();
671    for pat in patterns {
672        if let Ok(g) = glob::glob(pat) {
673            for e in g.flatten() {
674                paths.push(normalize_glob_path_display(
675                    e.to_string_lossy().into_owned(),
676                ));
677            }
678        }
679    }
680    paths.sort();
681    paths.dedup();
682    PerlValue::array(paths.into_iter().map(PerlValue::string).collect())
683}
684
685/// Directory prefix of `pat` with no glob metacharacters in any path component.
686fn glob_base_path(pat: &str) -> PathBuf {
687    let p = Path::new(pat);
688    let mut acc = PathBuf::new();
689    for c in p.components() {
690        let s = c.as_os_str().to_string_lossy();
691        if s.contains('*') || s.contains('?') || s.contains('[') {
692            break;
693        }
694        acc.push(c.as_os_str());
695    }
696    if acc.as_os_str().is_empty() {
697        PathBuf::from(".")
698    } else {
699        acc
700    }
701}
702
703fn glob_par_walk(dir: &Path, pattern: &Pattern, options: &MatchOptions) -> Vec<String> {
704    let read = match std::fs::read_dir(dir) {
705        Ok(r) => r,
706        Err(_) => return Vec::new(),
707    };
708    let entries: Vec<_> = read.filter_map(|e| e.ok()).collect();
709    entries
710        .par_iter()
711        .flat_map_iter(|e| {
712            let path = e.path();
713            let mut out = Vec::new();
714            let s = path.to_string_lossy();
715            if pattern.matches_with(s.as_ref(), *options) {
716                out.push(s.into_owned());
717            }
718            if path.is_dir() {
719                out.extend(glob_par_walk(&path, pattern, options));
720            }
721            out.into_iter()
722        })
723        .collect()
724}
725
726/// Parallel recursive glob: same pattern semantics as [`glob_patterns`], but walks the
727/// filesystem with rayon per directory (and parallelizes across patterns).
728pub fn glob_par_patterns(patterns: &[String]) -> PerlValue {
729    glob_par_patterns_inner(patterns, None)
730}
731
732/// Same as [`glob_par_patterns`], with a stderr progress bar (one tick per pattern) when
733/// `progress` is true.
734pub fn glob_par_patterns_with_progress(patterns: &[String], progress: bool) -> PerlValue {
735    if patterns.is_empty() {
736        return PerlValue::array(Vec::new());
737    }
738    let pmap = PmapProgress::new(progress, patterns.len());
739    let v = glob_par_patterns_inner(patterns, Some(&pmap));
740    pmap.finish();
741    v
742}
743
744fn glob_par_patterns_inner(patterns: &[String], progress: Option<&PmapProgress>) -> PerlValue {
745    let options = MatchOptions::new();
746    let out: Vec<String> = patterns
747        .par_iter()
748        .flat_map_iter(|pat| {
749            let rows = (|| {
750                let Ok(pattern) = Pattern::new(pat) else {
751                    return Vec::new();
752                };
753                let base = glob_base_path(pat);
754                if !base.exists() {
755                    return Vec::new();
756                }
757                glob_par_walk(&base, &pattern, &options)
758            })();
759            if let Some(p) = progress {
760                p.tick();
761            }
762            rows
763        })
764        .collect();
765    let mut paths: Vec<String> = out.into_iter().map(normalize_glob_path_display).collect();
766    paths.sort();
767    paths.dedup();
768    PerlValue::array(paths.into_iter().map(PerlValue::string).collect())
769}
770
771/// Stable display form for glob results: relative paths get a `./` prefix when missing.
772fn normalize_glob_path_display(s: String) -> String {
773    let p = Path::new(&s);
774    if p.is_absolute() || s.starts_with("./") || s.starts_with("../") {
775        s
776    } else {
777        format!("./{s}")
778    }
779}
780
781/// `rename OLD, NEW` — 1 on success, 0 on failure (Perl-style).
782pub fn rename_paths(old: &str, new: &str) -> PerlValue {
783    PerlValue::integer(if std::fs::rename(old, new).is_ok() {
784        1
785    } else {
786        0
787    })
788}
789
790#[inline]
791fn is_cross_device_rename(e: &io::Error) -> bool {
792    if e.kind() == io::ErrorKind::CrossesDevices {
793        return true;
794    }
795    #[cfg(unix)]
796    {
797        if e.raw_os_error() == Some(libc::EXDEV) {
798            return true;
799        }
800    }
801    false
802}
803
804fn try_move_path(from: &str, to: &str) -> io::Result<()> {
805    match std::fs::rename(from, to) {
806        Ok(()) => Ok(()),
807        Err(e) => {
808            if !is_cross_device_rename(&e) {
809                return Err(e);
810            }
811            let meta = std::fs::symlink_metadata(from)?;
812            if meta.is_dir() {
813                return Err(io::Error::new(
814                    io::ErrorKind::Unsupported,
815                    "move: cross-device directory move is not supported",
816                ));
817            }
818            if !meta.is_file() && !meta.is_symlink() {
819                return Err(io::Error::new(
820                    io::ErrorKind::Unsupported,
821                    "move: cross-device move supports files and symlinks only",
822                ));
823            }
824            std::fs::copy(from, to)?;
825            std::fs::remove_file(from)?;
826            Ok(())
827        }
828    }
829}
830
831/// `move OLD, NEW` / `mv` — like `rename`, but on cross-device failure copies the file then removes
832/// the source (directories not supported for cross-device).
833pub fn move_path(from: &str, to: &str) -> PerlValue {
834    PerlValue::integer(if try_move_path(from, to).is_ok() {
835        1
836    } else {
837        0
838    })
839}
840
841#[cfg(unix)]
842fn unix_path_executable(path: &Path) -> bool {
843    use std::os::unix::fs::PermissionsExt;
844    std::fs::metadata(path)
845        .ok()
846        .filter(|m| m.is_file())
847        .is_some_and(|m| m.permissions().mode() & 0o111 != 0)
848}
849
850#[cfg(not(unix))]
851fn unix_path_executable(path: &Path) -> bool {
852    path.is_file()
853}
854
855fn display_executable_path(path: &Path) -> Option<String> {
856    if !unix_path_executable(path) {
857        return None;
858    }
859    path.canonicalize()
860        .ok()
861        .map(|p| p.to_string_lossy().into_owned())
862        .or_else(|| Some(path.to_string_lossy().into_owned()))
863}
864
865#[cfg(windows)]
866fn pathext_suffixes() -> Vec<String> {
867    env::var_os("PATHEXT")
868        .map(|s| {
869            env::split_paths(&s)
870                .filter_map(|p| p.to_str().map(str::to_ascii_lowercase))
871                .collect()
872        })
873        .unwrap_or_else(|| vec![".exe".into(), ".cmd".into(), ".bat".into(), ".com".into()])
874}
875
876#[cfg(windows)]
877fn which_in_dir(dir: &Path, program: &str) -> Option<String> {
878    let plain = dir.join(program);
879    if let Some(s) = display_executable_path(&plain) {
880        return Some(s);
881    }
882    if !program.contains('.') {
883        for ext in pathext_suffixes() {
884            let cand = dir.join(format!("{program}{ext}"));
885            if let Some(s) = display_executable_path(&cand) {
886                return Some(s);
887            }
888        }
889    }
890    None
891}
892
893#[cfg(not(windows))]
894fn which_in_dir(dir: &Path, program: &str) -> Option<String> {
895    display_executable_path(&dir.join(program))
896}
897
898/// Resolve `program` using `PATH` (and optional current directory when `include_dot`).
899/// Returns a path string or `None` if not found.
900pub fn which_executable(program: &str, include_dot: bool) -> Option<String> {
901    if program.is_empty() {
902        return None;
903    }
904    if program.contains('/') || (cfg!(windows) && program.contains('\\')) {
905        return display_executable_path(Path::new(program));
906    }
907    let path_os = env::var_os("PATH")?;
908    for dir in env::split_paths(&path_os) {
909        if let Some(s) = which_in_dir(&dir, program) {
910            return Some(s);
911        }
912    }
913    if include_dot {
914        return which_in_dir(Path::new("."), program);
915    }
916    None
917}
918
919/// Read entire file as raw bytes (no text decoding).
920pub fn read_file_bytes(path: &str) -> io::Result<Arc<Vec<u8>>> {
921    Ok(Arc::new(std::fs::read(path)?))
922}
923
924/// Temp file adjacent to `target` for atomic replace (`rename` into place).
925fn adjacent_temp_path(target: &Path) -> PathBuf {
926    let dir = target.parent().unwrap_or_else(|| Path::new("."));
927    let name = target
928        .file_name()
929        .map(|s| s.to_string_lossy().into_owned())
930        .unwrap_or_else(|| "file".to_string());
931    let rnd: u32 = rand::thread_rng().gen();
932    dir.join(format!("{name}.spurt-tmp-{rnd}"))
933}
934
935/// Write bytes to `path`. When `mkdir_parents`, creates parent directories. When `atomic`, writes
936/// to a unique temp file in the same directory then `rename`s into place (best-effort crash safety).
937pub fn spurt_path(path: &str, data: &[u8], mkdir_parents: bool, atomic: bool) -> io::Result<()> {
938    let path = Path::new(path);
939    if mkdir_parents {
940        if let Some(parent) = path.parent() {
941            if !parent.as_os_str().is_empty() {
942                std::fs::create_dir_all(parent)?;
943            }
944        }
945    }
946    if !atomic {
947        return std::fs::write(path, data);
948    }
949    let tmp = adjacent_temp_path(path);
950    {
951        let mut f = std::fs::File::create(&tmp)?;
952        f.write_all(data)?;
953        f.sync_all().ok();
954    }
955    std::fs::rename(&tmp, path)?;
956    Ok(())
957}
958
959/// `copy FROM, TO` — 1 on success, 0 on failure. When `preserve_metadata`, best-effort copy of
960/// access/modification times from the source (after a successful byte copy).
961pub fn copy_file(from: &str, to: &str, preserve_metadata: bool) -> PerlValue {
962    let times = if preserve_metadata {
963        std::fs::metadata(from).ok().map(|src_meta| {
964            let at = src_meta
965                .accessed()
966                .ok()
967                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
968                .map(|d| d.as_secs() as i64)
969                .unwrap_or(0);
970            let mt = src_meta
971                .modified()
972                .ok()
973                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
974                .map(|d| d.as_secs() as i64)
975                .unwrap_or(0);
976            (at, mt)
977        })
978    } else {
979        None
980    };
981    if std::fs::copy(from, to).is_err() {
982        return PerlValue::integer(0);
983    }
984    if let Some((at, mt)) = times {
985        let _ = utime_paths(at, mt, &[to.to_string()]);
986    }
987    PerlValue::integer(1)
988}
989
990/// [`std::path::Path::file_name`] as a string (empty if none).
991pub fn path_basename(path: &str) -> String {
992    Path::new(path)
993        .file_name()
994        .map(|s| s.to_string_lossy().into_owned())
995        .unwrap_or_default()
996}
997
998/// Parent directory string; `"."` when absent; `"/"` for POSIX root-ish paths.
999pub fn path_dirname(path: &str) -> String {
1000    if path.is_empty() {
1001        return String::new();
1002    }
1003    let p = Path::new(path);
1004    if path == "/" {
1005        return "/".to_string();
1006    }
1007    match p.parent() {
1008        None => ".".to_string(),
1009        Some(parent) => {
1010            let s = parent.to_string_lossy();
1011            if s.is_empty() {
1012                ".".to_string()
1013            } else {
1014                s.into_owned()
1015            }
1016        }
1017    }
1018}
1019
1020/// `(base, dir, suffix)` like Perl `File::Basename::fileparse` with a single optional suffix.
1021/// When `suffix` is `Some` and `full_base` ends with it, `base` has the suffix removed and `suffix`
1022/// is the matched suffix; otherwise `suffix` in the return is empty.
1023pub fn fileparse_path(path: &str, suffix: Option<&str>) -> (String, String, String) {
1024    let dir = path_dirname(path);
1025    let full_base = path_basename(path);
1026    let (base, sfx) = if let Some(suf) = suffix.filter(|s| !s.is_empty()) {
1027        if full_base.ends_with(suf) && full_base.len() > suf.len() {
1028            (
1029                full_base[..full_base.len() - suf.len()].to_string(),
1030                suf.to_string(),
1031            )
1032        } else {
1033            (full_base.clone(), String::new())
1034        }
1035    } else {
1036        (full_base.clone(), String::new())
1037    };
1038    (base, dir, sfx)
1039}
1040
1041/// `chmod MODE, FILES...` — count of files successfully chmod'd.
1042pub fn chmod_paths(paths: &[String], mode: i64) -> i64 {
1043    #[cfg(unix)]
1044    {
1045        use std::os::unix::fs::PermissionsExt;
1046        let mut count = 0i64;
1047        for path in paths {
1048            if let Ok(meta) = std::fs::metadata(path) {
1049                let mut perms = meta.permissions();
1050                let old = perms.mode();
1051                // Perl passes permission bits (e.g. 0644); preserve st_mode file-type bits.
1052                perms.set_mode((old & !0o777) | (mode as u32 & 0o777));
1053                if std::fs::set_permissions(path, perms).is_ok() {
1054                    count += 1;
1055                }
1056            }
1057        }
1058        count
1059    }
1060    #[cfg(not(unix))]
1061    {
1062        let _ = (paths, mode);
1063        0
1064    }
1065}
1066
1067/// `utime ATIME, MTIME, FILES...` — count of paths successfully updated (Unix `utimes`; 0 on non-Unix).
1068pub fn utime_paths(atime_sec: i64, mtime_sec: i64, paths: &[String]) -> i64 {
1069    #[cfg(unix)]
1070    {
1071        use std::ffi::CString;
1072        let mut count = 0i64;
1073        let tv = [
1074            libc::timeval {
1075                tv_sec: atime_sec as libc::time_t,
1076                tv_usec: 0,
1077            },
1078            libc::timeval {
1079                tv_sec: mtime_sec as libc::time_t,
1080                tv_usec: 0,
1081            },
1082        ];
1083        for path in paths {
1084            let Ok(cs) = CString::new(path.as_str()) else {
1085                continue;
1086            };
1087            if unsafe { libc::utimes(cs.as_ptr(), tv.as_ptr()) } == 0 {
1088                count += 1;
1089            }
1090        }
1091        count
1092    }
1093    #[cfg(not(unix))]
1094    {
1095        let _ = (atime_sec, mtime_sec, paths);
1096        0
1097    }
1098}
1099
1100/// `chown UID, GID, FILES...` — count of files successfully chown'd (Unix only; 0 on non-Unix).
1101pub fn chown_paths(paths: &[String], uid: i64, gid: i64) -> i64 {
1102    #[cfg(unix)]
1103    {
1104        use std::ffi::CString;
1105        let u = if uid < 0 {
1106            (!0u32) as libc::uid_t
1107        } else {
1108            uid as libc::uid_t
1109        };
1110        let g = if gid < 0 {
1111            (!0u32) as libc::gid_t
1112        } else {
1113            gid as libc::gid_t
1114        };
1115        let mut count = 0i64;
1116        for path in paths {
1117            let Ok(c) = CString::new(path.as_str()) else {
1118                continue;
1119            };
1120            let r = unsafe { libc::chown(c.as_ptr(), u, g) };
1121            if r == 0 {
1122                count += 1;
1123            }
1124        }
1125        count
1126    }
1127    #[cfg(not(unix))]
1128    {
1129        let _ = (paths, uid, gid);
1130        0
1131    }
1132}
1133
1134/// `touch FILES...` — create files if they don't exist, update atime/mtime to
1135/// now if they do.  Returns count of files successfully touched.
1136pub fn touch_paths(paths: &[String]) -> i64 {
1137    use std::fs::OpenOptions;
1138    let mut count = 0i64;
1139    for path in paths {
1140        if path.is_empty() {
1141            continue;
1142        }
1143        // Create the file if it doesn't exist (like coreutils touch).
1144        let created = OpenOptions::new()
1145            .create(true)
1146            .append(true)
1147            .open(path)
1148            .is_ok();
1149        if !created {
1150            continue;
1151        }
1152        // Update atime + mtime to now.
1153        #[cfg(unix)]
1154        {
1155            use std::ffi::CString;
1156            if let Ok(cs) = CString::new(path.as_str()) {
1157                // null timeval pointer ⇒ set both times to now
1158                unsafe { libc::utimes(cs.as_ptr(), std::ptr::null()) };
1159            }
1160        }
1161        count += 1;
1162    }
1163    count
1164}
1165
1166#[cfg(test)]
1167mod tests {
1168    use super::*;
1169    use std::collections::HashSet;
1170
1171    #[test]
1172    fn glob_par_matches_sequential_glob_set() {
1173        let base = std::env::temp_dir().join(format!("stryke_glob_par_{}", std::process::id()));
1174        let _ = std::fs::remove_dir_all(&base);
1175        std::fs::create_dir_all(base.join("a")).unwrap();
1176        std::fs::create_dir_all(base.join("b")).unwrap();
1177        std::fs::create_dir_all(base.join("b/nested")).unwrap();
1178        std::fs::File::create(base.join("a/x.log")).unwrap();
1179        std::fs::File::create(base.join("b/y.log")).unwrap();
1180        std::fs::File::create(base.join("b/nested/z.log")).unwrap();
1181        std::fs::File::create(base.join("root.txt")).unwrap();
1182
1183        // Absolute patterns only — never `set_current_dir`; other tests run in parallel.
1184        let pat = format!("{}/**/*.log", base.display());
1185        let a = glob_patterns(std::slice::from_ref(&pat));
1186        let b = glob_par_patterns(std::slice::from_ref(&pat));
1187        let _ = std::fs::remove_dir_all(&base);
1188
1189        let set_a: HashSet<String> = a
1190            .as_array_vec()
1191            .expect("expected array")
1192            .into_iter()
1193            .map(|x| x.to_string())
1194            .collect();
1195        let set_b: HashSet<String> = b
1196            .as_array_vec()
1197            .expect("expected array")
1198            .into_iter()
1199            .map(|x| x.to_string())
1200            .collect();
1201        assert_eq!(set_a, set_b);
1202    }
1203
1204    #[test]
1205    fn glob_par_src_rs_matches_when_src_tree_present() {
1206        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1207        let src = root.join("src");
1208        if !src.is_dir() {
1209            return;
1210        }
1211        let pat = src.join("*.rs").to_string_lossy().into_owned();
1212        let v = glob_par_patterns(&[pat])
1213            .as_array_vec()
1214            .expect("expected array");
1215        assert!(
1216            !v.is_empty(),
1217            "glob_par src/*.rs should find at least one .rs under src/"
1218        );
1219    }
1220
1221    #[test]
1222    fn glob_par_progress_false_same_as_plain() {
1223        let tmp = Path::new(env!("CARGO_MANIFEST_DIR"))
1224            .join("target")
1225            .join(format!("glob_par_prog_false_{}", std::process::id()));
1226        let _ = std::fs::remove_dir_all(&tmp);
1227        std::fs::create_dir_all(&tmp).unwrap();
1228        std::fs::write(tmp.join("probe.rs"), b"// x\n").unwrap();
1229        let pat = tmp.join("*.rs").to_string_lossy().replace('\\', "/");
1230        let a = glob_par_patterns(std::slice::from_ref(&pat));
1231        let b = glob_par_patterns_with_progress(std::slice::from_ref(&pat), false);
1232        let _ = std::fs::remove_dir_all(&tmp);
1233        let va = a.as_array_vec().expect("a");
1234        let vb = b.as_array_vec().expect("b");
1235        assert_eq!(va.len(), vb.len(), "glob_par vs glob_par(..., progress=>0)");
1236        for (x, y) in va.iter().zip(vb.iter()) {
1237            assert_eq!(x.to_string(), y.to_string());
1238        }
1239    }
1240
1241    #[test]
1242    fn read_file_text_perl_compat_maps_invalid_utf8_to_latin1_octets() {
1243        let path = std::env::temp_dir().join(format!("stryke_bad_utf8_{}.txt", std::process::id()));
1244        // Lone continuation bytes — invalid UTF-8 as a whole; per-line Latin-1.
1245        std::fs::write(&path, b"ok\xff\xfe\x80\n").unwrap();
1246        let s = read_file_text_perl_compat(&path).expect("read");
1247        assert!(s.starts_with("ok"));
1248        assert_eq!(&s[2..], "\u{00ff}\u{00fe}\u{0080}\n");
1249        let _ = std::fs::remove_file(&path);
1250    }
1251
1252    #[test]
1253    fn read_logical_line_perl_compat_splits_and_decodes_per_line() {
1254        use std::io::Cursor;
1255        let mut r = Cursor::new(b"a\xff\nb\n");
1256        assert_eq!(
1257            read_logical_line_perl_compat(&mut r).unwrap(),
1258            Some("a\u{00ff}".to_string())
1259        );
1260        assert_eq!(
1261            read_logical_line_perl_compat(&mut r).unwrap(),
1262            Some("b".to_string())
1263        );
1264        assert_eq!(read_logical_line_perl_compat(&mut r).unwrap(), None);
1265    }
1266}