Skip to main content

stryke/
perl_fs.rs

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