@@ -177,3 +177,230 @@ pub fn timestamp(delimiter: Option<&str>) -> String {
177177 let format = format ! ( "%Y{0}%m{0}%d{0}%H{0}%M{0}%S{0}%f" , delimiter) ;
178178 chrono:: Local :: now ( ) . format ( & format) . to_string ( )
179179}
180+
181+ /// Natural sort comparison for strings (handles embedded numbers correctly)
182+ ///
183+ /// Compares strings by breaking them into chunks of digits and non-digits,
184+ /// comparing digit chunks numerically and text chunks lexicographically.
185+ ///
186+ /// # Examples
187+ /// ```ignore
188+ /// use usls::natural_compare;
189+ ///
190+ /// assert!(natural_compare("file1.txt", "file2.txt") == std::cmp::Ordering::Less);
191+ /// assert!(natural_compare("file2.txt", "file10.txt") == std::cmp::Ordering::Less);
192+ /// assert!(natural_compare("img001.jpg", "img100.jpg") == std::cmp::Ordering::Less);
193+ /// ```
194+ pub fn natural_compare ( a : & str , b : & str ) -> std:: cmp:: Ordering {
195+ use std:: cmp:: Ordering ;
196+
197+ let mut a_chars = a. chars ( ) . peekable ( ) ;
198+ let mut b_chars = b. chars ( ) . peekable ( ) ;
199+
200+ loop {
201+ match ( a_chars. peek ( ) , b_chars. peek ( ) ) {
202+ ( None , None ) => return Ordering :: Equal ,
203+ ( None , Some ( _) ) => return Ordering :: Less ,
204+ ( Some ( _) , None ) => return Ordering :: Greater ,
205+ ( Some ( & ca) , Some ( & cb) ) => {
206+ // Both are digits - compare numerically
207+ if ca. is_ascii_digit ( ) && cb. is_ascii_digit ( ) {
208+ // Extract full number from both strings
209+ let mut num_a = String :: new ( ) ;
210+ let mut num_b = String :: new ( ) ;
211+
212+ while let Some ( & c) = a_chars. peek ( ) {
213+ if c. is_ascii_digit ( ) {
214+ num_a. push ( c) ;
215+ a_chars. next ( ) ;
216+ } else {
217+ break ;
218+ }
219+ }
220+
221+ while let Some ( & c) = b_chars. peek ( ) {
222+ if c. is_ascii_digit ( ) {
223+ num_b. push ( c) ;
224+ b_chars. next ( ) ;
225+ } else {
226+ break ;
227+ }
228+ }
229+
230+ // Compare as numbers
231+ // Handle potential parsing errors by falling back to string comparison
232+ match ( num_a. parse :: < u64 > ( ) , num_b. parse :: < u64 > ( ) ) {
233+ ( Ok ( na) , Ok ( nb) ) => match na. cmp ( & nb) {
234+ Ordering :: Equal => continue ,
235+ other => return other,
236+ } ,
237+ // If numbers are too large for u64, compare string length first
238+ // then lexicographically (maintains correct ordering for large numbers)
239+ _ => match num_a. len ( ) . cmp ( & num_b. len ( ) ) {
240+ Ordering :: Equal => match num_a. cmp ( & num_b) {
241+ Ordering :: Equal => continue ,
242+ other => return other,
243+ } ,
244+ other => return other,
245+ } ,
246+ }
247+ } else {
248+ // At least one is not a digit - compare lexicographically
249+ a_chars. next ( ) ;
250+ b_chars. next ( ) ;
251+ match ca. cmp ( & cb) {
252+ Ordering :: Equal => continue ,
253+ other => return other,
254+ }
255+ }
256+ }
257+ }
258+ }
259+ }
260+
261+ #[ cfg( test) ]
262+ mod tests {
263+ use super :: * ;
264+ use std:: cmp:: Ordering ;
265+
266+ #[ test]
267+ fn test_natural_compare_basic ( ) {
268+ assert_eq ! ( natural_compare( "file1.txt" , "file2.txt" ) , Ordering :: Less ) ;
269+ assert_eq ! ( natural_compare( "file2.txt" , "file10.txt" ) , Ordering :: Less ) ;
270+ assert_eq ! (
271+ natural_compare( "file10.txt" , "file2.txt" ) ,
272+ Ordering :: Greater
273+ ) ;
274+ assert_eq ! ( natural_compare( "file5.txt" , "file5.txt" ) , Ordering :: Equal ) ;
275+ }
276+
277+ #[ test]
278+ fn test_natural_compare_leading_zeros ( ) {
279+ assert_eq ! ( natural_compare( "img001.jpg" , "img002.jpg" ) , Ordering :: Less ) ;
280+ assert_eq ! ( natural_compare( "img001.jpg" , "img100.jpg" ) , Ordering :: Less ) ;
281+ assert_eq ! ( natural_compare( "img099.jpg" , "img100.jpg" ) , Ordering :: Less ) ;
282+
283+ // Leading zeros are preserved in text but compared numerically
284+ assert_eq ! ( natural_compare( "img01.jpg" , "img1.jpg" ) , Ordering :: Equal ) ; // 01 == 1
285+ }
286+
287+ #[ test]
288+ fn test_natural_compare_version_numbers ( ) {
289+ assert_eq ! ( natural_compare( "v1.0.1" , "v1.0.2" ) , Ordering :: Less ) ;
290+ assert_eq ! ( natural_compare( "v1.0.9" , "v1.0.10" ) , Ordering :: Less ) ;
291+ assert_eq ! ( natural_compare( "v1.9.0" , "v1.10.0" ) , Ordering :: Less ) ;
292+ assert_eq ! ( natural_compare( "v2.0.0" , "v1.99.99" ) , Ordering :: Greater ) ;
293+ }
294+
295+ #[ test]
296+ fn test_natural_compare_mixed_content ( ) {
297+ assert_eq ! ( natural_compare( "test" , "test123" ) , Ordering :: Less ) ;
298+ assert_eq ! ( natural_compare( "test123" , "test" ) , Ordering :: Greater ) ;
299+ assert_eq ! ( natural_compare( "abc123def" , "abc123def" ) , Ordering :: Equal ) ;
300+ assert_eq ! ( natural_compare( "abc123def" , "abc124def" ) , Ordering :: Less ) ;
301+ }
302+
303+ #[ test]
304+ fn test_natural_compare_edge_cases ( ) {
305+ // Empty strings
306+ assert_eq ! ( natural_compare( "" , "" ) , Ordering :: Equal ) ;
307+ assert_eq ! ( natural_compare( "" , "a" ) , Ordering :: Less ) ;
308+ assert_eq ! ( natural_compare( "a" , "" ) , Ordering :: Greater ) ;
309+
310+ // Pure numbers
311+ assert_eq ! ( natural_compare( "1" , "2" ) , Ordering :: Less ) ;
312+ assert_eq ! ( natural_compare( "9" , "10" ) , Ordering :: Less ) ;
313+ assert_eq ! ( natural_compare( "100" , "99" ) , Ordering :: Greater ) ;
314+
315+ // Pure text
316+ assert_eq ! ( natural_compare( "apple" , "banana" ) , Ordering :: Less ) ;
317+ assert_eq ! ( natural_compare( "zebra" , "apple" ) , Ordering :: Greater ) ;
318+ }
319+
320+ #[ test]
321+ fn test_natural_compare_complex_filenames ( ) {
322+ // Common image sequence patterns
323+ assert_eq ! (
324+ natural_compare( "frame_0001.png" , "frame_0010.png" ) ,
325+ Ordering :: Less
326+ ) ;
327+ assert_eq ! (
328+ natural_compare( "shot1_take5.mp4" , "shot1_take15.mp4" ) ,
329+ Ordering :: Less
330+ ) ;
331+ assert_eq ! (
332+ natural_compare( "scene2_v3.mov" , "scene10_v1.mov" ) ,
333+ Ordering :: Less
334+ ) ;
335+
336+ // Dataset naming
337+ assert_eq ! (
338+ natural_compare( "data_batch_1" , "data_batch_10" ) ,
339+ Ordering :: Less
340+ ) ;
341+ assert_eq ! (
342+ natural_compare( "train_001.jpg" , "train_1000.jpg" ) ,
343+ Ordering :: Less
344+ ) ;
345+ }
346+
347+ #[ test]
348+ fn test_natural_compare_large_numbers ( ) {
349+ // Numbers larger than u64::MAX
350+ let large_a = "file99999999999999999999.txt" ;
351+ let large_b = "file100000000000000000000.txt" ;
352+ assert_eq ! ( natural_compare( large_a, large_b) , Ordering :: Less ) ;
353+
354+ // Same length, different values
355+ assert_eq ! (
356+ natural_compare(
357+ "file12345678901234567890.txt" ,
358+ "file12345678901234567891.txt"
359+ ) ,
360+ Ordering :: Less
361+ ) ;
362+ }
363+
364+ #[ test]
365+ fn test_natural_compare_multiple_numbers ( ) {
366+ // Multiple number sequences in one string
367+ assert_eq ! ( natural_compare( "v1.2.3" , "v1.2.10" ) , Ordering :: Less ) ;
368+ assert_eq ! (
369+ natural_compare( "chapter1_page5" , "chapter1_page15" ) ,
370+ Ordering :: Less
371+ ) ;
372+ assert_eq ! ( natural_compare( "2024_01_05" , "2024_01_15" ) , Ordering :: Less ) ;
373+ assert_eq ! ( natural_compare( "2024_12_31" , "2025_01_01" ) , Ordering :: Less ) ;
374+ }
375+
376+ #[ test]
377+ fn test_natural_compare_case_sensitivity ( ) {
378+ // Case-sensitive comparison (standard behavior)
379+ assert_eq ! ( natural_compare( "File1.txt" , "file1.txt" ) , Ordering :: Less ) ; // 'F' < 'f'
380+ assert_eq ! ( natural_compare( "ABC" , "abc" ) , Ordering :: Less ) ;
381+ }
382+
383+ #[ test]
384+ fn test_natural_sorting ( ) {
385+ let mut files = vec ! [
386+ "file10.txt" ,
387+ "file2.txt" ,
388+ "file1.txt" ,
389+ "file20.txt" ,
390+ "file3.txt" ,
391+ ] ;
392+
393+ files. sort_by ( |a, b| natural_compare ( a, b) ) ;
394+
395+ assert_eq ! (
396+ files,
397+ vec![
398+ "file1.txt" ,
399+ "file2.txt" ,
400+ "file3.txt" ,
401+ "file10.txt" ,
402+ "file20.txt"
403+ ]
404+ ) ;
405+ }
406+ }
0 commit comments