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    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    /// Parse sort criteria from strings like "-niche", "-frecency", "time".
92    #[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    /// Parse limit size from a string.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if the string cannot be parsed.
110    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/// Metadata consumed by [`Sorter`].
117#[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/// Folder aggregates computed from entries.
181#[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/// Compute folder aggregates for sorting.
202#[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            // Default: immediate parent folder
223            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            // Multi-level aggregation based on depth constraints
231            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/// Sort entries using one of the supported content discovery rankings.
278#[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    /// Filter entries by seeders before sorting.
290    #[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    /// Populate `entry.peers` from a path→count map.
311    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    /// Compute `entry.niche` from `entry.peers` using `config.niche` as ideal count.
320    /// Formula: `1.0 / (1.0 + |peers - ideal|)`
321    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    /// Populate `entry.frequency` from a path→access-count map.
331    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    /// Sort entries by multiple criteria.
340    ///
341    /// Returns an iterator that yields paths until `limit_size` is reached.
342    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, // handled separately
372                    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
461/// Result of sorting, with iterator support for `limit_size`.
462pub 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    /// Iterate over sorted entries, stopping when `limit_size` is reached.
470    #[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    /// Get the folder aggregates.
481    #[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
496/// Iterator over sorted entries with size limit.
497pub 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}