Skip to main content

coreutils_rs/ls/
core.rs

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