1use 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, RwLock};
14
15static ZSH_GLOB_GLOBAL_MUTEX: Mutex<()> = Mutex::new(());
22
23const 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
40pub(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
82pub 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
89pub(crate) fn raw_haswilds(pattern: &str) -> bool {
95 let mut tok = pattern.to_string();
96 zsh::glob::tokenize(&mut tok);
97 zsh::ported::pattern::haswilds(&tok)
98}
99
100pub(crate) fn stryke_glob(pattern: &str) -> Vec<String> {
109 let (stripped_leading, had_dot_slash) = if let Some(rest) = pattern.strip_prefix("./") {
119 (rest.to_string(), true)
120 } else {
121 (pattern.to_string(), false)
122 };
123 let normalized = stripped_leading.replace("/./", "/");
124 let is_relative_pattern = !std::path::Path::new(&normalized).is_absolute();
125
126 let has_qual = zsh::glob::split_qualifier(&normalized).1.is_some();
138 let glob_arg = if has_qual {
139 normalized.clone()
140 } else {
141 let mut tok = normalized.clone();
142 zsh::glob::tokenize(&mut tok);
143 tok
144 };
145 let results = {
146 let _lock = ZSH_GLOB_GLOBAL_MUTEX.lock();
147 let _opts = StrykeGlobOptsGuard::new();
148 zsh::glob::glob_path(&glob_arg)
149 };
150
151 let results = if results.len() == 1
160 && results[0] == normalized
161 && (has_qual || zsh::ported::pattern::haswilds(&glob_arg))
162 {
163 Vec::new()
164 } else {
165 results
166 };
167 let stripped: Vec<String> = if is_relative_pattern {
174 let cwd = std::env::current_dir().ok();
175 let cwd_canonical = cwd
176 .as_ref()
177 .and_then(|p| std::fs::canonicalize(p).ok())
178 .map(|p| p.to_string_lossy().into_owned());
179 let cwd_plain = cwd.as_ref().map(|p| p.to_string_lossy().into_owned());
180 results
181 .into_iter()
182 .map(|p| {
183 for pref in [cwd_canonical.as_deref(), cwd_plain.as_deref()]
184 .into_iter()
185 .flatten()
186 {
187 if let Some(rest) = p.strip_prefix(pref) {
188 let r = rest.trim_start_matches('/');
189 return if r.is_empty() {
190 ".".to_string()
191 } else {
192 r.to_string()
193 };
194 }
195 }
196 p
197 })
198 .collect()
199 } else {
200 results
201 };
202 if had_dot_slash {
203 stripped
204 .into_iter()
205 .map(|p| {
206 if p.starts_with("./") {
207 p
208 } else {
209 format!("./{}", p)
210 }
211 })
212 .collect()
213 } else {
214 stripped
215 }
216}
217
218pub fn read_file_text_or_glob(path: &str) -> io::Result<String> {
232 let (stripped, qual) = zsh::glob::split_qualifier(path);
233 let is_glob = qual.is_some() || raw_haswilds(stripped);
234 if !is_glob {
235 return read_file_text_perl_compat(path);
236 }
237 let paths = stryke_glob(path);
238 if paths.is_empty() {
239 return Err(io::Error::new(
240 io::ErrorKind::NotFound,
241 format!("no files matched glob: {}", path),
242 ));
243 }
244 let strict = qual.is_some();
245 let mut out = String::new();
246 for p in &paths {
247 match slurp_glob_entry(p, strict, |p| read_file_text_perl_compat(p))? {
248 Some(s) => out.push_str(&s),
249 None => continue,
250 }
251 }
252 Ok(out)
253}
254
255fn slurp_glob_entry<T>(
263 p: &str,
264 strict: bool,
265 read: impl FnOnce(&str) -> io::Result<T>,
266) -> io::Result<Option<T>> {
267 let meta = match std::fs::metadata(p) {
268 Ok(m) => m,
269 Err(e) => {
270 if strict {
271 return Err(io::Error::new(e.kind(), format!("{}: {}", p, e)));
272 }
273 tracing::debug!("slurp: skipping unreadable glob match {}: {}", p, e);
274 return Ok(None);
275 }
276 };
277 if !meta.is_file() {
278 if strict {
279 return Err(io::Error::new(
280 io::ErrorKind::InvalidInput,
281 format!("not a regular file: {}", p),
282 ));
283 }
284 tracing::debug!("slurp: skipping non-regular glob match: {}", p);
285 return Ok(None);
286 }
287 match read(p) {
288 Ok(v) => Ok(Some(v)),
289 Err(e) => {
290 if strict {
291 return Err(io::Error::new(e.kind(), format!("{}: {}", p, e)));
292 }
293 tracing::debug!("slurp: skipping unreadable glob match {}: {}", p, e);
294 Ok(None)
295 }
296 }
297}
298
299pub fn read_bytes_or_glob(path: &str) -> io::Result<Arc<Vec<u8>>> {
306 let (stripped, qual) = zsh::glob::split_qualifier(path);
307 let is_glob = qual.is_some() || raw_haswilds(stripped);
308 if !is_glob {
309 return read_file_bytes(path);
310 }
311 let paths = stryke_glob(path);
312 if paths.is_empty() {
313 return Err(io::Error::new(
314 io::ErrorKind::NotFound,
315 format!("no files matched glob: {}", path),
316 ));
317 }
318 let strict = qual.is_some();
319 let mut out = Vec::new();
320 for p in &paths {
321 match slurp_glob_entry(p, strict, |p| std::fs::read(p))? {
322 Some(bytes) => out.extend_from_slice(&bytes),
323 None => continue,
324 }
325 }
326 Ok(Arc::new(out))
327}
328
329fn pattern_is_glob(path: &str) -> bool {
334 let (stripped, qual) = zsh::glob::split_qualifier(path);
335 qual.is_some() || raw_haswilds(stripped) || zsh::glob::hasbraces(stripped, true)
336}
337
338pub fn read_line_perl_compat(reader: &mut impl BufRead, buf: &mut String) -> io::Result<usize> {
340 read_line_perl_compat_with_sep(reader, buf, Some(b'\n'))
341}
342
343pub fn read_line_perl_compat_with_sep(
348 reader: &mut impl BufRead,
349 buf: &mut String,
350 sep: Option<u8>,
351) -> io::Result<usize> {
352 buf.clear();
353 let mut raw = Vec::new();
354 let n = match sep {
355 None => reader.read_to_end(&mut raw)?,
356 Some(byte) => reader.read_until(byte, &mut raw)?,
357 };
358 if n == 0 {
359 return Ok(0);
360 }
361 buf.push_str(&decode_utf8_or_latin1_read_until(&raw));
362 Ok(n)
363}
364
365pub fn read_logical_line_perl_compat(reader: &mut impl BufRead) -> io::Result<Option<String>> {
372 let mut buf = Vec::new();
373 let n = reader.read_until(b'\n', &mut buf)?;
374 if n == 0 {
375 return Ok(None);
376 }
377 if buf.ends_with(b"\n") {
378 buf.pop();
379 if buf.ends_with(b"\r") {
380 buf.pop();
381 }
382 }
383 Ok(Some(decode_utf8_or_latin1_line(&buf)))
384}
385
386pub fn read_logical_line_perl_compat_with_sep(
395 reader: &mut impl BufRead,
396 sep: Option<u8>,
397) -> io::Result<Option<String>> {
398 let mut buf = Vec::new();
399 let n = match sep {
400 None => reader.read_to_end(&mut buf)?,
401 Some(byte) => reader.read_until(byte, &mut buf)?,
402 };
403 if n == 0 {
404 return Ok(None);
405 }
406 Ok(Some(decode_utf8_or_latin1_line(&buf)))
407}
408
409pub fn filetest_is_tty(path: &str) -> bool {
412 #[cfg(unix)]
413 {
414 use std::os::unix::io::AsRawFd;
415 if let Some(fd) = tty_fd_literal(path) {
416 return unsafe { libc::isatty(fd) != 0 };
417 }
418 if let Ok(f) = std::fs::File::open(path) {
419 return unsafe { libc::isatty(f.as_raw_fd()) != 0 };
420 }
421 }
422 #[cfg(not(unix))]
423 {
424 let _ = path;
425 }
426 false
427}
428
429#[cfg(unix)]
430fn tty_fd_literal(path: &str) -> Option<i32> {
431 match path {
432 "" | "STDIN" | "-" | "/dev/stdin" => Some(0),
433 "STDOUT" | "/dev/stdout" => Some(1),
434 "STDERR" | "/dev/stderr" => Some(2),
435 p if p.starts_with("/dev/fd/") => p.strip_prefix("/dev/fd/").and_then(|s| s.parse().ok()),
436 _ => path.parse::<i32>().ok().filter(|&n| (0..128).contains(&n)),
437 }
438}
439
440#[cfg(unix)]
443pub fn filetest_effective_access(path: &str, check: u32) -> bool {
444 use std::os::unix::fs::MetadataExt;
445 let meta = match std::fs::metadata(path) {
446 Ok(m) => m,
447 Err(_) => return false,
448 };
449 let mode = meta.mode();
450 let euid = unsafe { libc::geteuid() };
451 let egid = unsafe { libc::getegid() };
452 if euid == 0 {
454 return if check == 1 { mode & 0o111 != 0 } else { true };
455 }
456 if meta.uid() == euid {
457 return mode & (check << 6) != 0;
458 }
459 if meta.gid() == egid {
460 return mode & (check << 3) != 0;
461 }
462 mode & check != 0
463}
464
465#[cfg(unix)]
467pub fn filetest_real_access(path: &str, amode: libc::c_int) -> bool {
468 match std::ffi::CString::new(path) {
469 Ok(c) => unsafe { libc::access(c.as_ptr(), amode) == 0 },
470 Err(_) => false,
471 }
472}
473
474#[cfg(unix)]
476pub fn filetest_owned_effective(path: &str) -> bool {
477 use std::os::unix::fs::MetadataExt;
478 std::fs::metadata(path)
479 .map(|m| m.uid() == unsafe { libc::geteuid() })
480 .unwrap_or(false)
481}
482
483#[cfg(unix)]
485pub fn filetest_owned_real(path: &str) -> bool {
486 use std::os::unix::fs::MetadataExt;
487 std::fs::metadata(path)
488 .map(|m| m.uid() == unsafe { libc::getuid() })
489 .unwrap_or(false)
490}
491
492#[cfg(unix)]
494pub fn filetest_is_pipe(path: &str) -> bool {
495 use std::os::unix::fs::FileTypeExt;
496 std::fs::metadata(path)
497 .map(|m| m.file_type().is_fifo())
498 .unwrap_or(false)
499}
500
501#[cfg(unix)]
503pub fn filetest_is_socket(path: &str) -> bool {
504 use std::os::unix::fs::FileTypeExt;
505 std::fs::metadata(path)
506 .map(|m| m.file_type().is_socket())
507 .unwrap_or(false)
508}
509
510#[cfg(unix)]
512pub fn filetest_is_block_device(path: &str) -> bool {
513 use std::os::unix::fs::FileTypeExt;
514 std::fs::metadata(path)
515 .map(|m| m.file_type().is_block_device())
516 .unwrap_or(false)
517}
518
519#[cfg(unix)]
521pub fn filetest_is_char_device(path: &str) -> bool {
522 use std::os::unix::fs::FileTypeExt;
523 std::fs::metadata(path)
524 .map(|m| m.file_type().is_char_device())
525 .unwrap_or(false)
526}
527
528#[cfg(unix)]
530pub fn filetest_is_setuid(path: &str) -> bool {
531 use std::os::unix::fs::MetadataExt;
532 std::fs::metadata(path)
533 .map(|m| m.mode() & 0o4000 != 0)
534 .unwrap_or(false)
535}
536
537#[cfg(unix)]
539pub fn filetest_is_setgid(path: &str) -> bool {
540 use std::os::unix::fs::MetadataExt;
541 std::fs::metadata(path)
542 .map(|m| m.mode() & 0o2000 != 0)
543 .unwrap_or(false)
544}
545
546#[cfg(unix)]
548pub fn filetest_is_sticky(path: &str) -> bool {
549 use std::os::unix::fs::MetadataExt;
550 std::fs::metadata(path)
551 .map(|m| m.mode() & 0o1000 != 0)
552 .unwrap_or(false)
553}
554
555pub fn filetest_is_text(path: &str) -> bool {
557 filetest_text_binary(path, true)
558}
559
560pub fn filetest_is_binary(path: &str) -> bool {
562 filetest_text_binary(path, false)
563}
564
565fn filetest_text_binary(path: &str, want_text: bool) -> bool {
566 use std::io::Read;
567 let mut f = match std::fs::File::open(path) {
568 Ok(f) => f,
569 Err(_) => return false,
570 };
571 let mut buf = [0u8; 512];
572 let n = match f.read(&mut buf) {
573 Ok(n) => n,
574 Err(_) => return false,
575 };
576 if n == 0 {
577 return want_text;
579 }
580 let slice = &buf[..n];
581 let non_text = slice
583 .iter()
584 .filter(|&&b| b == 0 || (b < 0x20 && b != b'\t' && b != b'\n' && b != b'\r' && b != 0x1b))
585 .count();
586 let is_text = (non_text as f64 / n as f64) < 0.30;
587 if want_text {
588 is_text
589 } else {
590 !is_text
591 }
592}
593
594#[cfg(unix)]
596pub fn filetest_age_days(path: &str, which: char) -> Option<f64> {
597 use std::os::unix::fs::MetadataExt;
598 let meta = std::fs::metadata(path).ok()?;
599 let t = match which {
600 'M' => meta.mtime() as f64,
601 'A' => meta.atime() as f64,
602 _ => meta.ctime() as f64,
603 };
604 let now = std::time::SystemTime::now()
605 .duration_since(std::time::UNIX_EPOCH)
606 .unwrap_or_default()
607 .as_secs_f64();
608 Some((now - t) / 86400.0)
609}
610
611pub fn stat_path(path: &str, symlink: bool) -> StrykeValue {
613 let res = if symlink {
614 std::fs::symlink_metadata(path)
615 } else {
616 std::fs::metadata(path)
617 };
618 match res {
619 Ok(meta) => StrykeValue::array(perl_stat_from_metadata(&meta)),
620 Err(_) => StrykeValue::array(vec![]),
621 }
622}
623pub fn perl_stat_from_metadata(meta: &std::fs::Metadata) -> Vec<StrykeValue> {
625 #[cfg(unix)]
626 {
627 use std::os::unix::fs::MetadataExt;
628 vec![
629 StrykeValue::integer(meta.dev() as i64),
630 StrykeValue::integer(meta.ino() as i64),
631 StrykeValue::integer(meta.mode() as i64),
632 StrykeValue::integer(meta.nlink() as i64),
633 StrykeValue::integer(meta.uid() as i64),
634 StrykeValue::integer(meta.gid() as i64),
635 StrykeValue::integer(meta.rdev() as i64),
636 StrykeValue::integer(meta.len() as i64),
637 StrykeValue::integer(meta.atime()),
638 StrykeValue::integer(meta.mtime()),
639 StrykeValue::integer(meta.ctime()),
640 StrykeValue::integer(meta.blksize() as i64),
641 StrykeValue::integer(meta.blocks() as i64),
642 ]
643 }
644 #[cfg(not(unix))]
645 {
646 let len = meta.len() as i64;
647 vec![
648 StrykeValue::integer(0),
649 StrykeValue::integer(0),
650 StrykeValue::integer(0),
651 StrykeValue::integer(0),
652 StrykeValue::integer(0),
653 StrykeValue::integer(0),
654 StrykeValue::integer(0),
655 StrykeValue::integer(len),
656 StrykeValue::integer(0),
657 StrykeValue::integer(0),
658 StrykeValue::integer(0),
659 StrykeValue::integer(0),
660 StrykeValue::integer(0),
661 ]
662 }
663}
664pub fn link_hard(old: &str, new: &str) -> StrykeValue {
666 StrykeValue::integer(if std::fs::hard_link(old, new).is_ok() {
667 1
668 } else {
669 0
670 })
671}
672pub fn link_sym(old: &str, new: &str) -> StrykeValue {
674 #[cfg(unix)]
675 {
676 use std::os::unix::fs::symlink;
677 StrykeValue::integer(if symlink(old, new).is_ok() { 1 } else { 0 })
678 }
679 #[cfg(not(unix))]
680 {
681 let _ = (old, new);
682 StrykeValue::integer(0)
683 }
684}
685pub fn read_link(path: &str) -> StrykeValue {
687 match std::fs::read_link(path) {
688 Ok(p) => StrykeValue::string(p.to_string_lossy().into_owned()),
689 Err(_) => StrykeValue::UNDEF,
690 }
691}
692
693pub fn realpath_resolved(path: &str) -> io::Result<String> {
695 std::fs::canonicalize(path).map(|p| p.to_string_lossy().into_owned())
696}
697
698pub fn canonpath_logical(path: &str) -> String {
702 use std::path::Component;
703 if path.is_empty() {
704 return String::new();
705 }
706 let mut stack: Vec<String> = Vec::new();
707 let mut anchored = false;
708 for c in Path::new(path).components() {
709 match c {
710 Component::Prefix(p) => {
711 stack.push(p.as_os_str().to_string_lossy().into_owned());
712 }
713 Component::RootDir => {
714 anchored = true;
715 stack.clear();
716 }
717 Component::CurDir => {}
718 Component::Normal(s) => {
719 stack.push(s.to_string_lossy().into_owned());
720 }
721 Component::ParentDir => {
722 if anchored {
723 if !stack.is_empty() {
724 stack.pop();
725 }
726 } else if stack.is_empty() || stack.last().is_some_and(|t| t == "..") {
727 stack.push("..".to_string());
728 } else {
729 stack.pop();
730 }
731 }
732 }
733 }
734 let body = stack.join("/");
735 if anchored {
736 if body.is_empty() {
737 "/".to_string()
738 } else {
739 format!("/{body}")
740 }
741 } else if body.is_empty() {
742 ".".to_string()
743 } else {
744 body
745 }
746}
747
748pub fn list_files(dir: &str) -> StrykeValue {
751 let mut names: Vec<String> = Vec::new();
752 if let Ok(entries) = std::fs::read_dir(dir) {
753 for entry in entries.flatten() {
754 if let Some(name) = entry.file_name().to_str() {
755 names.push(name.to_string());
756 }
757 }
758 }
759 names.sort();
760 StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
761}
762
763pub fn list_filesf(dir: &str) -> StrykeValue {
767 let mut names: Vec<String> = Vec::new();
768 if let Ok(entries) = std::fs::read_dir(dir) {
769 for entry in entries.flatten() {
770 if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
771 if let Some(name) = entry.file_name().to_str() {
772 names.push(name.to_string());
773 }
774 }
775 }
776 }
777 names.sort();
778 StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
779}
780
781pub fn list_filesf_recursive(dir: &str) -> StrykeValue {
785 let root = std::path::Path::new(dir);
786 let mut paths: Vec<String> = Vec::new();
787 fn walk(base: &std::path::Path, rel: &str, out: &mut Vec<String>) {
788 let Ok(entries) = std::fs::read_dir(base) else {
789 return;
790 };
791 for entry in entries.flatten() {
792 let ft = match entry.file_type() {
793 Ok(ft) => ft,
794 Err(_) => continue,
795 };
796 let name = match entry.file_name().into_string() {
797 Ok(n) => n,
798 Err(_) => continue,
799 };
800 let child_rel = if rel.is_empty() {
801 name.clone()
802 } else {
803 format!("{rel}/{name}")
804 };
805 if ft.is_file() {
806 out.push(child_rel);
807 } else if ft.is_dir() {
808 walk(&base.join(&name), &child_rel, out);
809 }
810 }
811 }
812 walk(root, "", &mut paths);
813 paths.sort();
814 StrykeValue::array(paths.into_iter().map(StrykeValue::string).collect())
815}
816
817pub fn list_dirs(dir: &str) -> StrykeValue {
820 let mut names: Vec<String> = Vec::new();
821 if let Ok(entries) = std::fs::read_dir(dir) {
822 for entry in entries.flatten() {
823 if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
824 if let Some(name) = entry.file_name().to_str() {
825 names.push(name.to_string());
826 }
827 }
828 }
829 }
830 names.sort();
831 StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
832}
833
834pub fn list_dirs_recursive(dir: &str) -> StrykeValue {
838 let root = std::path::Path::new(dir);
839 let mut paths: Vec<String> = Vec::new();
840 fn walk(base: &std::path::Path, rel: &str, out: &mut Vec<String>) {
841 let Ok(entries) = std::fs::read_dir(base) else {
842 return;
843 };
844 for entry in entries.flatten() {
845 let ft = match entry.file_type() {
846 Ok(ft) => ft,
847 Err(_) => continue,
848 };
849 if !ft.is_dir() {
850 continue;
851 }
852 let name = match entry.file_name().into_string() {
853 Ok(n) => n,
854 Err(_) => continue,
855 };
856 let child_rel = if rel.is_empty() {
857 name.clone()
858 } else {
859 format!("{rel}/{name}")
860 };
861 out.push(child_rel.clone());
862 walk(&base.join(&name), &child_rel, out);
863 }
864 }
865 walk(root, "", &mut paths);
866 paths.sort();
867 StrykeValue::array(paths.into_iter().map(StrykeValue::string).collect())
868}
869
870pub fn list_sym_links(dir: &str) -> StrykeValue {
873 let mut names: Vec<String> = Vec::new();
874 if let Ok(entries) = std::fs::read_dir(dir) {
875 for entry in entries.flatten() {
876 if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
877 if let Some(name) = entry.file_name().to_str() {
878 names.push(name.to_string());
879 }
880 }
881 }
882 }
883 names.sort();
884 StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
885}
886
887pub fn list_sockets(dir: &str) -> StrykeValue {
890 let mut names: Vec<String> = Vec::new();
891 #[cfg(unix)]
892 {
893 use std::os::unix::fs::FileTypeExt;
894 if let Ok(entries) = std::fs::read_dir(dir) {
895 for entry in entries.flatten() {
896 if entry.file_type().map(|ft| ft.is_socket()).unwrap_or(false) {
897 if let Some(name) = entry.file_name().to_str() {
898 names.push(name.to_string());
899 }
900 }
901 }
902 }
903 }
904 let _ = dir;
905 names.sort();
906 StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
907}
908
909pub fn list_pipes(dir: &str) -> StrykeValue {
912 let mut names: Vec<String> = Vec::new();
913 #[cfg(unix)]
914 {
915 use std::os::unix::fs::FileTypeExt;
916 if let Ok(entries) = std::fs::read_dir(dir) {
917 for entry in entries.flatten() {
918 if entry.file_type().map(|ft| ft.is_fifo()).unwrap_or(false) {
919 if let Some(name) = entry.file_name().to_str() {
920 names.push(name.to_string());
921 }
922 }
923 }
924 }
925 }
926 let _ = dir;
927 names.sort();
928 StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
929}
930
931pub fn list_block_devices(dir: &str) -> StrykeValue {
934 let mut names: Vec<String> = Vec::new();
935 #[cfg(unix)]
936 {
937 use std::os::unix::fs::FileTypeExt;
938 if let Ok(entries) = std::fs::read_dir(dir) {
939 for entry in entries.flatten() {
940 if entry
941 .file_type()
942 .map(|ft| ft.is_block_device())
943 .unwrap_or(false)
944 {
945 if let Some(name) = entry.file_name().to_str() {
946 names.push(name.to_string());
947 }
948 }
949 }
950 }
951 }
952 let _ = dir;
953 names.sort();
954 StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
955}
956
957pub fn list_executables(dir: &str) -> StrykeValue {
960 let mut names: Vec<String> = Vec::new();
961 #[cfg(unix)]
962 {
963 if let Ok(entries) = std::fs::read_dir(dir) {
964 for entry in entries.flatten() {
965 if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false)
966 && unix_path_executable(&entry.path())
967 {
968 if let Some(name) = entry.file_name().to_str() {
969 names.push(name.to_string());
970 }
971 }
972 }
973 }
974 }
975 #[cfg(not(unix))]
976 {
977 if let Ok(entries) = std::fs::read_dir(dir) {
978 for entry in entries.flatten() {
979 if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
980 let p = entry.path();
981 if let Some(ext) = p.extension() {
982 if ext == "exe" || ext == "bat" || ext == "cmd" {
983 if let Some(name) = entry.file_name().to_str() {
984 names.push(name.to_string());
985 }
986 }
987 }
988 }
989 }
990 }
991 }
992 let _ = dir;
993 names.sort();
994 StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
995}
996
997pub fn list_char_devices(dir: &str) -> StrykeValue {
1000 let mut names: Vec<String> = Vec::new();
1001 #[cfg(unix)]
1002 {
1003 use std::os::unix::fs::FileTypeExt;
1004 if let Ok(entries) = std::fs::read_dir(dir) {
1005 for entry in entries.flatten() {
1006 if entry
1007 .file_type()
1008 .map(|ft| ft.is_char_device())
1009 .unwrap_or(false)
1010 {
1011 if let Some(name) = entry.file_name().to_str() {
1012 names.push(name.to_string());
1013 }
1014 }
1015 }
1016 }
1017 }
1018 let _ = dir;
1019 names.sort();
1020 StrykeValue::array(names.into_iter().map(StrykeValue::string).collect())
1021}
1022pub fn glob_patterns(patterns: &[String]) -> StrykeValue {
1024 let mut paths: Vec<String> = Vec::new();
1025 for pat in patterns {
1026 if !pattern_is_glob(pat) {
1027 paths.push(normalize_glob_path_display(pat.clone()));
1028 continue;
1029 }
1030 for s in stryke_glob(pat) {
1031 paths.push(normalize_glob_path_display(s));
1032 }
1033 }
1034 paths.sort();
1035 paths.dedup();
1036 StrykeValue::array(paths.into_iter().map(StrykeValue::string).collect())
1037}
1038
1039pub fn swallow_to_hash(pattern: &str) -> io::Result<StrykeValue> {
1051 let paths: Vec<String> = if pattern_is_glob(pattern) {
1052 stryke_glob(pattern)
1053 } else {
1054 vec![pattern.to_string()]
1055 };
1056 let (_, qual) = zsh::glob::split_qualifier(pattern);
1057 let strict = qual.is_some();
1058 let mut out: indexmap::IndexMap<String, StrykeValue> = indexmap::IndexMap::new();
1059 for p in &paths {
1060 let entry = slurp_glob_entry(p, strict, |p| {
1065 let canon = std::fs::canonicalize(p)?.to_string_lossy().into_owned();
1066 let bytes = read_file_bytes(p)?;
1067 Ok((canon, StrykeValue::bytes(bytes)))
1068 })?;
1069 if let Some((canon, val)) = entry {
1070 out.insert(canon, val);
1071 }
1072 }
1073 Ok(StrykeValue::hash(out))
1074}
1075
1076pub struct IngestIterator {
1086 queue: Mutex<std::collections::VecDeque<String>>,
1088}
1089
1090impl IngestIterator {
1091 pub fn new(paths: Vec<String>) -> Self {
1093 Self {
1094 queue: Mutex::new(paths.into_iter().collect()),
1095 }
1096 }
1097}
1098
1099impl crate::value::StrykeIterator for IngestIterator {
1100 fn next_item(&self) -> Option<StrykeValue> {
1101 let path = self.queue.lock().pop_front()?;
1102 let bytes = match std::fs::read(&path) {
1103 Ok(b) => Arc::new(b),
1104 Err(_) => return None,
1108 };
1109 let pair = vec![StrykeValue::string(path), StrykeValue::bytes(bytes)];
1110 Some(StrykeValue::array_ref(Arc::new(RwLock::new(pair))))
1111 }
1112}
1113
1114pub fn ingest_iterator(pattern: &str) -> io::Result<StrykeValue> {
1121 let raw_paths: Vec<String> = if pattern_is_glob(pattern) {
1122 stryke_glob(pattern)
1123 } else {
1124 vec![pattern.to_string()]
1125 };
1126 let (_, qual) = zsh::glob::split_qualifier(pattern);
1127 let strict = qual.is_some();
1128 let mut canon: Vec<String> = Vec::with_capacity(raw_paths.len());
1129 for p in &raw_paths {
1130 let resolved = slurp_glob_entry(p, strict, |p| {
1134 std::fs::canonicalize(p).map(|c| c.to_string_lossy().into_owned())
1135 })?;
1136 if let Some(c) = resolved {
1137 canon.push(c);
1138 }
1139 }
1140 Ok(StrykeValue::iterator(Arc::new(IngestIterator::new(canon))))
1141}
1142
1143pub fn burp_hash_to_disk(v: &StrykeValue) -> io::Result<i64> {
1154 let entries: Vec<(String, Vec<u8>)> = if let Some(h) = v.as_hash_map() {
1155 h.into_iter()
1156 .map(|(k, val)| (k, value_to_bytes(&val)))
1157 .collect()
1158 } else if let Some(href) = v.as_hash_ref() {
1159 href.read()
1160 .iter()
1161 .map(|(k, val)| (k.clone(), value_to_bytes(val)))
1162 .collect()
1163 } else {
1164 return Err(io::Error::new(
1165 io::ErrorKind::InvalidInput,
1166 format!(
1167 "burp: expected a HASH or HASHREF, got {} \
1168 — pass `\\%h` or an inline hashref `{{ ... }}`",
1169 v.type_name()
1170 ),
1171 ));
1172 };
1173 let mut written: i64 = 0;
1174 for (path, bytes) in &entries {
1175 spurt_path(
1176 path, bytes, true, false,
1177 )?;
1178 written += 1;
1179 }
1180 Ok(written)
1181}
1182
1183fn value_to_bytes(v: &StrykeValue) -> Vec<u8> {
1188 if let Some(b) = v.as_bytes_arc() {
1189 return b.as_ref().clone();
1190 }
1191 v.to_string().into_bytes()
1192}
1193
1194pub fn glob_par_patterns(patterns: &[String]) -> StrykeValue {
1197 glob_par_patterns_inner(patterns, None)
1198}
1199
1200pub fn glob_par_patterns_with_progress(patterns: &[String], progress: bool) -> StrykeValue {
1203 if patterns.is_empty() {
1204 return StrykeValue::array(Vec::new());
1205 }
1206 let pmap = PmapProgress::new(progress, patterns.len());
1207 let v = glob_par_patterns_inner(patterns, Some(&pmap));
1208 pmap.finish();
1209 v
1210}
1211
1212fn glob_par_patterns_inner(patterns: &[String], progress: Option<&PmapProgress>) -> StrykeValue {
1213 let out: Vec<String> = patterns
1218 .par_iter()
1219 .flat_map_iter(|pat| {
1220 let rows: Vec<String> = if !pattern_is_glob(pat) {
1221 vec![pat.clone()]
1222 } else {
1223 stryke_glob(pat)
1224 };
1225 if let Some(p) = progress {
1226 p.tick();
1227 }
1228 rows
1229 })
1230 .collect();
1231 let mut paths: Vec<String> = out.into_iter().map(normalize_glob_path_display).collect();
1232 paths.sort();
1233 paths.dedup();
1234 StrykeValue::array(paths.into_iter().map(StrykeValue::string).collect())
1235}
1236
1237fn normalize_glob_path_display(s: String) -> String {
1242 s
1243}
1244
1245pub fn rename_paths(old: &str, new: &str) -> StrykeValue {
1247 StrykeValue::integer(if std::fs::rename(old, new).is_ok() {
1248 1
1249 } else {
1250 0
1251 })
1252}
1253
1254#[inline]
1255fn is_cross_device_rename(e: &io::Error) -> bool {
1256 if e.kind() == io::ErrorKind::CrossesDevices {
1257 return true;
1258 }
1259 #[cfg(unix)]
1260 {
1261 if e.raw_os_error() == Some(libc::EXDEV) {
1262 return true;
1263 }
1264 }
1265 false
1266}
1267
1268fn try_move_path(from: &str, to: &str) -> io::Result<()> {
1269 match std::fs::rename(from, to) {
1270 Ok(()) => Ok(()),
1271 Err(e) => {
1272 if !is_cross_device_rename(&e) {
1273 return Err(e);
1274 }
1275 let meta = std::fs::symlink_metadata(from)?;
1276 if meta.is_dir() {
1277 return Err(io::Error::new(
1278 io::ErrorKind::Unsupported,
1279 "move: cross-device directory move is not supported",
1280 ));
1281 }
1282 if !meta.is_file() && !meta.is_symlink() {
1283 return Err(io::Error::new(
1284 io::ErrorKind::Unsupported,
1285 "move: cross-device move supports files and symlinks only",
1286 ));
1287 }
1288 std::fs::copy(from, to)?;
1289 std::fs::remove_file(from)?;
1290 Ok(())
1291 }
1292 }
1293}
1294
1295pub fn move_path(from: &str, to: &str) -> StrykeValue {
1298 StrykeValue::integer(if try_move_path(from, to).is_ok() {
1299 1
1300 } else {
1301 0
1302 })
1303}
1304
1305#[cfg(unix)]
1306fn unix_path_executable(path: &Path) -> bool {
1307 use std::os::unix::fs::PermissionsExt;
1308 std::fs::metadata(path)
1309 .ok()
1310 .filter(|m| m.is_file())
1311 .is_some_and(|m| m.permissions().mode() & 0o111 != 0)
1312}
1313
1314#[cfg(not(unix))]
1315fn unix_path_executable(path: &Path) -> bool {
1316 path.is_file()
1317}
1318
1319fn display_executable_path(path: &Path) -> Option<String> {
1320 if !unix_path_executable(path) {
1321 return None;
1322 }
1323 path.canonicalize()
1324 .ok()
1325 .map(|p| p.to_string_lossy().into_owned())
1326 .or_else(|| Some(path.to_string_lossy().into_owned()))
1327}
1328
1329#[cfg(windows)]
1330fn pathext_suffixes() -> Vec<String> {
1331 env::var_os("PATHEXT")
1332 .map(|s| {
1333 env::split_paths(&s)
1334 .filter_map(|p| p.to_str().map(str::to_ascii_lowercase))
1335 .collect()
1336 })
1337 .unwrap_or_else(|| vec![".exe".into(), ".cmd".into(), ".bat".into(), ".com".into()])
1338}
1339
1340#[cfg(windows)]
1341fn which_in_dir(dir: &Path, program: &str) -> Option<String> {
1342 let plain = dir.join(program);
1343 if let Some(s) = display_executable_path(&plain) {
1344 return Some(s);
1345 }
1346 if !program.contains('.') {
1347 for ext in pathext_suffixes() {
1348 let cand = dir.join(format!("{program}{ext}"));
1349 if let Some(s) = display_executable_path(&cand) {
1350 return Some(s);
1351 }
1352 }
1353 }
1354 None
1355}
1356
1357#[cfg(not(windows))]
1358fn which_in_dir(dir: &Path, program: &str) -> Option<String> {
1359 display_executable_path(&dir.join(program))
1360}
1361
1362pub fn which_executable(program: &str, include_dot: bool) -> Option<String> {
1365 if program.is_empty() {
1366 return None;
1367 }
1368 if program.contains('/') || (cfg!(windows) && program.contains('\\')) {
1369 return display_executable_path(Path::new(program));
1370 }
1371 let path_os = env::var_os("PATH")?;
1372 for dir in env::split_paths(&path_os) {
1373 if let Some(s) = which_in_dir(&dir, program) {
1374 return Some(s);
1375 }
1376 }
1377 if include_dot {
1378 return which_in_dir(Path::new("."), program);
1379 }
1380 None
1381}
1382
1383pub fn read_file_bytes(path: &str) -> io::Result<Arc<Vec<u8>>> {
1385 Ok(Arc::new(std::fs::read(path)?))
1386}
1387
1388fn adjacent_temp_path(target: &Path) -> PathBuf {
1390 let dir = target.parent().unwrap_or_else(|| Path::new("."));
1391 let name = target
1392 .file_name()
1393 .map(|s| s.to_string_lossy().into_owned())
1394 .unwrap_or_else(|| "file".to_string());
1395 let rnd: u32 = rand::thread_rng().gen();
1396 dir.join(format!("{name}.spurt-tmp-{rnd}"))
1397}
1398
1399pub fn spurt_path(path: &str, data: &[u8], mkdir_parents: bool, atomic: bool) -> io::Result<()> {
1402 let path = Path::new(path);
1403 if mkdir_parents {
1404 if let Some(parent) = path.parent() {
1405 if !parent.as_os_str().is_empty() {
1406 std::fs::create_dir_all(parent)?;
1407 }
1408 }
1409 }
1410 if !atomic {
1411 return std::fs::write(path, data);
1412 }
1413 let tmp = adjacent_temp_path(path);
1414 {
1415 let mut f = std::fs::File::create(&tmp)?;
1416 f.write_all(data)?;
1417 f.sync_all().ok();
1418 }
1419 std::fs::rename(&tmp, path)?;
1420 Ok(())
1421}
1422
1423pub fn copy_file(from: &str, to: &str, preserve_metadata: bool) -> StrykeValue {
1426 let times = if preserve_metadata {
1427 std::fs::metadata(from).ok().map(|src_meta| {
1428 let at = src_meta
1429 .accessed()
1430 .ok()
1431 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
1432 .map(|d| d.as_secs() as i64)
1433 .unwrap_or(0);
1434 let mt = src_meta
1435 .modified()
1436 .ok()
1437 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
1438 .map(|d| d.as_secs() as i64)
1439 .unwrap_or(0);
1440 (at, mt)
1441 })
1442 } else {
1443 None
1444 };
1445 if std::fs::copy(from, to).is_err() {
1446 return StrykeValue::integer(0);
1447 }
1448 if let Some((at, mt)) = times {
1449 let _ = utime_paths(at, mt, &[to.to_string()]);
1450 }
1451 StrykeValue::integer(1)
1452}
1453
1454pub fn path_basename(path: &str) -> String {
1456 Path::new(path)
1457 .file_name()
1458 .map(|s| s.to_string_lossy().into_owned())
1459 .unwrap_or_default()
1460}
1461
1462pub fn path_dirname(path: &str) -> String {
1464 if path.is_empty() {
1465 return String::new();
1466 }
1467 let p = Path::new(path);
1468 if path == "/" {
1469 return "/".to_string();
1470 }
1471 match p.parent() {
1472 None => ".".to_string(),
1473 Some(parent) => {
1474 let s = parent.to_string_lossy();
1475 if s.is_empty() {
1476 ".".to_string()
1477 } else {
1478 s.into_owned()
1479 }
1480 }
1481 }
1482}
1483
1484pub fn fileparse_path(path: &str, suffix: Option<&str>) -> (String, String, String) {
1488 let dir = path_dirname(path);
1489 let full_base = path_basename(path);
1490 let (base, sfx) = if let Some(suf) = suffix.filter(|s| !s.is_empty()) {
1491 if full_base.ends_with(suf) && full_base.len() > suf.len() {
1492 (
1493 full_base[..full_base.len() - suf.len()].to_string(),
1494 suf.to_string(),
1495 )
1496 } else {
1497 (full_base.clone(), String::new())
1498 }
1499 } else {
1500 (full_base.clone(), String::new())
1501 };
1502 (base, dir, sfx)
1503}
1504
1505pub fn chmod_paths(paths: &[String], mode: i64) -> i64 {
1507 #[cfg(unix)]
1508 {
1509 use std::os::unix::fs::PermissionsExt;
1510 let mut count = 0i64;
1511 for path in paths {
1512 if let Ok(meta) = std::fs::metadata(path) {
1513 let mut perms = meta.permissions();
1514 let old = perms.mode();
1515 perms.set_mode((old & !0o777) | (mode as u32 & 0o777));
1517 if std::fs::set_permissions(path, perms).is_ok() {
1518 count += 1;
1519 }
1520 }
1521 }
1522 count
1523 }
1524 #[cfg(not(unix))]
1525 {
1526 let _ = (paths, mode);
1527 0
1528 }
1529}
1530
1531pub fn utime_paths(atime_sec: i64, mtime_sec: i64, paths: &[String]) -> i64 {
1533 #[cfg(unix)]
1534 {
1535 use std::ffi::CString;
1536 let mut count = 0i64;
1537 let tv = [
1538 libc::timeval {
1539 tv_sec: atime_sec as libc::time_t,
1540 tv_usec: 0,
1541 },
1542 libc::timeval {
1543 tv_sec: mtime_sec as libc::time_t,
1544 tv_usec: 0,
1545 },
1546 ];
1547 for path in paths {
1548 let Ok(cs) = CString::new(path.as_str()) else {
1549 continue;
1550 };
1551 if unsafe { libc::utimes(cs.as_ptr(), tv.as_ptr()) } == 0 {
1552 count += 1;
1553 }
1554 }
1555 count
1556 }
1557 #[cfg(not(unix))]
1558 {
1559 let _ = (atime_sec, mtime_sec, paths);
1560 0
1561 }
1562}
1563
1564pub fn chown_paths(paths: &[String], uid: i64, gid: i64) -> i64 {
1566 #[cfg(unix)]
1567 {
1568 use std::ffi::CString;
1569 let u = if uid < 0 {
1570 (!0u32) as libc::uid_t
1571 } else {
1572 uid as libc::uid_t
1573 };
1574 let g = if gid < 0 {
1575 (!0u32) as libc::gid_t
1576 } else {
1577 gid as libc::gid_t
1578 };
1579 let mut count = 0i64;
1580 for path in paths {
1581 let Ok(c) = CString::new(path.as_str()) else {
1582 continue;
1583 };
1584 let r = unsafe { libc::chown(c.as_ptr(), u, g) };
1585 if r == 0 {
1586 count += 1;
1587 }
1588 }
1589 count
1590 }
1591 #[cfg(not(unix))]
1592 {
1593 let _ = (paths, uid, gid);
1594 0
1595 }
1596}
1597
1598pub fn touch_paths(paths: &[String]) -> i64 {
1601 use std::fs::OpenOptions;
1602 let mut count = 0i64;
1603 for path in paths {
1604 if path.is_empty() {
1605 continue;
1606 }
1607 let created = OpenOptions::new()
1609 .create(true)
1610 .append(true)
1611 .open(path)
1612 .is_ok();
1613 if !created {
1614 continue;
1615 }
1616 #[cfg(unix)]
1618 {
1619 use std::ffi::CString;
1620 if let Ok(cs) = CString::new(path.as_str()) {
1621 unsafe { libc::utimes(cs.as_ptr(), std::ptr::null()) };
1623 }
1624 }
1625 count += 1;
1626 }
1627 count
1628}
1629
1630#[cfg(test)]
1631mod tests {
1632 use super::*;
1633 use std::collections::HashSet;
1634
1635 #[test]
1636 fn glob_par_matches_sequential_glob_set() {
1637 let base = std::env::temp_dir().join(format!("stryke_glob_par_{}", std::process::id()));
1638 let _ = std::fs::remove_dir_all(&base);
1639 std::fs::create_dir_all(base.join("a")).unwrap();
1640 std::fs::create_dir_all(base.join("b")).unwrap();
1641 std::fs::create_dir_all(base.join("b/nested")).unwrap();
1642 std::fs::File::create(base.join("a/x.log")).unwrap();
1643 std::fs::File::create(base.join("b/y.log")).unwrap();
1644 std::fs::File::create(base.join("b/nested/z.log")).unwrap();
1645 std::fs::File::create(base.join("root.txt")).unwrap();
1646
1647 let pat = format!("{}/**/*.log", base.display());
1649 let a = glob_patterns(std::slice::from_ref(&pat));
1650 let b = glob_par_patterns(std::slice::from_ref(&pat));
1651 let _ = std::fs::remove_dir_all(&base);
1652
1653 let set_a: HashSet<String> = a
1654 .as_array_vec()
1655 .expect("expected array")
1656 .into_iter()
1657 .map(|x| x.to_string())
1658 .collect();
1659 let set_b: HashSet<String> = b
1660 .as_array_vec()
1661 .expect("expected array")
1662 .into_iter()
1663 .map(|x| x.to_string())
1664 .collect();
1665 assert_eq!(set_a, set_b);
1666 }
1667
1668 #[test]
1669 fn glob_par_src_rs_matches_when_src_tree_present() {
1670 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1671 let src = root.join("src");
1672 if !src.is_dir() {
1673 return;
1674 }
1675 let pat = src.join("*.rs").to_string_lossy().into_owned();
1676 let v = glob_par_patterns(&[pat])
1677 .as_array_vec()
1678 .expect("expected array");
1679 assert!(
1680 !v.is_empty(),
1681 "glob_par src/*.rs should find at least one .rs under src/"
1682 );
1683 }
1684
1685 #[test]
1686 fn glob_par_progress_false_same_as_plain() {
1687 let tmp = Path::new(env!("CARGO_MANIFEST_DIR"))
1688 .join("target")
1689 .join(format!("glob_par_prog_false_{}", std::process::id()));
1690 let _ = std::fs::remove_dir_all(&tmp);
1691 std::fs::create_dir_all(&tmp).unwrap();
1692 std::fs::write(tmp.join("probe.rs"), b"// x\n").unwrap();
1693 let pat = tmp.join("*.rs").to_string_lossy().replace('\\', "/");
1694 let a = glob_par_patterns(std::slice::from_ref(&pat));
1695 let b = glob_par_patterns_with_progress(std::slice::from_ref(&pat), false);
1696 let _ = std::fs::remove_dir_all(&tmp);
1697 let va = a.as_array_vec().expect("a");
1698 let vb = b.as_array_vec().expect("b");
1699 assert_eq!(va.len(), vb.len(), "glob_par vs glob_par(..., progress=>0)");
1700 for (x, y) in va.iter().zip(vb.iter()) {
1701 assert_eq!(x.to_string(), y.to_string());
1702 }
1703 }
1704
1705 #[test]
1706 fn read_file_text_perl_compat_maps_invalid_utf8_to_latin1_octets() {
1707 let path = std::env::temp_dir().join(format!("stryke_bad_utf8_{}.txt", std::process::id()));
1708 std::fs::write(&path, b"ok\xff\xfe\x80\n").unwrap();
1710 let s = read_file_text_perl_compat(&path).expect("read");
1711 assert!(s.starts_with("ok"));
1712 assert_eq!(&s[2..], "\u{00ff}\u{00fe}\u{0080}\n");
1713 let _ = std::fs::remove_file(&path);
1714 }
1715
1716 #[cfg(unix)]
1717 #[test]
1718 fn slurp_glob_skips_dangling_symlink_in_plain_sweep() {
1719 use std::os::unix::fs::symlink;
1725 let dir = std::env::temp_dir().join(format!("stryke_slurp_dangling_{}", std::process::id()));
1726 let _ = std::fs::remove_dir_all(&dir);
1727 std::fs::create_dir_all(&dir).unwrap();
1728 std::fs::write(dir.join("real.txt"), b"hello").unwrap();
1729 symlink(dir.join("does_not_exist"), dir.join("dead.txt")).unwrap();
1730 let pat = format!("{}/*.txt", dir.display());
1731 let out = read_file_text_or_glob(&pat).expect("plain sweep must not fail on a dangling link");
1732 assert_eq!(out, "hello");
1733 let _ = std::fs::remove_dir_all(&dir);
1734 }
1735
1736 #[cfg(unix)]
1737 #[test]
1738 fn swallow_and_ingest_skip_dangling_symlink_in_plain_sweep() {
1739 use std::os::unix::fs::symlink;
1743 let dir = std::env::temp_dir().join(format!("stryke_swallow_dangling_{}", std::process::id()));
1744 let _ = std::fs::remove_dir_all(&dir);
1745 std::fs::create_dir_all(&dir).unwrap();
1746 std::fs::write(dir.join("real.txt"), b"hello").unwrap();
1747 symlink(dir.join("does_not_exist"), dir.join("dead.txt")).unwrap();
1748 let pat = format!("{}/*.txt", dir.display());
1749
1750 let hash = swallow_to_hash(&pat).expect("swallow must skip the dangling link");
1751 let keys: Vec<String> = hash.as_hash_map().unwrap().keys().cloned().collect();
1752 assert_eq!(keys.len(), 1, "only the real file survives: {:?}", keys);
1753 assert!(keys[0].ends_with("real.txt"));
1754
1755 ingest_iterator(&pat).expect("ingest must skip the dangling link");
1757 let _ = std::fs::remove_dir_all(&dir);
1758 }
1759
1760 #[test]
1761 fn read_logical_line_perl_compat_splits_and_decodes_per_line() {
1762 use std::io::Cursor;
1763 let mut r = Cursor::new(b"a\xff\nb\n");
1764 assert_eq!(
1765 read_logical_line_perl_compat(&mut r).unwrap(),
1766 Some("a\u{00ff}".to_string())
1767 );
1768 assert_eq!(
1769 read_logical_line_perl_compat(&mut r).unwrap(),
1770 Some("b".to_string())
1771 );
1772 assert_eq!(read_logical_line_perl_compat(&mut r).unwrap(), None);
1773 }
1774
1775 #[test]
1786 fn read_logical_line_with_sep_nul_reads_whole_no_nul_file_as_one_record() {
1787 use std::io::Cursor;
1788 let mut r = Cursor::new(b"{\n\n}\n");
1789 assert_eq!(
1790 read_logical_line_perl_compat_with_sep(&mut r, Some(0u8)).unwrap(),
1791 Some("{\n\n}\n".to_string()),
1792 );
1793 assert_eq!(
1794 read_logical_line_perl_compat_with_sep(&mut r, Some(0u8)).unwrap(),
1795 None,
1796 );
1797 }
1798
1799 #[test]
1802 fn read_logical_line_with_sep_splits_on_arbitrary_byte() {
1803 use std::io::Cursor;
1804 let mut r = Cursor::new(b"a b c");
1805 assert_eq!(
1806 read_logical_line_perl_compat_with_sep(&mut r, Some(b' ')).unwrap(),
1807 Some("a ".to_string()),
1808 );
1809 assert_eq!(
1810 read_logical_line_perl_compat_with_sep(&mut r, Some(b' ')).unwrap(),
1811 Some("b ".to_string()),
1812 );
1813 assert_eq!(
1814 read_logical_line_perl_compat_with_sep(&mut r, Some(b' ')).unwrap(),
1815 Some("c".to_string()),
1816 );
1817 assert_eq!(
1818 read_logical_line_perl_compat_with_sep(&mut r, Some(b' ')).unwrap(),
1819 None,
1820 );
1821 }
1822
1823 #[test]
1825 fn read_logical_line_with_sep_none_slurps_to_eof() {
1826 use std::io::Cursor;
1827 let mut r = Cursor::new(b"line1\nline2\nline3");
1828 assert_eq!(
1829 read_logical_line_perl_compat_with_sep(&mut r, None).unwrap(),
1830 Some("line1\nline2\nline3".to_string()),
1831 );
1832 assert_eq!(
1833 read_logical_line_perl_compat_with_sep(&mut r, None).unwrap(),
1834 None,
1835 );
1836 }
1837
1838 #[test]
1841 fn read_logical_line_with_sep_line_mode_preserves_trailing_newline() {
1842 use std::io::Cursor;
1843 let mut r = Cursor::new(b"foo\nbar\n");
1844 assert_eq!(
1845 read_logical_line_perl_compat_with_sep(&mut r, Some(b'\n')).unwrap(),
1846 Some("foo\n".to_string()),
1847 );
1848 assert_eq!(
1849 read_logical_line_perl_compat_with_sep(&mut r, Some(b'\n')).unwrap(),
1850 Some("bar\n".to_string()),
1851 );
1852 assert_eq!(
1853 read_logical_line_perl_compat_with_sep(&mut r, Some(b'\n')).unwrap(),
1854 None,
1855 );
1856 }
1857}