1use std::collections::HashMap;
2
3use rand::seq::SliceRandom;
4
5use crate::parsing::{parse_depth_constraints, parse_size_constraint};
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9#[non_exhaustive]
10pub enum SortCriterion {
11 Niche,
12 Frecency,
13 Peers,
14 Random,
15 FolderAggregate,
16 Time,
18 Date,
19 Week,
20 Month,
21 Year,
22 Size,
23 FolderSize,
24 FolderAvgSize,
25 FolderDate,
26 FolderTime,
27 Count,
28}
29
30impl SortCriterion {
31 pub fn parse_str(s: &str) -> Result<Self, String> {
37 match s.to_ascii_lowercase().as_str() {
38 "niche" => Ok(Self::Niche),
39 "frecency" => Ok(Self::Frecency),
40 "peers" | "seeds" | "copies" => Ok(Self::Peers),
41 "random" => Ok(Self::Random),
42 "folder" | "folderaggregate" => Ok(Self::FolderAggregate),
43 "time" => Ok(Self::Time),
44 "date" | "day" => Ok(Self::Date),
45 "week" => Ok(Self::Week),
46 "month" => Ok(Self::Month),
47 "year" => Ok(Self::Year),
48 "size" => Ok(Self::Size),
49 "folder-size" | "foldersize" => Ok(Self::FolderSize),
50 "folder-avg-size" | "folder-size-avg" | "foldersize-avg" => Ok(Self::FolderAvgSize),
51 "folder-date" | "folderdate" => Ok(Self::FolderDate),
52 "folder-time" | "foldertime" => Ok(Self::FolderTime),
53 "count" | "file-count" => Ok(Self::Count),
54 _ => Err(format!("unsupported sort criterion: {s:?}")),
55 }
56 }
57}
58
59#[derive(Clone, Debug)]
61#[non_exhaustive]
62pub struct SortConfig {
63 pub criteria: Vec<(SortCriterion, bool)>, pub niche: usize,
65 pub frecency_weight: u64,
66 pub min_seeders: Option<usize>,
67 pub max_seeders: Option<usize>,
68 pub limit_size: Option<u64>,
69 pub min_depth: Option<usize>,
70 pub max_depth: Option<usize>,
71 pub enrich: bool,
72}
73
74impl Default for SortConfig {
75 fn default() -> Self {
76 Self {
77 criteria: vec![(SortCriterion::Niche, true), (SortCriterion::Frecency, true)],
78 niche: 3,
79 frecency_weight: 3,
80 min_seeders: None,
81 max_seeders: None,
82 limit_size: None,
83 min_depth: None,
84 max_depth: None,
85 enrich: false,
86 }
87 }
88}
89
90impl SortConfig {
91 #[must_use]
93 pub fn parse_criteria(criteria: &[String]) -> Vec<(SortCriterion, bool)> {
94 criteria
95 .iter()
96 .filter_map(|s| {
97 let (descending, name) = s.strip_prefix('-').map_or((false, s.as_str()), |rest| (true, rest));
98 SortCriterion::parse_str(name)
99 .ok()
100 .map(|criterion| (criterion, descending))
101 })
102 .collect()
103 }
104
105 pub fn parse_limit_size(s: &str) -> Result<u64, crate::error::SyncwebError> {
111 let (min, _max) = parse_size_constraint(s)?;
112 min.ok_or_else(|| crate::error::SyncwebError::InvalidConfig(format!("invalid limit size: {s:?}")))
113 }
114}
115
116#[derive(Clone, Debug, PartialEq)]
118#[non_exhaustive]
119pub struct SortEntry {
120 pub path: std::path::PathBuf,
121 pub folder: String,
122 pub niche: f64,
123 pub frequency: u64,
124 pub peers: usize,
125 pub modified: std::time::SystemTime,
126 pub size: u64,
127}
128
129impl SortEntry {
130 #[must_use]
131 pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
132 Self {
133 path: path.into(),
134 folder: String::new(),
135 niche: 0.0,
136 frequency: 0,
137 peers: 0,
138 modified: std::time::SystemTime::UNIX_EPOCH,
139 size: 0,
140 }
141 }
142
143 #[must_use]
144 pub fn with_folder(mut self, folder: impl Into<String>) -> Self {
145 self.folder = folder.into();
146 self
147 }
148
149 #[must_use]
150 pub const fn with_niche(mut self, niche: f64) -> Self {
151 self.niche = niche;
152 self
153 }
154
155 #[must_use]
156 pub const fn with_frequency(mut self, frequency: u64) -> Self {
157 self.frequency = frequency;
158 self
159 }
160
161 #[must_use]
162 pub const fn with_peers(mut self, peers: usize) -> Self {
163 self.peers = peers;
164 self
165 }
166
167 #[must_use]
168 pub const fn with_modified(mut self, modified: std::time::SystemTime) -> Self {
169 self.modified = modified;
170 self
171 }
172
173 #[must_use]
174 pub const fn with_size(mut self, size: u64) -> Self {
175 self.size = size;
176 self
177 }
178}
179
180#[derive(Clone, Copy, Debug)]
182#[non_exhaustive]
183pub struct FolderAggregate {
184 pub modified_median: std::time::SystemTime,
185 pub size_median: u64,
186 pub size_sum: u64,
187 pub file_count: usize,
188}
189
190impl Default for FolderAggregate {
191 fn default() -> Self {
192 Self {
193 modified_median: std::time::SystemTime::UNIX_EPOCH,
194 size_median: 0,
195 size_sum: 0,
196 file_count: 0,
197 }
198 }
199}
200
201#[must_use]
203pub fn aggregate_folders(
204 entries: &[SortEntry],
205 depth_list: &[String],
206 min_depth: usize,
207 max_depth: Option<usize>,
208) -> HashMap<String, FolderAggregate> {
209 let (effective_min, effective_max) = parse_depth_constraints(depth_list, min_depth, max_depth);
210
211 let mut grouped: HashMap<String, Vec<&SortEntry>> = HashMap::new();
212 for entry in entries {
213 let path = entry.path.to_string_lossy();
214 let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
215 let folder_parts = if parts.len() > 1 {
216 parts.get(..parts.len().saturating_sub(1)).unwrap_or(&[])
217 } else {
218 &[]
219 };
220
221 if effective_min == 0 && effective_max.is_none() {
222 let parent = if folder_parts.is_empty() {
224 String::new()
225 } else {
226 folder_parts.join("/")
227 };
228 grouped.entry(parent).or_default().push(entry);
229 } else {
230 let root = if path.starts_with('/') { "/" } else { "" };
232 for depth in effective_min..=effective_max.unwrap_or(folder_parts.len()) {
233 if depth <= folder_parts.len() {
234 let folder = if depth == 0 {
235 root.to_string()
236 } else {
237 format!("{root}{}", folder_parts.get(..depth).unwrap_or(&[]).join("/"))
238 };
239 grouped.entry(folder).or_default().push(entry);
240 }
241 }
242 }
243 }
244
245 let mut aggregates = HashMap::new();
246 for (folder, folder_entries) in grouped {
247 let mut modified_times: Vec<_> = folder_entries.iter().map(|e| e.modified).collect();
248 modified_times.sort();
249
250 let mut sizes: Vec<u64> = folder_entries.iter().map(|e| e.size).collect();
251 sizes.sort_unstable();
252
253 let size_sum: u64 = sizes.iter().sum();
254 let file_count = folder_entries.len();
255
256 let modified_median = modified_times
257 .get(modified_times.len().checked_div(2).unwrap_or(0))
258 .copied()
259 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
260
261 let size_median = sizes.get(sizes.len().checked_div(2).unwrap_or(0)).copied().unwrap_or(0);
262
263 aggregates.insert(
264 folder,
265 FolderAggregate {
266 modified_median,
267 size_median,
268 size_sum,
269 file_count,
270 },
271 );
272 }
273
274 aggregates
275}
276
277#[derive(Clone, Debug)]
279pub struct Sorter {
280 config: SortConfig,
281}
282
283impl Sorter {
284 #[must_use]
285 pub const fn new(config: SortConfig) -> Self {
286 Self { config }
287 }
288
289 #[must_use]
291 pub fn filter_seeders(&self, entries: Vec<SortEntry>) -> Vec<SortEntry> {
292 entries
293 .into_iter()
294 .filter(|entry| {
295 if let Some(min) = self.config.min_seeders
296 && entry.peers < min
297 {
298 return false;
299 }
300 if let Some(max) = self.config.max_seeders
301 && entry.peers > max
302 {
303 return false;
304 }
305 true
306 })
307 .collect()
308 }
309
310 pub fn enrich_peers(&self, entries: &mut [SortEntry], peer_map: &HashMap<String, usize>) {
312 for entry in entries.iter_mut() {
313 if let Some(count) = peer_map.get(&entry.path.to_string_lossy().to_string()) {
314 entry.peers = *count;
315 }
316 }
317 }
318
319 pub fn enrich_niche(&self, entries: &mut [SortEntry]) {
322 let ideal = self.config.niche;
323 for entry in entries.iter_mut() {
324 let distance = entry.peers.abs_diff(ideal);
325 let dist_u32 = u32::try_from(distance).unwrap_or(u32::MAX);
326 entry.niche = f64::from(dist_u32.saturating_add(1_u32)).recip();
327 }
328 }
329
330 pub fn enrich_frequency(&self, entries: &mut [SortEntry], freq_map: &HashMap<String, u64>) {
332 for entry in entries.iter_mut() {
333 if let Some(count) = freq_map.get(&entry.path.to_string_lossy().to_string()) {
334 entry.frequency = *count;
335 }
336 }
337 }
338
339 pub fn sort<'a>(&'a self, entries: &'a mut [SortEntry]) -> SortResult<'a> {
343 let folder_aggregates =
344 aggregate_folders(entries, &[], self.config.min_depth.unwrap_or(0), self.config.max_depth);
345
346 let frecency_weight = self.config.frecency_weight;
347
348 if self.config.criteria.iter().any(|(c, _)| *c == SortCriterion::Random) {
349 let mut rng = rand::rng();
350 entries.shuffle(&mut rng);
351 return SortResult {
352 entries,
353 limit_size: self.config.limit_size,
354 folder_aggregates,
355 };
356 }
357
358 entries.sort_by(|left, right| {
359 for (criterion, descending) in &self.config.criteria {
360 let ordering = match criterion {
361 SortCriterion::Niche => left
362 .niche
363 .partial_cmp(&right.niche)
364 .unwrap_or(std::cmp::Ordering::Equal),
365 SortCriterion::Frecency => {
366 let left_frec = frecency(left, frecency_weight);
367 let right_frec = frecency(right, frecency_weight);
368 left_frec.cmp(&right_frec)
369 }
370 SortCriterion::Peers => left.peers.cmp(&right.peers),
371 SortCriterion::Random => std::cmp::Ordering::Equal, SortCriterion::FolderAggregate => {
373 let left_count = folder_aggregates.get(&left.folder).map_or(0, |agg| agg.file_count);
374 let right_count = folder_aggregates.get(&right.folder).map_or(0, |agg| agg.file_count);
375 left_count.cmp(&right_count)
376 }
377 SortCriterion::Time => left.modified.cmp(&right.modified),
378 SortCriterion::Date => {
379 let left_day = time_since_epoch(left.modified, 86400);
380 let right_day = time_since_epoch(right.modified, 86400);
381 left_day.cmp(&right_day)
382 }
383 SortCriterion::Week => {
384 let left_week = time_since_epoch(left.modified, 604_800);
385 let right_week = time_since_epoch(right.modified, 604_800);
386 left_week.cmp(&right_week)
387 }
388 SortCriterion::Month => {
389 let left_month = time_since_epoch(left.modified, 2_592_000);
390 let right_month = time_since_epoch(right.modified, 2_592_000);
391 left_month.cmp(&right_month)
392 }
393 SortCriterion::Year => {
394 let left_year = time_since_epoch(left.modified, 31_536_000);
395 let right_year = time_since_epoch(right.modified, 31_536_000);
396 left_year.cmp(&right_year)
397 }
398 SortCriterion::Size => left.size.cmp(&right.size),
399 SortCriterion::FolderSize => {
400 let left_size = folder_aggregates.get(&left.folder).map_or(0, |agg| agg.size_sum);
401 let right_size = folder_aggregates.get(&right.folder).map_or(0, |agg| agg.size_sum);
402 left_size.cmp(&right_size)
403 }
404 SortCriterion::FolderAvgSize => {
405 let avg_size = |agg: &FolderAggregate| -> u64 {
406 if agg.file_count > 0 {
407 let count = u64::try_from(agg.file_count).unwrap_or(u64::MAX);
408 agg.size_sum.checked_div(count).unwrap_or(0)
409 } else {
410 0
411 }
412 };
413 let left_avg = folder_aggregates.get(&left.folder).map_or(0, avg_size);
414 let right_avg = folder_aggregates.get(&right.folder).map_or(0, avg_size);
415 left_avg.cmp(&right_avg)
416 }
417 SortCriterion::FolderDate => {
418 let left_time = folder_aggregates
419 .get(&left.folder)
420 .map_or(std::time::SystemTime::UNIX_EPOCH, |agg| agg.modified_median);
421 let right_time = folder_aggregates
422 .get(&right.folder)
423 .map_or(std::time::SystemTime::UNIX_EPOCH, |agg| agg.modified_median);
424 let left_day = time_since_epoch(left_time, 86400);
425 let right_day = time_since_epoch(right_time, 86400);
426 left_day.cmp(&right_day)
427 }
428 SortCriterion::FolderTime => {
429 let left_time = folder_aggregates
430 .get(&left.folder)
431 .map_or(std::time::SystemTime::UNIX_EPOCH, |agg| agg.modified_median);
432 let right_time = folder_aggregates
433 .get(&right.folder)
434 .map_or(std::time::SystemTime::UNIX_EPOCH, |agg| agg.modified_median);
435 left_time.cmp(&right_time)
436 }
437 SortCriterion::Count => {
438 let left_count = folder_aggregates.get(&left.folder).map_or(0, |agg| agg.file_count);
439 let right_count = folder_aggregates.get(&right.folder).map_or(0, |agg| agg.file_count);
440 left_count.cmp(&right_count)
441 }
442 };
443
444 let final_ordering = if *descending { ordering.reverse() } else { ordering };
445
446 if final_ordering != std::cmp::Ordering::Equal {
447 return final_ordering;
448 }
449 }
450 std::cmp::Ordering::Equal
451 });
452
453 SortResult {
454 entries,
455 limit_size: self.config.limit_size,
456 folder_aggregates,
457 }
458 }
459}
460
461pub struct SortResult<'a> {
463 entries: &'a mut [SortEntry],
464 limit_size: Option<u64>,
465 folder_aggregates: HashMap<String, FolderAggregate>,
466}
467
468impl<'a> SortResult<'a> {
469 #[must_use]
471 pub const fn iter(&'a self) -> SortIterator<'a> {
472 SortIterator {
473 entries: self.entries,
474 index: 0,
475 cumulative_size: 0,
476 limit_size: self.limit_size,
477 }
478 }
479
480 #[must_use]
482 pub const fn folder_aggregates(&self) -> &HashMap<String, FolderAggregate> {
483 &self.folder_aggregates
484 }
485}
486
487impl<'a> IntoIterator for &'a SortResult<'a> {
488 type Item = &'a SortEntry;
489 type IntoIter = SortIterator<'a>;
490
491 fn into_iter(self) -> Self::IntoIter {
492 self.iter()
493 }
494}
495
496pub struct SortIterator<'a> {
498 entries: &'a [SortEntry],
499 index: usize,
500 cumulative_size: u64,
501 limit_size: Option<u64>,
502}
503
504impl<'a> Iterator for SortIterator<'a> {
505 type Item = &'a SortEntry;
506
507 fn next(&mut self) -> Option<Self::Item> {
508 if self.index >= self.entries.len() {
509 return None;
510 }
511
512 let entry = self.entries.get(self.index)?;
513 let new_size = self.cumulative_size.saturating_add(entry.size);
514
515 if let Some(limit) = self.limit_size
516 && self.index > 0
517 && new_size > limit
518 {
519 return None;
520 }
521
522 self.index = self.index.saturating_add(1);
523 self.cumulative_size = new_size;
524 Some(entry)
525 }
526}
527
528fn frecency(entry: &SortEntry, frecency_weight: u64) -> i64 {
529 let days_since = days_since_modified(entry.modified);
530
531 let freq = i64::try_from(entry.frequency).unwrap_or(i64::MAX);
532
533 let penalty = days_since.checked_div(frecency_weight).unwrap_or(0);
534
535 freq.saturating_sub(i64::try_from(penalty).unwrap_or(i64::MAX))
536}
537
538#[must_use]
539fn days_since_modified(time: std::time::SystemTime) -> u64 {
540 let now = std::time::SystemTime::now();
541 now.duration_since(time)
542 .map_or(0, |d| d.as_secs().checked_div(86400).unwrap_or(0))
543}
544
545fn time_since_epoch(time: std::time::SystemTime, divisor: u64) -> u64 {
546 time.duration_since(std::time::UNIX_EPOCH)
547 .map_or(0, |d| d.as_secs().checked_div(divisor).unwrap_or(0))
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn test_parse_criteria() {
556 let criteria = SortConfig::parse_criteria(&["-niche".to_string(), "-frecency".to_string(), "time".to_string()]);
557 assert_eq!(criteria.len(), 3);
558 assert_eq!(criteria.first().copied().unwrap(), (SortCriterion::Niche, true));
559 assert_eq!(criteria.get(1).copied().unwrap(), (SortCriterion::Frecency, true));
560 assert_eq!(criteria.get(2).copied().unwrap(), (SortCriterion::Time, false));
561 }
562
563 #[test]
564 fn test_sort_by_peers() {
565 let config = SortConfig {
566 criteria: vec![(SortCriterion::Peers, true)],
567 ..SortConfig::default()
568 };
569 let sorter = Sorter::new(config);
570 let mut entries = vec![
571 SortEntry::new("a.txt").with_peers(1),
572 SortEntry::new("b.txt").with_peers(3),
573 SortEntry::new("c.txt").with_peers(2),
574 ];
575 let result = sorter.sort(&mut entries);
576 let paths: Vec<_> = result.iter().map(|e| e.path.clone()).collect();
577 assert_eq!(
578 paths,
579 [
580 std::path::PathBuf::from("b.txt"),
581 std::path::PathBuf::from("c.txt"),
582 std::path::PathBuf::from("a.txt")
583 ]
584 );
585 }
586
587 #[test]
588 fn test_sort_by_size() {
589 let config = SortConfig {
590 criteria: vec![(SortCriterion::Size, false)],
591 ..SortConfig::default()
592 };
593 let sorter = Sorter::new(config);
594 let mut entries = vec![
595 SortEntry::new("a.txt").with_size(100),
596 SortEntry::new("b.txt").with_size(300),
597 SortEntry::new("c.txt").with_size(200),
598 ];
599 let result = sorter.sort(&mut entries);
600 let paths: Vec<_> = result.iter().map(|e| e.path.clone()).collect();
601 assert_eq!(
602 paths,
603 [
604 std::path::PathBuf::from("a.txt"),
605 std::path::PathBuf::from("c.txt"),
606 std::path::PathBuf::from("b.txt")
607 ]
608 );
609 }
610
611 #[test]
612 fn test_limit_size() {
613 let config = SortConfig {
614 criteria: vec![(SortCriterion::Size, false)],
615 limit_size: Some(300),
616 ..SortConfig::default()
617 };
618 let sorter = Sorter::new(config);
619 let mut entries = vec![
620 SortEntry::new("a.txt").with_size(100),
621 SortEntry::new("b.txt").with_size(300),
622 SortEntry::new("c.txt").with_size(200),
623 ];
624 let result = sorter.sort(&mut entries);
625 let paths: Vec<_> = result.iter().map(|e| e.path.clone()).collect();
626 assert_eq!(
627 paths,
628 [std::path::PathBuf::from("a.txt"), std::path::PathBuf::from("c.txt")]
629 );
630 }
631
632 #[test]
633 fn test_filter_seeders() {
634 let config = SortConfig {
635 criteria: vec![(SortCriterion::Peers, true)],
636 min_seeders: Some(2),
637 max_seeders: Some(4),
638 ..SortConfig::default()
639 };
640 let sorter = Sorter::new(config);
641 let entries = vec![
642 SortEntry::new("a.txt").with_peers(1),
643 SortEntry::new("b.txt").with_peers(3),
644 SortEntry::new("c.txt").with_peers(5),
645 ];
646 let filtered = sorter.filter_seeders(entries);
647 assert_eq!(filtered.len(), 1);
648 assert_eq!(filtered.first().unwrap().path, std::path::PathBuf::from("b.txt"));
649 }
650}