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}
155
156impl Default for LsConfig {
157    fn default() -> Self {
158        LsConfig {
159            all: false,
160            almost_all: false,
161            long_format: false,
162            human_readable: false,
163            si: false,
164            reverse: false,
165            recursive: false,
166            sort_by: SortBy::Name,
167            format: OutputFormat::Columns,
168            classify: ClassifyMode::Never,
169            color: ColorMode::Auto,
170            group_directories_first: false,
171            show_inode: false,
172            show_size: false,
173            show_owner: true,
174            show_group: true,
175            numeric_ids: false,
176            dereference: false,
177            directory: false,
178            time_field: TimeField::Mtime,
179            time_style: TimeStyle::Locale,
180            ignore_patterns: Vec::new(),
181            ignore_backups: false,
182            width: 80,
183            quoting_style: QuotingStyle::Literal,
184            hide_control_chars: false,
185            kibibytes: false,
186            indicator_style: IndicatorStyle::None,
187            tab_size: 8,
188            hyperlink: HyperlinkMode::Never,
189            context: false,
190            literal: false,
191        }
192    }
193}
194
195// ---------------------------------------------------------------------------
196// Default LS_COLORS
197// ---------------------------------------------------------------------------
198
199/// Parsed colour database.
200#[derive(Debug, Clone)]
201pub struct ColorDb {
202    pub map: HashMap<String, String>,
203    pub dir: String,
204    pub link: String,
205    pub exec: String,
206    pub pipe: String,
207    pub socket: String,
208    pub block_dev: String,
209    pub char_dev: String,
210    pub orphan: String,
211    pub setuid: String,
212    pub setgid: String,
213    pub sticky: String,
214    pub other_writable: String,
215    pub sticky_other_writable: String,
216    pub reset: String,
217}
218
219impl Default for ColorDb {
220    fn default() -> Self {
221        ColorDb {
222            map: HashMap::new(),
223            dir: "\x1b[01;34m".to_string(),            // bold blue
224            link: "\x1b[01;36m".to_string(),           // bold cyan
225            exec: "\x1b[01;32m".to_string(),           // bold green
226            pipe: "\x1b[33m".to_string(),              // yellow
227            socket: "\x1b[01;35m".to_string(),         // bold magenta
228            block_dev: "\x1b[01;33m".to_string(),      // bold yellow
229            char_dev: "\x1b[01;33m".to_string(),       // bold yellow
230            orphan: "\x1b[01;31m".to_string(),         // bold red
231            setuid: "\x1b[37;41m".to_string(),         // white on red
232            setgid: "\x1b[30;43m".to_string(),         // black on yellow
233            sticky: "\x1b[37;44m".to_string(),         // white on blue
234            other_writable: "\x1b[34;42m".to_string(), // blue on green
235            sticky_other_writable: "\x1b[30;42m".to_string(), // black on green
236            reset: "\x1b[0m".to_string(),
237        }
238    }
239}
240
241impl ColorDb {
242    /// Parse from LS_COLORS environment variable.
243    pub fn from_env() -> Self {
244        let mut db = ColorDb::default();
245        if let Ok(val) = std::env::var("LS_COLORS") {
246            for item in val.split(':') {
247                if let Some((key, code)) = item.split_once('=') {
248                    let esc = format!("\x1b[{}m", code);
249                    match key {
250                        "di" => db.dir = esc,
251                        "ln" => db.link = esc,
252                        "ex" => db.exec = esc,
253                        "pi" | "fi" if key == "pi" => db.pipe = esc,
254                        "so" => db.socket = esc,
255                        "bd" => db.block_dev = esc,
256                        "cd" => db.char_dev = esc,
257                        "or" => db.orphan = esc,
258                        "su" => db.setuid = esc,
259                        "sg" => db.setgid = esc,
260                        "st" => db.sticky = esc,
261                        "ow" => db.other_writable = esc,
262                        "tw" => db.sticky_other_writable = esc,
263                        "rs" => db.reset = esc,
264                        _ => {
265                            if key.starts_with('*') {
266                                db.map.insert(key[1..].to_string(), esc);
267                            }
268                        }
269                    }
270                }
271            }
272        }
273        db
274    }
275
276    /// Look up the colour escape for a file entry.
277    fn color_for(&self, entry: &FileEntry) -> &str {
278        let mode = entry.mode;
279        let ft = mode & (libc::S_IFMT as u32);
280
281        // Symlink
282        if ft == libc::S_IFLNK as u32 {
283            if entry.link_target_ok {
284                return &self.link;
285            } else {
286                return &self.orphan;
287            }
288        }
289
290        // Directory with special bits
291        if ft == libc::S_IFDIR as u32 {
292            let sticky = mode & (libc::S_ISVTX as u32) != 0;
293            let ow = mode & (libc::S_IWOTH as u32) != 0;
294            if sticky && ow {
295                return &self.sticky_other_writable;
296            }
297            if ow {
298                return &self.other_writable;
299            }
300            if sticky {
301                return &self.sticky;
302            }
303            return &self.dir;
304        }
305
306        // Special files
307        if ft == libc::S_IFIFO as u32 {
308            return &self.pipe;
309        }
310        if ft == libc::S_IFSOCK as u32 {
311            return &self.socket;
312        }
313        if ft == libc::S_IFBLK as u32 {
314            return &self.block_dev;
315        }
316        if ft == libc::S_IFCHR as u32 {
317            return &self.char_dev;
318        }
319
320        // Setuid / setgid
321        if mode & (libc::S_ISUID as u32) != 0 {
322            return &self.setuid;
323        }
324        if mode & (libc::S_ISGID as u32) != 0 {
325            return &self.setgid;
326        }
327
328        // Extension match
329        if let Some(ext_pos) = entry.name.rfind('.') {
330            let ext = &entry.name[ext_pos..];
331            if let Some(c) = self.map.get(ext) {
332                return c;
333            }
334        }
335
336        // Executable
337        if ft == libc::S_IFREG as u32
338            && mode & (libc::S_IXUSR as u32 | libc::S_IXGRP as u32 | libc::S_IXOTH as u32) != 0
339        {
340            return &self.exec;
341        }
342
343        ""
344    }
345}
346
347// ---------------------------------------------------------------------------
348// File entry
349// ---------------------------------------------------------------------------
350
351/// One entry to display.
352#[derive(Debug, Clone)]
353pub struct FileEntry {
354    pub name: String,
355    pub path: PathBuf,
356    /// Pre-computed CString for locale-aware sorting (avoids allocation in comparator).
357    pub sort_key: CString,
358    pub ino: u64,
359    pub nlink: u64,
360    pub mode: u32,
361    pub uid: u32,
362    pub gid: u32,
363    pub size: u64,
364    pub blocks: u64,
365    pub mtime: i64,
366    pub mtime_nsec: i64,
367    pub atime: i64,
368    pub atime_nsec: i64,
369    pub ctime: i64,
370    pub ctime_nsec: i64,
371    pub rdev_major: u32,
372    pub rdev_minor: u32,
373    pub is_dir: bool,
374    pub link_target: Option<String>,
375    pub link_target_ok: bool,
376}
377
378impl FileEntry {
379    /// Create from a DirEntry.
380    fn from_dir_entry(de: &DirEntry, config: &LsConfig) -> io::Result<Self> {
381        let name = de.file_name().to_string_lossy().into_owned();
382        let path = de.path();
383
384        let meta = if config.dereference {
385            fs::metadata(&path).or_else(|_| fs::symlink_metadata(&path))?
386        } else {
387            fs::symlink_metadata(&path)?
388        };
389
390        Self::from_metadata(name, path, &meta, config)
391    }
392
393    /// Create from a path using the full path as the display name (for -d with
394    /// arguments, or for the `.` and `..` virtual entries).
395    pub fn from_path_with_name(name: String, path: &Path, config: &LsConfig) -> io::Result<Self> {
396        let meta = if config.dereference {
397            fs::metadata(path).or_else(|_| fs::symlink_metadata(path))?
398        } else {
399            fs::symlink_metadata(path)?
400        };
401        Self::from_metadata(name, path.to_path_buf(), &meta, config)
402    }
403
404    fn from_metadata(
405        name: String,
406        path: PathBuf,
407        meta: &Metadata,
408        _config: &LsConfig,
409    ) -> io::Result<Self> {
410        let file_type = meta.file_type();
411        let is_symlink = file_type.is_symlink();
412
413        let (link_target, link_target_ok) = if is_symlink {
414            match fs::read_link(&path) {
415                Ok(target) => {
416                    let ok = fs::metadata(&path).is_ok();
417                    (Some(target.to_string_lossy().into_owned()), ok)
418                }
419                Err(_) => (None, false),
420            }
421        } else {
422            (None, true)
423        };
424
425        let rdev = meta.rdev();
426        let sort_key = CString::new(name.as_str()).unwrap_or_default();
427
428        Ok(FileEntry {
429            name,
430            path,
431            sort_key,
432            ino: meta.ino(),
433            nlink: meta.nlink(),
434            mode: meta.mode(),
435            uid: meta.uid(),
436            gid: meta.gid(),
437            size: meta.size(),
438            blocks: meta.blocks(),
439            mtime: meta.mtime(),
440            mtime_nsec: meta.mtime_nsec(),
441            atime: meta.atime(),
442            atime_nsec: meta.atime_nsec(),
443            ctime: meta.ctime(),
444            ctime_nsec: meta.ctime_nsec(),
445            rdev_major: ((rdev >> 8) & 0xfff) as u32,
446            rdev_minor: (rdev & 0xff) as u32,
447            is_dir: meta.is_dir(),
448            link_target,
449            link_target_ok,
450        })
451    }
452
453    /// Get the timestamp for the chosen time field.
454    fn time_secs(&self, field: TimeField) -> i64 {
455        match field {
456            TimeField::Mtime => self.mtime,
457            TimeField::Atime => self.atime,
458            TimeField::Ctime | TimeField::Birth => self.ctime,
459        }
460    }
461
462    fn time_nsec(&self, field: TimeField) -> i64 {
463        match field {
464            TimeField::Mtime => self.mtime_nsec,
465            TimeField::Atime => self.atime_nsec,
466            TimeField::Ctime | TimeField::Birth => self.ctime_nsec,
467        }
468    }
469
470    /// Return the extension (lowercase) for sorting.
471    fn extension(&self) -> &str {
472        match self.name.rfind('.') {
473            Some(pos) if pos > 0 => &self.name[pos + 1..],
474            _ => "",
475        }
476    }
477
478    /// Is this a directory (or symlink-to-directory when dereferencing)?
479    fn is_directory(&self) -> bool {
480        self.is_dir
481    }
482
483    /// Indicator character for classify.
484    fn indicator(&self, style: IndicatorStyle) -> &'static str {
485        let ft = self.mode & (libc::S_IFMT as u32);
486        match style {
487            IndicatorStyle::None => "",
488            IndicatorStyle::Slash => {
489                if ft == libc::S_IFDIR as u32 {
490                    "/"
491                } else {
492                    ""
493                }
494            }
495            IndicatorStyle::FileType => match ft {
496                x if x == libc::S_IFDIR as u32 => "/",
497                x if x == libc::S_IFLNK as u32 => "@",
498                x if x == libc::S_IFIFO as u32 => "|",
499                x if x == libc::S_IFSOCK as u32 => "=",
500                _ => "",
501            },
502            IndicatorStyle::Classify => match ft {
503                x if x == libc::S_IFDIR as u32 => "/",
504                x if x == libc::S_IFLNK as u32 => "@",
505                x if x == libc::S_IFIFO as u32 => "|",
506                x if x == libc::S_IFSOCK as u32 => "=",
507                _ => {
508                    if ft == libc::S_IFREG as u32
509                        && self.mode
510                            & (libc::S_IXUSR as u32 | libc::S_IXGRP as u32 | libc::S_IXOTH as u32)
511                            != 0
512                    {
513                        "*"
514                    } else {
515                        ""
516                    }
517                }
518            },
519        }
520    }
521
522    /// Display width of the name (accounting for quoting, indicator).
523    fn display_width(&self, config: &LsConfig) -> usize {
524        let quoted = quote_name(&self.name, config);
525        let ind = self.indicator(config.indicator_style);
526        quoted.len() + ind.len()
527    }
528}
529
530// ---------------------------------------------------------------------------
531// Name quoting
532// ---------------------------------------------------------------------------
533
534/// Quote a filename according to the configured quoting style.
535pub fn quote_name(name: &str, config: &LsConfig) -> String {
536    match config.quoting_style {
537        QuotingStyle::Literal => {
538            if config.hide_control_chars {
539                hide_control(name)
540            } else {
541                name.to_string()
542            }
543        }
544        QuotingStyle::Escape => escape_name(name),
545        QuotingStyle::C => c_quote(name),
546        QuotingStyle::Shell => shell_quote(name, false, false),
547        QuotingStyle::ShellAlways => shell_quote(name, true, false),
548        QuotingStyle::ShellEscape => shell_quote(name, false, true),
549        QuotingStyle::ShellEscapeAlways => shell_quote(name, true, true),
550        QuotingStyle::Locale => locale_quote(name),
551    }
552}
553
554fn hide_control(name: &str) -> String {
555    name.chars()
556        .map(|c| if c.is_control() { '?' } else { c })
557        .collect()
558}
559
560fn escape_name(name: &str) -> String {
561    let mut out = String::with_capacity(name.len());
562    for c in name.chars() {
563        match c {
564            '\\' => out.push_str("\\\\"),
565            '\n' => out.push_str("\\n"),
566            '\r' => out.push_str("\\r"),
567            '\t' => out.push_str("\\t"),
568            c if c.is_control() => {
569                out.push_str(&format!("\\{:03o}", c as u32));
570            }
571            c => out.push(c),
572        }
573    }
574    out
575}
576
577fn c_quote(name: &str) -> String {
578    let mut out = String::with_capacity(name.len() + 2);
579    out.push('"');
580    for c in name.chars() {
581        match c {
582            '"' => out.push_str("\\\""),
583            '\\' => out.push_str("\\\\"),
584            '\n' => out.push_str("\\n"),
585            '\r' => out.push_str("\\r"),
586            '\t' => out.push_str("\\t"),
587            '\x07' => out.push_str("\\a"),
588            '\x08' => out.push_str("\\b"),
589            '\x0C' => out.push_str("\\f"),
590            '\x0B' => out.push_str("\\v"),
591            c if c.is_control() => {
592                out.push_str(&format!("\\{:03o}", c as u32));
593            }
594            c => out.push(c),
595        }
596    }
597    out.push('"');
598    out
599}
600
601fn shell_quote(name: &str, always: bool, escape: bool) -> String {
602    let needs_quoting = name.is_empty()
603        || name
604            .chars()
605            .any(|c| " \t\n'\"\\|&;()<>!$`#~{}[]?*".contains(c) || c.is_control());
606
607    if !needs_quoting && !always {
608        return name.to_string();
609    }
610
611    if escape {
612        // Use $'...' form with escape sequences for control chars
613        let has_control = name.chars().any(|c| c.is_control());
614        if has_control {
615            let mut out = String::with_capacity(name.len() + 4);
616            out.push_str("$'");
617            for c in name.chars() {
618                match c {
619                    '\'' => out.push_str("\\'"),
620                    '\\' => out.push_str("\\\\"),
621                    '\n' => out.push_str("\\n"),
622                    '\r' => out.push_str("\\r"),
623                    '\t' => out.push_str("\\t"),
624                    c if c.is_control() => {
625                        out.push_str(&format!("\\{:03o}", c as u32));
626                    }
627                    c => out.push(c),
628                }
629            }
630            out.push('\'');
631            return out;
632        }
633    }
634
635    // Use single quotes
636    let mut out = String::with_capacity(name.len() + 2);
637    out.push('\'');
638    for c in name.chars() {
639        if c == '\'' {
640            out.push_str("'\\''");
641        } else {
642            out.push(c);
643        }
644    }
645    out.push('\'');
646    out
647}
648
649fn locale_quote(name: &str) -> String {
650    // Use \u{2018} and \u{2019} (left/right single quotation marks)
651    let mut out = String::with_capacity(name.len() + 2);
652    out.push('\u{2018}');
653    for c in name.chars() {
654        match c {
655            '\\' => out.push_str("\\\\"),
656            '\n' => out.push_str("\\n"),
657            '\r' => out.push_str("\\r"),
658            '\t' => out.push_str("\\t"),
659            c if c.is_control() => {
660                out.push_str(&format!("\\{:03o}", c as u32));
661            }
662            c => out.push(c),
663        }
664    }
665    out.push('\u{2019}');
666    out
667}
668
669// ---------------------------------------------------------------------------
670// Sorting
671// ---------------------------------------------------------------------------
672
673/// Natural version sort comparison (like GNU `ls -v` / `sort -V`).
674pub(crate) fn version_cmp(a: &str, b: &str) -> Ordering {
675    let ab = a.as_bytes();
676    let bb = b.as_bytes();
677    let mut ai = 0;
678    let mut bi = 0;
679    while ai < ab.len() && bi < bb.len() {
680        let ac = ab[ai];
681        let bc = bb[bi];
682        if ac.is_ascii_digit() && bc.is_ascii_digit() {
683            // Skip leading zeros
684            let a_start = ai;
685            let b_start = bi;
686            while ai < ab.len() && ab[ai] == b'0' {
687                ai += 1;
688            }
689            while bi < bb.len() && bb[bi] == b'0' {
690                bi += 1;
691            }
692            let a_num_start = ai;
693            let b_num_start = bi;
694            while ai < ab.len() && ab[ai].is_ascii_digit() {
695                ai += 1;
696            }
697            while bi < bb.len() && bb[bi].is_ascii_digit() {
698                bi += 1;
699            }
700            let a_len = ai - a_num_start;
701            let b_len = bi - b_num_start;
702            if a_len != b_len {
703                return a_len.cmp(&b_len);
704            }
705            let ord = ab[a_num_start..ai].cmp(&bb[b_num_start..bi]);
706            if ord != Ordering::Equal {
707                return ord;
708            }
709            // If numeric parts are equal, fewer leading zeros comes first
710            let a_zeros = a_num_start - a_start;
711            let b_zeros = b_num_start - b_start;
712            if a_zeros != b_zeros {
713                return a_zeros.cmp(&b_zeros);
714            }
715        } else {
716            let ord = ac.cmp(&bc);
717            if ord != Ordering::Equal {
718                return ord;
719            }
720            ai += 1;
721            bi += 1;
722        }
723    }
724    ab.len().cmp(&bb.len())
725}
726
727fn sort_entries(entries: &mut [FileEntry], config: &LsConfig) {
728    if config.group_directories_first {
729        // Stable sort: directories first, then sort within each group
730        entries.sort_by(|a, b| {
731            let a_dir = a.is_directory();
732            let b_dir = b.is_directory();
733            match (a_dir, b_dir) {
734                (true, false) => Ordering::Less,
735                (false, true) => Ordering::Greater,
736                _ => compare_entries(a, b, config),
737            }
738        });
739    } else {
740        entries.sort_by(|a, b| compare_entries(a, b, config));
741    }
742}
743
744/// Locale-aware string comparison matching GNU ls behavior.
745/// Uses pre-computed CStrings with `strcoll()` for non-C locales,
746/// or fast byte comparison for C/POSIX locale.
747#[inline]
748fn locale_cmp_cstr(a: &CString, b: &CString) -> Ordering {
749    if IS_C_LOCALE.load(AtomicOrdering::Relaxed) {
750        a.as_bytes().cmp(b.as_bytes())
751    } else {
752        let result = unsafe { libc::strcoll(a.as_ptr(), b.as_ptr()) };
753        result.cmp(&0)
754    }
755}
756
757/// Locale-aware comparison for ad-hoc strings (e.g. directory args).
758fn locale_cmp(a: &str, b: &str) -> Ordering {
759    if IS_C_LOCALE.load(AtomicOrdering::Relaxed) {
760        a.cmp(b)
761    } else {
762        let ca = CString::new(a).unwrap_or_default();
763        let cb = CString::new(b).unwrap_or_default();
764        let result = unsafe { libc::strcoll(ca.as_ptr(), cb.as_ptr()) };
765        result.cmp(&0)
766    }
767}
768
769fn compare_entries(a: &FileEntry, b: &FileEntry, config: &LsConfig) -> Ordering {
770    // Use pre-computed CString sort keys to avoid allocation during sorting.
771    let ord = match config.sort_by {
772        SortBy::Name => locale_cmp_cstr(&a.sort_key, &b.sort_key),
773        SortBy::Size => {
774            let size_ord = b.size.cmp(&a.size);
775            if size_ord == Ordering::Equal {
776                locale_cmp_cstr(&a.sort_key, &b.sort_key)
777            } else {
778                size_ord
779            }
780        }
781        SortBy::Time => {
782            let ta = a.time_secs(config.time_field);
783            let tb = b.time_secs(config.time_field);
784            let ord = tb.cmp(&ta);
785            if ord == Ordering::Equal {
786                let na = a.time_nsec(config.time_field);
787                let nb = b.time_nsec(config.time_field);
788                let nsec_ord = nb.cmp(&na);
789                if nsec_ord == Ordering::Equal {
790                    locale_cmp_cstr(&a.sort_key, &b.sort_key)
791                } else {
792                    nsec_ord
793                }
794            } else {
795                ord
796            }
797        }
798        SortBy::Extension => {
799            let ea = a.extension();
800            let eb = b.extension();
801            let ord = locale_cmp(ea, eb);
802            if ord == Ordering::Equal {
803                locale_cmp_cstr(&a.sort_key, &b.sort_key)
804            } else {
805                ord
806            }
807        }
808        SortBy::Version => version_cmp(&a.name, &b.name),
809        SortBy::None => Ordering::Equal,
810        SortBy::Width => {
811            let wa = a.display_width(config);
812            let wb = b.display_width(config);
813            wa.cmp(&wb)
814        }
815    };
816
817    if config.reverse { ord.reverse() } else { ord }
818}
819
820// ---------------------------------------------------------------------------
821// Permission formatting
822// ---------------------------------------------------------------------------
823
824/// Format permission bits as `drwxr-xr-x` (10 chars).
825pub fn format_permissions(mode: u32) -> String {
826    let mut s = String::with_capacity(10);
827
828    // File type character
829    s.push(match mode & (libc::S_IFMT as u32) {
830        x if x == libc::S_IFDIR as u32 => 'd',
831        x if x == libc::S_IFLNK as u32 => 'l',
832        x if x == libc::S_IFBLK as u32 => 'b',
833        x if x == libc::S_IFCHR as u32 => 'c',
834        x if x == libc::S_IFIFO as u32 => 'p',
835        x if x == libc::S_IFSOCK as u32 => 's',
836        _ => '-',
837    });
838
839    // User
840    s.push(if mode & (libc::S_IRUSR as u32) != 0 {
841        'r'
842    } else {
843        '-'
844    });
845    s.push(if mode & (libc::S_IWUSR as u32) != 0 {
846        'w'
847    } else {
848        '-'
849    });
850    s.push(if mode & (libc::S_ISUID as u32) != 0 {
851        if mode & (libc::S_IXUSR as u32) != 0 {
852            's'
853        } else {
854            'S'
855        }
856    } else if mode & (libc::S_IXUSR as u32) != 0 {
857        'x'
858    } else {
859        '-'
860    });
861
862    // Group
863    s.push(if mode & (libc::S_IRGRP as u32) != 0 {
864        'r'
865    } else {
866        '-'
867    });
868    s.push(if mode & (libc::S_IWGRP as u32) != 0 {
869        'w'
870    } else {
871        '-'
872    });
873    s.push(if mode & (libc::S_ISGID as u32) != 0 {
874        if mode & (libc::S_IXGRP as u32) != 0 {
875            's'
876        } else {
877            'S'
878        }
879    } else if mode & (libc::S_IXGRP as u32) != 0 {
880        'x'
881    } else {
882        '-'
883    });
884
885    // Other
886    s.push(if mode & (libc::S_IROTH as u32) != 0 {
887        'r'
888    } else {
889        '-'
890    });
891    s.push(if mode & (libc::S_IWOTH as u32) != 0 {
892        'w'
893    } else {
894        '-'
895    });
896    s.push(if mode & (libc::S_ISVTX as u32) != 0 {
897        if mode & (libc::S_IXOTH as u32) != 0 {
898            't'
899        } else {
900            'T'
901        }
902    } else if mode & (libc::S_IXOTH as u32) != 0 {
903        'x'
904    } else {
905        '-'
906    });
907
908    s
909}
910
911// ---------------------------------------------------------------------------
912// Size formatting
913// ---------------------------------------------------------------------------
914
915/// Format a file size for display.
916pub fn format_size(size: u64, human: bool, si: bool, kibibytes: bool) -> String {
917    if human || si {
918        let base: f64 = if si { 1000.0 } else { 1024.0 };
919        let suffixes = ["", "K", "M", "G", "T", "P", "E"];
920
921        if size == 0 {
922            return "0".to_string();
923        }
924
925        let mut val = size as f64;
926        let mut idx = 0;
927        while val >= base && idx < suffixes.len() - 1 {
928            val /= base;
929            idx += 1;
930        }
931
932        if idx == 0 {
933            format!("{}", size)
934        } else if val >= 10.0 {
935            format!("{:.0}{}", val, suffixes[idx])
936        } else {
937            format!("{:.1}{}", val, suffixes[idx])
938        }
939    } else if kibibytes {
940        // Show blocks in 1K units
941        let blocks_k = (size + 1023) / 1024;
942        format!("{}", blocks_k)
943    } else {
944        format!("{}", size)
945    }
946}
947
948/// Format blocks for the -s option (in 1K units by default, or --si / -h).
949pub fn format_blocks(blocks_512: u64, human: bool, si: bool, kibibytes: bool) -> String {
950    let bytes = blocks_512 * 512;
951    if human || si {
952        format_size(bytes, human, si, false)
953    } else if kibibytes {
954        let k = (bytes + 1023) / 1024;
955        format!("{}", k)
956    } else {
957        // Default: 1K blocks
958        let k = (bytes + 1023) / 1024;
959        format!("{}", k)
960    }
961}
962
963// ---------------------------------------------------------------------------
964// Timestamp formatting
965// ---------------------------------------------------------------------------
966
967/// Format a unix timestamp for long listing.
968pub fn format_time(secs: i64, nsec: i64, style: &TimeStyle) -> String {
969    // Convert to SystemTime for the six-months-ago check
970    let now_sys = SystemTime::now();
971    let now_secs = now_sys
972        .duration_since(SystemTime::UNIX_EPOCH)
973        .map(|d| d.as_secs() as i64)
974        .unwrap_or(0);
975    let six_months_ago = now_secs - 6 * 30 * 24 * 3600;
976
977    // Break down the timestamp
978    let tm = time_from_epoch(secs);
979
980    match style {
981        TimeStyle::FullIso => {
982            format!(
983                "{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:09} {}",
984                tm.year,
985                tm.month,
986                tm.day,
987                tm.hour,
988                tm.min,
989                tm.sec,
990                nsec,
991                format_tz_offset(tm.utc_offset_secs)
992            )
993        }
994        TimeStyle::LongIso => {
995            format!(
996                "{:04}-{:02}-{:02} {:02}:{:02}",
997                tm.year, tm.month, tm.day, tm.hour, tm.min
998            )
999        }
1000        TimeStyle::Iso => {
1001            if secs > six_months_ago && secs <= now_secs {
1002                format!("{:02}-{:02} {:02}:{:02}", tm.month, tm.day, tm.hour, tm.min)
1003            } else {
1004                format!("{:02}-{:02}  {:04}", tm.month, tm.day, tm.year)
1005            }
1006        }
1007        TimeStyle::Locale | TimeStyle::Custom(_) => {
1008            let month_names = [
1009                "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
1010            ];
1011            let mon = if tm.month >= 1 && tm.month <= 12 {
1012                month_names[(tm.month - 1) as usize]
1013            } else {
1014                "???"
1015            };
1016
1017            if secs > six_months_ago && secs <= now_secs {
1018                format!("{} {:>2} {:02}:{:02}", mon, tm.day, tm.hour, tm.min)
1019            } else {
1020                format!("{} {:>2}  {:04}", mon, tm.day, tm.year)
1021            }
1022        }
1023    }
1024}
1025
1026fn format_tz_offset(offset_secs: i32) -> String {
1027    let sign = if offset_secs >= 0 { '+' } else { '-' };
1028    let abs = offset_secs.unsigned_abs();
1029    let hours = abs / 3600;
1030    let mins = (abs % 3600) / 60;
1031    format!("{}{:02}{:02}", sign, hours, mins)
1032}
1033
1034struct BrokenDownTime {
1035    year: i32,
1036    month: u32,
1037    day: u32,
1038    hour: u32,
1039    min: u32,
1040    sec: u32,
1041    utc_offset_secs: i32,
1042}
1043
1044/// Convert epoch seconds to broken-down local time using libc::localtime_r.
1045fn time_from_epoch(secs: i64) -> BrokenDownTime {
1046    let mut tm: libc::tm = unsafe { std::mem::zeroed() };
1047    let time_t = secs as libc::time_t;
1048    unsafe {
1049        libc::localtime_r(&time_t, &mut tm);
1050    }
1051    BrokenDownTime {
1052        year: tm.tm_year + 1900,
1053        month: (tm.tm_mon + 1) as u32,
1054        day: tm.tm_mday as u32,
1055        hour: tm.tm_hour as u32,
1056        min: tm.tm_min as u32,
1057        sec: tm.tm_sec as u32,
1058        utc_offset_secs: tm.tm_gmtoff as i32,
1059    }
1060}
1061
1062// ---------------------------------------------------------------------------
1063// User/group name lookup
1064// ---------------------------------------------------------------------------
1065
1066/// Look up a username by UID. Returns numeric string on failure.
1067/// Cached user name lookup to avoid repeated getpwuid_r syscalls.
1068fn lookup_user(uid: u32) -> String {
1069    use std::cell::RefCell;
1070    thread_local! {
1071        static CACHE: RefCell<HashMap<u32, String>> = RefCell::new(HashMap::new());
1072    }
1073    CACHE.with(|c| {
1074        let mut cache = c.borrow_mut();
1075        if let Some(name) = cache.get(&uid) {
1076            return name.clone();
1077        }
1078        let name = lookup_user_uncached(uid);
1079        cache.insert(uid, name.clone());
1080        name
1081    })
1082}
1083
1084fn lookup_user_uncached(uid: u32) -> String {
1085    let mut buf = vec![0u8; 1024];
1086    let mut pwd: libc::passwd = unsafe { std::mem::zeroed() };
1087    let mut result: *mut libc::passwd = std::ptr::null_mut();
1088    let ret = unsafe {
1089        libc::getpwuid_r(
1090            uid,
1091            &mut pwd,
1092            buf.as_mut_ptr() as *mut libc::c_char,
1093            buf.len(),
1094            &mut result,
1095        )
1096    };
1097    if ret == 0 && !result.is_null() {
1098        let cstr = unsafe { std::ffi::CStr::from_ptr(pwd.pw_name) };
1099        cstr.to_string_lossy().into_owned()
1100    } else {
1101        uid.to_string()
1102    }
1103}
1104
1105/// Cached group name lookup to avoid repeated getgrgid_r syscalls.
1106fn lookup_group(gid: u32) -> String {
1107    use std::cell::RefCell;
1108    thread_local! {
1109        static CACHE: RefCell<HashMap<u32, String>> = RefCell::new(HashMap::new());
1110    }
1111    CACHE.with(|c| {
1112        let mut cache = c.borrow_mut();
1113        if let Some(name) = cache.get(&gid) {
1114            return name.clone();
1115        }
1116        let name = lookup_group_uncached(gid);
1117        cache.insert(gid, name.clone());
1118        name
1119    })
1120}
1121
1122fn lookup_group_uncached(gid: u32) -> String {
1123    let mut buf = vec![0u8; 1024];
1124    let mut grp: libc::group = unsafe { std::mem::zeroed() };
1125    let mut result: *mut libc::group = std::ptr::null_mut();
1126    let ret = unsafe {
1127        libc::getgrgid_r(
1128            gid,
1129            &mut grp,
1130            buf.as_mut_ptr() as *mut libc::c_char,
1131            buf.len(),
1132            &mut result,
1133        )
1134    };
1135    if ret == 0 && !result.is_null() {
1136        let cstr = unsafe { std::ffi::CStr::from_ptr(grp.gr_name) };
1137        cstr.to_string_lossy().into_owned()
1138    } else {
1139        gid.to_string()
1140    }
1141}
1142
1143// ---------------------------------------------------------------------------
1144// Pattern matching (for --ignore)
1145// ---------------------------------------------------------------------------
1146
1147/// Simple glob matching (supports * and ?).
1148pub fn glob_match(pattern: &str, name: &str) -> bool {
1149    let pat = pattern.as_bytes();
1150    let txt = name.as_bytes();
1151    let mut pi = 0;
1152    let mut ti = 0;
1153    let mut star_p = usize::MAX;
1154    let mut star_t = 0;
1155
1156    while ti < txt.len() {
1157        if pi < pat.len() && (pat[pi] == b'?' || pat[pi] == txt[ti]) {
1158            pi += 1;
1159            ti += 1;
1160        } else if pi < pat.len() && pat[pi] == b'*' {
1161            star_p = pi;
1162            star_t = ti;
1163            pi += 1;
1164        } else if star_p != usize::MAX {
1165            pi = star_p + 1;
1166            star_t += 1;
1167            ti = star_t;
1168        } else {
1169            return false;
1170        }
1171    }
1172    while pi < pat.len() && pat[pi] == b'*' {
1173        pi += 1;
1174    }
1175    pi == pat.len()
1176}
1177
1178fn should_ignore(name: &str, config: &LsConfig) -> bool {
1179    if config.ignore_backups && name.ends_with('~') {
1180        return true;
1181    }
1182    for pat in &config.ignore_patterns {
1183        if glob_match(pat, name) {
1184            return true;
1185        }
1186    }
1187    false
1188}
1189
1190// ---------------------------------------------------------------------------
1191// Reading directory entries
1192// ---------------------------------------------------------------------------
1193
1194/// Read entries from a directory path.
1195pub fn read_entries(path: &Path, config: &LsConfig) -> io::Result<Vec<FileEntry>> {
1196    let mut entries = Vec::new();
1197
1198    if config.all {
1199        // Add . and ..
1200        if let Ok(e) = FileEntry::from_path_with_name(".".to_string(), path, config) {
1201            entries.push(e);
1202        }
1203        let parent = path.parent().unwrap_or(path);
1204        if let Ok(e) = FileEntry::from_path_with_name("..".to_string(), parent, config) {
1205            entries.push(e);
1206        }
1207    }
1208
1209    for entry in fs::read_dir(path)? {
1210        let entry = entry?;
1211        let name = entry.file_name().to_string_lossy().into_owned();
1212
1213        // Filter hidden files
1214        if !config.all && !config.almost_all && name.starts_with('.') {
1215            continue;
1216        }
1217        if config.almost_all && (name == "." || name == "..") {
1218            continue;
1219        }
1220
1221        // Filter ignored patterns
1222        if should_ignore(&name, config) {
1223            continue;
1224        }
1225
1226        match FileEntry::from_dir_entry(&entry, config) {
1227            Ok(fe) => entries.push(fe),
1228            Err(e) => {
1229                eprintln!("ls: cannot access '{}': {}", entry.path().display(), e);
1230            }
1231        }
1232    }
1233
1234    Ok(entries)
1235}
1236
1237// ---------------------------------------------------------------------------
1238// Long format output
1239// ---------------------------------------------------------------------------
1240
1241/// Print entries in long format to the writer.
1242fn print_long(
1243    out: &mut impl Write,
1244    entries: &[FileEntry],
1245    config: &LsConfig,
1246    color_db: Option<&ColorDb>,
1247) -> io::Result<()> {
1248    if entries.is_empty() {
1249        return Ok(());
1250    }
1251
1252    // Calculate column widths for alignment
1253    let max_nlink = entries
1254        .iter()
1255        .map(|e| count_digits(e.nlink))
1256        .max()
1257        .unwrap_or(1);
1258    let max_owner = if config.show_owner {
1259        entries
1260            .iter()
1261            .map(|e| {
1262                if config.numeric_ids {
1263                    e.uid.to_string().len()
1264                } else {
1265                    lookup_user(e.uid).len()
1266                }
1267            })
1268            .max()
1269            .unwrap_or(0)
1270    } else {
1271        0
1272    };
1273    let max_group = if config.show_group {
1274        entries
1275            .iter()
1276            .map(|e| {
1277                if config.numeric_ids {
1278                    e.gid.to_string().len()
1279                } else {
1280                    lookup_group(e.gid).len()
1281                }
1282            })
1283            .max()
1284            .unwrap_or(0)
1285    } else {
1286        0
1287    };
1288
1289    // Size width: use the formatted size for human-readable, else raw digits
1290    let has_device = entries.iter().any(|e| {
1291        let ft = e.mode & (libc::S_IFMT as u32);
1292        ft == libc::S_IFBLK as u32 || ft == libc::S_IFCHR as u32
1293    });
1294    let max_size = if has_device {
1295        // For device files, need room for "major, minor"
1296        entries
1297            .iter()
1298            .map(|e| {
1299                let ft = e.mode & (libc::S_IFMT as u32);
1300                if ft == libc::S_IFBLK as u32 || ft == libc::S_IFCHR as u32 {
1301                    format!("{}, {}", e.rdev_major, e.rdev_minor).len()
1302                } else {
1303                    format_size(e.size, config.human_readable, config.si, config.kibibytes).len()
1304                }
1305            })
1306            .max()
1307            .unwrap_or(1)
1308    } else {
1309        entries
1310            .iter()
1311            .map(|e| format_size(e.size, config.human_readable, config.si, config.kibibytes).len())
1312            .max()
1313            .unwrap_or(1)
1314    };
1315
1316    let max_inode = if config.show_inode {
1317        entries
1318            .iter()
1319            .map(|e| count_digits(e.ino))
1320            .max()
1321            .unwrap_or(1)
1322    } else {
1323        0
1324    };
1325
1326    let max_blocks = if config.show_size {
1327        entries
1328            .iter()
1329            .map(|e| {
1330                format_blocks(e.blocks, config.human_readable, config.si, config.kibibytes).len()
1331            })
1332            .max()
1333            .unwrap_or(1)
1334    } else {
1335        0
1336    };
1337
1338    for entry in entries {
1339        // Inode
1340        if config.show_inode {
1341            write!(out, "{:>width$} ", entry.ino, width = max_inode)?;
1342        }
1343
1344        // Block size
1345        if config.show_size {
1346            let bs = format_blocks(
1347                entry.blocks,
1348                config.human_readable,
1349                config.si,
1350                config.kibibytes,
1351            );
1352            write!(out, "{:>width$} ", bs, width = max_blocks)?;
1353        }
1354
1355        // Permissions
1356        write!(out, "{} ", format_permissions(entry.mode))?;
1357
1358        // Hard link count
1359        write!(out, "{:>width$} ", entry.nlink, width = max_nlink)?;
1360
1361        // Owner
1362        if config.show_owner {
1363            let owner = if config.numeric_ids {
1364                entry.uid.to_string()
1365            } else {
1366                lookup_user(entry.uid)
1367            };
1368            write!(out, "{:<width$} ", owner, width = max_owner)?;
1369        }
1370
1371        // Group
1372        if config.show_group {
1373            let group = if config.numeric_ids {
1374                entry.gid.to_string()
1375            } else {
1376                lookup_group(entry.gid)
1377            };
1378            write!(out, "{:<width$} ", group, width = max_group)?;
1379        }
1380
1381        // Size or device numbers
1382        let ft = entry.mode & (libc::S_IFMT as u32);
1383        if ft == libc::S_IFBLK as u32 || ft == libc::S_IFCHR as u32 {
1384            let dev = format!("{}, {}", entry.rdev_major, entry.rdev_minor);
1385            write!(out, "{:>width$} ", dev, width = max_size)?;
1386        } else {
1387            let sz = format_size(
1388                entry.size,
1389                config.human_readable,
1390                config.si,
1391                config.kibibytes,
1392            );
1393            write!(out, "{:>width$} ", sz, width = max_size)?;
1394        }
1395
1396        // Timestamp
1397        let ts = format_time(
1398            entry.time_secs(config.time_field),
1399            entry.time_nsec(config.time_field),
1400            &config.time_style,
1401        );
1402        write!(out, "{} ", ts)?;
1403
1404        // Name (with colour)
1405        let quoted = quote_name(&entry.name, config);
1406        if let Some(db) = color_db {
1407            let c = db.color_for(entry);
1408            if c.is_empty() {
1409                write!(out, "{}", quoted)?;
1410            } else {
1411                write!(out, "{}{}{}", c, quoted, db.reset)?;
1412            }
1413        } else {
1414            write!(out, "{}", quoted)?;
1415        }
1416
1417        // Indicator
1418        let ind = entry.indicator(config.indicator_style);
1419        if !ind.is_empty() {
1420            write!(out, "{}", ind)?;
1421        }
1422
1423        // Symlink target
1424        if let Some(ref target) = entry.link_target {
1425            write!(out, " -> {}", target)?;
1426        }
1427
1428        writeln!(out)?;
1429    }
1430
1431    Ok(())
1432}
1433
1434fn count_digits(n: u64) -> usize {
1435    if n == 0 {
1436        return 1;
1437    }
1438    let mut count = 0;
1439    let mut v = n;
1440    while v > 0 {
1441        count += 1;
1442        v /= 10;
1443    }
1444    count
1445}
1446
1447// ---------------------------------------------------------------------------
1448// Column format output
1449// ---------------------------------------------------------------------------
1450
1451/// Print entries in multi-column format.
1452fn print_columns(
1453    out: &mut impl Write,
1454    entries: &[FileEntry],
1455    config: &LsConfig,
1456    color_db: Option<&ColorDb>,
1457) -> io::Result<()> {
1458    if entries.is_empty() {
1459        return Ok(());
1460    }
1461
1462    let term_width = config.width;
1463    let tab = config.tab_size;
1464
1465    // Collect display names and their display widths
1466    let items: Vec<(String, usize, &FileEntry)> = entries
1467        .iter()
1468        .map(|e| {
1469            let quoted = quote_name(&e.name, config);
1470            let ind = e.indicator(config.indicator_style);
1471            let display = format!("{}{}", quoted, ind);
1472            let w = display.len();
1473            (display, w, e)
1474        })
1475        .collect();
1476
1477    let max_inode_w = if config.show_inode {
1478        entries
1479            .iter()
1480            .map(|e| count_digits(e.ino))
1481            .max()
1482            .unwrap_or(1)
1483    } else {
1484        0
1485    };
1486    let max_blocks_w = if config.show_size {
1487        entries
1488            .iter()
1489            .map(|e| {
1490                format_blocks(e.blocks, config.human_readable, config.si, config.kibibytes).len()
1491            })
1492            .max()
1493            .unwrap_or(1)
1494    } else {
1495        0
1496    };
1497
1498    let prefix_width = if config.show_inode && config.show_size {
1499        max_inode_w + 1 + max_blocks_w + 1
1500    } else if config.show_inode {
1501        max_inode_w + 1
1502    } else if config.show_size {
1503        max_blocks_w + 1
1504    } else {
1505        0
1506    };
1507
1508    // Try to fit into columns
1509    let n = items.len();
1510    let max_name_width = items.iter().map(|(_, w, _)| *w).max().unwrap_or(0);
1511    let col_width_raw = max_name_width + prefix_width;
1512
1513    // Round up to next tab stop
1514    let col_width = if tab > 0 {
1515        ((col_width_raw + tab) / tab) * tab
1516    } else {
1517        col_width_raw + 2
1518    };
1519
1520    if col_width == 0 || col_width >= term_width {
1521        // Fall back to single column
1522        return print_single_column(out, entries, config, color_db);
1523    }
1524
1525    let num_cols = std::cmp::max(1, term_width / col_width);
1526    let num_rows = (n + num_cols - 1) / num_cols;
1527
1528    for row in 0..num_rows {
1529        let mut col = 0;
1530        loop {
1531            let idx = col * num_rows + row;
1532            if idx >= n {
1533                break;
1534            }
1535
1536            let (ref display, w, entry) = items[idx];
1537            let is_last_col = col + 1 >= num_cols || (col + 1) * num_rows + row >= n;
1538
1539            // inode prefix
1540            if config.show_inode {
1541                write!(out, "{:>width$} ", entry.ino, width = max_inode_w)?;
1542            }
1543            // blocks prefix
1544            if config.show_size {
1545                let bs = format_blocks(
1546                    entry.blocks,
1547                    config.human_readable,
1548                    config.si,
1549                    config.kibibytes,
1550                );
1551                write!(out, "{:>width$} ", bs, width = max_blocks_w)?;
1552            }
1553
1554            // Name with colour
1555            if let Some(db) = color_db {
1556                let c = db.color_for(entry);
1557                let quoted = quote_name(&entry.name, config);
1558                let ind = entry.indicator(config.indicator_style);
1559                if c.is_empty() {
1560                    write!(out, "{}{}", quoted, ind)?;
1561                } else {
1562                    write!(out, "{}{}{}{}", c, quoted, db.reset, ind)?;
1563                }
1564            } else {
1565                write!(out, "{}", display)?;
1566            }
1567
1568            if !is_last_col {
1569                // Pad to column width
1570                let name_w = w + prefix_width;
1571                let padding = if col_width > name_w {
1572                    col_width - name_w
1573                } else {
1574                    2
1575                };
1576                for _ in 0..padding {
1577                    write!(out, " ")?;
1578                }
1579            }
1580
1581            col += 1;
1582        }
1583        writeln!(out)?;
1584    }
1585
1586    Ok(())
1587}
1588
1589// ---------------------------------------------------------------------------
1590// Single column output
1591// ---------------------------------------------------------------------------
1592
1593fn print_single_column(
1594    out: &mut impl Write,
1595    entries: &[FileEntry],
1596    config: &LsConfig,
1597    color_db: Option<&ColorDb>,
1598) -> io::Result<()> {
1599    let max_inode_w = if config.show_inode {
1600        entries
1601            .iter()
1602            .map(|e| count_digits(e.ino))
1603            .max()
1604            .unwrap_or(1)
1605    } else {
1606        0
1607    };
1608    let max_blocks_w = if config.show_size {
1609        entries
1610            .iter()
1611            .map(|e| {
1612                format_blocks(e.blocks, config.human_readable, config.si, config.kibibytes).len()
1613            })
1614            .max()
1615            .unwrap_or(1)
1616    } else {
1617        0
1618    };
1619
1620    for entry in entries {
1621        if config.show_inode {
1622            write!(out, "{:>width$} ", entry.ino, width = max_inode_w)?;
1623        }
1624        if config.show_size {
1625            let bs = format_blocks(
1626                entry.blocks,
1627                config.human_readable,
1628                config.si,
1629                config.kibibytes,
1630            );
1631            write!(out, "{:>width$} ", bs, width = max_blocks_w)?;
1632        }
1633
1634        let quoted = quote_name(&entry.name, config);
1635        if let Some(db) = color_db {
1636            let c = db.color_for(entry);
1637            if c.is_empty() {
1638                write!(out, "{}", quoted)?;
1639            } else {
1640                write!(out, "{}{}{}", c, quoted, db.reset)?;
1641            }
1642        } else {
1643            write!(out, "{}", quoted)?;
1644        }
1645
1646        let ind = entry.indicator(config.indicator_style);
1647        if !ind.is_empty() {
1648            write!(out, "{}", ind)?;
1649        }
1650
1651        writeln!(out)?;
1652    }
1653    Ok(())
1654}
1655
1656// ---------------------------------------------------------------------------
1657// Comma-separated output
1658// ---------------------------------------------------------------------------
1659
1660pub fn print_comma(
1661    out: &mut impl Write,
1662    entries: &[FileEntry],
1663    config: &LsConfig,
1664    color_db: Option<&ColorDb>,
1665) -> io::Result<()> {
1666    for (i, entry) in entries.iter().enumerate() {
1667        if i > 0 {
1668            write!(out, ", ")?;
1669        }
1670        let quoted = quote_name(&entry.name, config);
1671        if let Some(db) = color_db {
1672            let c = db.color_for(entry);
1673            if c.is_empty() {
1674                write!(out, "{}", quoted)?;
1675            } else {
1676                write!(out, "{}{}{}", c, quoted, db.reset)?;
1677            }
1678        } else {
1679            write!(out, "{}", quoted)?;
1680        }
1681        let ind = entry.indicator(config.indicator_style);
1682        if !ind.is_empty() {
1683            write!(out, "{}", ind)?;
1684        }
1685    }
1686    if !entries.is_empty() {
1687        writeln!(out)?;
1688    }
1689    Ok(())
1690}
1691
1692// ---------------------------------------------------------------------------
1693// Total blocks line
1694// ---------------------------------------------------------------------------
1695
1696fn print_total(out: &mut impl Write, entries: &[FileEntry], config: &LsConfig) -> io::Result<()> {
1697    let total_blocks: u64 = entries.iter().map(|e| e.blocks).sum();
1698    let formatted = format_blocks(
1699        total_blocks,
1700        config.human_readable,
1701        config.si,
1702        config.kibibytes,
1703    );
1704    writeln!(out, "total {}", formatted)
1705}
1706
1707// ---------------------------------------------------------------------------
1708// Main entry point
1709// ---------------------------------------------------------------------------
1710
1711/// List a single directory to the provided writer.
1712pub fn ls_dir(
1713    out: &mut impl Write,
1714    path: &Path,
1715    config: &LsConfig,
1716    color_db: Option<&ColorDb>,
1717    show_header: bool,
1718) -> io::Result<bool> {
1719    if show_header {
1720        writeln!(out, "{}:", path.display())?;
1721    }
1722
1723    let mut entries = read_entries(path, config)?;
1724    sort_entries(&mut entries, config);
1725
1726    // Print total in long / show_size modes
1727    if config.long_format || config.show_size {
1728        print_total(out, &entries, config)?;
1729    }
1730
1731    match config.format {
1732        OutputFormat::Long => print_long(out, &entries, config, color_db)?,
1733        OutputFormat::SingleColumn => print_single_column(out, &entries, config, color_db)?,
1734        OutputFormat::Columns | OutputFormat::Across => {
1735            print_columns(out, &entries, config, color_db)?
1736        }
1737        OutputFormat::Comma => print_comma(out, &entries, config, color_db)?,
1738    }
1739
1740    // Recursive
1741    if config.recursive {
1742        let dirs: Vec<PathBuf> = entries
1743            .iter()
1744            .filter(|e| {
1745                e.is_directory()
1746                    && e.name != "."
1747                    && e.name != ".."
1748                    && (e.mode & (libc::S_IFMT as u32)) != libc::S_IFLNK as u32
1749            })
1750            .map(|e| e.path.clone())
1751            .collect();
1752
1753        for dir in dirs {
1754            writeln!(out)?;
1755            ls_dir(out, &dir, config, color_db, true)?;
1756        }
1757    }
1758
1759    Ok(true)
1760}
1761
1762/// Top-level entry: list the given paths.
1763///
1764/// Returns `true` if all operations succeeded.
1765pub fn ls_main(paths: &[String], config: &LsConfig) -> io::Result<bool> {
1766    let stdout = io::stdout();
1767    let is_tty = atty_stdout();
1768    // For pipes: shrink kernel pipe buffer to 4 KB so our writes block once the
1769    // buffer fills, allowing SIGPIPE to be delivered when the reader closes
1770    // early (e.g. `ls /big-dir | head -5` → exit 141 like GNU ls).
1771    // For TTYs: use a 64 KB BufWriter for performance.
1772    #[cfg(target_os = "linux")]
1773    if !is_tty {
1774        unsafe {
1775            libc::fcntl(1, 1031 /* F_SETPIPE_SZ */, 4096i32)
1776        };
1777    }
1778    let buf_cap = if is_tty { 64 * 1024 } else { 4 * 1024 };
1779    let mut out = BufWriter::with_capacity(buf_cap, stdout.lock());
1780
1781    let color_db = match config.color {
1782        ColorMode::Always => Some(ColorDb::from_env()),
1783        ColorMode::Auto => {
1784            if atty_stdout() {
1785                Some(ColorDb::from_env())
1786            } else {
1787                None
1788            }
1789        }
1790        ColorMode::Never => None,
1791    };
1792
1793    let mut had_error = false;
1794
1795    // Separate files and directories
1796    let mut file_args: Vec<FileEntry> = Vec::new();
1797    let mut dir_args: Vec<PathBuf> = Vec::new();
1798
1799    for p in paths {
1800        let path = PathBuf::from(p);
1801        let meta_result = if config.dereference {
1802            fs::metadata(&path).or_else(|_| fs::symlink_metadata(&path))
1803        } else {
1804            fs::symlink_metadata(&path)
1805        };
1806
1807        match meta_result {
1808            Ok(meta) => {
1809                if config.directory || !meta.is_dir() {
1810                    match FileEntry::from_path_with_name(p.to_string(), &path, config) {
1811                        Ok(fe) => file_args.push(fe),
1812                        Err(e) => {
1813                            eprintln!("ls: cannot access '{}': {}", p, e);
1814                            had_error = true;
1815                        }
1816                    }
1817                } else {
1818                    dir_args.push(path);
1819                }
1820            }
1821            Err(e) => {
1822                eprintln!(
1823                    "ls: cannot access '{}': {}",
1824                    p,
1825                    crate::common::io_error_msg(&e)
1826                );
1827                had_error = true;
1828            }
1829        }
1830    }
1831
1832    // Sort file args
1833    sort_entries(&mut file_args, config);
1834
1835    // Print file arguments
1836    if !file_args.is_empty() {
1837        match config.format {
1838            OutputFormat::Long => print_long(&mut out, &file_args, config, color_db.as_ref())?,
1839            OutputFormat::SingleColumn => {
1840                print_single_column(&mut out, &file_args, config, color_db.as_ref())?
1841            }
1842            OutputFormat::Columns | OutputFormat::Across => {
1843                print_columns(&mut out, &file_args, config, color_db.as_ref())?
1844            }
1845            OutputFormat::Comma => print_comma(&mut out, &file_args, config, color_db.as_ref())?,
1846        }
1847    }
1848
1849    // Sort directory args by name using locale-aware comparison
1850    dir_args.sort_by(|a, b| {
1851        let an = a.to_string_lossy();
1852        let bn = b.to_string_lossy();
1853        let ord = locale_cmp(&an, &bn);
1854        if config.reverse { ord.reverse() } else { ord }
1855    });
1856
1857    let show_header =
1858        dir_args.len() > 1 || (!file_args.is_empty() && !dir_args.is_empty()) || config.recursive;
1859
1860    for (i, dir) in dir_args.iter().enumerate() {
1861        if i > 0 || !file_args.is_empty() {
1862            writeln!(out)?;
1863        }
1864        match ls_dir(&mut out, dir, config, color_db.as_ref(), show_header) {
1865            Ok(_) => {}
1866            Err(e) if e.kind() == io::ErrorKind::BrokenPipe => return Err(e),
1867            Err(e) => {
1868                eprintln!(
1869                    "ls: cannot open directory '{}': {}",
1870                    dir.display(),
1871                    crate::common::io_error_msg(&e)
1872                );
1873                had_error = true;
1874            }
1875        }
1876    }
1877
1878    out.flush()?;
1879
1880    Ok(!had_error)
1881}
1882
1883/// Check if stdout is a TTY.
1884pub fn atty_stdout() -> bool {
1885    unsafe { libc::isatty(1) != 0 }
1886}
1887
1888// ---------------------------------------------------------------------------
1889// Testable helpers (exported for tests module)
1890// ---------------------------------------------------------------------------
1891
1892/// Collect entries for a directory into a Vec (for testing).
1893pub fn collect_entries(path: &Path, config: &LsConfig) -> io::Result<Vec<FileEntry>> {
1894    let mut entries = read_entries(path, config)?;
1895    sort_entries(&mut entries, config);
1896    Ok(entries)
1897}
1898
1899/// Render long format lines to a String (for testing).
1900pub fn render_long(entries: &[FileEntry], config: &LsConfig) -> io::Result<String> {
1901    let mut buf = Vec::new();
1902    print_long(&mut buf, entries, config, None)?;
1903    Ok(String::from_utf8_lossy(&buf).into_owned())
1904}
1905
1906/// Render single-column output to a String (for testing).
1907pub fn render_single_column(entries: &[FileEntry], config: &LsConfig) -> io::Result<String> {
1908    let mut buf = Vec::new();
1909    print_single_column(&mut buf, entries, config, None)?;
1910    Ok(String::from_utf8_lossy(&buf).into_owned())
1911}
1912
1913/// Render full ls_dir output to a String (for testing).
1914pub fn render_dir(path: &Path, config: &LsConfig) -> io::Result<String> {
1915    let mut buf = Vec::new();
1916    ls_dir(&mut buf, path, config, None, false)?;
1917    Ok(String::from_utf8_lossy(&buf).into_owned())
1918}