use std::cmp::Ordering;
use crate::app::state::{Entry, EntryKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortBy {
#[default]
Name,
Size,
Modified,
Extension,
}
impl SortBy {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"name" => Some(Self::Name),
"size" => Some(Self::Size),
"modified" | "time" => Some(Self::Modified),
"extension" | "ext" => Some(Self::Extension),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Name => "name",
Self::Size => "size",
Self::Modified => "modified",
Self::Extension => "extension",
}
}
pub fn short_label(self) -> &'static str {
match self {
Self::Name => "name",
Self::Size => "size",
Self::Modified => "time",
Self::Extension => "ext",
}
}
pub fn descends_by_default(self) -> bool {
matches!(self, Self::Size | Self::Modified)
}
}
pub const SORT_BY_REASON: &str = "must be name, size, modified or extension";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SortSettings {
pub by: SortBy,
pub reverse: bool,
pub dirs_first: bool,
}
impl Default for SortSettings {
fn default() -> Self {
Self {
by: SortBy::Name,
reverse: false,
dirs_first: true,
}
}
}
impl SortSettings {
pub fn is_descending(self) -> bool {
self.by.descends_by_default() != self.reverse
}
pub fn describe(self) -> String {
let mut out = format!("sort: {}", self.by.as_str());
if self.reverse {
out.push_str(", reversed");
}
if !self.dirs_first {
out.push_str(", mixed with directories");
}
out
}
}
pub fn sort_entries(entries: &mut [Entry], settings: SortSettings) {
entries.sort_by(|a, b| compare(a, b, settings));
}
fn compare(a: &Entry, b: &Entry, settings: SortSettings) -> Ordering {
if settings.dirs_first {
let a_dir = a.kind == EntryKind::Dir;
let b_dir = b.kind == EntryKind::Dir;
match (a_dir, b_dir) {
(true, false) => return Ordering::Less,
(false, true) => return Ordering::Greater,
_ => {}
}
}
let by = if settings.by == SortBy::Size && a.kind == EntryKind::Dir && b.kind == EntryKind::Dir
{
SortBy::Name
} else {
settings.by
};
if matches!(by, SortBy::Size | SortBy::Modified) {
match (a.metadata.is_some(), b.metadata.is_some()) {
(true, false) => return Ordering::Less,
(false, true) => return Ordering::Greater,
_ => {}
}
}
let key = match by {
SortBy::Name => Ordering::Equal,
SortBy::Size => size_of(a).cmp(&size_of(b)),
SortBy::Modified => modified_of(a).cmp(&modified_of(b)),
SortBy::Extension => extension_of(a).cmp(&extension_of(b)),
};
let key = if by.descends_by_default() {
key.reverse()
} else {
key
};
let ord = key.then_with(|| a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()));
if settings.reverse {
ord.reverse()
} else {
ord
}
}
fn size_of(entry: &Entry) -> u64 {
entry.metadata.as_ref().map(|m| m.len()).unwrap_or(0)
}
fn modified_of(entry: &Entry) -> Option<std::time::SystemTime> {
entry.metadata.as_ref().and_then(|m| m.modified().ok())
}
fn extension_of(entry: &Entry) -> String {
entry
.path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_accepts_every_canonical_spelling() {
for key in ["name", "size", "modified", "extension"] {
let parsed = SortBy::parse(key).expect("canonical spelling parses");
assert_eq!(parsed.as_str(), key, "round trip for {key}");
}
}
#[test]
fn parse_accepts_aliases_and_case() {
assert_eq!(SortBy::parse("TIME"), Some(SortBy::Modified));
assert_eq!(SortBy::parse(" ext "), Some(SortBy::Extension));
assert_eq!(SortBy::parse("Name"), Some(SortBy::Name));
}
#[test]
fn parse_rejects_an_unknown_key() {
assert_eq!(SortBy::parse("created"), None);
assert_eq!(SortBy::parse(""), None);
}
#[test]
fn default_is_the_pre_1_8_order() {
let settings = SortSettings::default();
assert_eq!(settings.by, SortBy::Name);
assert!(!settings.reverse);
assert!(settings.dirs_first);
}
#[test]
fn size_and_time_descend_by_default_and_the_others_do_not() {
assert!(SortBy::Size.descends_by_default());
assert!(SortBy::Modified.descends_by_default());
assert!(!SortBy::Name.descends_by_default());
assert!(!SortBy::Extension.descends_by_default());
}
#[test]
fn reverse_flips_the_reported_direction_for_every_key() {
for by in [
SortBy::Name,
SortBy::Size,
SortBy::Modified,
SortBy::Extension,
] {
let forward = SortSettings {
by,
reverse: false,
dirs_first: true,
};
let flipped = SortSettings {
reverse: true,
..forward
};
assert_eq!(
forward.is_descending(),
by.descends_by_default(),
"{by:?} unreversed should report its natural direction"
);
assert_ne!(
forward.is_descending(),
flipped.is_descending(),
"{by:?} reversed should report the other direction"
);
}
}
#[test]
fn short_labels_stay_inside_the_badge_budget() {
for by in [
SortBy::Name,
SortBy::Size,
SortBy::Modified,
SortBy::Extension,
] {
let label = by.short_label();
assert!(
(1..=4).contains(&label.chars().count()),
"{by:?} label {label:?} does not fit the badge"
);
}
}
#[test]
fn describe_names_only_what_is_not_default() {
assert_eq!(SortSettings::default().describe(), "sort: name");
let reversed = SortSettings {
reverse: true,
..SortSettings::default()
};
assert_eq!(reversed.describe(), "sort: name, reversed");
}
}