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 (pos, ch) in arg.char_indices() {
400                // c:410-411 — `rest` is `arg+1` for an option that takes a value.
401                let rest: &str = &arg[pos + 1..];
402                match ch {
403                    'g' | 'l' | 'L' | 'n' | 'N' | 'o' | 'r' | 's' | 't' | 'T' => {
404                        ops[ch as u8 as usize] = true; // c:411
405                    }
406                    'A' => {
407                        // c:412-418 — `if (arg[1]) arrnam = arg+1; else if
408                        // (!(arrnam = *++args)) { missing parameter name }`.
409                        if !rest.is_empty() {
410                            arrnam = Some(rest.to_string()); // c:414
411                        } else {
412                            i += 1;
413                            if i >= args.len() {
414                                zwarnnam(nam, "missing parameter name"); // c:416
415                                return 1;
416                            }
417                            arrnam = Some(args[i].to_string()); // c:415
418                        }
419                        flags |= STF_ARRAY;
420                        break;
421                    }
422                    'H' => {
423                        // c:421-427 — same attached-or-next rule as -A.
424                        if !rest.is_empty() {
425                            hashnam = Some(rest.to_string()); // c:422
426                        } else {
427                            i += 1;
428                            if i >= args.len() {
429                                zwarnnam(nam, "missing parameter name"); // c:424
430                                return 1;
431                            }
432                            hashnam = Some(args[i].to_string()); // c:423
433                        }
434                        flags |= STF_HASH;
435                        break;
436                    }
437                    'f' => {
438                        ops[b'f' as usize] = true; // c:429
439                        // c:430-435 — `if (arg[1]) sfd = arg+1; else if
440                        // (!(sfd = *++args)) { missing file descriptor }`.
441                        let sfd: &str = if !rest.is_empty() {
442                            rest // c:431
443                        } else {
444                            i += 1;
445                            if i >= args.len() {
446                                zwarnnam(nam, "missing file descriptor"); // c:433
447                                return 1;
448                            }
449                            args[i] // c:432
450                        };
451                        let (val, endptr) = zstrtol(sfd, 10); // c:436
452                        if !endptr.is_empty() {
453                            zwarnnam(nam, "bad file descriptor");
454                            return 1;
455                        }
456                        fd = val as i32;
457                        break;
458                    }
459                    'F' => {
460                        // c:442-451 — `-F FMT`. If the same arg has chars
461                        // after 'F' (e.g. `-F%Y`), use those; else consume
462                        // the next argv entry. Force STF_STRING via -s so
463                        // the format actually gets used (c:449-450).
464                        let inline: &str = rest;
465                        let fmt: &str = if !inline.is_empty() {
466                            // c:443-444
467                            inline
468                        } else {
469                            i += 1;
470                            if i >= args.len() {
471                                // c:446
472                                zwarnnam(nam, "missing time format");
473                                return 1;
474                            }
475                            args[i]
476                        };
477                        // c:444 — `timefmt = arg+1;` / c:445 — `timefmt = *++args;`
478                        if let Ok(mut g) = TIMEFMT
479                            .get_or_init(|| std::sync::Mutex::new(TIMEFMT_DEFAULT.to_string()))
480                            .lock()
481                        {
482                            g.clear();
483                            g.push_str(fmt);
484                        }
485                        ops[b's' as usize] = true; // c:450 — force STF_STRING.
486                        break;
487                    }
488                    _ => {
489                        zwarnnam(nam, &format!("bad option: -{}", ch));
490                        return 1;
491                    }
492                }
493            }
494        }
495        i += 1;
496    }
497    while i < args.len() {
498        argv.push(args[i]);
499        i += 1;
500    }
501
502    if (flags & STF_ARRAY) != 0 && (flags & STF_HASH) != 0 {
503        // c:459
504        zwarnnam(nam, "both array and hash requested");
505        return 1;
506    }
507
508    if ops[b'l' as usize] {
509        // c:467
510        // List elements + return.
511        if let Some(ref name) = arrnam {
512            // c:469
513            // c:485 — `setaparam(arrnam, array);` — REAL array of
514            // element names. Prior port colon-joined into a scalar
515            // via setsparam — `$names[1]` returned the first CHAR of
516            // "device:inode:mode:..." instead of "device", and
517            // ${(t)names} reported scalar where zsh reports array.
518            let names: Vec<String> = STATELTS.iter().map(|s| s.to_string()).collect();
519            setaparam(name, names); // c:485
520        } else {
521            let joined: Vec<&str> = STATELTS.iter().copied().collect();
522            println!("{}", joined.join(" ")); // c:478 putchar
523        }
524        return 0; // c:489
525    }
526
527    if argv.is_empty() && !ops[b'f' as usize] {
528        // c:491
529        zwarnnam(nam, "no files given");
530        return 1;
531    } else if !argv.is_empty() && ops[b'f' as usize] {
532        // c:493
533        zwarnnam(nam, "no files allowed with -f");
534        return 1;
535    }
536
537    // c:496+ — per-file stat + dispatch loop.
538    let use_lstat = ops[b'L' as usize];
539    let mut hash_out: Vec<(String, String)> = Vec::new();
540    let mut array_out: Vec<String> = Vec::new();
541    let show_type = ops[b't' as usize]; // c: -t
542    let mut local_flags = flags;
543    // c:510-513 — `if (OPT_ISSET(ops,'g')) { flags |= STF_GMT;
544    //                                        ops->ind['s'] = 1; }`
545    // -g picks gmtime over localtime (consumed by stattimeprint at c:201)
546    // AND auto-enables string formatting since raw epoch seconds carry
547    // no timezone semantics.
548    if ops[b'g' as usize] {
549        local_flags |= STF_GMT; // c:511
550        ops[b's' as usize] = true; // c:512
551    }
552    // c:514-515 — `if (OPT_ISSET(ops,'s') || OPT_ISSET(ops,'r'))` STF_STRING.
553    if ops[b's' as usize] || ops[b'r' as usize] {
554        local_flags |= STF_STRING;
555    }
556    // c:516 — `if (OPT_ISSET(ops,'r') || !OPT_ISSET(ops,'s'))` STF_RAW.
557    if ops[b'r' as usize] || !ops[b's' as usize] {
558        // c:516
559        local_flags |= STF_RAW;
560    }
561    // c:518-519 — `-n` → STF_FILE (filename prefix).
562    if ops[b'n' as usize] {
563        local_flags |= STF_FILE;
564    } // c:519
565      // c:520-521 — `-o` → STF_OCTAL.
566    if ops[b'o' as usize] {
567        local_flags |= STF_OCTAL;
568    } // c:521
569      // c:522-523 — `-t` → STF_NAME explicit.
570    if ops[b't' as usize] {
571        local_flags |= STF_NAME;
572    } // c:523
573      // c:525-530 — default STF_NAME when neither -A nor -H and no
574      // single-element pick: every line gets a `name<sp>` prefix so
575      // `zstat /etc/hosts` looks like `mode    33188` etc.
576    if arrnam.is_none() && hashnam.is_none() {
577        if argv.len() > 1 {
578            local_flags |= STF_FILE;
579        } // c:527
580        if (local_flags & STF_PICK) == 0 {
581            // c:528
582            local_flags |= STF_NAME; // c:529
583        }
584    }
585    // c:532-535 — explicit -N / -f turn off STF_FILE; -T / -H turn off
586    // STF_NAME (suppress prefix for `read` / hash use).
587    if ops[b'N' as usize] || ops[b'f' as usize] {
588        // c:532
589        local_flags &= !STF_FILE;
590    }
591    if ops[b'T' as usize] || ops[b'H' as usize] {
592        // c:534
593        local_flags &= !STF_NAME;
594    }
595    let _ = show_type;
596
597    // c:Src/Modules/stat.c:bin_stat — C tracks per-file stat(2) failures
598    // and propagates a non-zero rc when any path errored (`ret = 1` at
599    // c:560-565 inside the per-path loop). The Rust port previously
600    // skipped the path on error via `continue` but unconditionally
601    // returned 0 at the end, masking ENOENT (and any other stat error)
602    // as success. Track the first failure rc and return it after the
603    // loop.
604    let mut rc: i32 = 0;
605    // c:557 — `for (; OPT_ISSET(ops,'f') || *args; args++)`: with -f there
606    // are no file arguments (c:493) and the single pass fstat()s `fd`; the
607    // c:563 diagnostic then names the descriptor (`sprintf(outbuf, "%d", fd)`).
608    let fd_label = fd.to_string();
609    let targets: Vec<&str> = if ops[b'f' as usize] { vec![fd_label.as_str()] } else { argv.clone() };
610    for path in &targets {
611        let meta = if ops[b'f' as usize] {
612            // c:559 — `fstat(fd, &statbuf)`. The descriptor is borrowed,
613            // never closed: ManuallyDrop keeps File's Drop off it.
614            use std::os::unix::io::FromRawFd;
615            let file = std::mem::ManuallyDrop::new(unsafe { fs::File::from_raw_fd(fd) });
616            file.metadata()
617        } else if use_lstat {
618            fs::symlink_metadata(path) // c:560
619        } else {
620            fs::metadata(path) // c:561
621        };
622        let meta = match meta {
623            Ok(m) => m,
624            Err(e) => {
625                // Bug #112 — strip Rust's " (os error N)" suffix by
626                // routing the errno through the canonical strerror
627                // port (Src/compat.c:194).
628                let errno = e.raw_os_error().unwrap_or(0);
629                let raw = crate::ported::compat::strerror(errno);
630                // c:Src/stat.c:564 — `zwarnnam(name, "%s: %e", ...)`.
631                // The `%e` directive (utils.c:360-367) uncapitalizes the
632                // first letter of strerror unless errno==EIO, so zsh
633                // prints "no such file or directory", not "No such…".
634                const EIO: i32 = 5;
635                let msg = if errno == EIO {
636                    raw
637                } else {
638                    let mut cs = raw.chars();
639                    match cs.next() {
640                        Some(f) => f.to_lowercase().collect::<String>() + cs.as_str(),
641                        None => raw,
642                    }
643                };
644                zwarnnam(nam, &format!("{}: {}", path, msg));
645                rc = 1;
646                // c:567-568 — `if (OPT_ISSET(ops,'f') || arrnam) break; else continue;`
647                // With -A (or -f) the whole result is a single aggregate, so the
648                // first failure abandons the loop; without one, the remaining
649                // files are still reported.
650                if arrnam.is_some() || ops[b'f' as usize] {
651                    break;
652                }
653                continue;
654            }
655        };
656
657        // c:574-582 — `STF_FILE`: the file name leads this file's entries —
658        // an array element for `-A`, the HNAMEKEY pair for `-H`, a printed
659        // prefix otherwise.
660        if (local_flags & STF_FILE) != 0 {
661            if arrnam.is_some() {
662                array_out.push(path.to_string()); // c:576 ztrdup_metafy(*args)
663            } else if hashnam.is_some() {
664                hash_out.push((HNAMEKEY.to_string(), path.to_string())); // c:578-579
665            } else if (local_flags & STF_PICK) != 0 {
666                print!("{} ", path); // c:581
667            } else {
668                println!("{}:", path); // c:581
669            }
670        }
671        if iwhich >= 0 {
672            // -E single element.
673            let val = statprint(&meta, path, iwhich, local_flags);
674            if let Some(ref aname) = arrnam {
675                array_out.push(val);
676                let _ = aname;
677            } else if let Some(ref hname) = hashnam {
678                hash_out.push((STATELTS[iwhich as usize].to_string(), val));
679                let _ = hname;
680            } else {
681                println!("{}", val); // c:591
682            }
683        } else {
684            // All elements.
685            for idx in 0..STATELTS.len() {
686                let val = statprint(&meta, path, idx as i32, local_flags);
687                if let Some(_) = &arrnam {
688                    array_out.push(val);
689                } else if let Some(_) = &hashnam {
690                    hash_out.push((STATELTS[idx].to_string(), val));
691                } else {
692                    println!("{}", val); // c:603
693                }
694            }
695        }
696    }
697
698    // c:613-631 — `if (ret) freearray(array); else setaparam(...)`. The result is
699    // accumulated into a LOCAL buffer and only published to the parameter when
700    // every file stat'd cleanly. On any failure the buffer is dropped and the
701    // target parameter keeps whatever it already held — so a failing
702    // `zstat -H h <dangling-link>` must not wipe the assoc a previous successful
703    // zstat left in `h`.
704    if rc == 0 {
705        if let Some(name) = arrnam {
706            // c — `setaparam(name, zarrdup(array_out));` — real indexed array.
707            setaparam(&name, array_out); // c:params.c:3595
708        }
709        if let Some(name) = hashnam {
710            // c — `sethparam(name, ...);` — real assoc array. Flatten
711            // hash_out into alternating [k,v,k,v,...].
712            let mut flat: Vec<String> = Vec::with_capacity(hash_out.len() * 2);
713            for (k, v) in hash_out {
714                flat.push(k);
715                flat.push(v);
716            }
717            sethparam(&name, flat); // c:params.c:3602
718        }
719    }
720    rc
721}
722
723// `bintab` — port of `static struct builtin bintab[]` (stat.c:638).
724
725// `module_features` — port of `static struct features module_features`
726// from stat.c:642.
727
728/// Port of `setup_(UNUSED(Module m))` from `Src/Modules/stat.c:651`.
729#[allow(unused_variables)]
730pub fn setup_(m: *const module) -> i32 {
731    // c:651
732    // C body c:653-654 — `return 0`. Faithful empty-body port.
733    0
734}
735
736/// Port of `features_(UNUSED(Module m), UNUSED(char ***features))` from `Src/Modules/stat.c:658`.
737/// C body: `*features = featuresarray(m, &module_features); return 0;`
738pub fn features_(m: *const module, features: &mut Vec<String>) -> i32 {
739    // c:658
740    *features = featuresarray(m, module_features());
741    0 // c:673
742}
743
744/// Port of `enables_(UNUSED(Module m), UNUSED(int **enables))` from `Src/Modules/stat.c:666`.
745/// C body: `return handlefeatures(m, &module_features, enables);`
746pub fn enables_(m: *const module, enables: &mut Option<Vec<i32>>) -> i32 {
747    // c:666
748    handlefeatures(m, module_features(), enables) // c:673
749}
750
751/// Port of `boot_(UNUSED(Module m))` from `Src/Modules/stat.c:673`.
752#[allow(unused_variables)]
753pub fn boot_(m: *const module) -> i32 {
754    // c:673
755    // C body c:675-676 — `return 0`. Faithful empty-body port; the
756    //                    zstat builtin registers via the bn_list dispatch.
757    0
758}
759
760/// Port of `cleanup_(UNUSED(Module m))` from `Src/Modules/stat.c:680`.
761/// C body: `return setfeatureenables(m, &module_features, NULL);`
762pub fn cleanup_(m: *const module) -> i32 {
763    // c:680
764    setfeatureenables(m, module_features(), None) // c:687
765}
766
767/// Port of `finish_(UNUSED(Module m))` from `Src/Modules/stat.c:687`.
768#[allow(unused_variables)]
769pub fn finish_(m: *const module) -> i32 {
770    // c:687
771    // C body c:689-690 — `return 0`. Faithful empty-body port; the
772    //                    zstat builtin unregisters via cleanup_'s setfeatureenables.
773    0
774}
775/// `ST_DEV` constant.
776pub const ST_DEV: i32 = 0; // c:33
777/// `ST_INO` constant.
778pub const ST_INO: i32 = 1;
779/// `ST_MODE` constant.
780pub const ST_MODE: i32 = 2;
781/// `ST_NLINK` constant.
782pub const ST_NLINK: i32 = 3;
783/// `ST_UID` constant.
784pub const ST_UID: i32 = 4;
785/// `ST_GID` constant.
786pub const ST_GID: i32 = 5;
787/// `ST_RDEV` constant.
788pub const ST_RDEV: i32 = 6;
789/// `ST_SIZE` constant.
790pub const ST_SIZE: i32 = 7;
791/// `ST_ATIM` constant.
792pub const ST_ATIM: i32 = 8;
793/// `ST_MTIM` constant.
794pub const ST_MTIM: i32 = 9;
795/// `ST_CTIM` constant.
796pub const ST_CTIM: i32 = 10;
797/// `ST_BLKSIZE` constant.
798pub const ST_BLKSIZE: i32 = 11;
799/// `ST_BLOCKS` constant.
800pub const ST_BLOCKS: i32 = 12;
801/// `ST_READLINK` constant.
802pub const ST_READLINK: i32 = 13;
803/// `ST_COUNT` constant.
804pub const ST_COUNT: i32 = 14; // c:34
805
806// ============================================================
807// Port of `enum statflags` from `Src/Modules/stat.c:36-38`.
808// Bitmask flags passed to the print ported + bin_stat dispatch.
809// ============================================================
810/// `STF_NAME` constant.
811pub const STF_NAME: i32 = 1; // c:36
812/// `STF_FILE` constant.
813pub const STF_FILE: i32 = 2;
814/// `STF_STRING` constant.
815pub const STF_STRING: i32 = 4;
816/// `STF_RAW` constant.
817pub const STF_RAW: i32 = 8;
818
819// =====================================================================
820// static struct builtin bintab[]                                    c:638
821// static struct features module_features                            c:642
822// =====================================================================
823/// `STF_PICK` constant.
824pub const STF_PICK: i32 = 16;
825/// `STF_ARRAY` constant.
826pub const STF_ARRAY: i32 = 32;
827/// `STF_GMT` constant.
828pub const STF_GMT: i32 = 64;
829/// `STF_HASH` constant.
830pub const STF_HASH: i32 = 128;
831/// `STF_OCTAL` constant.
832pub const STF_OCTAL: i32 = 256; // c:38
833
834/// Port of `statelts[]` from `Src/Modules/stat.c:39`. Names of the
835/// 14 stat-elements, indexed by the `ST_*` constants above.
836pub static STATELTS: &[&str] = &[
837    // c:39
838    "device", "inode", "mode", "nlink", "uid", "gid", "rdev", "size", "atime", "mtime", "ctime",
839    "blksize", "blocks", "link",
840];
841
842static MODULE_FEATURES: OnceLock<Mutex<features>> = OnceLock::new();
843
844// Local stubs for the per-module entry points. C uses generic
845// `featuresarray`/`handlefeatures`/`setfeatureenables` (module.c:
846// 3275/3370/3445) but those take `Builtin` + `Features` pointer
847// fields the Rust port doesn't carry. The hardcoded descriptor
848// list mirrors the C bintab/conddefs/mathfuncs/paramdefs.
849// WARNING: NOT IN STAT.C — Rust-only module-framework shim.
850// C uses generic featuresarray/handlefeatures/setfeatureenables from
851// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
852// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
853fn featuresarray(_m: *const module, _f: &Mutex<features>) -> Vec<String> {
854    vec!["b:stat".to_string(), "b:zstat".to_string()]
855}
856
857// WARNING: NOT IN STAT.C — Rust-only module-framework shim.
858// C uses generic featuresarray/handlefeatures/setfeatureenables from
859// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
860// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
861fn handlefeatures(m: *const module, f: &Mutex<features>, enables: &mut Option<Vec<i32>>) -> i32 {
862    // c:3392 — the name-keyed variant in src/ported/module.rs; this
863    // module ships no `Features` descriptor tables for the per-feature
864    // ADDED bit to live on (see MODULE_FEATURE_ENABLES there).
865    crate::ported::module::handlefeatures("zsh/stat", &featuresarray(m, f), enables)
866}
867
868// WARNING: NOT IN STAT.C — Rust-only module-framework shim.
869// C uses generic featuresarray/handlefeatures/setfeatureenables from
870// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
871// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
872fn setfeatureenables(_m: *const module, _f: &Mutex<features>, _e: Option<&[i32]>) -> i32 {
873    0
874}
875
876// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
877// ─── RUST-ONLY ACCESSORS ───
878//
879// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
880// RwLock<T>>` globals declared above. C zsh uses direct global
881// access; Rust needs these wrappers because `OnceLock::get_or_init`
882// is the only way to lazily construct shared state. These ported sit
883// here so the body of this file reads in C source order without
884// the accessor wrappers interleaved between real port ported.
885// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
886
887// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
888// ─── RUST-ONLY ACCESSORS ───
889//
890// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
891// RwLock<T>>` globals declared above. C zsh uses direct global
892// access; Rust needs these wrappers because `OnceLock::get_or_init`
893// is the only way to lazily construct shared state. These ported sit
894// here so the body of this file reads in C source order without
895// the accessor wrappers interleaved between real port ported.
896// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
897
898// WARNING: NOT IN STAT.C — Rust-only module-framework shim.
899// C uses generic featuresarray/handlefeatures/setfeatureenables from
900// Src/module.c:3275/3370/3445 with C-side Builtin/Features pointers;
901// Rust per-module shims hardcode the bintab/conddefs/mathfuncs/paramdefs.
902fn module_features() -> &'static Mutex<features> {
903    MODULE_FEATURES.get_or_init(|| {
904        Mutex::new(features {
905            bn_list: None,
906            bn_size: 2,
907            cd_list: None,
908            cd_size: 0,
909            mf_list: None,
910            mf_size: 0,
911            pd_list: None,
912            pd_size: 0,
913            n_abstract: 0,
914        })
915    })
916}
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921    use std::fs::File;
922    use std::io::Write;
923
924    #[test]
925    fn statelts_count_matches_st_count() {
926        let _g = crate::test_util::global_state_lock();
927        assert_eq!(STATELTS.len() as i32, ST_COUNT);
928    }
929
930    #[test]
931    fn statmodeprint_octal_only() {
932        let _g = crate::test_util::global_state_lock();
933        let s = statmodeprint(0o100644, STF_RAW | STF_OCTAL);
934        assert!(s.starts_with('0'));
935        assert!(s.contains("644"));
936    }
937
938    #[test]
939    fn statmodeprint_string_only() {
940        let _g = crate::test_util::global_state_lock();
941        let s = statmodeprint(0o100644, STF_STRING);
942        assert_eq!(s.len(), 10);
943        assert_eq!(&s[..1], "-");
944    }
945
946    #[test]
947    fn statmodeprint_directory() {
948        let _g = crate::test_util::global_state_lock();
949        let s = statmodeprint(0o040755, STF_STRING);
950        assert_eq!(&s[..1], "d");
951    }
952
953    #[test]
954    fn statulprint_decimal() {
955        let _g = crate::test_util::global_state_lock();
956        assert_eq!(statulprint(12345), "12345");
957    }
958
959    #[test]
960    fn statprint_size_via_index() {
961        let _g = crate::test_util::global_state_lock();
962        let dir = tempfile::TempDir::new().unwrap();
963        let path = dir.path().join("x.txt");
964        File::create(&path).unwrap().write_all(b"hello").unwrap();
965        let meta = fs::metadata(&path).unwrap();
966        let s = statprint(&meta, path.to_str().unwrap(), ST_SIZE, 0);
967        assert_eq!(s, "5");
968    }
969
970    /// c:47 — `statmodeprint` for the BLOCK device file type. The
971    /// `pm[0]` lookup at c:84-103 must yield 'b'. Pin all file-type
972    /// chars so a regen that swaps `0o060_000` and `0o020_000`
973    /// silently renders block devices as char devices.
974    #[test]
975    fn statmodeprint_file_type_chars_match_ls_output() {
976        let _g = crate::test_util::global_state_lock();
977        // S_IFREG (regular file)
978        assert_eq!(&statmodeprint(0o100_644, STF_STRING)[..1], "-");
979        // S_IFDIR
980        assert_eq!(&statmodeprint(0o040_755, STF_STRING)[..1], "d");
981        // S_IFLNK (symlink)
982        assert_eq!(&statmodeprint(0o120_777, STF_STRING)[..1], "l");
983        // S_IFCHR (char device)
984        assert_eq!(&statmodeprint(0o020_644, STF_STRING)[..1], "c");
985        // S_IFBLK (block device)
986        assert_eq!(&statmodeprint(0o060_644, STF_STRING)[..1], "b");
987        // S_IFIFO (named pipe)
988        assert_eq!(&statmodeprint(0o010_644, STF_STRING)[..1], "p");
989        // S_IFSOCK
990        assert_eq!(&statmodeprint(0o140_644, STF_STRING)[..1], "s");
991    }
992
993    /// c:111-115 — setuid bit renders as 's' in the user-execute
994    /// slot when execute is set, 'S' when not. Pin both polarities
995    /// for setuid, setgid, and sticky so a regen flipping the
996    /// uppercase/lowercase dispatch gets caught.
997    #[test]
998    fn statmodeprint_setuid_setgid_sticky_render_correctly() {
999        let _g = crate::test_util::global_state_lock();
1000        // 4755 = setuid + executable → 's' in user slot
1001        let s = statmodeprint(0o104_755, STF_STRING);
1002        assert_eq!(
1003            s.chars().nth(3),
1004            Some('s'),
1005            "setuid+x must render as 's' in user-execute slot"
1006        );
1007
1008        // 4644 = setuid + NOT executable → 'S' in user slot
1009        let s = statmodeprint(0o104_644, STF_STRING);
1010        assert_eq!(
1011            s.chars().nth(3),
1012            Some('S'),
1013            "setuid without x must render as 'S' (uppercase)"
1014        );
1015
1016        // 2755 = setgid + group-x → 's' in group-execute slot
1017        let s = statmodeprint(0o102_755, STF_STRING);
1018        assert_eq!(
1019            s.chars().nth(6),
1020            Some('s'),
1021            "setgid+gx must render as 's' in group-execute slot"
1022        );
1023
1024        // 2644 = setgid without group-x → 'S'
1025        let s = statmodeprint(0o102_644, STF_STRING);
1026        assert_eq!(
1027            s.chars().nth(6),
1028            Some('S'),
1029            "setgid without gx must render as 'S'"
1030        );
1031
1032        // 1755 = sticky + world-x → 't' in other-execute slot
1033        let s = statmodeprint(0o101_755, STF_STRING);
1034        assert_eq!(
1035            s.chars().nth(9),
1036            Some('t'),
1037            "sticky+ox must render as 't' in other-execute slot"
1038        );
1039
1040        // 1644 = sticky without world-x → 'T'
1041        let s = statmodeprint(0o101_644, STF_STRING);
1042        assert_eq!(
1043            s.chars().nth(9),
1044            Some('T'),
1045            "sticky without ox must render as 'T'"
1046        );
1047    }
1048
1049    /// c:47-93 — `STF_RAW | STF_STRING` produces "raw (string)" with
1050    /// the raw form OUTSIDE parens and the string form inside.
1051    /// Pin the format because user scripts grep for "^(0?[0-9]+) \("
1052    /// to split the two halves.
1053    #[test]
1054    fn statmodeprint_raw_and_string_renders_with_parens() {
1055        let _g = crate::test_util::global_state_lock();
1056        let s = statmodeprint(0o100_644, STF_RAW | STF_STRING);
1057        // Decimal raw form, space, paren, 10-char string, close paren
1058        assert!(s.contains(" ("), "missing ' (' separator: {}", s);
1059        assert!(s.ends_with(')'), "missing closing ')': {}", s);
1060        // The closing paren must come right after the 10-char ls form
1061        let open = s.find('(').unwrap();
1062        let close = s.rfind(')').unwrap();
1063        assert_eq!(
1064            close - open - 1,
1065            10,
1066            "expected 10-char ls-mode between parens, got: {:?}",
1067            &s[open + 1..close]
1068        );
1069    }
1070
1071    /// c:47-93 — `statmodeprint(0)` with STF_STRING renders all dashes
1072    /// after the file-type indicator. Edge case: zero permissions on
1073    /// a "no file-type" mode falls through to '?'.
1074    #[test]
1075    fn statmodeprint_zero_mode_renders_unknown_type_no_perms() {
1076        let _g = crate::test_util::global_state_lock();
1077        let s = statmodeprint(0, STF_STRING);
1078        assert_eq!(s.len(), 10);
1079        assert_eq!(&s[..1], "?", "mode with no S_IFMT bits → unknown");
1080        assert_eq!(&s[1..], "---------", "no permission bits → all dashes");
1081    }
1082
1083    /// c:132 — `statuidprint` raw form is just the decimal uid;
1084    /// pin the no-leading-zeros, no-prefix shape so a regen that
1085    /// renders octal silently breaks `${(t)f[uid]}`.
1086    #[test]
1087    fn statuidprint_raw_is_decimal() {
1088        let _g = crate::test_util::global_state_lock();
1089        let s = statuidprint(1000, STF_RAW);
1090        assert_eq!(s, "1000");
1091    }
1092
1093    /// c:132 — `statuidprint` for uid 0 must include "root" in the
1094    /// string form (every Unix has uid 0 = root). Pin the well-known
1095    /// case so a regen that breaks the getpwuid path doesn't silently
1096    /// fall back to numeric.
1097    #[test]
1098    fn statuidprint_uid_zero_resolves_to_root() {
1099        let _g = crate::test_util::global_state_lock();
1100        let s = statuidprint(0, STF_STRING);
1101        // Some hardened systems map uid 0 to a different name, but
1102        // it MUST resolve to non-numeric.
1103        assert!(
1104            !s.parse::<u32>().is_ok(),
1105            "uid 0 fell back to numeric form: {}",
1106            s
1107        );
1108        assert!(!s.is_empty());
1109    }
1110
1111    /// c:161 — `statgidprint` raw form is decimal.
1112    #[test]
1113    fn statgidprint_raw_is_decimal() {
1114        let _g = crate::test_util::global_state_lock();
1115        let s = statgidprint(100, STF_RAW);
1116        assert_eq!(s, "100");
1117    }
1118
1119    /// c:211 — `statulprint` for zero must render "0". A regression
1120    /// that prints "" or "0x0" silently breaks numeric script
1121    /// comparisons.
1122    #[test]
1123    fn statulprint_zero_renders_as_zero_digit() {
1124        let _g = crate::test_util::global_state_lock();
1125        assert_eq!(statulprint(0), "0");
1126    }
1127
1128    /// c:211 — `statulprint` for u64::MAX renders the full decimal
1129    /// digit string. Pin the no-overflow / no-truncation behavior.
1130    #[test]
1131    fn statulprint_u64_max_renders_full_digits() {
1132        let _g = crate::test_util::global_state_lock();
1133        let s = statulprint(u64::MAX);
1134        assert_eq!(s, "18446744073709551615");
1135    }
1136
1137    /// c:36-38 — STF_* flag values must each occupy a unique single
1138    /// bit, AND must not overlap with each other (so they can be
1139    /// OR'd together). Pin the bit-distinctness because the flags
1140    /// are AND-tested individually throughout statprint.
1141    #[test]
1142    fn stf_flag_values_are_distinct_single_bits() {
1143        let _g = crate::test_util::global_state_lock();
1144        for f in [
1145            STF_NAME, STF_FILE, STF_STRING, STF_RAW, STF_PICK, STF_ARRAY, STF_GMT, STF_HASH,
1146            STF_OCTAL,
1147        ] {
1148            assert!(f > 0, "STF_* flag {} must be positive", f);
1149            assert_eq!(
1150                (f as u32).count_ones(),
1151                1,
1152                "STF_* flag {} = 0b{:b} must be a single bit",
1153                f,
1154                f
1155            );
1156        }
1157        // Pairwise: no two flags share a bit
1158        let flags = [
1159            STF_NAME, STF_FILE, STF_STRING, STF_RAW, STF_PICK, STF_ARRAY, STF_GMT, STF_HASH,
1160            STF_OCTAL,
1161        ];
1162        for (i, &a) in flags.iter().enumerate() {
1163            for &b in &flags[i + 1..] {
1164                assert_eq!(a & b, 0, "STF flags {} and {} overlap", a, b);
1165            }
1166        }
1167    }
1168
1169    /// c:651-690 — module-lifecycle stubs all return 0 in C.
1170    #[test]
1171    fn module_lifecycle_shims_all_return_zero() {
1172        let _g = crate::test_util::global_state_lock();
1173        let m: *const module = std::ptr::null();
1174        assert_eq!(setup_(m), 0);
1175        assert_eq!(boot_(m), 0);
1176        assert_eq!(cleanup_(m), 0);
1177        assert_eq!(finish_(m), 0);
1178    }
1179
1180    // ─── zsh-corpus pins for statmodeprint ─────────────────────────
1181
1182    /// Regular file 0644 → "-rw-r--r--" string form.
1183    #[test]
1184    fn stat_corpus_modeprint_regular_644() {
1185        let mode = 0o100_644; // S_IFREG | 0644
1186        let r = statmodeprint(mode, STF_STRING);
1187        assert_eq!(r, "-rw-r--r--", "regular 0644 → -rw-r--r--, got {r:?}");
1188    }
1189
1190    /// Regular file 0755 → "-rwxr-xr-x".
1191    #[test]
1192    fn stat_corpus_modeprint_regular_755() {
1193        let mode = 0o100_755;
1194        let r = statmodeprint(mode, STF_STRING);
1195        assert_eq!(r, "-rwxr-xr-x");
1196    }
1197
1198    /// Directory 0755 → "drwxr-xr-x".
1199    #[test]
1200    fn stat_corpus_modeprint_directory() {
1201        let mode = 0o040_755; // S_IFDIR | 0755
1202        let r = statmodeprint(mode, STF_STRING);
1203        assert_eq!(r, "drwxr-xr-x");
1204    }
1205
1206    /// Symlink → 'l' file-type prefix.
1207    #[test]
1208    fn stat_corpus_modeprint_symlink_prefix() {
1209        let mode = 0o120_777; // S_IFLNK | 0777
1210        let r = statmodeprint(mode, STF_STRING);
1211        assert!(
1212            r.starts_with('l'),
1213            "symlink mode starts with 'l', got {r:?}"
1214        );
1215    }
1216
1217    /// FIFO → 'p' file-type prefix.
1218    #[test]
1219    fn stat_corpus_modeprint_fifo_prefix() {
1220        let mode = 0o010_644;
1221        let r = statmodeprint(mode, STF_STRING);
1222        assert!(r.starts_with('p'), "FIFO mode starts with 'p', got {r:?}");
1223    }
1224
1225    /// Socket → 's' file-type prefix.
1226    #[test]
1227    fn stat_corpus_modeprint_socket_prefix() {
1228        let mode = 0o140_644;
1229        let r = statmodeprint(mode, STF_STRING);
1230        assert!(r.starts_with('s'));
1231    }
1232
1233    /// `STF_RAW | STF_OCTAL` → numeric octal form.
1234    #[test]
1235    fn stat_corpus_modeprint_raw_octal() {
1236        let mode = 0o100_644;
1237        let r = statmodeprint(mode, STF_RAW | STF_OCTAL);
1238        assert!(
1239            r.starts_with('0'),
1240            "octal raw form starts with leading 0, got {r:?}"
1241        );
1242        assert!(
1243            r.contains("100644") || r.contains("644"),
1244            "octal repr contains 100644 or 644, got {r:?}"
1245        );
1246    }
1247
1248    /// `STF_RAW` alone → decimal numeric.
1249    #[test]
1250    fn stat_corpus_modeprint_raw_decimal() {
1251        let mode = 0o100_644;
1252        let r = statmodeprint(mode, STF_RAW);
1253        // Decimal form of 0o100644 = 33188.
1254        assert_eq!(r, "33188", "raw decimal form, got {r:?}");
1255    }
1256
1257    /// Empty flags → empty output.
1258    #[test]
1259    fn stat_corpus_modeprint_no_flags_empty() {
1260        let r = statmodeprint(0o100_644, 0);
1261        assert_eq!(r, "", "no flags → empty output");
1262    }
1263
1264    // ═══════════════════════════════════════════════════════════════════
1265    // Additional C-parity tests for Src/Modules/stat.c statmodeprint
1266    // file-type + rwx bit dispatch.
1267    // ═══════════════════════════════════════════════════════════════════
1268
1269    /// c:84-103 — regular file (S_IFREG=0o100000) → '-' first char.
1270    #[test]
1271    fn statmodeprint_regular_file_starts_with_dash() {
1272        let r = statmodeprint(0o100_644, STF_STRING);
1273        assert!(r.starts_with('-'), "regular file → '-', got {:?}", r);
1274    }
1275
1276    /// c:84-103 — directory (S_IFDIR=0o040000) → 'd' first char.
1277    #[test]
1278    fn statmodeprint_directory_starts_with_d() {
1279        let r = statmodeprint(0o040_755, STF_STRING);
1280        assert!(r.starts_with('d'), "directory → 'd', got {:?}", r);
1281    }
1282
1283    /// c:84-103 — symlink (S_IFLNK=0o120000) → 'l' first char.
1284    #[test]
1285    fn statmodeprint_symlink_starts_with_l() {
1286        let r = statmodeprint(0o120_777, STF_STRING);
1287        assert!(r.starts_with('l'), "symlink → 'l', got {:?}", r);
1288    }
1289
1290    /// c:84-103 — char device (S_IFCHR=0o020000) → 'c' first char.
1291    #[test]
1292    fn statmodeprint_char_device_starts_with_c() {
1293        let r = statmodeprint(0o020_644, STF_STRING);
1294        assert!(r.starts_with('c'), "char device → 'c', got {:?}", r);
1295    }
1296
1297    /// c:84-103 — block device (S_IFBLK=0o060000) → 'b' first char.
1298    #[test]
1299    fn statmodeprint_block_device_starts_with_b() {
1300        let r = statmodeprint(0o060_644, STF_STRING);
1301        assert!(r.starts_with('b'), "block device → 'b', got {:?}", r);
1302    }
1303
1304    /// c:84-103 — socket (S_IFSOCK=0o140000) → 's' first char.
1305    #[test]
1306    fn statmodeprint_socket_starts_with_s() {
1307        let r = statmodeprint(0o140_777, STF_STRING);
1308        assert!(r.starts_with('s'), "socket → 's', got {:?}", r);
1309    }
1310
1311    /// c:84-103 — FIFO (S_IFIFO=0o010000) → 'p' first char.
1312    #[test]
1313    fn statmodeprint_fifo_starts_with_p() {
1314        let r = statmodeprint(0o010_644, STF_STRING);
1315        assert!(r.starts_with('p'), "FIFO → 'p', got {:?}", r);
1316    }
1317
1318    /// c:84-103 — unknown ifmt → '?' fallback.
1319    #[test]
1320    fn statmodeprint_unknown_ifmt_returns_question_mark() {
1321        let r = statmodeprint(0o070_000, STF_STRING);
1322        assert!(r.starts_with('?'), "unknown ifmt → '?', got {:?}", r);
1323    }
1324
1325    /// c:111 — setuid sticky 's' when execute bit set on user.
1326    #[test]
1327    fn statmodeprint_setuid_with_exec_shows_lowercase_s() {
1328        // 0o4755 = setuid + rwxr-xr-x
1329        let r = statmodeprint(0o104_755, STF_STRING);
1330        // Position 3 (user exec) should be 's' (setuid + x).
1331        assert_eq!(r.as_bytes()[3], b's', "setuid+x → 's', got {:?}", r);
1332    }
1333
1334    /// c:111 — setuid uppercase 'S' when execute bit NOT set on user.
1335    #[test]
1336    fn statmodeprint_setuid_without_exec_shows_uppercase_S() {
1337        // 0o4644 = setuid + rw-r--r--
1338        let r = statmodeprint(0o104_644, STF_STRING);
1339        assert_eq!(r.as_bytes()[3], b'S', "setuid-no-x → 'S', got {:?}", r);
1340    }
1341
1342    /// c:115 — sticky bit 't' when other-exec set, 'T' when not.
1343    #[test]
1344    fn statmodeprint_sticky_bit_dispatch() {
1345        // 0o1777 = sticky + rwxrwxrwx
1346        let r1 = statmodeprint(0o101_777, STF_STRING);
1347        assert_eq!(r1.as_bytes()[9], b't', "sticky+other-x → 't'");
1348        // 0o1644 = sticky + rw-r--r--
1349        let r2 = statmodeprint(0o101_644, STF_STRING);
1350        assert_eq!(r2.as_bytes()[9], b'T', "sticky no other-x → 'T'");
1351    }
1352
1353    /// c:228 — `statulprint(N)` formats as decimal.
1354    #[test]
1355    fn statulprint_decimal_format() {
1356        assert_eq!(statulprint(0), "0");
1357        assert_eq!(statulprint(42), "42");
1358        assert_eq!(statulprint(u64::MAX), format!("{}", u64::MAX));
1359    }
1360
1361    // ═══════════════════════════════════════════════════════════════════
1362    // Additional C-parity tests for Src/Modules/stat.c
1363    // c:43 statmodeprint / c:112 statuidprint / c:156 statgidprint /
1364    // c:199 stattimeprint / c:228 statulprint / c:237 statlinkprint / lifecycle
1365    // ═══════════════════════════════════════════════════════════════════
1366
1367    /// c:228 — `statulprint` is pure (deterministic).
1368    #[test]
1369    fn statulprint_is_pure() {
1370        for v in [0u64, 1, 42, 1_000_000, u64::MAX] {
1371            let first = statulprint(v);
1372            for _ in 0..5 {
1373                assert_eq!(statulprint(v), first, "statulprint({}) must be pure", v);
1374            }
1375        }
1376    }
1377
1378    /// c:228 — `statulprint` output is non-empty ASCII digit string.
1379    #[test]
1380    fn statulprint_output_is_ascii_digits() {
1381        for v in [0u64, 1, 42, u32::MAX as u64, u64::MAX] {
1382            let s = statulprint(v);
1383            assert!(!s.is_empty(), "non-empty");
1384            assert!(
1385                s.chars().all(|c| c.is_ascii_digit()),
1386                "all chars must be digits: {:?}",
1387                s
1388            );
1389        }
1390    }
1391
1392    /// c:43 — `statmodeprint` is pure (deterministic for same mode+flags).
1393    #[test]
1394    fn statmodeprint_is_pure() {
1395        let mode = 0o100644u32;
1396        let first = statmodeprint(mode, 0);
1397        for _ in 0..5 {
1398            assert_eq!(statmodeprint(mode, 0), first, "statmodeprint must be pure");
1399        }
1400    }
1401
1402    /// c:43 — `statmodeprint(_, 0)` returns empty (no flags = no output).
1403    #[test]
1404    fn statmodeprint_no_flags_returns_empty() {
1405        assert_eq!(statmodeprint(0o100644, 0), "");
1406    }
1407
1408    /// c:199 — `stattimeprint` is pure.
1409    #[test]
1410    fn stattimeprint_is_pure() {
1411        let t = 1_700_000_000i64;
1412        let first = stattimeprint(t, 0, 0);
1413        for _ in 0..5 {
1414            assert_eq!(stattimeprint(t, 0, 0), first, "stattimeprint must be pure");
1415        }
1416    }
1417
1418    /// c:237 — `statlinkprint` for non-symlink returns empty.
1419    #[test]
1420    fn statlinkprint_non_symlink_returns_empty() {
1421        let r = statlinkprint(0o100644, "/tmp/__not_symlink__");
1422        // Regular file mode bit pattern → empty.
1423        assert!(
1424            r.is_empty() || r == "" || r.starts_with(" "),
1425            "non-symlink should return empty or marker, got {:?}",
1426            r
1427        );
1428    }
1429
1430    /// c:237 — `statlinkprint` empty fname doesn't panic.
1431    #[test]
1432    fn statlinkprint_empty_fname_no_panic() {
1433        let _ = statlinkprint(0o100644, "");
1434    }
1435
1436    /// c:112 — `statuidprint(uid, flags)` is pure (deterministic for same args).
1437    #[test]
1438    fn statuidprint_is_pure() {
1439        for uid in [0u32, 1, 1000, u32::MAX] {
1440            let first = statuidprint(uid, 0);
1441            for _ in 0..5 {
1442                assert_eq!(
1443                    statuidprint(uid, 0),
1444                    first,
1445                    "statuidprint({}, 0) must be pure",
1446                    uid
1447                );
1448            }
1449        }
1450    }
1451
1452    /// c:156 — `statgidprint(gid, flags)` is pure.
1453    #[test]
1454    fn statgidprint_is_pure() {
1455        for gid in [0u32, 1, 1000, u32::MAX] {
1456            let first = statgidprint(gid, 0);
1457            for _ in 0..5 {
1458                assert_eq!(
1459                    statgidprint(gid, 0),
1460                    first,
1461                    "statgidprint({}, 0) must be pure",
1462                    gid
1463                );
1464            }
1465        }
1466    }
1467
1468    // ═══════════════════════════════════════════════════════════════════
1469    // Additional C-parity tests for Src/Modules/stat.c
1470    // c:43 statmodeprint / c:112 statuidprint / c:156 statgidprint /
1471    // c:199 stattimeprint / c:228 statulprint / c:237 statlinkprint /
1472    // c:300 bin_stat + lifecycle type pins
1473    // ═══════════════════════════════════════════════════════════════════
1474
1475    /// c:43 — `statmodeprint` returns String (compile-time type pin).
1476    #[test]
1477    fn statmodeprint_returns_string_type() {
1478        let _: String = statmodeprint(0o100644, 0);
1479    }
1480
1481    /// c:112 — `statuidprint` returns String.
1482    #[test]
1483    fn statuidprint_returns_string_type() {
1484        let _: String = statuidprint(0, 0);
1485    }
1486
1487    /// c:156 — `statgidprint` returns String.
1488    #[test]
1489    fn statgidprint_returns_string_type() {
1490        let _: String = statgidprint(0, 0);
1491    }
1492
1493    /// c:199 — `stattimeprint` returns String.
1494    #[test]
1495    fn stattimeprint_returns_string_type() {
1496        let _: String = stattimeprint(0, 0, 0);
1497    }
1498
1499    /// c:228 — `statulprint` returns String.
1500    #[test]
1501    fn statulprint_returns_string_type() {
1502        let _: String = statulprint(0);
1503    }
1504
1505    /// c:237 — `statlinkprint` returns String.
1506    #[test]
1507    fn statlinkprint_returns_string_type() {
1508        let _: String = statlinkprint(0o100644, "");
1509    }
1510
1511    /// c:228 — `statulprint(0)` returns "0".
1512    #[test]
1513    fn statulprint_zero_returns_zero_digit() {
1514        assert_eq!(statulprint(0), "0", "0 → \"0\" canonical");
1515    }
1516
1517    /// c:228 — `statulprint(u64::MAX)` returns max decimal repr.
1518    #[test]
1519    fn statulprint_u64_max_returns_canonical_decimal() {
1520        let s = statulprint(u64::MAX);
1521        assert_eq!(s, u64::MAX.to_string(), "u64::MAX matches std decimal");
1522    }
1523
1524    /// c:300 — `bin_stat` returns i32 (compile-time type pin).
1525    #[test]
1526    fn bin_stat_returns_i32_type() {
1527        let _g = crate::test_util::global_state_lock();
1528        let ops = crate::ported::zsh_h::options {
1529            ind: [0u8; crate::ported::zsh_h::MAX_OPS],
1530            args: Vec::new(),
1531            argscount: 0,
1532            argsalloc: 0,
1533        };
1534        let _: i32 = bin_stat("stat", &[], &ops, 0);
1535    }
1536
1537    /// c:579 — `setup_` returns i32 (compile-time type pin).
1538    #[test]
1539    fn stat_setup_returns_i32_type() {
1540        let _g = crate::test_util::global_state_lock();
1541        let _: i32 = setup_(std::ptr::null());
1542    }
1543
1544    /// c:587 — features non-empty.
1545    #[test]
1546    fn stat_features_nonempty() {
1547        let _g = crate::test_util::global_state_lock();
1548        let mut feats = Vec::new();
1549        features_(std::ptr::null(), &mut feats);
1550        assert!(!feats.is_empty(), "stat must advertise ≥1 feature");
1551    }
1552
1553    /// c:587 — features use b:/p: prefix per zsh module spec.
1554    #[test]
1555    fn stat_features_use_canonical_prefix() {
1556        let _g = crate::test_util::global_state_lock();
1557        let mut feats = Vec::new();
1558        features_(std::ptr::null(), &mut feats);
1559        for f in &feats {
1560            assert!(
1561                f.starts_with("b:") || f.starts_with("p:"),
1562                "feature {:?} must use b:/p: prefix",
1563                f
1564            );
1565        }
1566    }
1567
1568    /// c:228 — `statulprint` is monotonically non-decreasing-length
1569    /// for monotonically increasing input (10 → 100 → 1000).
1570    #[test]
1571    fn statulprint_length_grows_with_magnitude() {
1572        let a = statulprint(10).len();
1573        let b = statulprint(100).len();
1574        let c = statulprint(1000).len();
1575        assert!(a <= b, "len(10) ≤ len(100), got {} vs {}", a, b);
1576        assert!(b <= c, "len(100) ≤ len(1000), got {} vs {}", b, c);
1577    }
1578
1579    /// c:579-618 — full lifecycle setup→features→enables→boot→cleanup→finish.
1580    #[test]
1581    fn stat_full_lifecycle_returns_zero_for_all() {
1582        let _g = crate::test_util::global_state_lock();
1583        let null = std::ptr::null();
1584        assert_eq!(setup_(null), 0);
1585        let mut feats = Vec::new();
1586        let _ = features_(null, &mut feats);
1587        let mut enables: Option<Vec<i32>> = None;
1588        let _ = enables_(null, &mut enables);
1589        assert_eq!(boot_(null), 0);
1590        assert_eq!(cleanup_(null), 0);
1591        assert_eq!(finish_(null), 0);
1592    }
1593
1594    // ═══════════════════════════════════════════════════════════════════
1595    // Additional C-parity tests for Src/Modules/stat.c
1596    // c:43 statmodeprint / c:112 statuidprint / c:156 statgidprint /
1597    // c:199 stattimeprint / c:228 statulprint / c:300 bin_stat
1598    // ═══════════════════════════════════════════════════════════════════
1599
1600    /// c:43 — `statmodeprint` returns String (compile-time pin, alt).
1601    #[test]
1602    fn statmodeprint_returns_string_pin_alt() {
1603        let _: String = statmodeprint(0o644, 0);
1604    }
1605
1606    /// c:43 — `statmodeprint` is deterministic for any mode.
1607    #[test]
1608    fn statmodeprint_deterministic_for_common_modes() {
1609        for mode in [0o644u32, 0o755, 0o600, 0o777, 0o000] {
1610            let a = statmodeprint(mode, 0);
1611            let b = statmodeprint(mode, 0);
1612            assert_eq!(a, b, "statmodeprint({:o}) must be pure", mode);
1613        }
1614    }
1615
1616    /// c:112 — `statuidprint` returns String (compile-time pin, alt).
1617    #[test]
1618    fn statuidprint_returns_string_pin_alt() {
1619        let _: String = statuidprint(0, 0);
1620    }
1621
1622    /// c:112 — `statuidprint(0, STF_RAW)` returns "0" (raw uid digit).
1623    /// Pin the c:115 STF_RAW arm; flags=0 produces empty by design.
1624    #[test]
1625    fn statuidprint_root_with_raw_flag_returns_zero_digit() {
1626        let s = statuidprint(0, STF_RAW);
1627        assert_eq!(s, "0", "uid 0 + STF_RAW must produce '0'");
1628    }
1629
1630    /// c:156 — `statgidprint` returns String (compile-time pin, alt).
1631    #[test]
1632    fn statgidprint_returns_string_pin_alt() {
1633        let _: String = statgidprint(0, 0);
1634    }
1635
1636    /// c:156 — `statgidprint(0, STF_RAW)` returns "0" (raw gid digit).
1637    /// Pin the c:159 STF_RAW arm; flags=0 produces empty by design.
1638    #[test]
1639    fn statgidprint_zero_with_raw_flag_returns_zero_digit() {
1640        let s = statgidprint(0, STF_RAW);
1641        assert_eq!(s, "0", "gid 0 + STF_RAW must produce '0'");
1642    }
1643
1644    /// c:199 — `stattimeprint` returns String (compile-time pin, alt).
1645    #[test]
1646    fn stattimeprint_returns_string_pin_alt() {
1647        let _: String = stattimeprint(0, 0, 0);
1648    }
1649
1650    /// c:199 — `stattimeprint(0, 0, STF_RAW)` returns "0" (raw epoch).
1651    /// Pin the c:202 STF_RAW arm; flags=0 produces empty by design.
1652    #[test]
1653    fn stattimeprint_epoch_zero_with_raw_flag_returns_zero() {
1654        let s = stattimeprint(0, 0, STF_RAW);
1655        assert_eq!(s, "0", "epoch 0 + STF_RAW must produce '0'");
1656    }
1657
1658    /// c:300 — `bin_stat` exit code is non-negative.
1659    #[test]
1660    fn bin_stat_exit_code_non_negative() {
1661        let _g = crate::test_util::global_state_lock();
1662        let ops = crate::ported::zsh_h::options {
1663            ind: [0u8; crate::ported::zsh_h::MAX_OPS],
1664            args: Vec::new(),
1665            argscount: 0,
1666            argsalloc: 0,
1667        };
1668        for argv in [
1669            vec![],
1670            vec!["/tmp".into()],
1671            vec!["/dev/null".into()],
1672            vec!["/__nonexistent_xyz__".into()],
1673        ] {
1674            let r = bin_stat("stat", &argv, &ops, 0);
1675            assert!(
1676                r >= 0,
1677                "exit code must be non-negative, got {} for {:?}",
1678                r,
1679                argv
1680            );
1681        }
1682    }
1683
1684    /// c:300 — `bin_stat` of nonexistent path MUST return nonzero
1685    /// (C uses `stat(2)` which fails ENOENT, then zwarnnam + return 1).
1686    /// In zshrs the port silently returns 0.
1687    #[test]
1688    fn bin_stat_nonexistent_path_returns_nonzero() {
1689        let _g = crate::test_util::global_state_lock();
1690        let ops = crate::ported::zsh_h::options {
1691            ind: [0u8; crate::ported::zsh_h::MAX_OPS],
1692            args: Vec::new(),
1693            argscount: 0,
1694            argsalloc: 0,
1695        };
1696        let r = bin_stat("stat", &["/__definitely_no_such_xyz__".into()], &ops, 0);
1697        assert_ne!(r, 0, "nonexistent path → nonzero");
1698    }
1699
1700    /// c:228 — `statulprint` deterministic for powers-of-2.
1701    #[test]
1702    fn statulprint_deterministic_for_powers_of_two() {
1703        for shift in 0..64 {
1704            let v = 1u64 << shift;
1705            let a = statulprint(v);
1706            let b = statulprint(v);
1707            assert_eq!(a, b, "statulprint({}) must be pure", v);
1708        }
1709    }
1710}