add new sorting options

This commit is contained in:
bgreenwell
2025-08-13 10:18:46 -04:00
parent 789f7ec15d
commit 9baab8b28a
10 changed files with 759 additions and 14 deletions

5
.gitignore vendored
View File

@@ -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
View File

@@ -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"

View File

@@ -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]

View File

@@ -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 (`|`).

View File

@@ -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 {

View File

@@ -7,6 +7,7 @@
mod app;
mod git;
mod icons;
mod sort;
mod tui;
mod utils;
mod view;

377
src/sort.rs Normal file
View 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('.'));
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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(())
}