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