Skip to main content

irontide_core/
file_selection.rs

1//! BEP 53 file selection for magnet URIs.
2//!
3//! The `so=` parameter selects specific files by index or range.
4//! Example: `so=0,2,4-6` selects files 0, 2, 4, 5, and 6.
5
6use crate::FilePriority;
7
8/// A file selection entry from a BEP 53 `so=` parameter.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum FileSelection {
11    /// A single file index.
12    Single(usize),
13    /// An inclusive range of file indices.
14    Range(usize, usize),
15}
16
17impl FileSelection {
18    /// Parse a `so=` parameter value into a list of file selections.
19    ///
20    /// Format: comma-separated entries, each either a single index or `start-end` range.
21    /// Example: `"0,2,4-6"` -> `[Single(0), Single(2), Range(4, 6)]`
22    ///
23    /// # Errors
24    ///
25    /// Returns an error if the value is empty or contains invalid entries.
26    pub fn parse(value: &str) -> Result<Vec<Self>, String> {
27        if value.is_empty() {
28            return Err("empty so= value".into());
29        }
30
31        let mut selections = Vec::new();
32        for part in value.split(',') {
33            let part = part.trim();
34            if part.is_empty() {
35                continue;
36            }
37            if let Some((start_str, end_str)) = part.split_once('-') {
38                let start: usize = start_str
39                    .parse()
40                    .map_err(|_| format!("invalid range start: {start_str}"))?;
41                let end: usize = end_str
42                    .parse()
43                    .map_err(|_| format!("invalid range end: {end_str}"))?;
44                if start > end {
45                    return Err(format!("invalid range: {start}-{end} (start > end)"));
46                }
47                selections.push(Self::Range(start, end));
48            } else {
49                let index: usize = part
50                    .parse()
51                    .map_err(|_| format!("invalid file index: {part}"))?;
52                selections.push(Self::Single(index));
53            }
54        }
55
56        if selections.is_empty() {
57            return Err("no valid entries in so= value".into());
58        }
59
60        Ok(selections)
61    }
62
63    /// Convert file selections into a `FilePriority` vector.
64    ///
65    /// Selected files get `Normal` priority, unselected get `Skip`.
66    /// `num_files` is the total number of files in the torrent.
67    /// Out-of-range selections are silently ignored.
68    #[must_use]
69    pub fn to_priorities(selections: &[Self], num_files: usize) -> Vec<FilePriority> {
70        let mut priorities = vec![FilePriority::Skip; num_files];
71        for sel in selections {
72            match *sel {
73                Self::Single(i) => {
74                    if i < num_files {
75                        priorities[i] = FilePriority::Normal;
76                    }
77                }
78                Self::Range(start, end) => {
79                    let end = end.min(num_files.saturating_sub(1));
80                    for p in priorities.iter_mut().take(end + 1).skip(start) {
81                        *p = FilePriority::Normal;
82                    }
83                }
84            }
85        }
86        priorities
87    }
88
89    /// Serialize file selections back to a `so=` parameter value string.
90    #[must_use]
91    pub fn to_so_value(selections: &[Self]) -> String {
92        selections
93            .iter()
94            .map(|s| match s {
95                Self::Single(i) => i.to_string(),
96                Self::Range(start, end) => format!("{start}-{end}"),
97            })
98            .collect::<Vec<_>>()
99            .join(",")
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn parse_single_index() {
109        let sels = FileSelection::parse("3").unwrap();
110        assert_eq!(sels, vec![FileSelection::Single(3)]);
111    }
112
113    #[test]
114    fn parse_multiple_singles() {
115        let sels = FileSelection::parse("0,2,5").unwrap();
116        assert_eq!(
117            sels,
118            vec![
119                FileSelection::Single(0),
120                FileSelection::Single(2),
121                FileSelection::Single(5),
122            ]
123        );
124    }
125
126    #[test]
127    fn parse_range() {
128        let sels = FileSelection::parse("4-6").unwrap();
129        assert_eq!(sels, vec![FileSelection::Range(4, 6)]);
130    }
131
132    #[test]
133    fn parse_mixed() {
134        let sels = FileSelection::parse("0,2,4-6").unwrap();
135        assert_eq!(
136            sels,
137            vec![
138                FileSelection::Single(0),
139                FileSelection::Single(2),
140                FileSelection::Range(4, 6),
141            ]
142        );
143    }
144
145    #[test]
146    fn parse_empty_rejected() {
147        assert!(FileSelection::parse("").is_err());
148    }
149
150    #[test]
151    fn parse_invalid_index() {
152        assert!(FileSelection::parse("abc").is_err());
153    }
154
155    #[test]
156    fn parse_invalid_range() {
157        assert!(FileSelection::parse("6-4").is_err()); // start > end
158    }
159
160    #[test]
161    fn to_priorities_basic() {
162        let sels = vec![
163            FileSelection::Single(0),
164            FileSelection::Single(2),
165            FileSelection::Range(4, 6),
166        ];
167        let prios = FileSelection::to_priorities(&sels, 8);
168        assert_eq!(prios[0], FilePriority::Normal);
169        assert_eq!(prios[1], FilePriority::Skip);
170        assert_eq!(prios[2], FilePriority::Normal);
171        assert_eq!(prios[3], FilePriority::Skip);
172        assert_eq!(prios[4], FilePriority::Normal);
173        assert_eq!(prios[5], FilePriority::Normal);
174        assert_eq!(prios[6], FilePriority::Normal);
175        assert_eq!(prios[7], FilePriority::Skip);
176    }
177
178    #[test]
179    fn to_priorities_out_of_range_ignored() {
180        let sels = vec![FileSelection::Single(10), FileSelection::Range(8, 12)];
181        let prios = FileSelection::to_priorities(&sels, 5);
182        // All should remain Skip since indices are beyond num_files
183        assert!(prios.iter().all(|&p| p == FilePriority::Skip));
184    }
185
186    #[test]
187    fn to_priorities_partial_range() {
188        let sels = vec![FileSelection::Range(3, 100)];
189        let prios = FileSelection::to_priorities(&sels, 5);
190        assert_eq!(prios[0], FilePriority::Skip);
191        assert_eq!(prios[1], FilePriority::Skip);
192        assert_eq!(prios[2], FilePriority::Skip);
193        assert_eq!(prios[3], FilePriority::Normal);
194        assert_eq!(prios[4], FilePriority::Normal);
195    }
196
197    #[test]
198    fn so_value_round_trip() {
199        let sels = vec![
200            FileSelection::Single(0),
201            FileSelection::Single(2),
202            FileSelection::Range(4, 6),
203        ];
204        let value = FileSelection::to_so_value(&sels);
205        assert_eq!(value, "0,2,4-6");
206        let parsed = FileSelection::parse(&value).unwrap();
207        assert_eq!(parsed, sels);
208    }
209}