mirror of
https://github.com/bgreenwell/lstr.git
synced 2025-12-16 12:00:11 +01:00
add new sorting options
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -70,4 +70,7 @@ ehthumbs_vista.db
|
||||
*.html
|
||||
|
||||
# Ignore all directories with _files suffix
|
||||
*_files/
|
||||
*_files/
|
||||
|
||||
# Claude Code context file - contains project-specific development notes
|
||||
CLAUDE.md
|
||||
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -649,6 +649,7 @@ dependencies = [
|
||||
"git2",
|
||||
"ignore",
|
||||
"lscolors",
|
||||
"natord",
|
||||
"predicates",
|
||||
"ratatui",
|
||||
"tempfile",
|
||||
@@ -673,6 +674,12 @@ dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "natord"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c"
|
||||
|
||||
[[package]]
|
||||
name = "normalize-line-endings"
|
||||
version = "0.3.0"
|
||||
|
||||
@@ -20,6 +20,7 @@ ignore = "0.4.22"
|
||||
lscolors = "0.9"
|
||||
url = "2.5.2"
|
||||
ratatui = "0.27.0"
|
||||
natord = "1.0"
|
||||
|
||||
# Dependencies for testing the command-line interface
|
||||
[dev-dependencies]
|
||||
|
||||
30
README.md
30
README.md
@@ -77,6 +77,12 @@ Note that `PATH` defaults to the current directory (`.`) if not specified.
|
||||
| `-L`, `--level <LEVEL>`| Maximum depth to descend. |
|
||||
| `-p`, `--permissions` | Display file permissions (Unix-like systems only). |
|
||||
| `-s`, `--size` | Display the size of files. |
|
||||
| `--sort <TYPE>` | Sort entries by the specified criteria (`name`, `size`, `modified`, `extension`). |
|
||||
| `--dirs-first` | Sort directories before files. |
|
||||
| `--case-sensitive` | Use case-sensitive sorting. |
|
||||
| `--natural-sort` | Use natural/version sorting (e.g., file1 < file10). |
|
||||
| `-r`, `--reverse` | Reverse the sort order. |
|
||||
| `--dotfiles-first` | Sort dotfiles and dotfolders first (dotfolders → folders → dotfiles → files). |
|
||||
| `--expand-level <LEVEL>`| **Interactive mode only:** Initial depth to expand the interactive tree. |
|
||||
|
||||
-----
|
||||
@@ -133,6 +139,30 @@ lstr --hyperlinks
|
||||
lstr interactive -gG --icons -s -p
|
||||
```
|
||||
|
||||
**7. Sort files naturally with directories first**
|
||||
|
||||
```bash
|
||||
lstr --dirs-first --natural-sort
|
||||
```
|
||||
|
||||
**8. Sort by file size in descending order**
|
||||
|
||||
```bash
|
||||
lstr --sort size --reverse
|
||||
```
|
||||
|
||||
**9. Sort by extension with case-sensitive ordering**
|
||||
|
||||
```bash
|
||||
lstr --sort extension --case-sensitive
|
||||
```
|
||||
|
||||
**10. Sort with dotfiles first and directories first**
|
||||
|
||||
```bash
|
||||
lstr --dotfiles-first --dirs-first -a
|
||||
```
|
||||
|
||||
## Piping and shell interaction
|
||||
|
||||
The classic `view` mode is designed to work well with other command-line tools via pipes (`|`).
|
||||
|
||||
97
src/app.rs
97
src/app.rs
@@ -3,6 +3,7 @@
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use crate::sort;
|
||||
|
||||
/// A blazingly fast, minimalist directory tree viewer, written in Rust.
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -63,6 +64,24 @@ pub struct ViewArgs {
|
||||
/// Render file paths as clickable hyperlinks.
|
||||
#[arg(long)]
|
||||
pub hyperlinks: bool,
|
||||
/// Sort entries by the specified criteria.
|
||||
#[arg(long, default_value_t = SortType::Name)]
|
||||
pub sort: SortType,
|
||||
/// Sort directories before files.
|
||||
#[arg(long)]
|
||||
pub dirs_first: bool,
|
||||
/// Use case-sensitive sorting.
|
||||
#[arg(long)]
|
||||
pub case_sensitive: bool,
|
||||
/// Use natural/version sorting (e.g., file1 < file10).
|
||||
#[arg(long)]
|
||||
pub natural_sort: bool,
|
||||
/// Reverse the sort order.
|
||||
#[arg(short = 'r', long)]
|
||||
pub reverse: bool,
|
||||
/// Sort dotfiles and dotfolders first.
|
||||
#[arg(long)]
|
||||
pub dotfiles_first: bool,
|
||||
}
|
||||
|
||||
/// Arguments for the `interactive` command.
|
||||
@@ -92,6 +111,38 @@ pub struct InteractiveArgs {
|
||||
/// Initial depth to expand the directory tree.
|
||||
#[arg(long, value_name = "LEVEL")]
|
||||
pub expand_level: Option<usize>,
|
||||
/// Sort entries by the specified criteria.
|
||||
#[arg(long, default_value_t = SortType::Name)]
|
||||
pub sort: SortType,
|
||||
/// Sort directories before files.
|
||||
#[arg(long)]
|
||||
pub dirs_first: bool,
|
||||
/// Use case-sensitive sorting.
|
||||
#[arg(long)]
|
||||
pub case_sensitive: bool,
|
||||
/// Use natural/version sorting (e.g., file1 < file10).
|
||||
#[arg(long)]
|
||||
pub natural_sort: bool,
|
||||
/// Reverse the sort order.
|
||||
#[arg(short = 'r', long)]
|
||||
pub reverse: bool,
|
||||
/// Sort dotfiles and dotfolders first.
|
||||
#[arg(long)]
|
||||
pub dotfiles_first: bool,
|
||||
}
|
||||
|
||||
/// Defines the available sorting strategies.
|
||||
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub enum SortType {
|
||||
/// Sort by name (default)
|
||||
#[default]
|
||||
Name,
|
||||
/// Sort by file size
|
||||
Size,
|
||||
/// Sort by modification time
|
||||
Modified,
|
||||
/// Sort by file extension
|
||||
Extension,
|
||||
}
|
||||
|
||||
/// Defines the choices for the --color option.
|
||||
@@ -103,6 +154,52 @@ pub enum ColorChoice {
|
||||
Never,
|
||||
}
|
||||
|
||||
impl From<SortType> for sort::SortType {
|
||||
fn from(sort_type: SortType) -> Self {
|
||||
match sort_type {
|
||||
SortType::Name => sort::SortType::Name,
|
||||
SortType::Size => sort::SortType::Size,
|
||||
SortType::Modified => sort::SortType::Modified,
|
||||
SortType::Extension => sort::SortType::Extension,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewArgs {
|
||||
/// Creates a SortOptions instance from the ViewArgs.
|
||||
pub fn to_sort_options(&self) -> sort::SortOptions {
|
||||
sort::SortOptions {
|
||||
sort_type: self.sort.into(),
|
||||
directories_first: self.dirs_first,
|
||||
case_sensitive: self.case_sensitive,
|
||||
natural_sort: self.natural_sort,
|
||||
reverse: self.reverse,
|
||||
dotfiles_first: self.dotfiles_first,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveArgs {
|
||||
/// Creates a SortOptions instance from the InteractiveArgs.
|
||||
pub fn to_sort_options(&self) -> sort::SortOptions {
|
||||
sort::SortOptions {
|
||||
sort_type: self.sort.into(),
|
||||
directories_first: self.dirs_first,
|
||||
case_sensitive: self.case_sensitive,
|
||||
natural_sort: self.natural_sort,
|
||||
reverse: self.reverse,
|
||||
dotfiles_first: self.dotfiles_first,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements the Display trait for SortType to show possible values in help messages.
|
||||
impl fmt::Display for SortType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.to_possible_value().expect("no values are skipped").get_name().fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements the Display trait for ColorChoice to show possible values in help messages.
|
||||
impl fmt::Display for ColorChoice {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
mod app;
|
||||
mod git;
|
||||
mod icons;
|
||||
mod sort;
|
||||
mod tui;
|
||||
mod utils;
|
||||
mod view;
|
||||
|
||||
377
src/sort.rs
Normal file
377
src/sort.rs
Normal file
@@ -0,0 +1,377 @@
|
||||
//! Provides OS-agnostic sorting functionality for directory entries.
|
||||
//!
|
||||
//! This module implements various sorting strategies for file and directory entries,
|
||||
//! ensuring consistent behavior across all supported platforms (Windows, macOS, Linux).
|
||||
|
||||
use ignore::DirEntry;
|
||||
use std::cmp::Ordering;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
/// Defines the available sorting strategies.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SortType {
|
||||
/// Sort by name (default)
|
||||
Name,
|
||||
/// Sort by file size
|
||||
Size,
|
||||
/// Sort by modification time
|
||||
Modified,
|
||||
/// Sort by file extension
|
||||
Extension,
|
||||
}
|
||||
|
||||
impl Default for SortType {
|
||||
fn default() -> Self {
|
||||
Self::Name
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration options for sorting directory entries.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SortOptions {
|
||||
/// The primary sorting strategy
|
||||
pub sort_type: SortType,
|
||||
/// Whether to sort directories before files
|
||||
pub directories_first: bool,
|
||||
/// Whether to use case-sensitive name sorting
|
||||
pub case_sensitive: bool,
|
||||
/// Whether to use natural/version sorting (e.g., file1 < file10)
|
||||
pub natural_sort: bool,
|
||||
/// Whether to reverse the sort order
|
||||
pub reverse: bool,
|
||||
/// Whether to sort dotfiles/dotfolders first
|
||||
pub dotfiles_first: bool,
|
||||
}
|
||||
|
||||
/// Sorts a vector of directory entries according to the given options.
|
||||
///
|
||||
/// This function provides OS-agnostic sorting that works consistently across
|
||||
/// all platforms. The sorting is stable, preserving the original order for
|
||||
/// equal elements.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `entries` - A mutable reference to the vector of entries to sort
|
||||
/// * `options` - The sorting configuration to apply
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use lstr::sort::{sort_entries, SortOptions, SortType};
|
||||
///
|
||||
/// let mut entries = vec![/* ... */];
|
||||
/// let options = SortOptions {
|
||||
/// sort_type: SortType::Name,
|
||||
/// directories_first: true,
|
||||
/// ..Default::default()
|
||||
/// };
|
||||
/// sort_entries(&mut entries, &options);
|
||||
/// ```
|
||||
pub fn sort_entries(entries: &mut [DirEntry], options: &SortOptions) {
|
||||
entries.sort_by(|a, b| {
|
||||
let result = compare_entries(a, b, options);
|
||||
if options.reverse {
|
||||
result.reverse()
|
||||
} else {
|
||||
result
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Compares two directory entries according to the sorting options.
|
||||
fn compare_entries(a: &DirEntry, b: &DirEntry, options: &SortOptions) -> Ordering {
|
||||
let a_is_dir = a.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
|
||||
let b_is_dir = b.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
|
||||
let a_is_dotfile = is_dotfile(a);
|
||||
let b_is_dotfile = is_dotfile(b);
|
||||
|
||||
// Handle dotfiles-first and directories-first sorting
|
||||
// Order: dotfolders → folders → dotfiles → files
|
||||
if options.dotfiles_first {
|
||||
match (a_is_dotfile, a_is_dir, b_is_dotfile, b_is_dir) {
|
||||
// Same category - continue to name sorting
|
||||
(true, true, true, true) | // Both dotfolders
|
||||
(false, true, false, true) | // Both regular folders
|
||||
(true, false, true, false) | // Both dotfiles
|
||||
(false, false, false, false) => {}, // Both regular files
|
||||
|
||||
// Different categories - apply priority order
|
||||
(true, true, _, _) => return Ordering::Less, // a is dotfolder (highest priority)
|
||||
(_, _, true, true) => return Ordering::Greater, // b is dotfolder
|
||||
(false, true, _, _) => return Ordering::Less, // a is regular folder
|
||||
(_, _, false, true) => return Ordering::Greater, // b is regular folder
|
||||
(true, false, _, _) => return Ordering::Less, // a is dotfile
|
||||
(_, _, true, false) => return Ordering::Greater, // b is dotfile
|
||||
}
|
||||
} else if options.directories_first {
|
||||
// Original directories-first logic (without dotfile priority)
|
||||
match (a_is_dir, b_is_dir) {
|
||||
(true, false) => return Ordering::Less,
|
||||
(false, true) => return Ordering::Greater,
|
||||
_ => {} // Both are dirs or both are files, continue
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the primary sorting strategy
|
||||
match options.sort_type {
|
||||
SortType::Name => compare_by_name(a, b, options),
|
||||
SortType::Size => compare_by_size(a, b),
|
||||
SortType::Modified => compare_by_modified(a, b),
|
||||
SortType::Extension => compare_by_extension(a, b, options),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares entries by name, handling case sensitivity and natural sorting.
|
||||
fn compare_by_name(a: &DirEntry, b: &DirEntry, options: &SortOptions) -> Ordering {
|
||||
let name_a = a.file_name();
|
||||
let name_b = b.file_name();
|
||||
|
||||
if options.natural_sort {
|
||||
compare_natural(name_a, name_b)
|
||||
} else if options.case_sensitive {
|
||||
// Use default order for case-sensitive sorting (numbers, uppercase, lowercase)
|
||||
compare_default_order(name_a, name_b)
|
||||
} else {
|
||||
compare_case_insensitive(name_a, name_b)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares entries by file size, with directories having size 0.
|
||||
fn compare_by_size(a: &DirEntry, b: &DirEntry) -> Ordering {
|
||||
let size_a = get_entry_size(a);
|
||||
let size_b = get_entry_size(b);
|
||||
size_a.cmp(&size_b)
|
||||
}
|
||||
|
||||
/// Compares entries by modification time.
|
||||
fn compare_by_modified(a: &DirEntry, b: &DirEntry) -> Ordering {
|
||||
let modified_a = a.metadata().ok().and_then(|m| m.modified().ok());
|
||||
let modified_b = b.metadata().ok().and_then(|m| m.modified().ok());
|
||||
|
||||
match (modified_a, modified_b) {
|
||||
(Some(a_time), Some(b_time)) => a_time.cmp(&b_time),
|
||||
(Some(_), None) => Ordering::Less, // Files with known time sort first
|
||||
(None, Some(_)) => Ordering::Greater,
|
||||
(None, None) => Ordering::Equal,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares entries by file extension, falling back to name comparison.
|
||||
fn compare_by_extension(a: &DirEntry, b: &DirEntry, options: &SortOptions) -> Ordering {
|
||||
let ext_a = get_extension(a.file_name());
|
||||
let ext_b = get_extension(b.file_name());
|
||||
|
||||
let ext_cmp = if options.case_sensitive {
|
||||
ext_a.cmp(&ext_b)
|
||||
} else {
|
||||
compare_case_insensitive_str(&ext_a, &ext_b)
|
||||
};
|
||||
|
||||
// If extensions are equal, fall back to name comparison
|
||||
if ext_cmp == Ordering::Equal {
|
||||
compare_by_name(a, b, options)
|
||||
} else {
|
||||
ext_cmp
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs natural/version sorting comparison on OS strings.
|
||||
fn compare_natural(a: &OsStr, b: &OsStr) -> Ordering {
|
||||
// Convert to strings for natural comparison
|
||||
let str_a = a.to_string_lossy();
|
||||
let str_b = b.to_string_lossy();
|
||||
|
||||
// Use the natord crate for natural ordering
|
||||
natord::compare(&str_a, &str_b)
|
||||
}
|
||||
|
||||
/// Performs case-insensitive comparison on OS strings.
|
||||
fn compare_case_insensitive(a: &OsStr, b: &OsStr) -> Ordering {
|
||||
let str_a = a.to_string_lossy().to_lowercase();
|
||||
let str_b = b.to_string_lossy().to_lowercase();
|
||||
str_a.cmp(&str_b)
|
||||
}
|
||||
|
||||
/// Implements the default sort order: numbers first, then uppercase, then lowercase.
|
||||
fn compare_default_order(a: &OsStr, b: &OsStr) -> Ordering {
|
||||
let str_a = a.to_string_lossy();
|
||||
let str_b = b.to_string_lossy();
|
||||
|
||||
// Compare character by character using the specified priority
|
||||
for (char_a, char_b) in str_a.chars().zip(str_b.chars()) {
|
||||
let order_a = char_sort_priority(char_a);
|
||||
let order_b = char_sort_priority(char_b);
|
||||
|
||||
match order_a.cmp(&order_b) {
|
||||
Ordering::Equal => {
|
||||
// Same priority category, compare within category
|
||||
match char_a.cmp(&char_b) {
|
||||
Ordering::Equal => continue,
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
|
||||
// If all compared characters are equal, compare by length
|
||||
str_a.len().cmp(&str_b.len())
|
||||
}
|
||||
|
||||
/// Returns sort priority for a character: numbers (0), uppercase (1), lowercase (2), others (3).
|
||||
fn char_sort_priority(c: char) -> u8 {
|
||||
if c.is_ascii_digit() {
|
||||
0 // Numbers first
|
||||
} else if c.is_ascii_uppercase() {
|
||||
1 // Uppercase second
|
||||
} else if c.is_ascii_lowercase() {
|
||||
2 // Lowercase third
|
||||
} else {
|
||||
3 // Everything else last
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a directory entry is a dotfile/dotfolder (starts with '.').
|
||||
fn is_dotfile(entry: &DirEntry) -> bool {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with('.')
|
||||
}
|
||||
|
||||
/// Performs case-insensitive comparison on regular strings.
|
||||
fn compare_case_insensitive_str(a: &str, b: &str) -> Ordering {
|
||||
a.to_lowercase().cmp(&b.to_lowercase())
|
||||
}
|
||||
|
||||
/// Extracts the file extension from an OS string, returning empty string if none.
|
||||
fn get_extension(filename: &OsStr) -> String {
|
||||
std::path::Path::new(filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Gets the size of a directory entry, returning 0 for directories.
|
||||
fn get_entry_size(entry: &DirEntry) -> u64 {
|
||||
if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
|
||||
0 // Directories have size 0 for sorting purposes
|
||||
} else {
|
||||
entry.metadata().ok().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive_name_sorting() {
|
||||
// Test case-insensitive comparison
|
||||
let name_a = OsStr::new("Apple");
|
||||
let name_b = OsStr::new("banana");
|
||||
|
||||
let result = compare_case_insensitive(name_a, name_b);
|
||||
assert_eq!(result, Ordering::Less); // "apple" < "banana"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_sensitive_name_sorting() {
|
||||
let name_a = OsStr::new("Apple");
|
||||
let name_b = OsStr::new("banana");
|
||||
|
||||
let result = name_a.cmp(name_b);
|
||||
assert_eq!(result, Ordering::Less); // "Apple" < "banana" in ASCII
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_natural_sorting() {
|
||||
let name_a = OsStr::new("file1.txt");
|
||||
let name_b = OsStr::new("file10.txt");
|
||||
|
||||
let result = compare_natural(name_a, name_b);
|
||||
assert_eq!(result, Ordering::Less); // file1 < file10 naturally
|
||||
|
||||
// Test that regular lexicographic would give opposite result
|
||||
let lexicographic = name_a.cmp(name_b);
|
||||
assert_eq!(lexicographic, Ordering::Less); // Actually "file1.txt" < "file10.txt" lexicographically too
|
||||
|
||||
// Better test: "file2.txt" vs "file10.txt"
|
||||
let name_c = OsStr::new("file2.txt");
|
||||
let name_d = OsStr::new("file10.txt");
|
||||
|
||||
let natural_result = compare_natural(name_c, name_d);
|
||||
let lexicographic_result = name_c.cmp(name_d);
|
||||
|
||||
assert_eq!(natural_result, Ordering::Less); // file2 < file10 naturally
|
||||
assert_eq!(lexicographic_result, Ordering::Greater); // "file2.txt" > "file10.txt" lexicographically
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extension_extraction() {
|
||||
assert_eq!(get_extension(OsStr::new("file.txt")), "txt");
|
||||
assert_eq!(get_extension(OsStr::new("file.tar.gz")), "gz");
|
||||
assert_eq!(get_extension(OsStr::new("file")), "");
|
||||
assert_eq!(get_extension(OsStr::new(".hidden")), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_options_default() {
|
||||
let options = SortOptions::default();
|
||||
assert_eq!(options.sort_type, SortType::Name);
|
||||
assert!(!options.directories_first);
|
||||
assert!(!options.case_sensitive);
|
||||
assert!(!options.natural_sort);
|
||||
assert!(!options.reverse);
|
||||
assert!(!options.dotfiles_first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_sorting() {
|
||||
let name_a = OsStr::new("apple");
|
||||
let name_b = OsStr::new("banana");
|
||||
|
||||
// Normal comparison: apple < banana
|
||||
let normal = compare_case_insensitive(name_a, name_b);
|
||||
assert_eq!(normal, Ordering::Less);
|
||||
|
||||
// With reverse option, the final result should be flipped
|
||||
// (This would be handled by the sort_entries function)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_sort_order() {
|
||||
// Test numbers first, then uppercase, then lowercase
|
||||
assert_eq!(compare_default_order(OsStr::new("1file"), OsStr::new("Afile")), Ordering::Less);
|
||||
assert_eq!(compare_default_order(OsStr::new("Afile"), OsStr::new("afile")), Ordering::Less);
|
||||
assert_eq!(compare_default_order(OsStr::new("afile"), OsStr::new("zfile")), Ordering::Less);
|
||||
|
||||
// Test within same category
|
||||
assert_eq!(compare_default_order(OsStr::new("1file"), OsStr::new("2file")), Ordering::Less);
|
||||
assert_eq!(compare_default_order(OsStr::new("Afile"), OsStr::new("Bfile")), Ordering::Less);
|
||||
assert_eq!(compare_default_order(OsStr::new("afile"), OsStr::new("bfile")), Ordering::Less);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_char_sort_priority() {
|
||||
assert_eq!(char_sort_priority('0'), 0); // digit
|
||||
assert_eq!(char_sort_priority('9'), 0); // digit
|
||||
assert_eq!(char_sort_priority('A'), 1); // uppercase
|
||||
assert_eq!(char_sort_priority('Z'), 1); // uppercase
|
||||
assert_eq!(char_sort_priority('a'), 2); // lowercase
|
||||
assert_eq!(char_sort_priority('z'), 2); // lowercase
|
||||
assert_eq!(char_sort_priority('_'), 3); // other
|
||||
assert_eq!(char_sort_priority('-'), 3); // other
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_dotfile() {
|
||||
// This test would need actual DirEntry objects, but we can test the concept
|
||||
// The function checks if filename starts with '.'
|
||||
assert!(OsStr::new(".hidden").to_string_lossy().starts_with('.'));
|
||||
assert!(OsStr::new(".git").to_string_lossy().starts_with('.'));
|
||||
assert!(!OsStr::new("visible.txt").to_string_lossy().starts_with('.'));
|
||||
assert!(!OsStr::new("normal").to_string_lossy().starts_with('.'));
|
||||
}
|
||||
}
|
||||
21
src/tui.rs
21
src/tui.rs
@@ -6,6 +6,7 @@
|
||||
use crate::app::InteractiveArgs;
|
||||
use crate::git::{self, StatusCache};
|
||||
use crate::icons;
|
||||
use crate::sort;
|
||||
use crate::utils;
|
||||
use ignore::WalkBuilder;
|
||||
use lscolors::{Color as LsColor, LsColors, Style as LsStyle};
|
||||
@@ -339,13 +340,23 @@ fn scan_directory(
|
||||
status_info: Option<(&StatusCache, &PathBuf)>,
|
||||
args: &InteractiveArgs,
|
||||
) -> anyhow::Result<Vec<FileEntry>> {
|
||||
let mut entries = Vec::new();
|
||||
let mut builder = WalkBuilder::new(path);
|
||||
builder.hidden(!args.all).git_ignore(args.gitignore);
|
||||
for result in builder.build().flatten() {
|
||||
if result.path() == path {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect all DirEntry objects first, filtering out the root path
|
||||
let mut dir_entries: Vec<_> = builder
|
||||
.build()
|
||||
.flatten()
|
||||
.filter(|result| result.path() != path)
|
||||
.collect();
|
||||
|
||||
// Apply sorting to the DirEntry objects
|
||||
let sort_options = args.to_sort_options();
|
||||
sort::sort_entries(&mut dir_entries, &sort_options);
|
||||
|
||||
// Convert DirEntry objects to FileEntry objects
|
||||
let mut entries = Vec::new();
|
||||
for result in dir_entries {
|
||||
let metadata = if args.size || args.permissions { result.metadata().ok() } else { None };
|
||||
let is_dir = result.file_type().is_some_and(|ft| ft.is_dir());
|
||||
let git_status = if let Some((cache, root)) = status_info {
|
||||
|
||||
28
src/view.rs
28
src/view.rs
@@ -3,6 +3,7 @@
|
||||
use crate::app::ViewArgs;
|
||||
use crate::git;
|
||||
use crate::icons;
|
||||
use crate::sort;
|
||||
use crate::utils;
|
||||
use colored::{control, Colorize};
|
||||
use ignore::{self, WalkBuilder};
|
||||
@@ -46,18 +47,29 @@ pub fn run(args: &ViewArgs, ls_colors: &LsColors) -> anyhow::Result<()> {
|
||||
let mut dir_count = 0;
|
||||
let mut file_count = 0;
|
||||
|
||||
for result in builder.build() {
|
||||
let entry = match result {
|
||||
Ok(entry) => entry,
|
||||
// Collect all entries first, then sort them
|
||||
let mut entries: Vec<_> = builder
|
||||
.build()
|
||||
.filter_map(|result| match result {
|
||||
Ok(entry) => {
|
||||
if entry.depth() == 0 {
|
||||
None // Skip the root directory
|
||||
} else {
|
||||
Some(entry)
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("lstr: ERROR: {err}");
|
||||
continue;
|
||||
None
|
||||
}
|
||||
};
|
||||
})
|
||||
.collect();
|
||||
|
||||
if entry.depth() == 0 {
|
||||
continue;
|
||||
}
|
||||
// Apply sorting
|
||||
let sort_options = args.to_sort_options();
|
||||
sort::sort_entries(&mut entries, &sort_options);
|
||||
|
||||
for entry in entries {
|
||||
|
||||
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
|
||||
if args.dirs_only && !is_dir {
|
||||
|
||||
206
tests/cli.rs
206
tests/cli.rs
@@ -163,3 +163,209 @@ fn test_git_status_flag() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_by_name() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = tempdir()?;
|
||||
fs::File::create(temp_dir.path().join("zebra.txt"))?;
|
||||
fs::File::create(temp_dir.path().join("apple.txt"))?;
|
||||
fs::File::create(temp_dir.path().join("banana.txt"))?;
|
||||
|
||||
let mut cmd = Command::cargo_bin("lstr")?;
|
||||
cmd.arg("--sort").arg("name").arg(temp_dir.path());
|
||||
|
||||
let output = cmd.output()?;
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
|
||||
// Files should appear in alphabetical order
|
||||
let apple_pos = stdout.find("apple.txt").unwrap();
|
||||
let banana_pos = stdout.find("banana.txt").unwrap();
|
||||
let zebra_pos = stdout.find("zebra.txt").unwrap();
|
||||
|
||||
assert!(apple_pos < banana_pos);
|
||||
assert!(banana_pos < zebra_pos);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dirs_first_sorting() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = tempdir()?;
|
||||
fs::File::create(temp_dir.path().join("aaa_file.txt"))?;
|
||||
fs::create_dir(temp_dir.path().join("zzz_dir"))?;
|
||||
|
||||
let mut cmd = Command::cargo_bin("lstr")?;
|
||||
cmd.arg("--dirs-first").arg(temp_dir.path());
|
||||
|
||||
let output = cmd.output()?;
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
|
||||
// Directory should appear before file, despite alphabetical order
|
||||
let dir_pos = stdout.find("zzz_dir").unwrap();
|
||||
let file_pos = stdout.find("aaa_file.txt").unwrap();
|
||||
|
||||
assert!(dir_pos < file_pos);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_natural_sorting() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = tempdir()?;
|
||||
fs::File::create(temp_dir.path().join("file1.txt"))?;
|
||||
fs::File::create(temp_dir.path().join("file10.txt"))?;
|
||||
fs::File::create(temp_dir.path().join("file2.txt"))?;
|
||||
|
||||
let mut cmd = Command::cargo_bin("lstr")?;
|
||||
cmd.arg("--natural-sort").arg(temp_dir.path());
|
||||
|
||||
let output = cmd.output()?;
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
|
||||
// With natural sorting: file1 < file2 < file10
|
||||
let file1_pos = stdout.find("file1.txt").unwrap();
|
||||
let file2_pos = stdout.find("file2.txt").unwrap();
|
||||
let file10_pos = stdout.find("file10.txt").unwrap();
|
||||
|
||||
assert!(file1_pos < file2_pos);
|
||||
assert!(file2_pos < file10_pos);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_sorting() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = tempdir()?;
|
||||
fs::File::create(temp_dir.path().join("apple.txt"))?;
|
||||
fs::File::create(temp_dir.path().join("zebra.txt"))?;
|
||||
|
||||
let mut cmd = Command::cargo_bin("lstr")?;
|
||||
cmd.arg("--reverse").arg(temp_dir.path());
|
||||
|
||||
let output = cmd.output()?;
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
|
||||
// With reverse sorting: zebra should come before apple
|
||||
let apple_pos = stdout.find("apple.txt").unwrap();
|
||||
let zebra_pos = stdout.find("zebra.txt").unwrap();
|
||||
|
||||
assert!(zebra_pos < apple_pos);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_sensitive_sorting() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = tempdir()?;
|
||||
fs::File::create(temp_dir.path().join("Apple.txt"))?;
|
||||
fs::File::create(temp_dir.path().join("banana.txt"))?;
|
||||
|
||||
// Test case-sensitive (Apple should come before banana in ASCII)
|
||||
let mut cmd = Command::cargo_bin("lstr")?;
|
||||
cmd.arg("--case-sensitive").arg(temp_dir.path());
|
||||
|
||||
let output = cmd.output()?;
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
|
||||
let apple_pos = stdout.find("Apple.txt").unwrap();
|
||||
let banana_pos = stdout.find("banana.txt").unwrap();
|
||||
|
||||
// In case-sensitive ASCII order: "Apple" < "banana" (uppercase < lowercase)
|
||||
assert!(apple_pos < banana_pos);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_by_extension() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = tempdir()?;
|
||||
fs::File::create(temp_dir.path().join("file.zzz"))?;
|
||||
fs::File::create(temp_dir.path().join("file.aaa"))?;
|
||||
fs::File::create(temp_dir.path().join("file.bbb"))?;
|
||||
|
||||
let mut cmd = Command::cargo_bin("lstr")?;
|
||||
cmd.arg("--sort").arg("extension").arg(temp_dir.path());
|
||||
|
||||
let output = cmd.output()?;
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
|
||||
// Files should be sorted by extension: .aaa < .bbb < .zzz
|
||||
let aaa_pos = stdout.find("file.aaa").unwrap();
|
||||
let bbb_pos = stdout.find("file.bbb").unwrap();
|
||||
let zzz_pos = stdout.find("file.zzz").unwrap();
|
||||
|
||||
assert!(aaa_pos < bbb_pos);
|
||||
assert!(bbb_pos < zzz_pos);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_sort_order() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = tempdir()?;
|
||||
|
||||
// Create files with explicit writes and different names to avoid conflicts
|
||||
let file1_path = temp_dir.path().join("0num.txt");
|
||||
let file_a_path = temp_dir.path().join("Upper.txt");
|
||||
let file_a_lower_path = temp_dir.path().join("lower.txt");
|
||||
|
||||
fs::write(&file1_path, "1")?;
|
||||
fs::write(&file_a_path, "A")?;
|
||||
fs::write(&file_a_lower_path, "a")?;
|
||||
|
||||
// Verify files exist
|
||||
assert!(file1_path.exists(), "0num.txt was not created");
|
||||
assert!(file_a_path.exists(), "Upper.txt was not created");
|
||||
assert!(file_a_lower_path.exists(), "lower.txt was not created");
|
||||
|
||||
let mut cmd = Command::cargo_bin("lstr")?;
|
||||
cmd.arg("--case-sensitive").arg(temp_dir.path());
|
||||
|
||||
let output = cmd.output()?;
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
|
||||
// Check if files are at least present
|
||||
assert!(stdout.contains("0num.txt"), "0num.txt missing from output");
|
||||
assert!(stdout.contains("Upper.txt"), "Upper.txt missing from output");
|
||||
assert!(stdout.contains("lower.txt"), "lower.txt missing from output");
|
||||
|
||||
// With default order: numbers < uppercase < lowercase
|
||||
let file1_pos = stdout.find("0num.txt").expect("0num.txt not found in output");
|
||||
let file_a_pos = stdout.find("Upper.txt").expect("Upper.txt not found in output");
|
||||
let file_a_lower_pos = stdout.find("lower.txt").expect("lower.txt not found in output");
|
||||
|
||||
assert!(file1_pos < file_a_pos);
|
||||
assert!(file_a_pos < file_a_lower_pos);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dotfiles_first_sorting() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = tempdir()?;
|
||||
|
||||
// Create files and folders with explicit writes/creates
|
||||
fs::write(temp_dir.path().join("regular.txt"), "regular")?;
|
||||
fs::write(temp_dir.path().join(".hidden.txt"), "hidden")?;
|
||||
fs::create_dir(temp_dir.path().join("folder"))?;
|
||||
fs::create_dir(temp_dir.path().join(".dotfolder"))?;
|
||||
|
||||
let mut cmd = Command::cargo_bin("lstr")?;
|
||||
cmd.arg("--dotfiles-first").arg("-a").arg(temp_dir.path());
|
||||
|
||||
let output = cmd.output()?;
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
|
||||
// Order should be: .dotfolder -> folder -> .hidden.txt -> regular.txt
|
||||
// Use full line matching to avoid substring issues
|
||||
let dotfolder_line_pos = stdout.find("└── .dotfolder").expect(".dotfolder line not found");
|
||||
let folder_line_pos = stdout.find("└── folder").expect("folder line not found");
|
||||
let hidden_line_pos = stdout.find("└── .hidden.txt").expect(".hidden.txt line not found");
|
||||
let regular_line_pos = stdout.find("└── regular.txt").expect("regular.txt line not found");
|
||||
|
||||
assert!(dotfolder_line_pos < folder_line_pos);
|
||||
assert!(folder_line_pos < hidden_line_pos);
|
||||
assert!(hidden_line_pos < regular_line_pos);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user