Skip to main content

syncweb_core/
sort.rs

1use std::collections::HashMap;
2
3use rand::seq::SliceRandom;
4
5use crate::parsing::{parse_depth_constraints, parse_size_constraint};
6
7/// Ranking strategy for synchronized entries.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9#[non_exhaustive]
10pub enum SortCriterion {
11    Niche,
12    Frecency,
13    Peers,
14    Random,
15    FolderAggregate,
16    // NEW:
17    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    /// Parse a criterion from a string.
32    ///
33    /// # Errors
34    ///
35    /// Returns an error if the string is not a valid criterion.
36    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/// Configuration for the sorter.
60#[derive(Clone, Debug)]
61#[non_exhaustive]
62pub struct SortConfig {
63    pub criteria: Vec<(SortCriterion, bool)>, // (criterion, descending)
64    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    /// Parse sort criteria from strings like "-niche", "-frecency", "time".
90    #[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    /// Parse limit size from a string.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if the string cannot be parsed.
108    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/// Metadata consumed by [`Sorter`].
115#[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/// Folder aggregates computed from entries.
179#[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/// Compute folder aggregates for sorting.
200#[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            // Default: immediate parent folder
221            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            // Multi-level aggregation based on depth constraints
229            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/// Sort entries using one of the supported content discovery rankings.
276#[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    /// Filter entries by seeders before sorting.
288    #[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    /// Sort entries by multiple criteria.
309    ///
310    /// Returns an iterator that yields paths until `limit_size` is reached.
311    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, // handled separately
331                    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        // Handle random shuffle if needed
405        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
418/// Result of sorting, with iterator support for `limit_size`.
419pub 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    /// Iterate over sorted entries, stopping when `limit_size` is reached.
427    #[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    /// Get the folder aggregates.
438    #[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
453/// Iterator over sorted entries with size limit.
454pub 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}