Skip to main content

coreutils_rs/ls/
core.rs

1use std::cmp::Ordering;
2use std::collections::HashMap;
3use std::ffi::CString;
4use std::fs::{self, DirEntry, Metadata};
5use std::io::{self, BufWriter, Write};
6use std::os::unix::fs::MetadataExt;
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
9use std::time::SystemTime;
10
11/// Whether the current locale uses simple byte-order collation (C/POSIX).
12/// When true, we skip the expensive `strcoll()` + CString allocation path.
13static IS_C_LOCALE: AtomicBool = AtomicBool::new(false);
14
15/// Detect whether the current locale is C/POSIX (byte-order collation).
16/// Must be called after `setlocale(LC_ALL, "")`.
17pub fn detect_c_locale() {
18    let lc = unsafe { libc::setlocale(libc::LC_COLLATE, std::ptr::null()) };
19    if lc.is_null() {
20        IS_C_LOCALE.store(true, AtomicOrdering::Relaxed);
21        return;
22    }
23    let s = unsafe { std::ffi::CStr::from_ptr(lc) }.to_bytes();
24    let is_c = s == b"C" || s == b"POSIX";
25    IS_C_LOCALE.store(is_c, AtomicOrdering::Relaxed);
26}
27
28// ---------------------------------------------------------------------------
29// Configuration types
30// ---------------------------------------------------------------------------
31
32/// How to sort directory entries.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum SortBy {
35    Name,
36    Size,
37    Time,
38    Extension,
39    Version,
40    None,
41    Width,
42}
43
44/// Output layout.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum OutputFormat {
47    Long,
48    SingleColumn,
49    Columns,
50    Comma,
51    Across,
52}
53
54/// When to emit ANSI colour escapes.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ColorMode {
57    Always,
58    Auto,
59    Never,
60}
61
62/// Which timestamp to show / sort by.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum TimeField {
65    Mtime,
66    Atime,
67    Ctime,
68    Birth,
69}
70
71/// How to format timestamps in long listing.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum TimeStyle {
74    FullIso,
75    LongIso,
76    Iso,
77    Locale,
78    Custom(String),
79}
80
81/// What indicators to append to names.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum IndicatorStyle {
84    None,
85    Slash,
86    FileType,
87    Classify,
88}
89
90/// File-type classify mode (for -F / --classify).
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum ClassifyMode {
93    Always,
94    Auto,
95    Never,
96}
97
98/// Quoting style for file names.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum QuotingStyle {
101    Literal,
102    Locale,
103    Shell,
104    ShellAlways,
105    ShellEscape,
106    ShellEscapeAlways,
107    C,
108    Escape,
109}
110
111/// When to emit hyperlinks.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum HyperlinkMode {
114    Always,
115    Auto,
116    Never,
117}
118
119/// Full configuration for ls.
120#[derive(Debug, Clone)]
121pub struct LsConfig {
122    pub all: bool,
123    pub almost_all: bool,
124    pub long_format: bool,
125    pub human_readable: bool,
126    pub si: bool,
127    pub reverse: bool,
128    pub recursive: bool,
129    pub sort_by: SortBy,
130    pub format: OutputFormat,
131    pub classify: ClassifyMode,
132    pub color: ColorMode,
133    pub group_directories_first: bool,
134    pub show_inode: bool,
135    pub show_size: bool,
136    pub show_owner: bool,
137    pub show_group: bool,
138    pub numeric_ids: bool,
139    pub dereference: bool,
140    pub directory: bool,
141    pub time_field: TimeField,
142    pub time_style: TimeStyle,
143    pub ignore_patterns: Vec<String>,
144    pub ignore_backups: bool,
145    pub width: usize,
146    pub quoting_style: QuotingStyle,
147    pub hide_control_chars: bool,
148    pub kibibytes: bool,
149    pub indicator_style: IndicatorStyle,
150    pub tab_size: usize,
151    pub hyperlink: HyperlinkMode,
152    pub context: bool,
153    pub literal: bool,
154    /// --zero: use NUL as line terminator instead of newline.
155    pub zero: bool,
156    /// --block-size=SIZE: scale sizes by SIZE before printing.
157    /// None means not set (use default behavior).
158    pub block_size: Option<u64>,
159    /// Suffix to append after block-size-scaled values (e.g. "K", "M", "kB").
160    pub block_size_suffix: String,
161}
162
163impl Default for LsConfig {
164    fn default() -> Self {
165        LsConfig {
166            all: false,
167            almost_all: false,
168            long_format: false,
169            human_readable: false,
170            si: false,
171            reverse: false,
172            recursive: false,
173            sort_by: SortBy::Name,
174            format: OutputFormat::Columns,
175            classify: ClassifyMode::Never,
176            color: ColorMode::Auto,
177            group_directories_first: false,
178            show_inode: false,
179            show_size: false,
180            show_owner: true,
181            show_group: true,
182            numeric_ids: false,
183            dereference: false,
184            directory: false,
185            time_field: TimeField::Mtime,
186            time_style: TimeStyle::Locale,
187            ignore_patterns: Vec::new(),
188            ignore_backups: false,
189            width: 80,
190            quoting_style: QuotingStyle::Literal,
191            hide_control_chars: false,
192            kibibytes: false,
193            indicator_style: IndicatorStyle::None,
194            tab_size: 8,
195            hyperlink: HyperlinkMode::Never,
196            context: false,
197            literal: false,
198            zero: false,
199            block_size: None,
200            block_size_suffix: String::new(),
201        }
202    }
203}
204
205// ---------------------------------------------------------------------------
206// Default LS_COLORS
207// ---------------------------------------------------------------------------
208
209/// Parsed colour database.
210#[derive(Debug, Clone)]
211pub struct ColorDb {
212    pub map: HashMap<String, String>,
213    pub dir: String,
214    pub link: String,
215    pub exec: String,
216    pub pipe: String,
217    pub socket: String,
218    pub block_dev: String,
219    pub char_dev: String,
220    pub orphan: String,
221    pub setuid: String,
222    pub setgid: String,
223    pub sticky: String,
224    pub other_writable: String,
225    pub sticky_other_writable: String,
226    pub reset: String,
227}
228
229impl Default for ColorDb {
230    fn default() -> Self {
231        ColorDb {
232            map: HashMap::new(),
233            dir: "\x1b[01;34m".to_string(),            // bold blue
234            link: "\x1b[01;36m".to_string(),           // bold cyan
235            exec: "\x1b[01;32m".to_string(),           // bold green
236            pipe: "\x1b[33m".to_string(),              // yellow
237            socket: "\x1b[01;35m".to_string(),         // bold magenta
238            block_dev: "\x1b[01;33m".to_string(),      // bold yellow
239            char_dev: "\x1b[01;33m".to_string(),       // bold yellow
240            orphan: "\x1b[01;31m".to_string(),         // bold red
241            setuid: "\x1b[37;41m".to_string(),         // white on red
242            setgid: "\x1b[30;43m".to_string(),         // black on yellow
243            sticky: "\x1b[37;44m".to_string(),         // white on blue
244            other_writable: "\x1b[34;42m".to_string(), // blue on green
245            sticky_other_writable: "\x1b[30;42m".to_string(), // black on green
246            reset: "\x1b[0m".to_string(),
247        }
248    }
249}
250
251impl ColorDb {
252    /// Parse from LS_COLORS environment variable.
253    pub fn from_env() -> Self {
254        let mut db = ColorDb::default();
255        if let Ok(val) = std::env::var("LS_COLORS") {
256            for item in val.split(':') {
257                if let Some((key, code)) = item.split_once('=') {
258                    let esc = format!("\x1b[{}m", code);
259                    match key {
260                        "di" => db.dir = esc,
261                        "ln" => db.link = esc,
262                        "ex" => db.exec = esc,
263                        "pi" | "fi" if key == "pi" => db.pipe = esc,
264                        "so" => db.socket = esc,
265                        "bd" => db.block_dev = esc,
266                        "cd" => db.char_dev = esc,
267                        "or" => db.orphan = esc,
268                        "su" => db.setuid = esc,
269                        "sg" => db.setgid = esc,
270                        "st" => db.sticky = esc,
271                        "ow" => db.other_writable = esc,
272                        "tw" => db.sticky_other_writable = esc,
273                        "rs" => db.reset = esc,
274                        _ => {
275                            if key.starts_with('*') {
276                                db.map.insert(key[1..].to_string(), esc);
277                            }
278                        }
279                    }
280                }
281            }
282        }
283        db
284    }
285
286    /// Look up the colour escape for a file entry.
287    fn color_for(&self, entry: &FileEntry) -> &str {
288        let mode = entry.mode;
289        let ft = mode & (libc::S_IFMT as u32);
290
291        // Symlink
292        if ft == libc::S_IFLNK as u32 {
293            if entry.link_target_ok {
294                return &self.link;
295            } else {
296                return &self.orphan;
297            }
298        }
299
300        // Directory with special bits
301        if ft == libc::S_IFDIR as u32 {
302            let sticky = mode & (libc::S_ISVTX as u32) != 0;
303            let ow = mode & (libc::S_IWOTH as u32) != 0;
304            if sticky && ow {
305                return &self.sticky_other_writable;
306            }
307            if ow {
308                return &self.other_writable;
309            }
310            if sticky {
311                return &self.sticky;
312            }
313            return &self.dir;
314        }
315
316        // Special files
317        if ft == libc::S_IFIFO as u32 {
318            return &self.pipe;
319        }
320        if ft == libc::S_IFSOCK as u32 {
321            return &self.socket;
322        }
323        if ft == libc::S_IFBLK as u32 {
324            return &self.block_dev;
325        }
326        if ft == libc::S_IFCHR as u32 {
327            return &self.char_dev;
328        }
329
330        // Setuid / setgid
331        if mode & (libc::S_ISUID as u32) != 0 {
332            return &self.setuid;
333        }
334        if mode & (libc::S_ISGID as u32) != 0 {
335            return &self.setgid;
336        }
337
338        // Extension match
339        if let Some(ext_pos) = entry.name.rfind('.') {
340            let ext = &entry.name[ext_pos..];
341            if let Some(c) = self.map.get(ext) {
342                return c;
343            }
344        }
345
346        // Executable
347        if ft == libc::S_IFREG as u32
348            && mode & (libc::S_IXUSR as u32 | libc::S_IXGRP as u32 | libc::S_IXOTH as u32) != 0
349        {
350            return &self.exec;
351        }
352
353        ""
354    }
355}
356
357// ---------------------------------------------------------------------------
358// File entry
359// ---------------------------------------------------------------------------
360
361/// One entry to display.
362#[derive(Debug, Clone)]
363pub struct FileEntry {
364    pub name: String,
365    pub path: PathBuf,
366    /// Pre-computed CString for locale-aware sorting (avoids allocation in comparator).
367    pub sort_key: CString,
368    pub ino: u64,
369    pub nlink: u64,
370    pub mode: u32,
371    pub uid: u32,
372    pub gid: u32,
373    pub size: u64,
374    pub blocks: u64,
375    pub mtime: i64,
376    pub mtime_nsec: i64,
377    pub atime: i64,
378    pub atime_nsec: i64,
379    pub ctime: i64,
380    pub ctime_nsec: i64,
381    pub rdev_major: u32,
382    pub rdev_minor: u32,
383    pub is_dir: bool,
384    pub link_target: Option<String>,
385    pub link_target_ok: bool,
386    /// Whether the symlink target is a directory (for --classify indicator on target).
387    pub link_target_is_dir: bool,
388    /// The mode of the symlink target (for full --classify indicator on target).
389    pub link_target_mode: Option<u32>,
390}
391
392impl FileEntry {
393    /// Create from a DirEntry.
394    fn from_dir_entry(de: &DirEntry, config: &LsConfig) -> io::Result<Self> {
395        let name = de.file_name().to_string_lossy().into_owned();
396        let path = de.path();
397
398        let meta = if config.dereference {
399            match fs::metadata(&path) {
400                Ok(m) => m,
401                Err(e) => {
402                    // Check if it's a broken symlink when dereferencing
403                    if let Ok(lmeta) = fs::symlink_metadata(&path) {
404                        if lmeta.file_type().is_symlink() {
405                            // For long format, show placeholder with error.
406                            // For non-long formats, just show the name from lstat.
407                            if config.long_format {
408                                eprintln!(
409                                    "ls: cannot access '{}': {}",
410                                    name,
411                                    crate::common::io_error_msg(&e)
412                                );
413                                return Ok(Self::broken_deref(name, path));
414                            }
415                            // Non-long format: use lstat data, no error
416                            return Self::from_metadata(name, path, &lmeta, config);
417                        }
418                    }
419                    return Err(e);
420                }
421            }
422        } else {
423            fs::symlink_metadata(&path)?
424        };
425
426        Self::from_metadata(name, path, &meta, config)
427    }
428
429    /// Create from a path using the full path as the display name (for -d with
430    /// arguments, or for the `.` and `..` virtual entries).
431    pub fn from_path_with_name(name: String, path: &Path, config: &LsConfig) -> io::Result<Self> {
432        let meta = if config.dereference {
433            fs::metadata(path).or_else(|_| fs::symlink_metadata(path))?
434        } else {
435            fs::symlink_metadata(path)?
436        };
437        Self::from_metadata(name, path.to_path_buf(), &meta, config)
438    }
439
440    fn from_metadata(
441        name: String,
442        path: PathBuf,
443        meta: &Metadata,
444        _config: &LsConfig,
445    ) -> io::Result<Self> {
446        let file_type = meta.file_type();
447        let is_symlink = file_type.is_symlink();
448
449        let (link_target, link_target_ok, link_target_is_dir, link_target_mode) = if is_symlink {
450            match fs::read_link(&path) {
451                Ok(target) => match fs::metadata(&path) {
452                    Ok(target_meta) => {
453                        let tmode = target_meta.mode();
454                        (
455                            Some(target.to_string_lossy().into_owned()),
456                            true,
457                            target_meta.is_dir(),
458                            Some(tmode),
459                        )
460                    }
461                    Err(_) => (
462                        Some(target.to_string_lossy().into_owned()),
463                        false,
464                        false,
465                        None,
466                    ),
467                },
468                Err(_) => (None, false, false, None),
469            }
470        } else {
471            (None, true, false, None)
472        };
473
474        let rdev = meta.rdev();
475        let sort_key = CString::new(name.as_str()).unwrap_or_default();
476
477        Ok(FileEntry {
478            name,
479            path,
480            sort_key,
481            ino: meta.ino(),
482            nlink: meta.nlink(),
483            mode: meta.mode(),
484            uid: meta.uid(),
485            gid: meta.gid(),
486            size: meta.size(),
487            blocks: meta.blocks(),
488            mtime: meta.mtime(),
489            mtime_nsec: meta.mtime_nsec(),
490            atime: meta.atime(),
491            atime_nsec: meta.atime_nsec(),
492            ctime: meta.ctime(),
493            ctime_nsec: meta.ctime_nsec(),
494            rdev_major: ((rdev >> 8) & 0xfff) as u32,
495            rdev_minor: (rdev & 0xff) as u32,
496            is_dir: meta.is_dir(),
497            link_target,
498            link_target_ok,
499            link_target_is_dir,
500            link_target_mode,
501        })
502    }
503
504    /// Get the timestamp for the chosen time field.
505    fn time_secs(&self, field: TimeField) -> i64 {
506        match field {
507            TimeField::Mtime => self.mtime,
508            TimeField::Atime => self.atime,
509            TimeField::Ctime | TimeField::Birth => self.ctime,
510        }
511    }
512
513    fn time_nsec(&self, field: TimeField) -> i64 {
514        match field {
515            TimeField::Mtime => self.mtime_nsec,
516            TimeField::Atime => self.atime_nsec,
517            TimeField::Ctime | TimeField::Birth => self.ctime_nsec,
518        }
519    }
520
521    /// Return the extension (lowercase) for sorting.
522    fn extension(&self) -> &str {
523        match self.name.rfind('.') {
524            Some(pos) if pos > 0 => &self.name[pos + 1..],
525            _ => "",
526        }
527    }
528
529    /// Is this a directory (or symlink-to-directory when dereferencing)?
530    fn is_directory(&self) -> bool {
531        self.is_dir
532    }
533
534    /// Indicator character for classify.
535    fn indicator(&self, style: IndicatorStyle) -> &'static str {
536        let ft = self.mode & (libc::S_IFMT as u32);
537        match style {
538            IndicatorStyle::None => "",
539            IndicatorStyle::Slash => {
540                if ft == libc::S_IFDIR as u32 {
541                    "/"
542                } else {
543                    ""
544                }
545            }
546            IndicatorStyle::FileType => match ft {
547                x if x == libc::S_IFDIR as u32 => "/",
548                x if x == libc::S_IFLNK as u32 => "@",
549                x if x == libc::S_IFIFO as u32 => "|",
550                x if x == libc::S_IFSOCK as u32 => "=",
551                _ => "",
552            },
553            IndicatorStyle::Classify => match ft {
554                x if x == libc::S_IFDIR as u32 => "/",
555                x if x == libc::S_IFLNK as u32 => "@",
556                x if x == libc::S_IFIFO as u32 => "|",
557                x if x == libc::S_IFSOCK as u32 => "=",
558                _ => {
559                    if ft == libc::S_IFREG as u32
560                        && self.mode
561                            & (libc::S_IXUSR as u32 | libc::S_IXGRP as u32 | libc::S_IXOTH as u32)
562                            != 0
563                    {
564                        "*"
565                    } else {
566                        ""
567                    }
568                }
569            },
570        }
571    }
572
573    /// Create a placeholder entry for a broken symlink when -L (dereference) is used.
574    /// GNU ls shows `l????????? ? ? ? ? ? name` for such entries with proper column padding.
575    pub fn broken_deref(name: String, path: PathBuf) -> Self {
576        let sort_key = CString::new(name.as_str()).unwrap_or_default();
577        FileEntry {
578            name,
579            path,
580            sort_key,
581            ino: 0,
582            nlink: 0, // marker: normal entries have nlink >= 1
583            mode: libc::S_IFLNK as u32,
584            uid: 0,
585            gid: 0,
586            size: 0,
587            blocks: 0,
588            mtime: 0,
589            mtime_nsec: 0,
590            atime: 0,
591            atime_nsec: 0,
592            ctime: 0,
593            ctime_nsec: 0,
594            rdev_major: 0,
595            rdev_minor: 0,
596            is_dir: false,
597            link_target: None,
598            link_target_ok: false,
599            link_target_is_dir: false,
600            link_target_mode: None,
601        }
602    }
603
604    /// Whether this is a broken dereference placeholder.
605    pub fn is_broken_deref(&self) -> bool {
606        self.nlink == 0 && (self.mode & libc::S_IFMT as u32) == libc::S_IFLNK as u32
607    }
608
609    /// Display width of the name (accounting for quoting, indicator).
610    fn display_width(&self, config: &LsConfig) -> usize {
611        let quoted = quote_name(&self.name, config);
612        let ind = self.indicator(config.indicator_style);
613        quoted.len() + ind.len()
614    }
615}
616
617// ---------------------------------------------------------------------------
618// Name quoting
619// ---------------------------------------------------------------------------
620
621/// Quote a filename according to the configured quoting style.
622pub fn quote_name(name: &str, config: &LsConfig) -> String {
623    match config.quoting_style {
624        QuotingStyle::Literal => {
625            if config.hide_control_chars {
626                hide_control(name)
627            } else {
628                name.to_string()
629            }
630        }
631        QuotingStyle::Escape => escape_name(name),
632        QuotingStyle::C => c_quote(name),
633        QuotingStyle::Shell => shell_quote(name, false, false),
634        QuotingStyle::ShellAlways => shell_quote(name, true, false),
635        QuotingStyle::ShellEscape => shell_quote(name, false, true),
636        QuotingStyle::ShellEscapeAlways => shell_quote(name, true, true),
637        QuotingStyle::Locale => locale_quote(name),
638    }
639}
640
641fn hide_control(name: &str) -> String {
642    name.chars()
643        .map(|c| if c.is_control() { '?' } else { c })
644        .collect()
645}
646
647fn escape_name(name: &str) -> String {
648    let mut out = String::with_capacity(name.len());
649    for c in name.chars() {
650        match c {
651            '\\' => out.push_str("\\\\"),
652            '\n' => out.push_str("\\n"),
653            '\r' => out.push_str("\\r"),
654            '\t' => out.push_str("\\t"),
655            ' ' => out.push_str("\\ "),
656            c if c.is_control() => {
657                out.push_str(&format!("\\{:03o}", c as u32));
658            }
659            c => out.push(c),
660        }
661    }
662    out
663}
664
665fn c_quote(name: &str) -> String {
666    let mut out = String::with_capacity(name.len() + 2);
667    out.push('"');
668    for c in name.chars() {
669        match c {
670            '"' => out.push_str("\\\""),
671            '\\' => out.push_str("\\\\"),
672            '\n' => out.push_str("\\n"),
673            '\r' => out.push_str("\\r"),
674            '\t' => out.push_str("\\t"),
675            '\x07' => out.push_str("\\a"),
676            '\x08' => out.push_str("\\b"),
677            '\x0C' => out.push_str("\\f"),
678            '\x0B' => out.push_str("\\v"),
679            c if c.is_control() => {
680                out.push_str(&format!("\\{:03o}", c as u32));
681            }
682            c => out.push(c),
683        }
684    }
685    out.push('"');
686    out
687}
688
689fn shell_quote(name: &str, always: bool, escape: bool) -> String {
690    let needs_quoting = name.is_empty()
691        || name
692            .chars()
693            .any(|c| " \t\n'\"\\|&;()<>!$`#~{}[]?*".contains(c) || c.is_control());
694
695    if !needs_quoting && !always {
696        return name.to_string();
697    }
698
699    if escape {
700        // Use $'...' form with escape sequences for control chars
701        let has_control = name.chars().any(|c| c.is_control());
702        if has_control {
703            let mut out = String::with_capacity(name.len() + 4);
704            out.push_str("$'");
705            for c in name.chars() {
706                match c {
707                    '\'' => out.push_str("\\'"),
708                    '\\' => out.push_str("\\\\"),
709                    '\n' => out.push_str("\\n"),
710                    '\r' => out.push_str("\\r"),
711                    '\t' => out.push_str("\\t"),
712                    c if c.is_control() => {
713                        out.push_str(&format!("\\{:03o}", c as u32));
714                    }
715                    c => out.push(c),
716                }
717            }
718            out.push('\'');
719            return out;
720        }
721    }
722
723    // Use single quotes
724    let mut out = String::with_capacity(name.len() + 2);
725    out.push('\'');
726    for c in name.chars() {
727        if c == '\'' {
728            out.push_str("'\\''");
729        } else {
730            out.push(c);
731        }
732    }
733    out.push('\'');
734    out
735}
736
737fn locale_quote(name: &str) -> String {
738    // Use \u{2018} and \u{2019} (left/right single quotation marks)
739    let mut out = String::with_capacity(name.len() + 2);
740    out.push('\u{2018}');
741    for c in name.chars() {
742        match c {
743            '\\' => out.push_str("\\\\"),
744            '\n' => out.push_str("\\n"),
745            '\r' => out.push_str("\\r"),
746            '\t' => out.push_str("\\t"),
747            c if c.is_control() => {
748                out.push_str(&format!("\\{:03o}", c as u32));
749            }
750            c => out.push(c),
751        }
752    }
753    out.push('\u{2019}');
754    out
755}
756
757// ---------------------------------------------------------------------------
758// Sorting
759// ---------------------------------------------------------------------------
760
761/// Natural version sort comparison (like GNU `ls -v` / `sort -V`).
762pub(crate) fn version_cmp(a: &str, b: &str) -> Ordering {
763    let ab = a.as_bytes();
764    let bb = b.as_bytes();
765    let mut ai = 0;
766    let mut bi = 0;
767    while ai < ab.len() && bi < bb.len() {
768        let ac = ab[ai];
769        let bc = bb[bi];
770        if ac.is_ascii_digit() && bc.is_ascii_digit() {
771            // Skip leading zeros
772            let a_start = ai;
773            let b_start = bi;
774            while ai < ab.len() && ab[ai] == b'0' {
775                ai += 1;
776            }
777            while bi < bb.len() && bb[bi] == b'0' {
778                bi += 1;
779            }
780            let a_num_start = ai;
781            let b_num_start = bi;
782            while ai < ab.len() && ab[ai].is_ascii_digit() {
783                ai += 1;
784            }
785            while bi < bb.len() && bb[bi].is_ascii_digit() {
786                bi += 1;
787            }
788            let a_len = ai - a_num_start;
789            let b_len = bi - b_num_start;
790            if a_len != b_len {
791                return a_len.cmp(&b_len);
792            }
793            let ord = ab[a_num_start..ai].cmp(&bb[b_num_start..bi]);
794            if ord != Ordering::Equal {
795                return ord;
796            }
797            // If numeric parts are equal, fewer leading zeros comes first
798            let a_zeros = a_num_start - a_start;
799            let b_zeros = b_num_start - b_start;
800            if a_zeros != b_zeros {
801                return a_zeros.cmp(&b_zeros);
802            }
803        } else {
804            let ord = ac.cmp(&bc);
805            if ord != Ordering::Equal {
806                return ord;
807            }
808            ai += 1;
809            bi += 1;
810        }
811    }
812    ab.len().cmp(&bb.len())
813}
814
815fn sort_entries(entries: &mut [FileEntry], config: &LsConfig) {
816    if config.group_directories_first {
817        // Stable sort: directories first, then sort within each group
818        entries.sort_by(|a, b| {
819            let a_dir = a.is_directory();
820            let b_dir = b.is_directory();
821            match (a_dir, b_dir) {
822                (true, false) => Ordering::Less,
823                (false, true) => Ordering::Greater,
824                _ => compare_entries(a, b, config),
825            }
826        });
827    } else {
828        entries.sort_by(|a, b| compare_entries(a, b, config));
829    }
830}
831
832/// Locale-aware string comparison matching GNU ls behavior.
833/// Uses pre-computed CStrings with `strcoll()` for non-C locales,
834/// or fast byte comparison for C/POSIX locale.
835#[inline]
836fn locale_cmp_cstr(a: &CString, b: &CString) -> Ordering {
837    if IS_C_LOCALE.load(AtomicOrdering::Relaxed) {
838        a.as_bytes().cmp(b.as_bytes())
839    } else {
840        let result = unsafe { libc::strcoll(a.as_ptr(), b.as_ptr()) };
841        result.cmp(&0)
842    }
843}
844
845/// Locale-aware comparison for ad-hoc strings (e.g. directory args).
846fn locale_cmp(a: &str, b: &str) -> Ordering {
847    if IS_C_LOCALE.load(AtomicOrdering::Relaxed) {
848        a.cmp(b)
849    } else {
850        let ca = CString::new(a).unwrap_or_default();
851        let cb = CString::new(b).unwrap_or_default();
852        let result = unsafe { libc::strcoll(ca.as_ptr(), cb.as_ptr()) };
853        result.cmp(&0)
854    }
855}
856
857fn compare_entries(a: &FileEntry, b: &FileEntry, config: &LsConfig) -> Ordering {
858    // Use pre-computed CString sort keys to avoid allocation during sorting.
859    let ord = match config.sort_by {
860        SortBy::Name => locale_cmp_cstr(&a.sort_key, &b.sort_key),
861        SortBy::Size => {
862            let size_ord = b.size.cmp(&a.size);
863            if size_ord == Ordering::Equal {
864                locale_cmp_cstr(&a.sort_key, &b.sort_key)
865            } else {
866                size_ord
867            }
868        }
869        SortBy::Time => {
870            let ta = a.time_secs(config.time_field);
871            let tb = b.time_secs(config.time_field);
872            let ord = tb.cmp(&ta);
873            if ord == Ordering::Equal {
874                let na = a.time_nsec(config.time_field);
875                let nb = b.time_nsec(config.time_field);
876                let nsec_ord = nb.cmp(&na);
877                if nsec_ord == Ordering::Equal {
878                    locale_cmp_cstr(&a.sort_key, &b.sort_key)
879                } else {
880                    nsec_ord
881                }
882            } else {
883                ord
884            }
885        }
886        SortBy::Extension => {
887            let ea = a.extension();
888            let eb = b.extension();
889            let ord = locale_cmp(ea, eb);
890            if ord == Ordering::Equal {
891                locale_cmp_cstr(&a.sort_key, &b.sort_key)
892            } else {
893                ord
894            }
895        }
896        SortBy::Version => version_cmp(&a.name, &b.name),
897        SortBy::None => Ordering::Equal,
898        SortBy::Width => {
899            let wa = a.display_width(config);
900            let wb = b.display_width(config);
901            wa.cmp(&wb)
902        }
903    };
904
905    if config.reverse { ord.reverse() } else { ord }
906}
907
908// ---------------------------------------------------------------------------
909// Permission formatting
910// ---------------------------------------------------------------------------
911
912/// Format permission bits as `drwxr-xr-x` (10 chars).
913pub fn format_permissions(mode: u32) -> String {
914    let mut s = String::with_capacity(10);
915
916    // File type character
917    s.push(match mode & (libc::S_IFMT as u32) {
918        x if x == libc::S_IFDIR as u32 => 'd',
919        x if x == libc::S_IFLNK as u32 => 'l',
920        x if x == libc::S_IFBLK as u32 => 'b',
921        x if x == libc::S_IFCHR as u32 => 'c',
922        x if x == libc::S_IFIFO as u32 => 'p',
923        x if x == libc::S_IFSOCK as u32 => 's',
924        _ => '-',
925    });
926
927    // User
928    s.push(if mode & (libc::S_IRUSR as u32) != 0 {
929        'r'
930    } else {
931        '-'
932    });
933    s.push(if mode & (libc::S_IWUSR as u32) != 0 {
934        'w'
935    } else {
936        '-'
937    });
938    s.push(if mode & (libc::S_ISUID as u32) != 0 {
939        if mode & (libc::S_IXUSR as u32) != 0 {
940            's'
941        } else {
942            'S'
943        }
944    } else if mode & (libc::S_IXUSR as u32) != 0 {
945        'x'
946    } else {
947        '-'
948    });
949
950    // Group
951    s.push(if mode & (libc::S_IRGRP as u32) != 0 {
952        'r'
953    } else {
954        '-'
955    });
956    s.push(if mode & (libc::S_IWGRP as u32) != 0 {
957        'w'
958    } else {
959        '-'
960    });
961    s.push(if mode & (libc::S_ISGID as u32) != 0 {
962        if mode & (libc::S_IXGRP as u32) != 0 {
963            's'
964        } else {
965            'S'
966        }
967    } else if mode & (libc::S_IXGRP as u32) != 0 {
968        'x'
969    } else {
970        '-'
971    });
972
973    // Other
974    s.push(if mode & (libc::S_IROTH as u32) != 0 {
975        'r'
976    } else {
977        '-'
978    });
979    s.push(if mode & (libc::S_IWOTH as u32) != 0 {
980        'w'
981    } else {
982        '-'
983    });
984    s.push(if mode & (libc::S_ISVTX as u32) != 0 {
985        if mode & (libc::S_IXOTH as u32) != 0 {
986            't'
987        } else {
988            'T'
989        }
990    } else if mode & (libc::S_IXOTH as u32) != 0 {
991        'x'
992    } else {
993        '-'
994    });
995
996    s
997}
998
999// ---------------------------------------------------------------------------
1000// Block size parsing (--block-size=SIZE)
1001// ---------------------------------------------------------------------------
1002
1003/// Parse a GNU coreutils --block-size=SIZE value.
1004///
1005/// Supported formats:
1006///   - Plain number: `1024` → (1024, "")
1007///   - Suffix only: `K` → (1024, "K"), `M` → (1048576, "M")
1008///   - Number + suffix: `1K` → (1024, ""), `2M` → (2097152, "")
1009///   - SI suffix (trailing B): `KB` → (1000, "kB"), `MB` → (1000000, "MB")
1010///
1011/// Returns `(block_size_bytes, suffix_string)`.
1012pub fn parse_block_size(s: &str) -> Result<(u64, String), String> {
1013    let s = s.trim();
1014    if s.is_empty() {
1015        return Err("empty block size".to_string());
1016    }
1017
1018    // Try to find where the numeric prefix ends and the suffix begins.
1019    // GNU: the SIZE may be an optional integer followed by one of:
1020    //   K, M, G, T, P, E, Z, Y (powers of 1024)
1021    //   KB, MB, GB, TB, PB, EB, ZB, YB (powers of 1000)
1022    let digit_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
1023
1024    let (num_str, suffix_str) = s.split_at(digit_end);
1025
1026    let suffix_upper = suffix_str.to_uppercase();
1027
1028    // Determine base and multiplier from suffix
1029    let (multiplier, display_suffix) = match suffix_upper.as_str() {
1030        "" => (1u64, String::new()),
1031        "K" => (1024, "K".to_string()),
1032        "M" => (1024 * 1024, "M".to_string()),
1033        "G" => (1024 * 1024 * 1024, "G".to_string()),
1034        "T" => (1024u64 * 1024 * 1024 * 1024, "T".to_string()),
1035        "P" => (1024u64 * 1024 * 1024 * 1024 * 1024, "P".to_string()),
1036        "E" => (1024u64 * 1024 * 1024 * 1024 * 1024 * 1024, "E".to_string()),
1037        "KB" => (1000, "kB".to_string()),
1038        "MB" => (1000 * 1000, "MB".to_string()),
1039        "GB" => (1000 * 1000 * 1000, "GB".to_string()),
1040        "TB" => (1000u64 * 1000 * 1000 * 1000, "TB".to_string()),
1041        "PB" => (1000u64 * 1000 * 1000 * 1000 * 1000, "PB".to_string()),
1042        "EB" => (1000u64 * 1000 * 1000 * 1000 * 1000 * 1000, "EB".to_string()),
1043        _ => {
1044            return Err(format!("invalid suffix in block size '{}'", s));
1045        }
1046    };
1047
1048    let numeric = if num_str.is_empty() {
1049        1u64
1050    } else {
1051        num_str
1052            .parse::<u64>()
1053            .map_err(|_| format!("invalid block size '{}'", s))?
1054    };
1055
1056    let block_size = numeric
1057        .checked_mul(multiplier)
1058        .ok_or_else(|| format!("block size '{}' is too large", s))?;
1059
1060    if block_size == 0 {
1061        return Err(format!("invalid block size '{}'", s));
1062    }
1063
1064    // GNU behavior for suffix display:
1065    // - If a numeric prefix was given (e.g. "1K", "2M"), no suffix is shown.
1066    // - If no numeric prefix (e.g. "K", "M", "KB"), suffix is shown.
1067    let suffix = if num_str.is_empty() {
1068        display_suffix
1069    } else {
1070        String::new()
1071    };
1072
1073    Ok((block_size, suffix))
1074}
1075
1076// ---------------------------------------------------------------------------
1077// Size formatting
1078// ---------------------------------------------------------------------------
1079
1080/// Format a file size for display.
1081pub fn format_size(size: u64, config: &LsConfig) -> String {
1082    // --block-size takes precedence over -h / --si / -k
1083    if let Some(bs) = config.block_size {
1084        let scaled = if bs == 0 { size } else { (size + bs - 1) / bs };
1085        return format!("{}{}", scaled, config.block_size_suffix);
1086    }
1087    if config.human_readable || config.si {
1088        let base: f64 = if config.si { 1000.0 } else { 1024.0 };
1089        let suffixes = ["", "K", "M", "G", "T", "P", "E"];
1090
1091        if size == 0 {
1092            return "0".to_string();
1093        }
1094
1095        let mut val = size as f64;
1096        let mut idx = 0;
1097        while val >= base && idx < suffixes.len() - 1 {
1098            val /= base;
1099            idx += 1;
1100        }
1101
1102        if idx == 0 {
1103            format!("{}", size)
1104        } else if val >= 10.0 {
1105            format!("{:.0}{}", val, suffixes[idx])
1106        } else {
1107            format!("{:.1}{}", val, suffixes[idx])
1108        }
1109    } else if config.kibibytes {
1110        // Show blocks in 1K units
1111        let blocks_k = (size + 1023) / 1024;
1112        format!("{}", blocks_k)
1113    } else {
1114        format!("{}", size)
1115    }
1116}
1117
1118/// Format blocks for the -s option (in 1K units by default, or --si / -h).
1119pub fn format_blocks(blocks_512: u64, config: &LsConfig) -> String {
1120    let bytes = blocks_512 * 512;
1121    if let Some(bs) = config.block_size {
1122        let scaled = if bs == 0 {
1123            bytes
1124        } else {
1125            (bytes + bs - 1) / bs
1126        };
1127        return format!("{}{}", scaled, config.block_size_suffix);
1128    }
1129    if config.human_readable || config.si {
1130        format_size(bytes, config)
1131    } else if config.kibibytes {
1132        let k = (bytes + 1023) / 1024;
1133        format!("{}", k)
1134    } else {
1135        // Default: 1K blocks
1136        let k = (bytes + 1023) / 1024;
1137        format!("{}", k)
1138    }
1139}
1140
1141// ---------------------------------------------------------------------------
1142// Timestamp formatting
1143// ---------------------------------------------------------------------------
1144
1145/// Format a unix timestamp for long listing.
1146pub fn format_time(secs: i64, nsec: i64, style: &TimeStyle) -> String {
1147    // Convert to SystemTime for the six-months-ago check
1148    let now_sys = SystemTime::now();
1149    let now_secs = now_sys
1150        .duration_since(SystemTime::UNIX_EPOCH)
1151        .map(|d| d.as_secs() as i64)
1152        .unwrap_or(0);
1153    let six_months_ago = now_secs - 6 * 30 * 24 * 3600;
1154
1155    // Break down the timestamp
1156    let tm = time_from_epoch(secs);
1157
1158    match style {
1159        TimeStyle::FullIso => {
1160            format!(
1161                "{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:09} {}",
1162                tm.year,
1163                tm.month,
1164                tm.day,
1165                tm.hour,
1166                tm.min,
1167                tm.sec,
1168                nsec,
1169                format_tz_offset(tm.utc_offset_secs)
1170            )
1171        }
1172        TimeStyle::LongIso => {
1173            format!(
1174                "{:04}-{:02}-{:02} {:02}:{:02}",
1175                tm.year, tm.month, tm.day, tm.hour, tm.min
1176            )
1177        }
1178        TimeStyle::Iso => {
1179            if secs > six_months_ago && secs <= now_secs {
1180                format!("{:02}-{:02} {:02}:{:02}", tm.month, tm.day, tm.hour, tm.min)
1181            } else {
1182                format!("{:02}-{:02}  {:04}", tm.month, tm.day, tm.year)
1183            }
1184        }
1185        TimeStyle::Locale | TimeStyle::Custom(_) => {
1186            let month_names = [
1187                "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
1188            ];
1189            let mon = if tm.month >= 1 && tm.month <= 12 {
1190                month_names[(tm.month - 1) as usize]
1191            } else {
1192                "???"
1193            };
1194
1195            if secs > six_months_ago && secs <= now_secs {
1196                format!("{} {:>2} {:02}:{:02}", mon, tm.day, tm.hour, tm.min)
1197            } else {
1198                format!("{} {:>2}  {:04}", mon, tm.day, tm.year)
1199            }
1200        }
1201    }
1202}
1203
1204fn format_tz_offset(offset_secs: i32) -> String {
1205    let sign = if offset_secs >= 0 { '+' } else { '-' };
1206    let abs = offset_secs.unsigned_abs();
1207    let hours = abs / 3600;
1208    let mins = (abs % 3600) / 60;
1209    format!("{}{:02}{:02}", sign, hours, mins)
1210}
1211
1212struct BrokenDownTime {
1213    year: i32,
1214    month: u32,
1215    day: u32,
1216    hour: u32,
1217    min: u32,
1218    sec: u32,
1219    utc_offset_secs: i32,
1220}
1221
1222/// Convert epoch seconds to broken-down local time using libc::localtime_r.
1223fn time_from_epoch(secs: i64) -> BrokenDownTime {
1224    let mut tm: libc::tm = unsafe { std::mem::zeroed() };
1225    let time_t = secs as libc::time_t;
1226    unsafe {
1227        libc::localtime_r(&time_t, &mut tm);
1228    }
1229    BrokenDownTime {
1230        year: tm.tm_year + 1900,
1231        month: (tm.tm_mon + 1) as u32,
1232        day: tm.tm_mday as u32,
1233        hour: tm.tm_hour as u32,
1234        min: tm.tm_min as u32,
1235        sec: tm.tm_sec as u32,
1236        utc_offset_secs: tm.tm_gmtoff as i32,
1237    }
1238}
1239
1240// ---------------------------------------------------------------------------
1241// User/group name lookup
1242// ---------------------------------------------------------------------------
1243
1244/// Look up a username by UID. Returns numeric string on failure.
1245/// Cached user name lookup to avoid repeated getpwuid_r syscalls.
1246fn lookup_user(uid: u32) -> String {
1247    use std::cell::RefCell;
1248    thread_local! {
1249        static CACHE: RefCell<HashMap<u32, String>> = RefCell::new(HashMap::new());
1250    }
1251    CACHE.with(|c| {
1252        let mut cache = c.borrow_mut();
1253        if let Some(name) = cache.get(&uid) {
1254            return name.clone();
1255        }
1256        let name = lookup_user_uncached(uid);
1257        cache.insert(uid, name.clone());
1258        name
1259    })
1260}
1261
1262fn lookup_user_uncached(uid: u32) -> String {
1263    let mut buf = vec![0u8; 1024];
1264    let mut pwd: libc::passwd = unsafe { std::mem::zeroed() };
1265    let mut result: *mut libc::passwd = std::ptr::null_mut();
1266    let ret = unsafe {
1267        libc::getpwuid_r(
1268            uid,
1269            &mut pwd,
1270            buf.as_mut_ptr() as *mut libc::c_char,
1271            buf.len(),
1272            &mut result,
1273        )
1274    };
1275    if ret == 0 && !result.is_null() {
1276        let cstr = unsafe { std::ffi::CStr::from_ptr(pwd.pw_name) };
1277        cstr.to_string_lossy().into_owned()
1278    } else {
1279        uid.to_string()
1280    }
1281}
1282
1283/// Cached group name lookup to avoid repeated getgrgid_r syscalls.
1284fn lookup_group(gid: u32) -> String {
1285    use std::cell::RefCell;
1286    thread_local! {
1287        static CACHE: RefCell<HashMap<u32, String>> = RefCell::new(HashMap::new());
1288    }
1289    CACHE.with(|c| {
1290        let mut cache = c.borrow_mut();
1291        if let Some(name) = cache.get(&gid) {
1292            return name.clone();
1293        }
1294        let name = lookup_group_uncached(gid);
1295        cache.insert(gid, name.clone());
1296        name
1297    })
1298}
1299
1300fn lookup_group_uncached(gid: u32) -> String {
1301    let mut buf = vec![0u8; 1024];
1302    let mut grp: libc::group = unsafe { std::mem::zeroed() };
1303    let mut result: *mut libc::group = std::ptr::null_mut();
1304    let ret = unsafe {
1305        libc::getgrgid_r(
1306            gid,
1307            &mut grp,
1308            buf.as_mut_ptr() as *mut libc::c_char,
1309            buf.len(),
1310            &mut result,
1311        )
1312    };
1313    if ret == 0 && !result.is_null() {
1314        let cstr = unsafe { std::ffi::CStr::from_ptr(grp.gr_name) };
1315        cstr.to_string_lossy().into_owned()
1316    } else {
1317        gid.to_string()
1318    }
1319}
1320
1321// ---------------------------------------------------------------------------
1322// Pattern matching (for --ignore)
1323// ---------------------------------------------------------------------------
1324
1325/// Simple glob matching (supports * and ?).
1326pub fn glob_match(pattern: &str, name: &str) -> bool {
1327    let pat = pattern.as_bytes();
1328    let txt = name.as_bytes();
1329    let mut pi = 0;
1330    let mut ti = 0;
1331    let mut star_p = usize::MAX;
1332    let mut star_t = 0;
1333
1334    while ti < txt.len() {
1335        if pi < pat.len() && (pat[pi] == b'?' || pat[pi] == txt[ti]) {
1336            pi += 1;
1337            ti += 1;
1338        } else if pi < pat.len() && pat[pi] == b'*' {
1339            star_p = pi;
1340            star_t = ti;
1341            pi += 1;
1342        } else if star_p != usize::MAX {
1343            pi = star_p + 1;
1344            star_t += 1;
1345            ti = star_t;
1346        } else {
1347            return false;
1348        }
1349    }
1350    while pi < pat.len() && pat[pi] == b'*' {
1351        pi += 1;
1352    }
1353    pi == pat.len()
1354}
1355
1356fn should_ignore(name: &str, config: &LsConfig) -> bool {
1357    if config.ignore_backups && name.ends_with('~') {
1358        return true;
1359    }
1360    for pat in &config.ignore_patterns {
1361        if glob_match(pat, name) {
1362            return true;
1363        }
1364    }
1365    false
1366}
1367
1368// ---------------------------------------------------------------------------
1369// Reading directory entries
1370// ---------------------------------------------------------------------------
1371
1372/// Read entries from a directory path.
1373pub fn read_entries(path: &Path, config: &LsConfig) -> io::Result<Vec<FileEntry>> {
1374    let mut entries = Vec::new();
1375
1376    // GNU behavior: -A overrides -a (when both are set, -aA means almost_all)
1377    let show_all = config.all && !config.almost_all;
1378    let show_hidden = config.all || config.almost_all;
1379
1380    if show_all {
1381        // Add . and ..
1382        if let Ok(e) = FileEntry::from_path_with_name(".".to_string(), path, config) {
1383            entries.push(e);
1384        }
1385        let parent = path.parent().unwrap_or(path);
1386        if let Ok(e) = FileEntry::from_path_with_name("..".to_string(), parent, config) {
1387            entries.push(e);
1388        }
1389    }
1390
1391    for entry in fs::read_dir(path)? {
1392        let entry = entry?;
1393        let name = entry.file_name().to_string_lossy().into_owned();
1394
1395        // Filter hidden files
1396        if !show_hidden && name.starts_with('.') {
1397            continue;
1398        }
1399
1400        // Filter ignored patterns
1401        if should_ignore(&name, config) {
1402            continue;
1403        }
1404
1405        match FileEntry::from_dir_entry(&entry, config) {
1406            Ok(fe) => entries.push(fe),
1407            Err(e) => {
1408                eprintln!("ls: cannot access '{}': {}", entry.path().display(), e);
1409            }
1410        }
1411    }
1412
1413    Ok(entries)
1414}
1415
1416// ---------------------------------------------------------------------------
1417// Long format output
1418// ---------------------------------------------------------------------------
1419
1420/// Print entries in long format to the writer.
1421fn print_long(
1422    out: &mut impl Write,
1423    entries: &[FileEntry],
1424    config: &LsConfig,
1425    color_db: Option<&ColorDb>,
1426) -> io::Result<()> {
1427    if entries.is_empty() {
1428        return Ok(());
1429    }
1430
1431    // Calculate column widths for alignment
1432    let max_nlink = entries
1433        .iter()
1434        .map(|e| count_digits(e.nlink))
1435        .max()
1436        .unwrap_or(1);
1437    let max_owner = if config.show_owner {
1438        entries
1439            .iter()
1440            .map(|e| {
1441                if config.numeric_ids {
1442                    e.uid.to_string().len()
1443                } else {
1444                    lookup_user(e.uid).len()
1445                }
1446            })
1447            .max()
1448            .unwrap_or(0)
1449    } else {
1450        0
1451    };
1452    let max_group = if config.show_group {
1453        entries
1454            .iter()
1455            .map(|e| {
1456                if config.numeric_ids {
1457                    e.gid.to_string().len()
1458                } else {
1459                    lookup_group(e.gid).len()
1460                }
1461            })
1462            .max()
1463            .unwrap_or(0)
1464    } else {
1465        0
1466    };
1467
1468    // Size width: use the formatted size for human-readable, else raw digits
1469    let has_device = entries.iter().any(|e| {
1470        let ft = e.mode & (libc::S_IFMT as u32);
1471        ft == libc::S_IFBLK as u32 || ft == libc::S_IFCHR as u32
1472    });
1473    let max_size = if has_device {
1474        // For device files, need room for "major, minor"
1475        entries
1476            .iter()
1477            .map(|e| {
1478                let ft = e.mode & (libc::S_IFMT as u32);
1479                if ft == libc::S_IFBLK as u32 || ft == libc::S_IFCHR as u32 {
1480                    format!("{}, {}", e.rdev_major, e.rdev_minor).len()
1481                } else {
1482                    format_size(e.size, config).len()
1483                }
1484            })
1485            .max()
1486            .unwrap_or(1)
1487    } else {
1488        entries
1489            .iter()
1490            .map(|e| format_size(e.size, config).len())
1491            .max()
1492            .unwrap_or(1)
1493    };
1494
1495    let max_inode = if config.show_inode {
1496        entries
1497            .iter()
1498            .map(|e| count_digits(e.ino))
1499            .max()
1500            .unwrap_or(1)
1501    } else {
1502        0
1503    };
1504
1505    let max_blocks = if config.show_size {
1506        entries
1507            .iter()
1508            .map(|e| format_blocks(e.blocks, config).len())
1509            .max()
1510            .unwrap_or(1)
1511    } else {
1512        0
1513    };
1514
1515    // Compute timestamp width from non-broken entries so broken-deref "?" aligns.
1516    let ts_width = entries
1517        .iter()
1518        .filter(|e| !e.is_broken_deref())
1519        .map(|e| {
1520            format_time(
1521                e.time_secs(config.time_field),
1522                e.time_nsec(config.time_field),
1523                &config.time_style,
1524            )
1525            .len()
1526        })
1527        .max()
1528        .unwrap_or(12);
1529
1530    for entry in entries {
1531        // Broken dereference placeholder: show l????????? ? ?<pad> ?<pad> ?<pad> ? name
1532        if entry.is_broken_deref() {
1533            let quoted = quote_name(&entry.name, config);
1534            if config.show_inode {
1535                write!(out, "{:>width$} ", "?", width = max_inode)?;
1536            }
1537            if config.show_size {
1538                write!(out, "{:>width$} ", "?", width = max_blocks)?;
1539            }
1540            write!(out, "l????????? ")?;
1541            write!(out, "{:>width$} ", "?", width = max_nlink)?;
1542            if config.show_owner {
1543                write!(out, "{:<width$} ", "?", width = max_owner)?;
1544            }
1545            if config.show_group {
1546                write!(out, "{:<width$} ", "?", width = max_group)?;
1547            }
1548            write!(out, "{:>width$} ", "?", width = max_size)?;
1549            write!(out, "{:>width$} ", "?", width = ts_width)?;
1550            writeln!(out, "{}", quoted)?;
1551            continue;
1552        }
1553
1554        // Inode
1555        if config.show_inode {
1556            write!(out, "{:>width$} ", entry.ino, width = max_inode)?;
1557        }
1558
1559        // Block size
1560        if config.show_size {
1561            let bs = format_blocks(entry.blocks, config);
1562            write!(out, "{:>width$} ", bs, width = max_blocks)?;
1563        }
1564
1565        // Permissions
1566        write!(out, "{} ", format_permissions(entry.mode))?;
1567
1568        // Hard link count
1569        write!(out, "{:>width$} ", entry.nlink, width = max_nlink)?;
1570
1571        // Owner
1572        if config.show_owner {
1573            let owner = if config.numeric_ids {
1574                entry.uid.to_string()
1575            } else {
1576                lookup_user(entry.uid)
1577            };
1578            write!(out, "{:<width$} ", owner, width = max_owner)?;
1579        }
1580
1581        // Group
1582        if config.show_group {
1583            let group = if config.numeric_ids {
1584                entry.gid.to_string()
1585            } else {
1586                lookup_group(entry.gid)
1587            };
1588            write!(out, "{:<width$} ", group, width = max_group)?;
1589        }
1590
1591        // Size or device numbers
1592        let ft = entry.mode & (libc::S_IFMT as u32);
1593        if ft == libc::S_IFBLK as u32 || ft == libc::S_IFCHR as u32 {
1594            let dev = format!("{}, {}", entry.rdev_major, entry.rdev_minor);
1595            write!(out, "{:>width$} ", dev, width = max_size)?;
1596        } else {
1597            let sz = format_size(entry.size, config);
1598            write!(out, "{:>width$} ", sz, width = max_size)?;
1599        }
1600
1601        // Timestamp
1602        let ts = format_time(
1603            entry.time_secs(config.time_field),
1604            entry.time_nsec(config.time_field),
1605            &config.time_style,
1606        );
1607        write!(out, "{} ", ts)?;
1608
1609        // Name (with colour)
1610        let quoted = quote_name(&entry.name, config);
1611        if let Some(db) = color_db {
1612            let c = db.color_for(entry);
1613            if c.is_empty() {
1614                write!(out, "{}", quoted)?;
1615            } else {
1616                write!(out, "{}{}{}", c, quoted, db.reset)?;
1617            }
1618        } else {
1619            write!(out, "{}", quoted)?;
1620        }
1621
1622        // Indicator — GNU ls does not append '@' to symlinks in long format;
1623        // the 'l' in the permission string already indicates a symlink.
1624        // Instead, the classify indicator goes on the TARGET filename.
1625        let is_symlink = (entry.mode & libc::S_IFMT as u32) == libc::S_IFLNK as u32;
1626        if !is_symlink {
1627            let ind = entry.indicator(config.indicator_style);
1628            if !ind.is_empty() {
1629                write!(out, "{}", ind)?;
1630            }
1631        }
1632
1633        // Symlink target
1634        if let Some(ref target) = entry.link_target {
1635            let quoted_target = quote_name(target, config);
1636            write!(out, " -> {}", quoted_target)?;
1637            // Append classify indicator for the target based on target's mode
1638            if config.indicator_style == IndicatorStyle::Classify
1639                || config.indicator_style == IndicatorStyle::FileType
1640                || config.indicator_style == IndicatorStyle::Slash
1641            {
1642                if let Some(tmode) = entry.link_target_mode {
1643                    let tft = tmode & libc::S_IFMT as u32;
1644                    let target_ind = if tft == libc::S_IFDIR as u32 {
1645                        "/"
1646                    } else if config.indicator_style == IndicatorStyle::Classify
1647                        && tft == libc::S_IFREG as u32
1648                        && tmode
1649                            & (libc::S_IXUSR as u32 | libc::S_IXGRP as u32 | libc::S_IXOTH as u32)
1650                            != 0
1651                    {
1652                        "*"
1653                    } else if (config.indicator_style == IndicatorStyle::Classify
1654                        || config.indicator_style == IndicatorStyle::FileType)
1655                        && tft == libc::S_IFIFO as u32
1656                    {
1657                        "|"
1658                    } else if (config.indicator_style == IndicatorStyle::Classify
1659                        || config.indicator_style == IndicatorStyle::FileType)
1660                        && tft == libc::S_IFSOCK as u32
1661                    {
1662                        "="
1663                    } else {
1664                        ""
1665                    };
1666                    if !target_ind.is_empty() {
1667                        write!(out, "{}", target_ind)?;
1668                    }
1669                }
1670            }
1671        }
1672
1673        if config.zero {
1674            out.write_all(&[0u8])?;
1675        } else {
1676            writeln!(out)?;
1677        }
1678    }
1679
1680    Ok(())
1681}
1682
1683fn count_digits(n: u64) -> usize {
1684    if n == 0 {
1685        return 1;
1686    }
1687    let mut count = 0;
1688    let mut v = n;
1689    while v > 0 {
1690        count += 1;
1691        v /= 10;
1692    }
1693    count
1694}
1695
1696// ---------------------------------------------------------------------------
1697// Column format output
1698// ---------------------------------------------------------------------------
1699
1700/// Write spaces (and optionally tabs) to advance from column `from` to `to`.
1701/// Matches GNU ls `indent()`.
1702fn indent(out: &mut impl Write, from: usize, to: usize, tab: usize) -> io::Result<()> {
1703    let mut pos = from;
1704    while pos < to {
1705        if tab != 0 && to / tab > (pos + 1) / tab {
1706            out.write_all(b"\t")?;
1707            pos += tab - pos % tab;
1708        } else {
1709            out.write_all(b" ")?;
1710            pos += 1;
1711        }
1712    }
1713    Ok(())
1714}
1715
1716/// Write inode/blocks prefix for column output.
1717fn write_entry_prefix(
1718    out: &mut impl Write,
1719    entry: &FileEntry,
1720    config: &LsConfig,
1721    max_inode_w: usize,
1722    max_blocks_w: usize,
1723) -> io::Result<()> {
1724    if config.show_inode {
1725        write!(out, "{:>width$} ", entry.ino, width = max_inode_w)?;
1726    }
1727    if config.show_size {
1728        let bs = format_blocks(entry.blocks, config);
1729        write!(out, "{:>width$} ", bs, width = max_blocks_w)?;
1730    }
1731    Ok(())
1732}
1733
1734/// Write a file name with optional colour.
1735fn write_entry_name(
1736    out: &mut impl Write,
1737    display: &str,
1738    entry: &FileEntry,
1739    config: &LsConfig,
1740    color_db: Option<&ColorDb>,
1741) -> io::Result<()> {
1742    if let Some(db) = color_db {
1743        let c = db.color_for(entry);
1744        let quoted = quote_name(&entry.name, config);
1745        let ind = entry.indicator(config.indicator_style);
1746        if c.is_empty() {
1747            write!(out, "{}{}", quoted, ind)?;
1748        } else {
1749            write!(out, "{}{}{}{}", c, quoted, db.reset, ind)?;
1750        }
1751    } else {
1752        write!(out, "{}", display)?;
1753    }
1754    Ok(())
1755}
1756
1757/// GNU-compatible `print_with_separator`: entries separated by `sep` + space/newline.
1758/// Used for `-w0` (unlimited width, all on one line separated by two spaces)
1759/// and `-m` (comma mode, wrapping at line width).
1760fn print_with_separator(
1761    out: &mut impl Write,
1762    entries: &[FileEntry],
1763    config: &LsConfig,
1764    color_db: Option<&ColorDb>,
1765    sep: u8,
1766    eol: u8,
1767) -> io::Result<()> {
1768    let line_length = config.width;
1769
1770    let max_inode_w = if config.show_inode {
1771        entries
1772            .iter()
1773            .map(|e| count_digits(e.ino))
1774            .max()
1775            .unwrap_or(1)
1776    } else {
1777        0
1778    };
1779    let max_blocks_w = if config.show_size {
1780        entries
1781            .iter()
1782            .map(|e| format_blocks(e.blocks, config).len())
1783            .max()
1784            .unwrap_or(1)
1785    } else {
1786        0
1787    };
1788
1789    let prefix_width = if config.show_inode && config.show_size {
1790        max_inode_w + 1 + max_blocks_w + 1
1791    } else if config.show_inode {
1792        max_inode_w + 1
1793    } else if config.show_size {
1794        max_blocks_w + 1
1795    } else {
1796        0
1797    };
1798
1799    let mut pos: usize = 0;
1800
1801    for (i, entry) in entries.iter().enumerate() {
1802        let quoted = quote_name(&entry.name, config);
1803        let ind = entry.indicator(config.indicator_style);
1804        let len = if line_length > 0 {
1805            quoted.len() + ind.len() + prefix_width
1806        } else {
1807            0
1808        };
1809
1810        if i > 0 {
1811            // GNU: if line_length == 0, never wrap.
1812            // Otherwise check if name + 2 (sep+space) fits on current line.
1813            let fits =
1814                line_length == 0 || (pos + len + 2 < line_length && pos <= usize::MAX - len - 2);
1815            let separator: u8 = if fits { b' ' } else { eol };
1816
1817            out.write_all(&[sep, separator])?;
1818            if fits {
1819                pos += 2;
1820            } else {
1821                pos = 0;
1822            }
1823        }
1824
1825        write_entry_prefix(out, entry, config, max_inode_w, max_blocks_w)?;
1826        if let Some(db) = color_db {
1827            let c = db.color_for(entry);
1828            if c.is_empty() {
1829                write!(out, "{}{}", quoted, ind)?;
1830            } else {
1831                write!(out, "{}{}{}{}", c, quoted, db.reset, ind)?;
1832            }
1833        } else {
1834            write!(out, "{}{}", quoted, ind)?;
1835        }
1836        pos += len;
1837    }
1838    if !entries.is_empty() {
1839        out.write_all(&[eol])?;
1840    }
1841    Ok(())
1842}
1843
1844/// Print entries in multi-column format.
1845fn print_columns(
1846    out: &mut impl Write,
1847    entries: &[FileEntry],
1848    config: &LsConfig,
1849    color_db: Option<&ColorDb>,
1850) -> io::Result<()> {
1851    if entries.is_empty() {
1852        return Ok(());
1853    }
1854
1855    let eol: u8 = if config.zero { 0 } else { b'\n' };
1856
1857    // GNU: when line_length == 0 (-w0), use print_with_separator(' ')
1858    // instead of column layout.  This outputs all entries on one line
1859    // separated by two spaces (sep + separator) with no tab indentation.
1860    if config.width == 0 {
1861        return print_with_separator(out, entries, config, color_db, b' ', eol);
1862    }
1863
1864    let by_columns = config.format == OutputFormat::Columns;
1865    let tab = config.tab_size;
1866    let term_width = config.width;
1867
1868    let max_inode_w = if config.show_inode {
1869        entries
1870            .iter()
1871            .map(|e| count_digits(e.ino))
1872            .max()
1873            .unwrap_or(1)
1874    } else {
1875        0
1876    };
1877    let max_blocks_w = if config.show_size {
1878        entries
1879            .iter()
1880            .map(|e| format_blocks(e.blocks, config).len())
1881            .max()
1882            .unwrap_or(1)
1883    } else {
1884        0
1885    };
1886
1887    let prefix_width = if config.show_inode && config.show_size {
1888        max_inode_w + 1 + max_blocks_w + 1
1889    } else if config.show_inode {
1890        max_inode_w + 1
1891    } else if config.show_size {
1892        max_blocks_w + 1
1893    } else {
1894        0
1895    };
1896
1897    // Pre-compute name display widths (including prefix and indicator)
1898    let items: Vec<(String, usize, &FileEntry)> = entries
1899        .iter()
1900        .map(|e| {
1901            let quoted = quote_name(&e.name, config);
1902            let ind = e.indicator(config.indicator_style);
1903            let display = format!("{}{}", quoted, ind);
1904            let w = display.len() + prefix_width;
1905            (display, w, e)
1906        })
1907        .collect();
1908
1909    let n = items.len();
1910
1911    // GNU algorithm: try every possible number of columns from max down to 1.
1912    // MIN_COLUMN_WIDTH = 3 (1 char name + 2 char gap)
1913    let min_col_w: usize = 3;
1914    let max_possible_cols = if term_width < min_col_w {
1915        1
1916    } else {
1917        let base = term_width / min_col_w;
1918        let extra = if !term_width.is_multiple_of(min_col_w) {
1919            1
1920        } else {
1921            0
1922        };
1923        std::cmp::min(base + extra, n)
1924    };
1925
1926    // For each column count, maintain per-column widths and total line length
1927    let mut col_arrs: Vec<Vec<usize>> = (0..max_possible_cols)
1928        .map(|i| vec![min_col_w; i + 1])
1929        .collect();
1930    let mut line_lens: Vec<usize> = (0..max_possible_cols)
1931        .map(|i| (i + 1) * min_col_w)
1932        .collect();
1933    let mut valid: Vec<bool> = vec![true; max_possible_cols];
1934
1935    for filesno in 0..n {
1936        let name_length = items[filesno].1;
1937
1938        for i in 0..max_possible_cols {
1939            if !valid[i] {
1940                continue;
1941            }
1942            let ncols = i + 1;
1943            let idx = if by_columns {
1944                filesno / ((n + i) / ncols)
1945            } else {
1946                filesno % ncols
1947            };
1948            // Non-last columns get +2 gap
1949            let real_length = name_length + if idx == i { 0 } else { 2 };
1950
1951            if col_arrs[i][idx] < real_length {
1952                line_lens[i] += real_length - col_arrs[i][idx];
1953                col_arrs[i][idx] = real_length;
1954                valid[i] = line_lens[i] < term_width;
1955            }
1956        }
1957    }
1958
1959    // Find the maximum valid column count
1960    let mut num_cols = 1;
1961    for cols in (1..=max_possible_cols).rev() {
1962        if valid[cols - 1] {
1963            num_cols = cols;
1964            break;
1965        }
1966    }
1967
1968    if num_cols <= 1 {
1969        return print_single_column(out, entries, config, color_db);
1970    }
1971
1972    let col_arr = &col_arrs[num_cols - 1];
1973
1974    if by_columns {
1975        // Column-major (-C): entries fill down columns first
1976        let num_rows = (n + num_cols - 1) / num_cols;
1977        for row in 0..num_rows {
1978            let mut pos = 0;
1979            let mut col = 0;
1980            let mut filesno = row;
1981
1982            loop {
1983                let (ref display, w, entry) = items[filesno];
1984                let max_w = col_arr[col];
1985
1986                write_entry_prefix(out, entry, config, max_inode_w, max_blocks_w)?;
1987                write_entry_name(out, display, entry, config, color_db)?;
1988
1989                if n.saturating_sub(num_rows) <= filesno {
1990                    break;
1991                }
1992                filesno += num_rows;
1993
1994                indent(out, pos + w, pos + max_w, tab)?;
1995                pos += max_w;
1996                col += 1;
1997            }
1998            out.write_all(&[eol])?;
1999        }
2000    } else {
2001        // Row-major (-x): entries fill across rows first
2002        let (ref display0, w0, entry0) = items[0];
2003        write_entry_prefix(out, entry0, config, max_inode_w, max_blocks_w)?;
2004        write_entry_name(out, display0, entry0, config, color_db)?;
2005
2006        let mut pos: usize = 0;
2007        let mut prev_w = w0;
2008        let mut prev_max_w = col_arr[0];
2009
2010        for filesno in 1..n {
2011            let col_idx = filesno % num_cols;
2012
2013            if col_idx == 0 {
2014                out.write_all(&[eol])?;
2015                pos = 0;
2016            } else {
2017                indent(out, pos + prev_w, pos + prev_max_w, tab)?;
2018                pos += prev_max_w;
2019            }
2020
2021            let (ref display, w, entry) = items[filesno];
2022            write_entry_prefix(out, entry, config, max_inode_w, max_blocks_w)?;
2023            write_entry_name(out, display, entry, config, color_db)?;
2024
2025            prev_w = w;
2026            prev_max_w = col_arr[col_idx];
2027        }
2028        out.write_all(&[eol])?;
2029    }
2030
2031    Ok(())
2032}
2033
2034// ---------------------------------------------------------------------------
2035// Single column output
2036// ---------------------------------------------------------------------------
2037
2038fn print_single_column(
2039    out: &mut impl Write,
2040    entries: &[FileEntry],
2041    config: &LsConfig,
2042    color_db: Option<&ColorDb>,
2043) -> io::Result<()> {
2044    let max_inode_w = if config.show_inode {
2045        entries
2046            .iter()
2047            .map(|e| count_digits(e.ino))
2048            .max()
2049            .unwrap_or(1)
2050    } else {
2051        0
2052    };
2053    let max_blocks_w = if config.show_size {
2054        entries
2055            .iter()
2056            .map(|e| format_blocks(e.blocks, config).len())
2057            .max()
2058            .unwrap_or(1)
2059    } else {
2060        0
2061    };
2062
2063    for entry in entries {
2064        if config.show_inode {
2065            write!(out, "{:>width$} ", entry.ino, width = max_inode_w)?;
2066        }
2067        if config.show_size {
2068            let bs = format_blocks(entry.blocks, config);
2069            write!(out, "{:>width$} ", bs, width = max_blocks_w)?;
2070        }
2071
2072        let quoted = quote_name(&entry.name, config);
2073        if let Some(db) = color_db {
2074            let c = db.color_for(entry);
2075            if c.is_empty() {
2076                write!(out, "{}", quoted)?;
2077            } else {
2078                write!(out, "{}{}{}", c, quoted, db.reset)?;
2079            }
2080        } else {
2081            write!(out, "{}", quoted)?;
2082        }
2083
2084        let ind = entry.indicator(config.indicator_style);
2085        if !ind.is_empty() {
2086            write!(out, "{}", ind)?;
2087        }
2088
2089        if config.zero {
2090            out.write_all(&[0u8])?;
2091        } else {
2092            writeln!(out)?;
2093        }
2094    }
2095    Ok(())
2096}
2097
2098// ---------------------------------------------------------------------------
2099// Comma-separated output
2100// ---------------------------------------------------------------------------
2101
2102pub fn print_comma(
2103    out: &mut impl Write,
2104    entries: &[FileEntry],
2105    config: &LsConfig,
2106    color_db: Option<&ColorDb>,
2107) -> io::Result<()> {
2108    let eol: u8 = if config.zero { 0 } else { b'\n' };
2109    let line_length = config.width;
2110    let mut pos: usize = 0;
2111
2112    for (i, entry) in entries.iter().enumerate() {
2113        let quoted = quote_name(&entry.name, config);
2114        let ind = entry.indicator(config.indicator_style);
2115        let name_len = if line_length > 0 {
2116            quoted.len() + ind.len()
2117        } else {
2118            0
2119        };
2120
2121        if i > 0 {
2122            // GNU: if line_length == 0, never wrap (no limit).
2123            // Otherwise, check if name + ", " fits on current line.
2124            let fits = line_length == 0
2125                || (pos + name_len + 2 < line_length && pos <= usize::MAX - name_len - 2);
2126            if fits {
2127                write!(out, ", ")?;
2128                pos += 2;
2129            } else {
2130                write!(out, ",")?;
2131                out.write_all(&[eol])?;
2132                pos = 0;
2133            }
2134        }
2135
2136        if let Some(db) = color_db {
2137            let c = db.color_for(entry);
2138            if c.is_empty() {
2139                write!(out, "{}{}", quoted, ind)?;
2140            } else {
2141                write!(out, "{}{}{}{}", c, quoted, db.reset, ind)?;
2142            }
2143        } else {
2144            write!(out, "{}{}", quoted, ind)?;
2145        }
2146        pos += name_len;
2147    }
2148    if !entries.is_empty() {
2149        out.write_all(&[eol])?;
2150    }
2151    Ok(())
2152}
2153
2154// ---------------------------------------------------------------------------
2155// Total blocks line
2156// ---------------------------------------------------------------------------
2157
2158fn print_total(out: &mut impl Write, entries: &[FileEntry], config: &LsConfig) -> io::Result<()> {
2159    let total_blocks: u64 = entries.iter().map(|e| e.blocks).sum();
2160    let formatted = format_blocks(total_blocks, config);
2161    write!(out, "total {}", formatted)?;
2162    if config.zero {
2163        out.write_all(&[0u8])
2164    } else {
2165        writeln!(out)
2166    }
2167}
2168
2169// ---------------------------------------------------------------------------
2170// Main entry point
2171// ---------------------------------------------------------------------------
2172
2173/// List a single directory to the provided writer.
2174pub fn ls_dir(
2175    out: &mut impl Write,
2176    path: &Path,
2177    config: &LsConfig,
2178    color_db: Option<&ColorDb>,
2179    show_header: bool,
2180) -> io::Result<bool> {
2181    if show_header {
2182        writeln!(out, "{}:", path.display())?;
2183    }
2184
2185    let mut entries = read_entries(path, config)?;
2186    sort_entries(&mut entries, config);
2187
2188    // Track if any entries have errors (e.g., broken symlink with -L)
2189    let has_broken_deref = entries.iter().any(|e| e.is_broken_deref());
2190
2191    // Print total in long / show_size modes
2192    if config.long_format || config.show_size {
2193        print_total(out, &entries, config)?;
2194    }
2195
2196    match config.format {
2197        OutputFormat::Long => print_long(out, &entries, config, color_db)?,
2198        OutputFormat::SingleColumn => print_single_column(out, &entries, config, color_db)?,
2199        OutputFormat::Columns | OutputFormat::Across => {
2200            print_columns(out, &entries, config, color_db)?
2201        }
2202        OutputFormat::Comma => print_comma(out, &entries, config, color_db)?,
2203    }
2204
2205    // Recursive
2206    if config.recursive {
2207        let dirs: Vec<PathBuf> = entries
2208            .iter()
2209            .filter(|e| {
2210                e.is_directory()
2211                    && e.name != "."
2212                    && e.name != ".."
2213                    && (e.mode & (libc::S_IFMT as u32)) != libc::S_IFLNK as u32
2214            })
2215            .map(|e| e.path.clone())
2216            .collect();
2217
2218        for dir in dirs {
2219            writeln!(out)?;
2220            ls_dir(out, &dir, config, color_db, true)?;
2221        }
2222    }
2223
2224    Ok(!has_broken_deref)
2225}
2226
2227/// Top-level entry: list the given paths.
2228///
2229/// Returns `true` if all operations succeeded.
2230pub fn ls_main(paths: &[String], config: &LsConfig) -> io::Result<bool> {
2231    let stdout = io::stdout();
2232    let is_tty = atty_stdout();
2233    // For pipes: shrink kernel pipe buffer to 4 KB so our writes block once the
2234    // buffer fills, allowing SIGPIPE to be delivered when the reader closes
2235    // early (e.g. `ls /big-dir | head -5` → exit 141 like GNU ls).
2236    // For TTYs: use a 64 KB BufWriter for performance.
2237    #[cfg(target_os = "linux")]
2238    if !is_tty {
2239        unsafe {
2240            libc::fcntl(1, 1031 /* F_SETPIPE_SZ */, 4096i32)
2241        };
2242    }
2243    let buf_cap = if is_tty { 64 * 1024 } else { 4 * 1024 };
2244    let mut out = BufWriter::with_capacity(buf_cap, stdout.lock());
2245
2246    let color_db = match config.color {
2247        ColorMode::Always => Some(ColorDb::from_env()),
2248        ColorMode::Auto => {
2249            if atty_stdout() {
2250                Some(ColorDb::from_env())
2251            } else {
2252                None
2253            }
2254        }
2255        ColorMode::Never => None,
2256    };
2257
2258    let mut had_error = false;
2259
2260    // Separate files and directories
2261    let mut file_args: Vec<FileEntry> = Vec::new();
2262    let mut dir_args: Vec<PathBuf> = Vec::new();
2263
2264    for p in paths {
2265        let path = PathBuf::from(p);
2266        let meta_result = if config.dereference {
2267            match fs::metadata(&path) {
2268                Ok(m) => Ok(m),
2269                Err(e) => {
2270                    // When -L and metadata fails, check if it's a broken symlink
2271                    if let Ok(lmeta) = fs::symlink_metadata(&path) {
2272                        if lmeta.file_type().is_symlink() {
2273                            // Broken symlink with -L: show error + placeholder entry
2274                            eprintln!(
2275                                "ls: cannot access '{}': {}",
2276                                p,
2277                                crate::common::io_error_msg(&e)
2278                            );
2279                            had_error = true;
2280                            file_args.push(FileEntry::broken_deref(p.to_string(), path));
2281                            continue;
2282                        }
2283                    }
2284                    Err(e)
2285                }
2286            }
2287        } else {
2288            fs::symlink_metadata(&path)
2289        };
2290
2291        match meta_result {
2292            Ok(meta) => {
2293                if config.directory || !meta.is_dir() {
2294                    match FileEntry::from_path_with_name(p.to_string(), &path, config) {
2295                        Ok(fe) => file_args.push(fe),
2296                        Err(e) => {
2297                            eprintln!("ls: cannot access '{}': {}", p, e);
2298                            had_error = true;
2299                        }
2300                    }
2301                } else {
2302                    dir_args.push(path);
2303                }
2304            }
2305            Err(e) => {
2306                eprintln!(
2307                    "ls: cannot access '{}': {}",
2308                    p,
2309                    crate::common::io_error_msg(&e)
2310                );
2311                had_error = true;
2312            }
2313        }
2314    }
2315
2316    // Sort file args
2317    sort_entries(&mut file_args, config);
2318
2319    // Print file arguments
2320    if !file_args.is_empty() {
2321        match config.format {
2322            OutputFormat::Long => print_long(&mut out, &file_args, config, color_db.as_ref())?,
2323            OutputFormat::SingleColumn => {
2324                print_single_column(&mut out, &file_args, config, color_db.as_ref())?
2325            }
2326            OutputFormat::Columns | OutputFormat::Across => {
2327                print_columns(&mut out, &file_args, config, color_db.as_ref())?
2328            }
2329            OutputFormat::Comma => print_comma(&mut out, &file_args, config, color_db.as_ref())?,
2330        }
2331    }
2332
2333    // Sort directory args by name using locale-aware comparison
2334    dir_args.sort_by(|a, b| {
2335        let an = a.to_string_lossy();
2336        let bn = b.to_string_lossy();
2337        let ord = locale_cmp(&an, &bn);
2338        if config.reverse { ord.reverse() } else { ord }
2339    });
2340
2341    let show_header =
2342        dir_args.len() > 1 || (!file_args.is_empty() && !dir_args.is_empty()) || config.recursive;
2343
2344    for (i, dir) in dir_args.iter().enumerate() {
2345        if i > 0 || !file_args.is_empty() {
2346            writeln!(out)?;
2347        }
2348        match ls_dir(&mut out, dir, config, color_db.as_ref(), show_header) {
2349            Ok(true) => {}
2350            Ok(false) => {
2351                had_error = true;
2352            }
2353            Err(e) if e.kind() == io::ErrorKind::BrokenPipe => return Err(e),
2354            Err(e) => {
2355                eprintln!(
2356                    "ls: cannot open directory '{}': {}",
2357                    dir.display(),
2358                    crate::common::io_error_msg(&e)
2359                );
2360                had_error = true;
2361            }
2362        }
2363    }
2364
2365    out.flush()?;
2366
2367    Ok(!had_error)
2368}
2369
2370/// Check if stdout is a TTY.
2371pub fn atty_stdout() -> bool {
2372    unsafe { libc::isatty(1) != 0 }
2373}
2374
2375// ---------------------------------------------------------------------------
2376// Testable helpers (exported for tests module)
2377// ---------------------------------------------------------------------------
2378
2379/// Collect entries for a directory into a Vec (for testing).
2380pub fn collect_entries(path: &Path, config: &LsConfig) -> io::Result<Vec<FileEntry>> {
2381    let mut entries = read_entries(path, config)?;
2382    sort_entries(&mut entries, config);
2383    Ok(entries)
2384}
2385
2386/// Render long format lines to a String (for testing).
2387pub fn render_long(entries: &[FileEntry], config: &LsConfig) -> io::Result<String> {
2388    let mut buf = Vec::new();
2389    print_long(&mut buf, entries, config, None)?;
2390    Ok(String::from_utf8_lossy(&buf).into_owned())
2391}
2392
2393/// Render single-column output to a String (for testing).
2394pub fn render_single_column(entries: &[FileEntry], config: &LsConfig) -> io::Result<String> {
2395    let mut buf = Vec::new();
2396    print_single_column(&mut buf, entries, config, None)?;
2397    Ok(String::from_utf8_lossy(&buf).into_owned())
2398}
2399
2400/// Render full ls_dir output to a String (for testing).
2401pub fn render_dir(path: &Path, config: &LsConfig) -> io::Result<String> {
2402    let mut buf = Vec::new();
2403    ls_dir(&mut buf, path, config, None, false)?;
2404    Ok(String::from_utf8_lossy(&buf).into_owned())
2405}