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