Skip to main content

f00_core/
sort.rs

1use crate::entry::{Entry, EntryKind};
2use crate::options::{ListOptions, SortBy};
3
4/// Case-insensitive name comparison.
5///
6/// In GNU mode, compare raw byte/string order without stripping leading dots.
7pub fn cmp_name(a: &str, b: &str) -> std::cmp::Ordering {
8    cmp_name_with_mode(a, b, false)
9}
10
11pub fn cmp_name_with_mode(a: &str, b: &str, gnu: bool) -> std::cmp::Ordering {
12    if gnu {
13        // Approximate LC_COLLATE=C: byte-wise, case-sensitive.
14        return a.cmp(b);
15    }
16    // Friendly default: case-insensitive, interleave hidden files.
17    let a_key = a.trim_start_matches('.').to_ascii_lowercase();
18    let b_key = b.trim_start_matches('.').to_ascii_lowercase();
19    match a_key.cmp(&b_key) {
20        std::cmp::Ordering::Equal => a.cmp(b),
21        other => other,
22    }
23}
24
25/// strverscmp-like natural / version comparison (`ls -v`).
26///
27/// Digit runs compare numerically (with leading-zero special-case similar to glibc);
28/// non-digit runs compare byte-wise.
29pub fn cmp_version(a: &str, b: &str) -> std::cmp::Ordering {
30    use std::cmp::Ordering;
31    let ab = a.as_bytes();
32    let bb = b.as_bytes();
33    let mut i = 0usize;
34    let mut j = 0usize;
35
36    while i < ab.len() || j < bb.len() {
37        if i >= ab.len() {
38            return Ordering::Less;
39        }
40        if j >= bb.len() {
41            return Ordering::Greater;
42        }
43
44        let a_digit = ab[i].is_ascii_digit();
45        let b_digit = bb[j].is_ascii_digit();
46
47        if a_digit && b_digit {
48            // Leading zeros: all-zero prefix is "fractional" in glibc strverscmp.
49            let a_zeros = count_leading(ab, i, b'0');
50            let b_zeros = count_leading(bb, j, b'0');
51            let i0 = i + a_zeros;
52            let j0 = j + b_zeros;
53            let a_digits = count_digits(ab, i0);
54            let b_digits = count_digits(bb, j0);
55
56            // If one side has more leading zeros, it compares as smaller when
57            // the remaining numeric parts are equal length / equal value (glibc).
58            match (a_zeros > 0, b_zeros > 0) {
59                (true, false) => {
60                    // a has leading zeros → treat as fractional / smaller if equal digits after.
61                    // Fall through to numeric compare of remaining digits first.
62                }
63                (false, true) => {}
64                _ => {}
65            }
66
67            // Compare digit values first by length then lexicographically.
68            match a_digits.cmp(&b_digits) {
69                Ordering::Equal => {
70                    let acmp = ab[i0..i0 + a_digits].cmp(&bb[j0..j0 + b_digits]);
71                    if acmp != Ordering::Equal {
72                        return acmp;
73                    }
74                    // Equal numbers: more leading zeros → less (glibc).
75                    if a_zeros != b_zeros {
76                        return b_zeros.cmp(&a_zeros);
77                    }
78                }
79                ord => return ord,
80            }
81            i = i0 + a_digits;
82            j = j0 + b_digits;
83            continue;
84        }
85
86        if a_digit != b_digit {
87            // Non-digit vs digit: compare bytes.
88            return ab[i].cmp(&bb[j]);
89        }
90
91        // Both non-digit: compare until digit or end.
92        while i < ab.len() && j < bb.len() && !ab[i].is_ascii_digit() && !bb[j].is_ascii_digit() {
93            match ab[i].cmp(&bb[j]) {
94                Ordering::Equal => {
95                    i += 1;
96                    j += 1;
97                }
98                ord => return ord,
99            }
100        }
101        if i < ab.len() && j < bb.len() {
102            // One hit a digit boundary.
103            if ab[i].is_ascii_digit() != bb[j].is_ascii_digit() {
104                return ab[i].cmp(&bb[j]);
105            }
106        }
107    }
108    Ordering::Equal
109}
110
111fn count_leading(bytes: &[u8], start: usize, ch: u8) -> usize {
112    let mut n = 0;
113    while start + n < bytes.len() && bytes[start + n] == ch {
114        n += 1;
115    }
116    n
117}
118
119fn count_digits(bytes: &[u8], start: usize) -> usize {
120    let mut n = 0;
121    while start + n < bytes.len() && bytes[start + n].is_ascii_digit() {
122        n += 1;
123    }
124    n
125}
126
127fn cmp_entry(a: &Entry, b: &Entry, opts: &ListOptions) -> std::cmp::Ordering {
128    use std::cmp::Ordering;
129
130    if opts.dirs_first {
131        let a_dir = a.kind == EntryKind::Directory;
132        let b_dir = b.kind == EntryKind::Directory;
133        match (a_dir, b_dir) {
134            (true, false) => return Ordering::Less,
135            (false, true) => return Ordering::Greater,
136            _ => {}
137        }
138    }
139
140    let name_cmp = || cmp_name_with_mode(&a.name, &b.name, opts.gnu_mode);
141
142    let primary = match opts.sort_by {
143        SortBy::None => Ordering::Equal,
144        SortBy::Name => name_cmp(),
145        // GNU `-S`: largest first.
146        SortBy::Size => b.size.cmp(&a.size).then_with(name_cmp),
147        SortBy::Time => {
148            let ta = a.time_for(opts.time_field);
149            let tb = b.time_for(opts.time_field);
150            // Newest first.
151            tb.cmp(&ta).then_with(name_cmp)
152        }
153        SortBy::Extension => {
154            let ea = a.extension().unwrap_or("");
155            let eb = b.extension().unwrap_or("");
156            if opts.gnu_mode {
157                ea.cmp(eb).then_with(name_cmp)
158            } else {
159                ea.to_ascii_lowercase()
160                    .cmp(&eb.to_ascii_lowercase())
161                    .then_with(name_cmp)
162            }
163        }
164        SortBy::Version => cmp_version(&a.name, &b.name).then_with(name_cmp),
165    };
166
167    if opts.reverse {
168        primary.reverse()
169    } else {
170        primary
171    }
172}
173
174/// Sort entries according to options. Directory headers keep relative order.
175pub fn sort_entries(entries: &mut [Entry], opts: &ListOptions) {
176    if opts.sort_by == SortBy::None {
177        if opts.reverse {
178            entries.reverse();
179        }
180        return;
181    }
182
183    entries.sort_by(|a, b| match (a.is_dir_header, b.is_dir_header) {
184        (true, false) => std::cmp::Ordering::Less,
185        (false, true) => std::cmp::Ordering::Greater,
186        _ => cmp_entry(a, b, opts),
187    });
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::entry::{Entry, EntryKind, GitStatus, TimeField};
194    use std::path::PathBuf;
195    use std::time::{Duration, SystemTime};
196
197    fn entry(name: &str, kind: EntryKind, size: u64, secs: Option<u64>) -> Entry {
198        Entry {
199            path: PathBuf::from(name),
200            name: name.to_string(),
201            kind,
202            size,
203            modified: secs.map(|s| SystemTime::UNIX_EPOCH + Duration::from_secs(s)),
204            created: None,
205            accessed: None,
206            changed: secs.map(|s| SystemTime::UNIX_EPOCH + Duration::from_secs(s)),
207            mode: 0,
208            readonly: false,
209            symlink_target: None,
210            depth: 0,
211            git_status: GitStatus::Clean,
212            is_dir_header: false,
213            nlink: 1,
214            uid: 0,
215            gid: 0,
216            inode: 0,
217            blocks: 0,
218            owner: "u".into(),
219            group: "g".into(),
220            author: "u".into(),
221            context: String::new(),
222        }
223    }
224
225    #[test]
226    fn sort_by_name_case_insensitive() {
227        let mut entries = vec![
228            entry("Banana", EntryKind::File, 0, None),
229            entry("apple", EntryKind::File, 0, None),
230            entry("Cherry", EntryKind::File, 0, None),
231        ];
232        sort_entries(&mut entries, &ListOptions::default());
233        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
234        assert_eq!(names, vec!["apple", "Banana", "Cherry"]);
235    }
236
237    #[test]
238    fn sort_by_size_largest_first() {
239        let mut entries = vec![
240            entry("big", EntryKind::File, 100, None),
241            entry("small", EntryKind::File, 1, None),
242            entry("mid", EntryKind::File, 50, None),
243        ];
244        let opts = ListOptions {
245            sort_by: SortBy::Size,
246            ..Default::default()
247        };
248        sort_entries(&mut entries, &opts);
249        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
250        assert_eq!(names, vec!["big", "mid", "small"]);
251    }
252
253    #[test]
254    fn sort_by_time_newest_first() {
255        let mut entries = vec![
256            entry("old", EntryKind::File, 0, Some(10)),
257            entry("new", EntryKind::File, 0, Some(100)),
258            entry("mid", EntryKind::File, 0, Some(50)),
259        ];
260        let opts = ListOptions {
261            sort_by: SortBy::Time,
262            ..Default::default()
263        };
264        sort_entries(&mut entries, &opts);
265        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
266        assert_eq!(names, vec!["new", "mid", "old"]);
267    }
268
269    #[test]
270    fn reverse_name_sort() {
271        let mut entries = vec![
272            entry("a", EntryKind::File, 0, None),
273            entry("b", EntryKind::File, 0, None),
274            entry("c", EntryKind::File, 0, None),
275        ];
276        let opts = ListOptions {
277            reverse: true,
278            ..Default::default()
279        };
280        sort_entries(&mut entries, &opts);
281        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
282        assert_eq!(names, vec!["c", "b", "a"]);
283    }
284
285    #[test]
286    fn dirs_first() {
287        let mut entries = vec![
288            entry("zfile", EntryKind::File, 0, None),
289            entry("adir", EntryKind::Directory, 0, None),
290            entry("bfile", EntryKind::File, 0, None),
291        ];
292        let opts = ListOptions {
293            dirs_first: true,
294            ..Default::default()
295        };
296        sort_entries(&mut entries, &opts);
297        assert_eq!(entries[0].name, "adir");
298    }
299
300    #[test]
301    fn sort_by_extension() {
302        let mut entries = vec![
303            entry("a.txt", EntryKind::File, 0, None),
304            entry("b.rs", EntryKind::File, 0, None),
305            entry("c.md", EntryKind::File, 0, None),
306            entry("noext", EntryKind::File, 0, None),
307        ];
308        let opts = ListOptions {
309            sort_by: SortBy::Extension,
310            ..Default::default()
311        };
312        sort_entries(&mut entries, &opts);
313        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
314        assert_eq!(names, vec!["noext", "c.md", "b.rs", "a.txt"]);
315    }
316
317    #[test]
318    fn sort_by_version() {
319        let mut entries = vec![
320            entry("file10.txt", EntryKind::File, 0, None),
321            entry("file2.txt", EntryKind::File, 0, None),
322            entry("file1.txt", EntryKind::File, 0, None),
323        ];
324        let opts = ListOptions {
325            sort_by: SortBy::Version,
326            ..Default::default()
327        };
328        sort_entries(&mut entries, &opts);
329        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
330        assert_eq!(names, vec!["file1.txt", "file2.txt", "file10.txt"]);
331    }
332
333    #[test]
334    fn cmp_version_basic() {
335        use std::cmp::Ordering;
336        assert_eq!(cmp_version("a2", "a10"), Ordering::Less);
337        assert_eq!(cmp_version("a10", "a2"), Ordering::Greater);
338        assert_eq!(cmp_version("abc", "abc"), Ordering::Equal);
339        assert_eq!(cmp_version("file1", "file01"), Ordering::Greater); // fewer leading zeros wins? glibc: more leading zeros is less
340                                                                       // file01 has leading zero → smaller than file1 when numbers equal
341        assert_eq!(cmp_version("file01", "file1"), Ordering::Less);
342    }
343
344    #[test]
345    fn cmp_name_strips_dot_for_key() {
346        assert!(cmp_name(".foo", "foo").is_le());
347        assert!(cmp_name("apple", "banana").is_lt());
348    }
349
350    #[test]
351    fn gnu_name_sort_is_bytewise() {
352        assert_eq!(cmp_name_with_mode("B", "a", true), std::cmp::Ordering::Less);
353        // 'B' < 'a' in ASCII
354    }
355
356    #[test]
357    fn time_field_access() {
358        let mut e = entry("x", EntryKind::File, 0, Some(50));
359        e.accessed = Some(SystemTime::UNIX_EPOCH + Duration::from_secs(10));
360        assert_eq!(
361            e.time_for(TimeField::Accessed),
362            Some(SystemTime::UNIX_EPOCH + Duration::from_secs(10))
363        );
364    }
365
366    #[test]
367    fn time_field_changed_uses_ctime() {
368        let mut e = entry("x", EntryKind::File, 0, Some(50));
369        e.changed = Some(SystemTime::UNIX_EPOCH + Duration::from_secs(7));
370        assert_eq!(
371            e.time_for(TimeField::Changed),
372            Some(SystemTime::UNIX_EPOCH + Duration::from_secs(7))
373        );
374    }
375}