use std::cmp::Ordering; use std::path::Path; use std::time::SystemTime; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EntryKind { Directory, ZipArchive, Image, Video, OtherFile, } impl EntryKind { pub fn is_viewable(self) -> bool { matches!(self, Self::ZipArchive | Self::Image | Self::Video) } } #[derive(Debug, Clone)] pub struct BrowserEntry { pub uri: String, pub name: String, pub kind: EntryKind, pub size: Option, pub modified: Option, pub created: Option, pub etag: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SortField { Name, Modified, Created, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SortDirection { Ascending, Descending, } pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: SortDirection) { entries.sort_by(|left, right| { let kind_order = match ( left.kind == EntryKind::Directory, right.kind == EntryKind::Directory, ) { (true, false) => Ordering::Less, (false, true) => Ordering::Greater, _ => Ordering::Equal, }; if kind_order != Ordering::Equal { return kind_order; } match field { SortField::Name => apply_direction(natural_cmp(&left.name, &right.name), direction), SortField::Modified => compare_optional_time(left.modified, right.modified, direction) .then_with(|| apply_direction(natural_cmp(&left.name, &right.name), direction)), SortField::Created => compare_optional_time(left.created, right.created, direction) .then_with(|| apply_direction(natural_cmp(&left.name, &right.name), direction)), } }); } fn compare_optional_time( left: Option, right: Option, direction: SortDirection, ) -> Ordering { match (left, right) { (Some(left), Some(right)) => apply_direction(left.cmp(&right), direction), (Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater, (None, None) => Ordering::Equal, } } fn apply_direction(order: Ordering, direction: SortDirection) -> Ordering { match direction { SortDirection::Ascending => order, SortDirection::Descending => order.reverse(), } } pub fn classify_file(path: &Path) -> EntryKind { let extension = path .extension() .and_then(|extension| extension.to_str()) .map(str::to_ascii_lowercase); match extension.as_deref() { Some("zip") => EntryKind::ZipArchive, Some("jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "avif") => EntryKind::Image, Some("mp4" | "m4v" | "mkv" | "webm" | "mov" | "avi" | "mpeg" | "mpg" | "ogv") => { EntryKind::Video } _ => EntryKind::OtherFile, } } pub fn natural_cmp(left: &str, right: &str) -> Ordering { let mut left_chars = left.chars().peekable(); let mut right_chars = right.chars().peekable(); loop { match (left_chars.peek(), right_chars.peek()) { (None, None) => return Ordering::Equal, (None, Some(_)) => return Ordering::Less, (Some(_), None) => return Ordering::Greater, (Some(left_char), Some(right_char)) if left_char.is_ascii_digit() && right_char.is_ascii_digit() => { let left_number = take_digits(&mut left_chars); let right_number = take_digits(&mut right_chars); let left_trimmed = left_number.trim_start_matches('0'); let right_trimmed = right_number.trim_start_matches('0'); let left_significant = if left_trimmed.is_empty() { "0" } else { left_trimmed }; let right_significant = if right_trimmed.is_empty() { "0" } else { right_trimmed }; let order = left_significant .len() .cmp(&right_significant.len()) .then_with(|| left_significant.cmp(right_significant)) .then_with(|| left_number.len().cmp(&right_number.len())); if order != Ordering::Equal { return order; } } (Some(_), Some(_)) => { let left_char = left_chars.next().unwrap(); let right_char = right_chars.next().unwrap(); let order = left_char .to_lowercase() .cmp(right_char.to_lowercase()) .then_with(|| left_char.cmp(&right_char)); if order != Ordering::Equal { return order; } } } } } fn take_digits(characters: &mut std::iter::Peekable) -> String where I: Iterator, { let mut digits = String::new(); while characters.peek().is_some_and(char::is_ascii_digit) { digits.push(characters.next().unwrap()); } digits } #[cfg(test)] mod tests { use super::*; #[test] fn natural_order_places_two_before_ten() { let mut names = ["page10.jpg", "page2.jpg", "page1.jpg"]; names.sort_by(|left, right| natural_cmp(left, right)); assert_eq!(names, ["page1.jpg", "page2.jpg", "page10.jpg"]); } #[test] fn natural_order_handles_numbers_larger_than_integers() { assert_eq!( natural_cmp("9.zip", "123456789012345678901234567890.zip"), Ordering::Less ); } #[test] fn zip_extension_is_case_insensitive() { assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive); assert_ne!(classify_file(Path::new("comic.cbz")), EntryKind::ZipArchive); } #[test] fn classifies_common_media_extensions_case_insensitively() { assert_eq!(classify_file(Path::new("photo.JPG")), EntryKind::Image); assert_eq!(classify_file(Path::new("animation.webp")), EntryKind::Image); assert_eq!(classify_file(Path::new("movie.MP4")), EntryKind::Video); assert_eq!(classify_file(Path::new("movie.mkv")), EntryKind::Video); assert_eq!(classify_file(Path::new("notes.txt")), EntryKind::OtherFile); } #[test] fn unknown_times_sort_after_known_times() { let now = SystemTime::now(); assert_eq!( compare_optional_time(Some(now), None, SortDirection::Ascending), Ordering::Less ); assert_eq!( compare_optional_time(Some(now), None, SortDirection::Descending), Ordering::Less ); } }