Skip to main content

zsh/ported/modules/
stat.rs

1//! File stat module — port of `Src/Modules/stat.c`.
2//!
3//! C source has 2 enums (`statnum`, `statflags`) — both anonymous-
4//! valued integer-constant tables. Rust port mirrors as constants
5//! to avoid Rust-only type wrappers.
6//!
7//! Functions (matching C 1:1):
8//!   - statmodeprint  `[c:47]`
9//!   - statuidprint   `[c:132]`
10//!   - statgidprint   `[c:161]`
11//!   - stattimeprint  `[c:191]`
12//!   - statulprint    `[c:211]`
13//!   - statlinkprint  `[c:219]`
14//!   - statprint      `[c:234]`
15//!   - bin_stat       `[c:368]`
16//!   - 6 module loaders
17
18use crate::ported::params::{setaparam, sethparam, setsparam};
19use crate::ported::utils::{zstrtol, ztrftime, zwarnnam};
20use crate::ported::zsh_h::{features, module, options};
21use std::fs;
22use std::os::unix::fs::MetadataExt;
23use std::sync::{Mutex, OnceLock};
24
25// ============================================================
26// Port of `enum statnum` from `Src/Modules/stat.c:33-35`.
27// Anonymous integer constants for the per-element index passed
28// to `statprint(..., iwhich, ...)`.
29// ============================================================
30/// Port of `HNAMEKEY` from `Src/Modules/stat.c:43`. Hash key the
31/// `zstat -H` mode uses to store the file name in the result assoc.
32pub const HNAMEKEY: &str = "name"; // c:43
33
34/// Port of `statmodeprint(mode_t mode, char *outbuf, int flags)` from `Src/Modules/stat.c:47`. Renders
35/// a Unix mode word per the STF_RAW / STF_OCTAL / STF_STRING flag
36/// combination — raw octal/decimal, "ls -l"-style permission
37/// string, or both with the raw form parenthesised.
38///
39/// C signature: `static void statmodeprint(mode_t mode, char *outbuf, int flags)`.
40/// Rust port returns the formatted string (caller writes to its
41/// own buffer) — same observable output for a given flag set.
42/// WARNING: param names don't match C — Rust=(mode, flags) vs C=(mode, outbuf, flags)
43pub fn statmodeprint(mode: u32, flags: i32) -> String {
44    // c:47
45    let mut out = String::new();
46    if (flags & STF_RAW) != 0 {
47        // c:50
48        if (flags & STF_OCTAL) != 0 {
49            // c:51
50            out.push_str(&format!("0{:o}", mode));
51        } else {
52            out.push_str(&format!("{}", mode));
53        }
54        if (flags & STF_STRING) != 0 {
55            // c:53
56            out.push_str(" ("); // c:54
57        }
58    }
59    if (flags & STF_STRING) != 0 {
60        // c:56
61        let modes = b"?rwxrwxrwx";
62        let mut pm = [b'-'; 10];
63        // c:84-103 — file-type char.
64        let ifmt = mode & 0o170_000; // S_IFMT
65        pm[0] = match ifmt {
66            0o020_000 => b'c', // S_ISCHR
67            0o040_000 => b'd', // S_ISDIR
68            0o060_000 => b'b', // S_ISBLK
69            0o100_000 => b'-', // S_ISREG
70            0o120_000 => b'l', // S_ISLNK
71            0o140_000 => b's', // S_ISSOCK
72            0o010_000 => b'p', // S_ISFIFO
73            _ => b'?',
74        };
75        // c:105-107 — owner/group/other rwx bits.
76        let bits = [
77            0o0400, 0o0200, 0o0100, 0o0040, 0o0020, 0o0010, 0o0004, 0o0002, 0o0001,
78        ];
79        for i in 0..9 {
80            pm[i + 1] = if (mode & bits[i]) != 0 {
81                modes[i + 1]
82            } else {
83                b'-'
84            };
85        }
86        // c:111-115 — setuid / setgid / sticky.
87        if (mode & 0o4000) != 0 {
88            // S_ISUID
89            pm[3] = if (mode & 0o0100) != 0 { b's' } else { b'S' };
90        }
91        if (mode & 0o2000) != 0 {
92            // S_ISGID
93            pm[6] = if (mode & 0o0010) != 0 { b's' } else { b'S' };
94        }
95        if (mode & 0o1000) != 0 {
96            // S_ISVTX
97            pm[9] = if (mode & 0o0001) != 0 { b't' } else { b'T' };
98        }
99        out.push_str(std::str::from_utf8(&pm).unwrap_or(""));
100        if (flags & STF_RAW) != 0 {
101            // c:132
102            out.push(')'); // c:132
103        }
104    }
105    out
106}
107
108/// Port of `statuidprint(uid_t uid, char *outbuf, int flags)` from `Src/Modules/stat.c:132`. Renders
109/// a uid in raw form (decimal), string form (user name via
110/// `getpwuid`), or both.
111/// WARNING: param names don't match C — Rust=(uid, flags) vs C=(uid, outbuf, flags)
112pub fn statuidprint(uid: u32, flags: i32) -> String {
113    // c:132
114    let mut out = String::new();
115    if (flags & STF_RAW) != 0 {
116        // c:135
117        out.push_str(&format!("{}", uid));
118        if (flags & STF_STRING) != 0 {
119            // c:137
120            out.push_str(" (");
121        }
122    }
123    if (flags & STF_STRING) != 0 {
124        // c:140
125        let name = unsafe {
126            // c:142 — `pwd = getpwuid(uid);`
127            let p = libc::getpwuid(uid);
128            if p.is_null() {
129                String::new()
130            } else {
131                let nm = (*p).pw_name;
132                if nm.is_null() {
133                    String::new()
134                } else {
135                    std::ffi::CStr::from_ptr(nm).to_string_lossy().into_owned()
136                }
137            }
138        };
139        if name.is_empty() {
140            // c:148 numeric fallback
141            out.push_str(&format!("{}", uid));
142        } else {
143            out.push_str(&name); // c:161 pwd->pw_name
144        }
145        if (flags & STF_RAW) != 0 {
146            // c:161
147            out.push(')');
148        }
149    }
150    out
151}
152
153/// Port of `statgidprint(gid_t gid, char *outbuf, int flags)` from `Src/Modules/stat.c:161`. Symmetric
154/// with `statuidprint` for gid via `getgrgid`.
155/// WARNING: param names don't match C — Rust=(gid, flags) vs C=(gid, outbuf, flags)
156pub fn statgidprint(gid: u32, flags: i32) -> String {
157    // c:161
158    let mut out = String::new();
159    if (flags & STF_RAW) != 0 {
160        // c:164
161        out.push_str(&format!("{}", gid));
162        if (flags & STF_STRING) != 0 {
163            // c:166
164            out.push_str(" (");
165        }
166    }
167    if (flags & STF_STRING) != 0 {
168        // c:169
169        let name = unsafe {
170            let g = libc::getgrgid(gid); // c:171
171            if g.is_null() {
172                String::new()
173            } else {
174                let nm = (*g).gr_name;
175                if nm.is_null() {
176                    String::new()
177                } else {
178                    std::ffi::CStr::from_ptr(nm).to_string_lossy().into_owned()
179                }
180            }
181        };
182        if name.is_empty() {
183            out.push_str(&format!("{}", gid)); // c:184
184        } else {
185            out.push_str(&name); // c:178
186        }
187        if (flags & STF_RAW) != 0 {
188            // c:191
189            out.push(')');
190        }
191    }
192    out
193}
194
195/// Port of `static char *timefmt;` from `Src/Modules/stat.c:187`. C uses a
196/// module-static global initialized to the ctime-like default at the top of
197/// `bin_stat` (c:376) and overwritten by `-F FMT`. Rust mirrors with a
198/// `Mutex<String>` so `stattimeprint` (c:201) can read the same global.
199/// Default constant lives next to the static; callers read/write the lock
200/// directly (no accessor helpers — those would be Rust-only fns).
201const TIMEFMT_DEFAULT: &str = "%a %b %e %k:%M:%S %Z %Y"; // c:376
202
203static TIMEFMT: std::sync::OnceLock<std::sync::Mutex<String>> = std::sync::OnceLock::new();
204
205/// Port of `stattimeprint(time_t tim, long nsecs, char *outbuf, int flags)` from `Src/Modules/stat.c:191`. Renders
206/// a Unix timestamp + nsec offset: raw form is integer seconds;
207/// string form is `ctime(3)` (or strftime via the timefmt global).
208/// WARNING: param names don't match C — Rust=(tim, _nsecs, flags) vs C=(tim, nsecs, outbuf, flags)
209pub fn stattimeprint(tim: i64, nsecs: i64, flags: i32) -> String {
210    // c:191
211    let mut out = String::new();
212    if (flags & STF_RAW) != 0 {
213        // c:194
214        out.push_str(&format!("{}", tim));
215        if (flags & STF_STRING) != 0 {
216            // c:196
217            out.push_str(" (");
218        }
219    }
220    if (flags & STF_STRING) != 0 {
221        // c:199
222        // c:201 — `ztrftime(oend, 40, timefmt,
223        //     (flags & STF_GMT) ? gmtime(&tim) : localtime(&tim), nsecs);`
224        // C reads the module-static `timefmt` here (initialized to the
225        // ctime default at bin_stat entry, possibly overwritten by -F).
226        // The GMT vs local choice comes from the STF_GMT flag (`stat -g`).
227        // c:201 — `nsecs` is the sub-second component (GET_ST_*_NSEC);
228        // pack into Duration::new(secs, nsec) so ztrftime sees the
229        // fractional digits for the `%.` / `%N.` zsh-extension.
230        let nsec_u32 = nsecs.clamp(0, 999_999_999) as u32;
231        let st = std::time::UNIX_EPOCH + std::time::Duration::new(tim.max(0) as u64, nsec_u32);
232        let fmt: String = TIMEFMT
233            .get_or_init(|| std::sync::Mutex::new(TIMEFMT_DEFAULT.to_string()))
234            .lock()
235            .map(|g| g.clone())
236            .unwrap_or_else(|_| TIMEFMT_DEFAULT.to_string());
237        let use_gmt = (flags & STF_GMT) != 0; // c:201 — picks gmtime(&tim)
238        let formatted = ztrftime(&fmt, st, use_gmt);
239        out.push_str(&formatted);
240        if (flags & STF_RAW) != 0 {
241            // c:211
242            out.push(')');
243        }
244    }
245    out
246}
247
248/// Port of `statulprint(unsigned long num, char *outbuf)` from `Src/Modules/stat.c:211`. Renders an
249/// unsigned-long stat field as decimal (always raw, no STF_STRING
250/// branch).
251/// WARNING: param names don't match C — Rust=(num) vs C=(num, outbuf)
252pub fn statulprint(num: u64) -> String {
253    // c:211
254    format!("{}", num) // c:219
255}
256
257/// Port of `statlinkprint(struct stat *sbuf, char *outbuf, char *fname)` from `Src/Modules/stat.c:219`. For
258/// symlinks, renders the link target via `readlink(2)`; otherwise
259/// returns empty.
260/// WARNING: param names don't match C — Rust=(sbuf_mode, fname) vs C=(sbuf, outbuf, fname)
261pub fn statlinkprint(sbuf_mode: u32, fname: &str) -> String {
262    // c:219
263    if (sbuf_mode & 0o170_000) != 0o120_000 {
264        // c:219 S_ISLNK
265        return String::new();
266    }
267    fs::read_link(fname) // c:226 readlink
268        .map(|p| p.to_string_lossy().into_owned())
269        .unwrap_or_default()
270}
271
272/// Port of `statprint(struct stat *sbuf, char *outbuf, char *fname, int iwhich, int flags)` from `Src/Modules/stat.c:234`. The unified
273/// per-field dispatcher: given a stat metadata, file name, the
274/// `iwhich` index from `STATELTS`, and a flag word, produce the
275/// formatted value string.
276///
277/// C signature: `static void statprint(struct stat *sbuf, char *outbuf,
278///                                      char *fname, int iwhich, int flags)`.
279/// WARNING: param names don't match C — Rust=(meta, fname, iwhich, flags) vs C=(sbuf, outbuf, fname, iwhich, flags)
280pub fn statprint(meta: &fs::Metadata, fname: &str, iwhich: i32, flags: i32) -> String {
281    // c:234
282    // c:234-241 — `if (flags & STF_NAME)` prefix with `name<space>`.
283    // `%-8s` left-justifies the name to 8 chars when not PICK/ARRAY,
284    // `%s ` otherwise.
285    let name_prefix = if (flags & STF_NAME) != 0 {
286        let n = STATELTS.get(iwhich as usize).copied().unwrap_or("");
287        if (flags & (STF_PICK | STF_ARRAY)) != 0 {
288            format!("{} ", n) // c:239
289        } else {
290            format!("{:<8}", n) // c:240
291        }
292    } else {
293        String::new()
294    };
295    let val = match iwhich {
296        ST_DEV => format!("{}", meta.dev()),          // c:240
297        ST_INO => format!("{}", meta.ino()),          // c:241
298        ST_MODE => statmodeprint(meta.mode(), flags), // c:242
299        ST_NLINK => format!("{}", meta.nlink()),      // c:243
300        ST_UID => statuidprint(meta.uid(), flags),    // c:244
301        ST_GID => statgidprint(meta.gid(), flags),    // c:245
302        ST_RDEV => format!("{}", meta.rdev()),        // c:246
303        ST_SIZE => statulprint(meta.size()),          // c:247
304        // c:290-296 — GET_ST_ATIME_NSEC(*sbuf): the per-platform macro
305        // pulls the sub-second component out of struct stat
306        // (st_atim.tv_nsec on POSIX 2008, st_atimespec.tv_nsec on
307        // BSD-derived systems, etc.). std::fs::Metadata's MetadataExt
308        // exposes the same value via atime_nsec() / mtime_nsec() /
309        // ctime_nsec(). Passing zero (as the prior port did) broke the
310        // ztrftime `%.` / `%N.` fractional-seconds specifier — every
311        // formatted timestamp printed `000` regardless of the actual
312        // sub-second value.
313        ST_ATIM => stattimeprint(meta.atime(), meta.atime_nsec(), flags), // c:292
314        ST_MTIM => stattimeprint(meta.mtime(), meta.mtime_nsec(), flags), // c:300
315        ST_CTIM => stattimeprint(meta.ctime(), meta.ctime_nsec(), flags), // c:308
316        ST_BLKSIZE => statulprint(meta.blksize()),                        // c:251
317        ST_BLOCKS => statulprint(meta.blocks()),                          // c:252
318        ST_READLINK => statlinkprint(meta.mode(), fname),                 // c:253
319        _ => String::new(),
320    };
321    format!("{}{}", name_prefix, val)
322}
323
324/// Port of `bin_stat(char *name, char **args, Options ops, UNUSED(int func))` from `Src/Modules/stat.c:368`. The `zstat`
325/// builtin entry. Parses the `+ELEMENT` / `-flag` / `-A NAME` /
326/// `-H NAME` / `-f FD` / `-F FORMAT` arg syntax, then calls
327/// `lstat`/`stat`/`fstat` per file, dispatching `statprint` for
328/// each requested element.
329///
330/// C signature: `static int bin_stat(char *name, char **args,
331///                                    Options ops, int func)`.
332/// WARNING: param names don't match C — Rust=(nam, args, _func) vs C=(name, args, ops, func)
333pub fn bin_stat(
334    nam: &str,
335    args: &[String], // c:368
336    _ops_unused: &options,
337    _func: i32,
338) -> i32 {
339    // c:370-374 — locals.
340    let mut iwhich: i32 = -1; // c:373
341    let mut flags: i32 = 0;
342    let mut found = 0i32; // c:375
343    let mut arrnam: Option<String> = None;
344    let mut hashnam: Option<String> = None;
345    let mut fd: i32 = 0;
346    // c:376 — `timefmt = "%a %b %e %k:%M:%S %Z %Y";`. Reset the module-
347    // static every entry so a prior `-F` doesn't leak into a fresh
348    // invocation without `-F`.
349    if let Ok(mut g) = TIMEFMT
350        .get_or_init(|| std::sync::Mutex::new(TIMEFMT_DEFAULT.to_string()))
351        .lock()
352    {
353        g.clear();
354        g.push_str(TIMEFMT_DEFAULT);
355    }
356    // The C `Options ops` bitmap is parsed inline by this fn (the
357    // BUILTIN spec at c:637 is `NULL`, so the framework doesn't pre-
358    // parse). Per PORT_CHECKLIST.md rule 3 we keep `ops` as a local
359    // 256-entry bitmap rather than introducing a Rust-only struct.
360    let mut ops = [false; 256];
361    let args: Vec<&str> = args.iter().map(String::as_str).collect();
362    let mut argv: Vec<&str> = Vec::with_capacity(args.len());
363    let mut i = 0;
364    // c:381 — arg loop.
365    while i < args.len() && (args[i].starts_with('+') || args[i].starts_with('-')) {
366        let arg = &args[i][1..];
367        if arg.is_empty() || arg.starts_with('-') || arg.starts_with('+') {
368            i += 1;
369            break; // c:386
370        }
371        if args[i].starts_with('+') {
372            // c:389
373            if found != 0 {
374                break;
375            }
376            for (idx, name) in STATELTS.iter().enumerate() {
377                // c:392
378                if name.starts_with(arg) {
379                    found += 1;
380                    iwhich = idx as i32;
381                }
382            }
383            if found > 1 {
384                // c:397
385                zwarnnam(nam, &format!("{}: ambiguous stat element", arg));
386                return 1;
387            } else if found == 0 {
388                // c:400
389                zwarnnam(nam, &format!("{}: no such stat element", arg));
390                return 1;
391            }
392            // c:404 — `if (iwhich == ST_READLINK) ops->ind['L'] = 1;`
393            if iwhich == ST_READLINK {
394                ops[b'L' as usize] = true;
395            }
396            flags |= STF_PICK; // c:406
397        } else {
398            // c:407 - flag arm
399            for ch in arg.chars() {
400                match ch {
401                    'g' | 'l' | 'L' | 'n' | 'N' | 'o' | 'r' | 's' | 't' | 'T' => {
402                        ops[ch as u8 as usize] = true; // c:411
403                    }
404                    'A' => {
405                        // c:412 — array name follows.
406                        i += 1;
407                        if i >= args.len() {
408                            zwarnnam(nam, "missing parameter name");
409                            return 1;
410                        }
411                        arrnam = Some(args[i].to_string());
412                        flags |= STF_ARRAY;
413                        break;
414                    }
415                    'H' => {
416                        i += 1;
417                        if i >= args.len() {
418                            zwarnnam(nam, "missing parameter name");
419                            return 1;
420                        }
421                        hashnam = Some(args[i].to_string());
422                        flags |= STF_HASH;
423                        break;
424                    }
425                    'f' => {
426                        ops[b'f' as usize] = true;
427                        i += 1;
428                        if i >= args.len() {
429                            zwarnnam(nam, "missing file descriptor");
430                            return 1;
431                        }
432                        let (val, endptr) = zstrtol(args[i], 10);
433                        if !endptr.is_empty() {
434                            zwarnnam(nam, "bad file descriptor");
435                            return 1;
436                        }
437                        fd = val as i32;
438                        break;
439                    }
440                    'F' => {
441                        // c:442-451 — `-F FMT`. If the same arg has chars
442                        // after 'F' (e.g. `-F%Y`), use those; else consume
443                        // the next argv entry. Force STF_STRING via -s so
444                        // the format actually gets used (c:449-450).
445                        let inline: &str = &arg[arg.find('F').unwrap() + 1..];
446                        let fmt: &str = if !inline.is_empty() {
447                            // c:443-444
448                            inline
449                        } else {
450                            i += 1;
451                            if i >= args.len() {
452                                // c:446
453                                zwarnnam(nam, "missing time format");
454                                return 1;
455                            }
456                            args[i]
457                        };
458                        // c:444 — `timefmt = arg+1;` / c:445 — `timefmt = *++args;`
459                        if let Ok(mut g) = TIMEFMT
460                            .get_or_init(|| std::sync::Mutex::new(TIMEFMT_DEFAULT.to_string()))
461                            .lock()
462                        {
463                            g.clear();
464                            g.push_str(fmt);
465                        }
466                        ops[b's' as usize] = true; // c:450 — force STF_STRING.
467                        break;
468                    }
469                    _ => {
470                        zwarnnam(nam, &format!("bad option: -{}", ch));
471                        return 1;
472                    }
473                }
474            }
475        }
476        i += 1;
477    }
478    while i < args.len() {
479        argv.push(args[i]);
480        i += 1;
481    }
482    let _ = fd;
483
484    if (flags & STF_ARRAY) != 0 && (flags & STF_HASH) != 0 {
485        // c:459
486        zwarnnam(nam, "both array and hash requested");
487        return 1;
488    }
489
490    if ops[b'l' as usize] {
491        // c:467
492        // List elements + return.
493        if let Some(ref name) = arrnam {
494            // c:469
495            // c:485 — `setaparam(arrnam, array);` — REAL array of
496            // element names. Prior port colon-joined into a scalar
497            // via setsparam — `$names[1]` returned the first CHAR of
498            // "device:inode:mode:..." instead of "device", and
499            // ${(t)names} reported scalar where zsh reports array.
500            let names: Vec<String> = STATELTS.iter().map(|s| s.to_string()).collect();
501            setaparam(name, names); // c:485
502        } else {
503            let joined: Vec<&str> = STATELTS.iter().copied().collect();
504            println!("{}", joined.join(" ")); // c:478 putchar
505        }
506        return 0; // c:489
507    }
508
509    if argv.is_empty() && !ops[b'f' as usize] {
510        // c:491
511        zwarnnam(nam, "no files given");
512        return 1;
513    } else if !argv.is_empty() && ops[b'f' as usize] {
514        // c:493
515        zwarnnam(nam, "no files allowed with -f");
516        return 1;
517    }
518
519    // c:496+ — per-file stat + dispatch loop.
520    let use_lstat = ops[b'L' as usize];
521    let mut hash_out: Vec<(String, String)> = Vec::new();
522    let mut array_out: Vec<String> = Vec::new();
523    let show_type = ops[b't' as usize]; // c: -t
524    let mut local_flags = flags;
525    // c:510-513 — `if (OPT_ISSET(ops,'g')) { flags |= STF_GMT;
526    //                                        ops->ind['s'] = 1; }`
527    // -g picks gmtime over localtime (consumed by stattimeprint at c:201)
528    // AND auto-enables string formatting since raw epoch seconds carry
529    // no timezone semantics.
530    if ops[b'g' as usize] {
531        local_flags |= STF_GMT; // c:511
532        ops[b's' as usize] = true; // c:512
533    }
534    // c:514-515 — `if (OPT_ISSET(ops,'s') || OPT_ISSET(ops,'r'))` STF_STRING.
535    if ops[b's' as usize] || ops[b'r' as usize] {
536        local_flags |= STF_STRING;
537    }
538    // c:516 — `if (OPT_ISSET(ops,'r') || !OPT_ISSET(ops,'s'))` STF_RAW.
539    if ops[b'r' as usize] || !ops[b's' as usize] {
540        // c:516
541        local_flags |= STF_RAW;
542    }
543    // c:518-519 — `-n` → STF_FILE (filename prefix).
544    if ops[b'n' as usize] {
545        local_flags |= STF_FILE;
546    } // c:519
547      // c:520-521 — `-o` → STF_OCTAL.
548    if ops[b'o' as usize] {
549        local_flags |= STF_OCTAL;
550    } // c:521
551      // c:522-523 — `-t` → STF_NAME explicit.
552    if ops[b't' as usize] {
553        local_flags |= STF_NAME;
554    } // c:523
555      // c:525-530 — default STF_NAME when neither -A nor -H and no
556      // single-element pick: every line gets a `name<sp>` prefix so
557      // `zstat /etc/hosts` looks like `mode    33188` etc.
558    if arrnam.is_none() && hashnam.is_none() {
559        if argv.len() > 1 {
560            local_flags |= STF_FILE;
561        } // c:527
562        if (local_flags & STF_PICK) == 0 {
563            // c:528
564            local_flags |= STF_NAME; // c:529
565        }
566    }
567    // c:532-535 — explicit -N / -f turn off STF_FILE; -T / -H turn off
568    // STF_NAME (suppress prefix for `read` / hash use).
569    if ops[b'N' as usize] || ops[b'f' as usize] {
570        // c:532
571        local_flags &= !STF_FILE;
572    }
573    if ops[b'T' as usize] || ops[b'H' as usize] {
574        // c:534
575        local_flags &= !STF_NAME;
576    }
577    let _ = show_type;
578
579    // c:Src/Modules/stat.c:bin_stat — C tracks per-file stat(2) failures
580    // and propagates a non-zero rc when any path errored (`ret = 1` at
581    // c:560-565 inside the per-path loop). The Rust port previously
582    // skipped the path on error via `continue` but unconditionally
583    // returned 0 at the end, masking ENOENT (and any other stat error)
584    // as success. Track the first failure rc and return it after the
585    // loop.
586    let mut rc: i32 = 0;
587    for path in &argv {
588        let meta = if use_lstat {
589            fs::symlink_metadata(path)
590        } else {
591            fs::metadata(path)
592        };
593        let meta = match meta {
594            Ok(m) => m,
595            Err(e) => {
596                // Bug #112 — strip Rust's " (os error N)" suffix by
597                // routing the errno through the canonical strerror
598                // port (Src/compat.c:194).
599                let errno = e.raw_os_error().unwrap_or(0);
600                let raw = crate::ported::compat::strerror(errno);
601                // c:Src/stat.c:564 — `zwarnnam(name, "%s: %e", ...)`.
602                // The `%e` directive (utils.c:360-367) uncapitalizes the
603                // first letter of strerror unless errno==EIO, so zsh
604                // prints "no such file or directory", not "No such…".
605                const EIO: i32 = 5;
606                let msg = if errno == EIO {
607                    raw
608                } else {
609                    let mut cs = raw.chars();
610                    match cs.next() {
611                        Some(f) => f.to_lowercase().collect::<String>() + cs.as_str(),
612                        None => raw,
613                    }
614                };
615                zwarnnam(nam, &format!("{}: {}", path, msg));
616                rc = 1;
617                // c:567-568 — `if (OPT_ISSET(ops,'f') || arrnam) break; else continue;`
618                // With -A (or -f) the whole result is a single aggregate, so the
619                // first failure abandons the loop; without one, the remaining
620                // files are still reported.
621                if arrnam.is_some() || ops[b'f' as usize] {
622                    break;
623                }
624                continue;
625            }
626        };
627
628        // c:573-581 — `STF_FILE` prefix the filename per file.
629        if (local_flags & STF_FILE) != 0 && arrnam.is_none() && hashnam.is_none() {
630            if (local_flags & STF_PICK) != 0 {
631                print!("{} ", path); // c:580
632            } else {
633                println!("{}:", path);
634            }
635        }
636        if iwhich >= 0 {
637            // -E single element.
638            let val = statprint(&meta, path, iwhich, local_flags);
639            if let Some(ref aname) = arrnam {
640                array_out.push(val);
641                let _ = aname;
642            } else if let Some(ref hname) = hashnam {
643                hash_out.push((STATELTS[iwhich as usize].to_string(), val));
644                let _ = hname;
645            } else {
646                println!("{}", val); // c:591
647            }
648        } else {
649            // All elements.
650            for idx in 0..STATELTS.len() {
651                let val = statprint(&meta, path, idx as i32, local_flags);
652                if let Some(_) = &arrnam {
653                    array_out.push(val);
654                } else if let Some(_) = &hashnam {
655                    hash_out.push((STATELTS[idx].to_string(), val));
656                } else {
657                    println!("{}", val); // c:603
658                }
659            }
660        }
661    }
662
663    // c:613-631 — `if (ret) freearray(array); else setaparam(...)`. The result is
664    // accumulated into a LOCAL buffer and only published to the parameter when
665    // every file stat'd cleanly. On any failure the buffer is dropped and the
666    // target parameter keeps whatever it already held — so a failing
667    // `zstat -H h <dangling-link>` must not wipe the assoc a previous successful
668    // zstat left in `h`.
669    if rc == 0 {
670        if let Some(name) = arrnam {
671            // c — `setaparam(name, zarrdup(array_out));` — real indexed array.
672            setaparam(&name, array_out); // c:params.c:3595
673        }
674        if let Some(name) = hashnam {
675            // c — `sethparam(name, ...);` — real assoc array. Flatten
676            // hash_out into alternating [k,v,k,v,...].
677            let mut flat: Vec<String> = Vec::with_capacity(hash_out.len() * 2);
678            for (k, v) in hash_out {
679                flat.push(k);
680                flat.push(v);
681            }
682            sethparam(&name, flat); // c:params.c:3602
683        }
684    }
685    rc
686}
687
688// `bintab` — port of `static struct builtin bintab[]` (stat.c:638).
689
690// `module_features` — port of `static struct features module_features`
691// from stat.c:642.
692
693/// Port of `setup_(UNUSED(Module m))` from `Src/Modules/stat.c:651`.
694#[allow(unused_variables)]
695pub fn setup_(m: *const module) -> i32 {
696    // c:651
697    // C body c:653-654 — `return 0`. Faithful empty-body port.
698    0
699}
700
701/// Port of `features_(UNUSED(Module m), UNUSED(char ***features))` from `Src/Modules/stat.c:658`.
702/// C body: `*features = featuresarray(m, &module_features); return 0;`
703pub fn features_(m: *const module, features: &mut Vec<String>) -> i32 {
704    // c:658
705    *features = featuresarray(m, module_features());
706    0 // c:673
707}
708
709/// Port of `enables_(UNUSED(Module m), UNUSED(int **enables))` from `Src/Modules/stat.c:666`.
710/// C body: `return handlefeatures(m, &module_features, enables);`
711pub fn enables_(m: *const module, enables: &mut Option<Vec<i32>>) -> i32 {
712    // c:666
713    handlefeatures(m, module_features(), enables) // c:673
714}
715
716/// Port of `boot_(UNUSED(Module m))` from `Src/Modules/stat.c:673`.
717#[allow(unused_variables)]
718pub fn boot_(m: *const module) -> i32 {
719    // c:673
720    // C body c:675-676 — `return 0`. Faithful empty-body port; the
721    //                    zstat builtin registers via the bn_list dispatch.
722    0
723}
724
725/// Port of `cleanup_(UNUSED(Module m))` from `Src/Modules/stat.c:680`.
726/// C body: `return setfeatureenables(m, &module_features, NULL);`
727pub fn cleanup_(m: *const module) -> i32 {
728    // c:680
729    setfeatureenables(m, module_features(), None) // c:687
730}
731
732/// Port of `finish_(UNUSED(Module m))` from `Src/Modules/stat.c:687`.
733#[allow(unused_variables)]
734pub fn finish_(m: *const module) -> i32 {
735    // c:687
736    // C body c:689-690 — `return 0`. Faithful empty-body port; the
737    //                    zstat builtin unregisters via cleanup_'s setfeatureenables.
738    0
739}
740/// `ST_DEV` constant.
741pub const ST_DEV: i32 = 0; // c:33
742/// `ST_INO` constant.
743pub const ST_INO: i32 = 1;
744/// `ST_MODE` constant.
745pub const ST_MODE: i32 = 2;
746/// `ST_NLINK` constant.
747pub const ST_NLINK: i32 = 3;
748/// `ST_UID` constant.
749pub const ST_UID: i32 = 4;
750/// `ST_GID` constant.
751pub const ST_GID: i32 = 5;
752/// `ST_RDEV` constant.
753pub const ST_RDEV: i32 = 6;
754/// `ST_SIZE` constant.
755pub const ST_SIZE: i32 = 7;
756/// `ST_ATIM` constant.
757pub const ST_ATIM: i32 = 8;
758/// `ST_MTIM` constant.
759pub const ST_MTIM: i32 = 9;
760/// `ST_CTIM` constant.
761pub const ST_CTIM: i32 = 10;
762/// `ST_BLKSIZE` constant.
763pub const ST_BLKSIZE: i32 = 11;
764/// `ST_BLOCKS` constant.
765pub const ST_BLOCKS: i32 = 12;
766/// `ST_READLINK` constant.
767pub const ST_READLINK: i32 = 13;
768/// `ST_COUNT` constant.
769pub const ST_COUNT: i32 = 14; // c:34
770
771// ============================================================
772// Port of `enum statflags` from `Src/Modules/stat.c:36-38`.
773// Bitmask flags passed to the print ported + bin_stat dispatch.
774// ============================================================
775/// `STF_NAME` constant.
776pub const STF_NAME: i32 = 1; // c:36
777/// `STF_FILE` constant.
778pub const STF_FILE: i32 = 2;
779/// `STF_STRING` constant.
780pub const STF_STRING: i32 = 4;
781/// `STF_RAW` constant.
782pub const STF_RAW: i32 = 8;
783
784// =====================================================================
785// static struct builtin bintab[]                                    c:638
786// static struct features module_features                            c:642
787// =====================================================================
788/// `STF_PICK` constant.
789pub const STF_PICK: i32 = 16;
790/// `STF_ARRAY` constant.
791pub const STF_ARRAY: i32 = 32;
792/// `STF_GMT` constant.
793pub const STF_GMT: i32 = 64;
794/// `STF_HASH` constant.
795pub const STF_HASH: i32 = 128;
796/// `STF_OCTAL` constant.
797pub const STF_OCTAL: i32 = 256; // c:38
798
799/// Port of `statelts[]` from `Src/Modules/stat.c:39`. Names of the
800/// 14 stat-elements, indexed by the `ST_*` constants above.
801pub static STATELTS: &[&str] = &[
802    // c:39
803    "device", "inode", "mode", "nlink", "uid", "gid", "rdev", "size", "atime", "mtime", "ctime",
804    "blksize", "blocks", "link",
805];
806
807static MODULE_FEATURES: OnceLock<Mutex<features>> = OnceLock::new();
808
809// Local stubs for the per-module entry points. C uses generic
810// `featuresarray`/`handlefeatures`/`setfeatureenables` (module.c:
811// 3275/3370/3445) but those take `Builtin` + `Features` pointer
812// fields the Rust port doesn't carry. The hardcoded descriptor
813// list mirrors the C bintab/conddefs/mathfuncs/paramdefs.
814// WARNING: NOT IN STAT.C — Rust-only module-framework shim.
815// C uses generic featuresarray/handlefeatures/setfeatureenables from
816// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
817// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
818fn featuresarray(_m: *const module, _f: &Mutex<features>) -> Vec<String> {
819    vec!["b:stat".to_string(), "b:zstat".to_string()]
820}
821
822// WARNING: NOT IN STAT.C — Rust-only module-framework shim.
823// C uses generic featuresarray/handlefeatures/setfeatureenables from
824// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
825// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
826fn handlefeatures(_m: *const module, _f: &Mutex<features>, enables: &mut Option<Vec<i32>>) -> i32 {
827    if enables.is_none() {
828        *enables = Some(vec![1; 2]);
829    }
830    0
831}
832
833// WARNING: NOT IN STAT.C — Rust-only module-framework shim.
834// C uses generic featuresarray/handlefeatures/setfeatureenables from
835// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
836// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
837fn setfeatureenables(_m: *const module, _f: &Mutex<features>, _e: Option<&[i32]>) -> i32 {
838    0
839}
840
841// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
842// ─── RUST-ONLY ACCESSORS ───
843//
844// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
845// RwLock<T>>` globals declared above. C zsh uses direct global
846// access; Rust needs these wrappers because `OnceLock::get_or_init`
847// is the only way to lazily construct shared state. These ported sit
848// here so the body of this file reads in C source order without
849// the accessor wrappers interleaved between real port ported.
850// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
851
852// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
853// ─── RUST-ONLY ACCESSORS ───
854//
855// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
856// RwLock<T>>` globals declared above. C zsh uses direct global
857// access; Rust needs these wrappers because `OnceLock::get_or_init`
858// is the only way to lazily construct shared state. These ported sit
859// here so the body of this file reads in C source order without
860// the accessor wrappers interleaved between real port ported.
861// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
862
863// WARNING: NOT IN STAT.C — Rust-only module-framework shim.
864// C uses generic featuresarray/handlefeatures/setfeatureenables from
865// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
866// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
867fn module_features() -> &'static Mutex<features> {
868    MODULE_FEATURES.get_or_init(|| {
869        Mutex::new(features {
870            bn_list: None,
871            bn_size: 2,
872            cd_list: None,
873            cd_size: 0,
874            mf_list: None,
875            mf_size: 0,
876            pd_list: None,
877            pd_size: 0,
878            n_abstract: 0,
879        })
880    })
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886    use std::fs::File;
887    use std::io::Write;
888
889    #[test]
890    fn statelts_count_matches_st_count() {
891        let _g = crate::test_util::global_state_lock();
892        assert_eq!(STATELTS.len() as i32, ST_COUNT);
893    }
894
895    #[test]
896    fn statmodeprint_octal_only() {
897        let _g = crate::test_util::global_state_lock();
898        let s = statmodeprint(0o100644, STF_RAW | STF_OCTAL);
899        assert!(s.starts_with('0'));
900        assert!(s.contains("644"));
901    }
902
903    #[test]
904    fn statmodeprint_string_only() {
905        let _g = crate::test_util::global_state_lock();
906        let s = statmodeprint(0o100644, STF_STRING);
907        assert_eq!(s.len(), 10);
908        assert_eq!(&s[..1], "-");
909    }
910
911    #[test]
912    fn statmodeprint_directory() {
913        let _g = crate::test_util::global_state_lock();
914        let s = statmodeprint(0o040755, STF_STRING);
915        assert_eq!(&s[..1], "d");
916    }
917
918    #[test]
919    fn statulprint_decimal() {
920        let _g = crate::test_util::global_state_lock();
921        assert_eq!(statulprint(12345), "12345");
922    }
923
924    #[test]
925    fn statprint_size_via_index() {
926        let _g = crate::test_util::global_state_lock();
927        let dir = tempfile::TempDir::new().unwrap();
928        let path = dir.path().join("x.txt");
929        File::create(&path).unwrap().write_all(b"hello").unwrap();
930        let meta = fs::metadata(&path).unwrap();
931        let s = statprint(&meta, path.to_str().unwrap(), ST_SIZE, 0);
932        assert_eq!(s, "5");
933    }
934
935    /// c:47 — `statmodeprint` for the BLOCK device file type. The
936    /// `pm[0]` lookup at c:84-103 must yield 'b'. Pin all file-type
937    /// chars so a regen that swaps `0o060_000` and `0o020_000`
938    /// silently renders block devices as char devices.
939    #[test]
940    fn statmodeprint_file_type_chars_match_ls_output() {
941        let _g = crate::test_util::global_state_lock();
942        // S_IFREG (regular file)
943        assert_eq!(&statmodeprint(0o100_644, STF_STRING)[..1], "-");
944        // S_IFDIR
945        assert_eq!(&statmodeprint(0o040_755, STF_STRING)[..1], "d");
946        // S_IFLNK (symlink)
947        assert_eq!(&statmodeprint(0o120_777, STF_STRING)[..1], "l");
948        // S_IFCHR (char device)
949        assert_eq!(&statmodeprint(0o020_644, STF_STRING)[..1], "c");
950        // S_IFBLK (block device)
951        assert_eq!(&statmodeprint(0o060_644, STF_STRING)[..1], "b");
952        // S_IFIFO (named pipe)
953        assert_eq!(&statmodeprint(0o010_644, STF_STRING)[..1], "p");
954        // S_IFSOCK
955        assert_eq!(&statmodeprint(0o140_644, STF_STRING)[..1], "s");
956    }
957
958    /// c:111-115 — setuid bit renders as 's' in the user-execute
959    /// slot when execute is set, 'S' when not. Pin both polarities
960    /// for setuid, setgid, and sticky so a regen flipping the
961    /// uppercase/lowercase dispatch gets caught.
962    #[test]
963    fn statmodeprint_setuid_setgid_sticky_render_correctly() {
964        let _g = crate::test_util::global_state_lock();
965        // 4755 = setuid + executable → 's' in user slot
966        let s = statmodeprint(0o104_755, STF_STRING);
967        assert_eq!(
968            s.chars().nth(3),
969            Some('s'),
970            "setuid+x must render as 's' in user-execute slot"
971        );
972
973        // 4644 = setuid + NOT executable → 'S' in user slot
974        let s = statmodeprint(0o104_644, STF_STRING);
975        assert_eq!(
976            s.chars().nth(3),
977            Some('S'),
978            "setuid without x must render as 'S' (uppercase)"
979        );
980
981        // 2755 = setgid + group-x → 's' in group-execute slot
982        let s = statmodeprint(0o102_755, STF_STRING);
983        assert_eq!(
984            s.chars().nth(6),
985            Some('s'),
986            "setgid+gx must render as 's' in group-execute slot"
987        );
988
989        // 2644 = setgid without group-x → 'S'
990        let s = statmodeprint(0o102_644, STF_STRING);
991        assert_eq!(
992            s.chars().nth(6),
993            Some('S'),
994            "setgid without gx must render as 'S'"
995        );
996
997        // 1755 = sticky + world-x → 't' in other-execute slot
998        let s = statmodeprint(0o101_755, STF_STRING);
999        assert_eq!(
1000            s.chars().nth(9),
1001            Some('t'),
1002            "sticky+ox must render as 't' in other-execute slot"
1003        );
1004
1005        // 1644 = sticky without world-x → 'T'
1006        let s = statmodeprint(0o101_644, STF_STRING);
1007        assert_eq!(
1008            s.chars().nth(9),
1009            Some('T'),
1010            "sticky without ox must render as 'T'"
1011        );
1012    }
1013
1014    /// c:47-93 — `STF_RAW | STF_STRING` produces "raw (string)" with
1015    /// the raw form OUTSIDE parens and the string form inside.
1016    /// Pin the format because user scripts grep for "^(0?[0-9]+) \("
1017    /// to split the two halves.
1018    #[test]
1019    fn statmodeprint_raw_and_string_renders_with_parens() {
1020        let _g = crate::test_util::global_state_lock();
1021        let s = statmodeprint(0o100_644, STF_RAW | STF_STRING);
1022        // Decimal raw form, space, paren, 10-char string, close paren
1023        assert!(s.contains(" ("), "missing ' (' separator: {}", s);
1024        assert!(s.ends_with(')'), "missing closing ')': {}", s);
1025        // The closing paren must come right after the 10-char ls form
1026        let open = s.find('(').unwrap();
1027        let close = s.rfind(')').unwrap();
1028        assert_eq!(
1029            close - open - 1,
1030            10,
1031            "expected 10-char ls-mode between parens, got: {:?}",
1032            &s[open + 1..close]
1033        );
1034    }
1035
1036    /// c:47-93 — `statmodeprint(0)` with STF_STRING renders all dashes
1037    /// after the file-type indicator. Edge case: zero permissions on
1038    /// a "no file-type" mode falls through to '?'.
1039    #[test]
1040    fn statmodeprint_zero_mode_renders_unknown_type_no_perms() {
1041        let _g = crate::test_util::global_state_lock();
1042        let s = statmodeprint(0, STF_STRING);
1043        assert_eq!(s.len(), 10);
1044        assert_eq!(&s[..1], "?", "mode with no S_IFMT bits → unknown");
1045        assert_eq!(&s[1..], "---------", "no permission bits → all dashes");
1046    }
1047
1048    /// c:132 — `statuidprint` raw form is just the decimal uid;
1049    /// pin the no-leading-zeros, no-prefix shape so a regen that
1050    /// renders octal silently breaks `${(t)f[uid]}`.
1051    #[test]
1052    fn statuidprint_raw_is_decimal() {
1053        let _g = crate::test_util::global_state_lock();
1054        let s = statuidprint(1000, STF_RAW);
1055        assert_eq!(s, "1000");
1056    }
1057
1058    /// c:132 — `statuidprint` for uid 0 must include "root" in the
1059    /// string form (every Unix has uid 0 = root). Pin the well-known
1060    /// case so a regen that breaks the getpwuid path doesn't silently
1061    /// fall back to numeric.
1062    #[test]
1063    fn statuidprint_uid_zero_resolves_to_root() {
1064        let _g = crate::test_util::global_state_lock();
1065        let s = statuidprint(0, STF_STRING);
1066        // Some hardened systems map uid 0 to a different name, but
1067        // it MUST resolve to non-numeric.
1068        assert!(
1069            !s.parse::<u32>().is_ok(),
1070            "uid 0 fell back to numeric form: {}",
1071            s
1072        );
1073        assert!(!s.is_empty());
1074    }
1075
1076    /// c:161 — `statgidprint` raw form is decimal.
1077    #[test]
1078    fn statgidprint_raw_is_decimal() {
1079        let _g = crate::test_util::global_state_lock();
1080        let s = statgidprint(100, STF_RAW);
1081        assert_eq!(s, "100");
1082    }
1083
1084    /// c:211 — `statulprint` for zero must render "0". A regression
1085    /// that prints "" or "0x0" silently breaks numeric script
1086    /// comparisons.
1087    #[test]
1088    fn statulprint_zero_renders_as_zero_digit() {
1089        let _g = crate::test_util::global_state_lock();
1090        assert_eq!(statulprint(0), "0");
1091    }
1092
1093    /// c:211 — `statulprint` for u64::MAX renders the full decimal
1094    /// digit string. Pin the no-overflow / no-truncation behavior.
1095    #[test]
1096    fn statulprint_u64_max_renders_full_digits() {
1097        let _g = crate::test_util::global_state_lock();
1098        let s = statulprint(u64::MAX);
1099        assert_eq!(s, "18446744073709551615");
1100    }
1101
1102    /// c:36-38 — STF_* flag values must each occupy a unique single
1103    /// bit, AND must not overlap with each other (so they can be
1104    /// OR'd together). Pin the bit-distinctness because the flags
1105    /// are AND-tested individually throughout statprint.
1106    #[test]
1107    fn stf_flag_values_are_distinct_single_bits() {
1108        let _g = crate::test_util::global_state_lock();
1109        for f in [
1110            STF_NAME, STF_FILE, STF_STRING, STF_RAW, STF_PICK, STF_ARRAY, STF_GMT, STF_HASH,
1111            STF_OCTAL,
1112        ] {
1113            assert!(f > 0, "STF_* flag {} must be positive", f);
1114            assert_eq!(
1115                (f as u32).count_ones(),
1116                1,
1117                "STF_* flag {} = 0b{:b} must be a single bit",
1118                f,
1119                f
1120            );
1121        }
1122        // Pairwise: no two flags share a bit
1123        let flags = [
1124            STF_NAME, STF_FILE, STF_STRING, STF_RAW, STF_PICK, STF_ARRAY, STF_GMT, STF_HASH,
1125            STF_OCTAL,
1126        ];
1127        for (i, &a) in flags.iter().enumerate() {
1128            for &b in &flags[i + 1..] {
1129                assert_eq!(a & b, 0, "STF flags {} and {} overlap", a, b);
1130            }
1131        }
1132    }
1133
1134    /// c:651-690 — module-lifecycle stubs all return 0 in C.
1135    #[test]
1136    fn module_lifecycle_shims_all_return_zero() {
1137        let _g = crate::test_util::global_state_lock();
1138        let m: *const module = std::ptr::null();
1139        assert_eq!(setup_(m), 0);
1140        assert_eq!(boot_(m), 0);
1141        assert_eq!(cleanup_(m), 0);
1142        assert_eq!(finish_(m), 0);
1143    }
1144
1145    // ─── zsh-corpus pins for statmodeprint ─────────────────────────
1146
1147    /// Regular file 0644 → "-rw-r--r--" string form.
1148    #[test]
1149    fn stat_corpus_modeprint_regular_644() {
1150        let mode = 0o100_644; // S_IFREG | 0644
1151        let r = statmodeprint(mode, STF_STRING);
1152        assert_eq!(r, "-rw-r--r--", "regular 0644 → -rw-r--r--, got {r:?}");
1153    }
1154
1155    /// Regular file 0755 → "-rwxr-xr-x".
1156    #[test]
1157    fn stat_corpus_modeprint_regular_755() {
1158        let mode = 0o100_755;
1159        let r = statmodeprint(mode, STF_STRING);
1160        assert_eq!(r, "-rwxr-xr-x");
1161    }
1162
1163    /// Directory 0755 → "drwxr-xr-x".
1164    #[test]
1165    fn stat_corpus_modeprint_directory() {
1166        let mode = 0o040_755; // S_IFDIR | 0755
1167        let r = statmodeprint(mode, STF_STRING);
1168        assert_eq!(r, "drwxr-xr-x");
1169    }
1170
1171    /// Symlink → 'l' file-type prefix.
1172    #[test]
1173    fn stat_corpus_modeprint_symlink_prefix() {
1174        let mode = 0o120_777; // S_IFLNK | 0777
1175        let r = statmodeprint(mode, STF_STRING);
1176        assert!(
1177            r.starts_with('l'),
1178            "symlink mode starts with 'l', got {r:?}"
1179        );
1180    }
1181
1182    /// FIFO → 'p' file-type prefix.
1183    #[test]
1184    fn stat_corpus_modeprint_fifo_prefix() {
1185        let mode = 0o010_644;
1186        let r = statmodeprint(mode, STF_STRING);
1187        assert!(r.starts_with('p'), "FIFO mode starts with 'p', got {r:?}");
1188    }
1189
1190    /// Socket → 's' file-type prefix.
1191    #[test]
1192    fn stat_corpus_modeprint_socket_prefix() {
1193        let mode = 0o140_644;
1194        let r = statmodeprint(mode, STF_STRING);
1195        assert!(r.starts_with('s'));
1196    }
1197
1198    /// `STF_RAW | STF_OCTAL` → numeric octal form.
1199    #[test]
1200    fn stat_corpus_modeprint_raw_octal() {
1201        let mode = 0o100_644;
1202        let r = statmodeprint(mode, STF_RAW | STF_OCTAL);
1203        assert!(
1204            r.starts_with('0'),
1205            "octal raw form starts with leading 0, got {r:?}"
1206        );
1207        assert!(
1208            r.contains("100644") || r.contains("644"),
1209            "octal repr contains 100644 or 644, got {r:?}"
1210        );
1211    }
1212
1213    /// `STF_RAW` alone → decimal numeric.
1214    #[test]
1215    fn stat_corpus_modeprint_raw_decimal() {
1216        let mode = 0o100_644;
1217        let r = statmodeprint(mode, STF_RAW);
1218        // Decimal form of 0o100644 = 33188.
1219        assert_eq!(r, "33188", "raw decimal form, got {r:?}");
1220    }
1221
1222    /// Empty flags → empty output.
1223    #[test]
1224    fn stat_corpus_modeprint_no_flags_empty() {
1225        let r = statmodeprint(0o100_644, 0);
1226        assert_eq!(r, "", "no flags → empty output");
1227    }
1228
1229    // ═══════════════════════════════════════════════════════════════════
1230    // Additional C-parity tests for Src/Modules/stat.c statmodeprint
1231    // file-type + rwx bit dispatch.
1232    // ═══════════════════════════════════════════════════════════════════
1233
1234    /// c:84-103 — regular file (S_IFREG=0o100000) → '-' first char.
1235    #[test]
1236    fn statmodeprint_regular_file_starts_with_dash() {
1237        let r = statmodeprint(0o100_644, STF_STRING);
1238        assert!(r.starts_with('-'), "regular file → '-', got {:?}", r);
1239    }
1240
1241    /// c:84-103 — directory (S_IFDIR=0o040000) → 'd' first char.
1242    #[test]
1243    fn statmodeprint_directory_starts_with_d() {
1244        let r = statmodeprint(0o040_755, STF_STRING);
1245        assert!(r.starts_with('d'), "directory → 'd', got {:?}", r);
1246    }
1247
1248    /// c:84-103 — symlink (S_IFLNK=0o120000) → 'l' first char.
1249    #[test]
1250    fn statmodeprint_symlink_starts_with_l() {
1251        let r = statmodeprint(0o120_777, STF_STRING);
1252        assert!(r.starts_with('l'), "symlink → 'l', got {:?}", r);
1253    }
1254
1255    /// c:84-103 — char device (S_IFCHR=0o020000) → 'c' first char.
1256    #[test]
1257    fn statmodeprint_char_device_starts_with_c() {
1258        let r = statmodeprint(0o020_644, STF_STRING);
1259        assert!(r.starts_with('c'), "char device → 'c', got {:?}", r);
1260    }
1261
1262    /// c:84-103 — block device (S_IFBLK=0o060000) → 'b' first char.
1263    #[test]
1264    fn statmodeprint_block_device_starts_with_b() {
1265        let r = statmodeprint(0o060_644, STF_STRING);
1266        assert!(r.starts_with('b'), "block device → 'b', got {:?}", r);
1267    }
1268
1269    /// c:84-103 — socket (S_IFSOCK=0o140000) → 's' first char.
1270    #[test]
1271    fn statmodeprint_socket_starts_with_s() {
1272        let r = statmodeprint(0o140_777, STF_STRING);
1273        assert!(r.starts_with('s'), "socket → 's', got {:?}", r);
1274    }
1275
1276    /// c:84-103 — FIFO (S_IFIFO=0o010000) → 'p' first char.
1277    #[test]
1278    fn statmodeprint_fifo_starts_with_p() {
1279        let r = statmodeprint(0o010_644, STF_STRING);
1280        assert!(r.starts_with('p'), "FIFO → 'p', got {:?}", r);
1281    }
1282
1283    /// c:84-103 — unknown ifmt → '?' fallback.
1284    #[test]
1285    fn statmodeprint_unknown_ifmt_returns_question_mark() {
1286        let r = statmodeprint(0o070_000, STF_STRING);
1287        assert!(r.starts_with('?'), "unknown ifmt → '?', got {:?}", r);
1288    }
1289
1290    /// c:111 — setuid sticky 's' when execute bit set on user.
1291    #[test]
1292    fn statmodeprint_setuid_with_exec_shows_lowercase_s() {
1293        // 0o4755 = setuid + rwxr-xr-x
1294        let r = statmodeprint(0o104_755, STF_STRING);
1295        // Position 3 (user exec) should be 's' (setuid + x).
1296        assert_eq!(r.as_bytes()[3], b's', "setuid+x → 's', got {:?}", r);
1297    }
1298
1299    /// c:111 — setuid uppercase 'S' when execute bit NOT set on user.
1300    #[test]
1301    fn statmodeprint_setuid_without_exec_shows_uppercase_S() {
1302        // 0o4644 = setuid + rw-r--r--
1303        let r = statmodeprint(0o104_644, STF_STRING);
1304        assert_eq!(r.as_bytes()[3], b'S', "setuid-no-x → 'S', got {:?}", r);
1305    }
1306
1307    /// c:115 — sticky bit 't' when other-exec set, 'T' when not.
1308    #[test]
1309    fn statmodeprint_sticky_bit_dispatch() {
1310        // 0o1777 = sticky + rwxrwxrwx
1311        let r1 = statmodeprint(0o101_777, STF_STRING);
1312        assert_eq!(r1.as_bytes()[9], b't', "sticky+other-x → 't'");
1313        // 0o1644 = sticky + rw-r--r--
1314        let r2 = statmodeprint(0o101_644, STF_STRING);
1315        assert_eq!(r2.as_bytes()[9], b'T', "sticky no other-x → 'T'");
1316    }
1317
1318    /// c:228 — `statulprint(N)` formats as decimal.
1319    #[test]
1320    fn statulprint_decimal_format() {
1321        assert_eq!(statulprint(0), "0");
1322        assert_eq!(statulprint(42), "42");
1323        assert_eq!(statulprint(u64::MAX), format!("{}", u64::MAX));
1324    }
1325
1326    // ═══════════════════════════════════════════════════════════════════
1327    // Additional C-parity tests for Src/Modules/stat.c
1328    // c:43 statmodeprint / c:112 statuidprint / c:156 statgidprint /
1329    // c:199 stattimeprint / c:228 statulprint / c:237 statlinkprint / lifecycle
1330    // ═══════════════════════════════════════════════════════════════════
1331
1332    /// c:228 — `statulprint` is pure (deterministic).
1333    #[test]
1334    fn statulprint_is_pure() {
1335        for v in [0u64, 1, 42, 1_000_000, u64::MAX] {
1336            let first = statulprint(v);
1337            for _ in 0..5 {
1338                assert_eq!(statulprint(v), first, "statulprint({}) must be pure", v);
1339            }
1340        }
1341    }
1342
1343    /// c:228 — `statulprint` output is non-empty ASCII digit string.
1344    #[test]
1345    fn statulprint_output_is_ascii_digits() {
1346        for v in [0u64, 1, 42, u32::MAX as u64, u64::MAX] {
1347            let s = statulprint(v);
1348            assert!(!s.is_empty(), "non-empty");
1349            assert!(
1350                s.chars().all(|c| c.is_ascii_digit()),
1351                "all chars must be digits: {:?}",
1352                s
1353            );
1354        }
1355    }
1356
1357    /// c:43 — `statmodeprint` is pure (deterministic for same mode+flags).
1358    #[test]
1359    fn statmodeprint_is_pure() {
1360        let mode = 0o100644u32;
1361        let first = statmodeprint(mode, 0);
1362        for _ in 0..5 {
1363            assert_eq!(statmodeprint(mode, 0), first, "statmodeprint must be pure");
1364        }
1365    }
1366
1367    /// c:43 — `statmodeprint(_, 0)` returns empty (no flags = no output).
1368    #[test]
1369    fn statmodeprint_no_flags_returns_empty() {
1370        assert_eq!(statmodeprint(0o100644, 0), "");
1371    }
1372
1373    /// c:199 — `stattimeprint` is pure.
1374    #[test]
1375    fn stattimeprint_is_pure() {
1376        let t = 1_700_000_000i64;
1377        let first = stattimeprint(t, 0, 0);
1378        for _ in 0..5 {
1379            assert_eq!(stattimeprint(t, 0, 0), first, "stattimeprint must be pure");
1380        }
1381    }
1382
1383    /// c:237 — `statlinkprint` for non-symlink returns empty.
1384    #[test]
1385    fn statlinkprint_non_symlink_returns_empty() {
1386        let r = statlinkprint(0o100644, "/tmp/__not_symlink__");
1387        // Regular file mode bit pattern → empty.
1388        assert!(
1389            r.is_empty() || r == "" || r.starts_with(" "),
1390            "non-symlink should return empty or marker, got {:?}",
1391            r
1392        );
1393    }
1394
1395    /// c:237 — `statlinkprint` empty fname doesn't panic.
1396    #[test]
1397    fn statlinkprint_empty_fname_no_panic() {
1398        let _ = statlinkprint(0o100644, "");
1399    }
1400
1401    /// c:112 — `statuidprint(uid, flags)` is pure (deterministic for same args).
1402    #[test]
1403    fn statuidprint_is_pure() {
1404        for uid in [0u32, 1, 1000, u32::MAX] {
1405            let first = statuidprint(uid, 0);
1406            for _ in 0..5 {
1407                assert_eq!(
1408                    statuidprint(uid, 0),
1409                    first,
1410                    "statuidprint({}, 0) must be pure",
1411                    uid
1412                );
1413            }
1414        }
1415    }
1416
1417    /// c:156 — `statgidprint(gid, flags)` is pure.
1418    #[test]
1419    fn statgidprint_is_pure() {
1420        for gid in [0u32, 1, 1000, u32::MAX] {
1421            let first = statgidprint(gid, 0);
1422            for _ in 0..5 {
1423                assert_eq!(
1424                    statgidprint(gid, 0),
1425                    first,
1426                    "statgidprint({}, 0) must be pure",
1427                    gid
1428                );
1429            }
1430        }
1431    }
1432
1433    // ═══════════════════════════════════════════════════════════════════
1434    // Additional C-parity tests for Src/Modules/stat.c
1435    // c:43 statmodeprint / c:112 statuidprint / c:156 statgidprint /
1436    // c:199 stattimeprint / c:228 statulprint / c:237 statlinkprint /
1437    // c:300 bin_stat + lifecycle type pins
1438    // ═══════════════════════════════════════════════════════════════════
1439
1440    /// c:43 — `statmodeprint` returns String (compile-time type pin).
1441    #[test]
1442    fn statmodeprint_returns_string_type() {
1443        let _: String = statmodeprint(0o100644, 0);
1444    }
1445
1446    /// c:112 — `statuidprint` returns String.
1447    #[test]
1448    fn statuidprint_returns_string_type() {
1449        let _: String = statuidprint(0, 0);
1450    }
1451
1452    /// c:156 — `statgidprint` returns String.
1453    #[test]
1454    fn statgidprint_returns_string_type() {
1455        let _: String = statgidprint(0, 0);
1456    }
1457
1458    /// c:199 — `stattimeprint` returns String.
1459    #[test]
1460    fn stattimeprint_returns_string_type() {
1461        let _: String = stattimeprint(0, 0, 0);
1462    }
1463
1464    /// c:228 — `statulprint` returns String.
1465    #[test]
1466    fn statulprint_returns_string_type() {
1467        let _: String = statulprint(0);
1468    }
1469
1470    /// c:237 — `statlinkprint` returns String.
1471    #[test]
1472    fn statlinkprint_returns_string_type() {
1473        let _: String = statlinkprint(0o100644, "");
1474    }
1475
1476    /// c:228 — `statulprint(0)` returns "0".
1477    #[test]
1478    fn statulprint_zero_returns_zero_digit() {
1479        assert_eq!(statulprint(0), "0", "0 → \"0\" canonical");
1480    }
1481
1482    /// c:228 — `statulprint(u64::MAX)` returns max decimal repr.
1483    #[test]
1484    fn statulprint_u64_max_returns_canonical_decimal() {
1485        let s = statulprint(u64::MAX);
1486        assert_eq!(s, u64::MAX.to_string(), "u64::MAX matches std decimal");
1487    }
1488
1489    /// c:300 — `bin_stat` returns i32 (compile-time type pin).
1490    #[test]
1491    fn bin_stat_returns_i32_type() {
1492        let _g = crate::test_util::global_state_lock();
1493        let ops = crate::ported::zsh_h::options {
1494            ind: [0u8; crate::ported::zsh_h::MAX_OPS],
1495            args: Vec::new(),
1496            argscount: 0,
1497            argsalloc: 0,
1498        };
1499        let _: i32 = bin_stat("stat", &[], &ops, 0);
1500    }
1501
1502    /// c:579 — `setup_` returns i32 (compile-time type pin).
1503    #[test]
1504    fn stat_setup_returns_i32_type() {
1505        let _g = crate::test_util::global_state_lock();
1506        let _: i32 = setup_(std::ptr::null());
1507    }
1508
1509    /// c:587 — features non-empty.
1510    #[test]
1511    fn stat_features_nonempty() {
1512        let _g = crate::test_util::global_state_lock();
1513        let mut feats = Vec::new();
1514        features_(std::ptr::null(), &mut feats);
1515        assert!(!feats.is_empty(), "stat must advertise ≥1 feature");
1516    }
1517
1518    /// c:587 — features use b:/p: prefix per zsh module spec.
1519    #[test]
1520    fn stat_features_use_canonical_prefix() {
1521        let _g = crate::test_util::global_state_lock();
1522        let mut feats = Vec::new();
1523        features_(std::ptr::null(), &mut feats);
1524        for f in &feats {
1525            assert!(
1526                f.starts_with("b:") || f.starts_with("p:"),
1527                "feature {:?} must use b:/p: prefix",
1528                f
1529            );
1530        }
1531    }
1532
1533    /// c:228 — `statulprint` is monotonically non-decreasing-length
1534    /// for monotonically increasing input (10 → 100 → 1000).
1535    #[test]
1536    fn statulprint_length_grows_with_magnitude() {
1537        let a = statulprint(10).len();
1538        let b = statulprint(100).len();
1539        let c = statulprint(1000).len();
1540        assert!(a <= b, "len(10) ≤ len(100), got {} vs {}", a, b);
1541        assert!(b <= c, "len(100) ≤ len(1000), got {} vs {}", b, c);
1542    }
1543
1544    /// c:579-618 — full lifecycle setup→features→enables→boot→cleanup→finish.
1545    #[test]
1546    fn stat_full_lifecycle_returns_zero_for_all() {
1547        let _g = crate::test_util::global_state_lock();
1548        let null = std::ptr::null();
1549        assert_eq!(setup_(null), 0);
1550        let mut feats = Vec::new();
1551        let _ = features_(null, &mut feats);
1552        let mut enables: Option<Vec<i32>> = None;
1553        let _ = enables_(null, &mut enables);
1554        assert_eq!(boot_(null), 0);
1555        assert_eq!(cleanup_(null), 0);
1556        assert_eq!(finish_(null), 0);
1557    }
1558
1559    // ═══════════════════════════════════════════════════════════════════
1560    // Additional C-parity tests for Src/Modules/stat.c
1561    // c:43 statmodeprint / c:112 statuidprint / c:156 statgidprint /
1562    // c:199 stattimeprint / c:228 statulprint / c:300 bin_stat
1563    // ═══════════════════════════════════════════════════════════════════
1564
1565    /// c:43 — `statmodeprint` returns String (compile-time pin, alt).
1566    #[test]
1567    fn statmodeprint_returns_string_pin_alt() {
1568        let _: String = statmodeprint(0o644, 0);
1569    }
1570
1571    /// c:43 — `statmodeprint` is deterministic for any mode.
1572    #[test]
1573    fn statmodeprint_deterministic_for_common_modes() {
1574        for mode in [0o644u32, 0o755, 0o600, 0o777, 0o000] {
1575            let a = statmodeprint(mode, 0);
1576            let b = statmodeprint(mode, 0);
1577            assert_eq!(a, b, "statmodeprint({:o}) must be pure", mode);
1578        }
1579    }
1580
1581    /// c:112 — `statuidprint` returns String (compile-time pin, alt).
1582    #[test]
1583    fn statuidprint_returns_string_pin_alt() {
1584        let _: String = statuidprint(0, 0);
1585    }
1586
1587    /// c:112 — `statuidprint(0, STF_RAW)` returns "0" (raw uid digit).
1588    /// Pin the c:115 STF_RAW arm; flags=0 produces empty by design.
1589    #[test]
1590    fn statuidprint_root_with_raw_flag_returns_zero_digit() {
1591        let s = statuidprint(0, STF_RAW);
1592        assert_eq!(s, "0", "uid 0 + STF_RAW must produce '0'");
1593    }
1594
1595    /// c:156 — `statgidprint` returns String (compile-time pin, alt).
1596    #[test]
1597    fn statgidprint_returns_string_pin_alt() {
1598        let _: String = statgidprint(0, 0);
1599    }
1600
1601    /// c:156 — `statgidprint(0, STF_RAW)` returns "0" (raw gid digit).
1602    /// Pin the c:159 STF_RAW arm; flags=0 produces empty by design.
1603    #[test]
1604    fn statgidprint_zero_with_raw_flag_returns_zero_digit() {
1605        let s = statgidprint(0, STF_RAW);
1606        assert_eq!(s, "0", "gid 0 + STF_RAW must produce '0'");
1607    }
1608
1609    /// c:199 — `stattimeprint` returns String (compile-time pin, alt).
1610    #[test]
1611    fn stattimeprint_returns_string_pin_alt() {
1612        let _: String = stattimeprint(0, 0, 0);
1613    }
1614
1615    /// c:199 — `stattimeprint(0, 0, STF_RAW)` returns "0" (raw epoch).
1616    /// Pin the c:202 STF_RAW arm; flags=0 produces empty by design.
1617    #[test]
1618    fn stattimeprint_epoch_zero_with_raw_flag_returns_zero() {
1619        let s = stattimeprint(0, 0, STF_RAW);
1620        assert_eq!(s, "0", "epoch 0 + STF_RAW must produce '0'");
1621    }
1622
1623    /// c:300 — `bin_stat` exit code is non-negative.
1624    #[test]
1625    fn bin_stat_exit_code_non_negative() {
1626        let _g = crate::test_util::global_state_lock();
1627        let ops = crate::ported::zsh_h::options {
1628            ind: [0u8; crate::ported::zsh_h::MAX_OPS],
1629            args: Vec::new(),
1630            argscount: 0,
1631            argsalloc: 0,
1632        };
1633        for argv in [
1634            vec![],
1635            vec!["/tmp".into()],
1636            vec!["/dev/null".into()],
1637            vec!["/__nonexistent_xyz__".into()],
1638        ] {
1639            let r = bin_stat("stat", &argv, &ops, 0);
1640            assert!(
1641                r >= 0,
1642                "exit code must be non-negative, got {} for {:?}",
1643                r,
1644                argv
1645            );
1646        }
1647    }
1648
1649    /// c:300 — `bin_stat` of nonexistent path MUST return nonzero
1650    /// (C uses `stat(2)` which fails ENOENT, then zwarnnam + return 1).
1651    /// In zshrs the port silently returns 0.
1652    #[test]
1653    fn bin_stat_nonexistent_path_returns_nonzero() {
1654        let _g = crate::test_util::global_state_lock();
1655        let ops = crate::ported::zsh_h::options {
1656            ind: [0u8; crate::ported::zsh_h::MAX_OPS],
1657            args: Vec::new(),
1658            argscount: 0,
1659            argsalloc: 0,
1660        };
1661        let r = bin_stat("stat", &["/__definitely_no_such_xyz__".into()], &ops, 0);
1662        assert_ne!(r, 0, "nonexistent path → nonzero");
1663    }
1664
1665    /// c:228 — `statulprint` deterministic for powers-of-2.
1666    #[test]
1667    fn statulprint_deterministic_for_powers_of_two() {
1668        for shift in 0..64 {
1669            let v = 1u64 << shift;
1670            let a = statulprint(v);
1671            let b = statulprint(v);
1672            assert_eq!(a, b, "statulprint({}) must be pure", v);
1673        }
1674    }
1675}