Skip to main content

aria2_core/config/
uri_list.rs

1use std::fmt;
2
3#[derive(Debug, Clone)]
4pub struct UriListEntry {
5    pub uris: Vec<String>,
6    pub options: std::collections::HashMap<String, String>,
7}
8
9impl UriListEntry {
10    pub fn new(uris: Vec<String>) -> Self {
11        Self {
12            uris,
13            options: std::collections::HashMap::new(),
14        }
15    }
16
17    pub fn with_options(mut self, options: std::collections::HashMap<String, String>) -> Self {
18        self.options = options;
19        self
20    }
21
22    pub fn is_valid(&self) -> bool {
23        !self.uris.is_empty()
24    }
25
26    pub fn primary_uri(&self) -> Option<&String> {
27        self.uris.first()
28    }
29
30    pub fn option(&self, key: &str) -> Option<&String> {
31        self.options.get(key)
32    }
33}
34
35#[derive(Debug, Clone)]
36pub struct UriListFile {
37    entries: Vec<UriListEntry>,
38    path: Option<String>,
39}
40
41#[derive(Debug, Clone)]
42pub enum UriListError {
43    FileNotFound(String),
44    ParseError(usize, String),
45    IoError(String),
46}
47
48impl fmt::Display for UriListError {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            Self::FileNotFound(p) => write!(f, "URI list file not found: {}", p),
52            Self::ParseError(line, msg) => write!(f, "parse error at line {}: {}", line, msg),
53            Self::IoError(e) => write!(f, "io error: {}", e),
54        }
55    }
56}
57
58impl std::error::Error for UriListError {}
59
60impl UriListFile {
61    pub fn new() -> Self {
62        Self {
63            entries: Vec::new(),
64            path: None,
65        }
66    }
67
68    pub fn from_file(path: &str) -> Result<Self, UriListError> {
69        let p = std::path::Path::new(path);
70        if !p.exists() {
71            return Err(UriListError::FileNotFound(path.to_string()));
72        }
73        let content = std::fs::read_to_string(path)
74            .map_err(|e| UriListError::IoError(format!("{}: {}", path, e)))?;
75        let mut parser = Self::new();
76        parser.path = Some(path.to_string());
77        parser.parse(&content)?;
78        Ok(parser)
79    }
80
81    pub fn parse(&mut self, content: &str) -> Result<(), UriListError> {
82        let mut current_uris: Vec<String> = Vec::new();
83        let mut current_options: std::collections::HashMap<String, String> =
84            std::collections::HashMap::new();
85
86        for raw_line in content.lines() {
87            let line = raw_line.trim();
88
89            if line.is_empty() || line.starts_with('#') {
90                if line.is_empty() && !current_uris.is_empty() {
91                    self.entries.push(UriListEntry {
92                        uris: std::mem::take(&mut current_uris),
93                        options: std::mem::take(&mut current_options),
94                    });
95                } else if line.is_empty() {
96                    current_options = std::collections::HashMap::new();
97                }
98                continue;
99            }
100
101            if raw_line.starts_with(' ') || raw_line.starts_with('\t') {
102                let opt_line = line.trim();
103                if let Some(eq_pos) = opt_line.find('=') {
104                    let opt_name = opt_line[..eq_pos].trim().to_string();
105                    let opt_value = opt_line[eq_pos + 1..].trim().to_string();
106                    current_options.insert(opt_name, opt_value);
107                }
108                continue;
109            }
110
111            if !current_uris.is_empty() {
112                self.entries.push(UriListEntry {
113                    uris: std::mem::take(&mut current_uris),
114                    options: std::mem::take(&mut current_options),
115                });
116            }
117
118            let uris: Vec<String> = line
119                .split('\t')
120                .map(|s| s.trim())
121                .filter(|s| !s.is_empty())
122                .map(String::from)
123                .collect();
124
125            if !uris.is_empty() {
126                current_uris = uris;
127            }
128        }
129
130        if !current_uris.is_empty() {
131            self.entries.push(UriListEntry {
132                uris: current_uris,
133                options: current_options,
134            });
135        }
136
137        Ok(())
138    }
139
140    pub fn entries(&self) -> &[UriListEntry] {
141        &self.entries
142    }
143    pub fn len(&self) -> usize {
144        self.entries.len()
145    }
146    pub fn is_empty(&self) -> bool {
147        self.entries.is_empty()
148    }
149    pub fn path(&self) -> Option<&str> {
150        self.path.as_deref()
151    }
152
153    pub fn all_uris(&self) -> Vec<&str> {
154        self.entries
155            .iter()
156            .flat_map(|e| e.uris.iter().map(|s| s.as_str()))
157            .collect()
158    }
159
160    pub fn valid_entries(&self) -> Vec<&UriListEntry> {
161        self.entries.iter().filter(|e| e.is_valid()).collect()
162    }
163
164    pub fn filter_by_option(&self, key: &str, value: &str) -> Vec<&UriListEntry> {
165        self.entries
166            .iter()
167            .filter(|e| e.option(key).is_some_and(|v| v == value))
168            .collect()
169    }
170}
171
172impl Default for UriListFile {
173    fn default() -> Self {
174        Self::new()
175    }
176}
177
178pub fn parse_uri_list(content: &str) -> Result<Vec<UriListEntry>, UriListError> {
179    let mut file = UriListFile::new();
180    file.parse(content)?;
181    Ok(file.entries)
182}
183
184pub fn parse_single_line(line: &str) -> Option<Vec<String>> {
185    let trimmed = line.trim();
186    if trimmed.is_empty() || trimmed.starts_with('#') {
187        return None;
188    }
189    let uris: Vec<String> = trimmed
190        .split('\t')
191        .map(|s| s.trim().to_string())
192        .filter(|s| !s.is_empty())
193        .collect();
194    if uris.is_empty() { None } else { Some(uris) }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_parse_simple_uris() {
203        let content = "http://example.com/file1.iso\nhttp://example.com/file2.zip\n";
204        let mut file = UriListFile::new();
205        file.parse(content).unwrap();
206        assert_eq!(file.len(), 2);
207        assert_eq!(file.entries()[0].uris.len(), 1);
208        assert_eq!(
209            file.entries()[0].primary_uri().unwrap(),
210            "http://example.com/file1.iso"
211        );
212    }
213
214    #[test]
215    fn test_parse_mirrors_on_same_line() {
216        let content = "http://mirror1.com/file.iso\thttp://mirror2.com/file.iso\thttp://mirror3.com/file.iso\n";
217        let mut file = UriListFile::new();
218        file.parse(content).unwrap();
219        assert_eq!(file.len(), 1);
220        assert_eq!(file.entries()[0].uris.len(), 3);
221    }
222
223    #[test]
224    fn test_parse_inline_options() {
225        let content = r#"  dir=/downloads
226  out=myfile.iso
227http://example.com/large.bin
228"#;
229        let mut file = UriListFile::new();
230        file.parse(content).unwrap();
231        assert_eq!(file.len(), 1);
232        assert_eq!(
233            file.entries()[0].option("dir"),
234            Some(&"/downloads".to_string())
235        );
236        assert_eq!(
237            file.entries()[0].option("out"),
238            Some(&"myfile.iso".to_string())
239        );
240    }
241
242    #[test]
243    fn test_parse_multiple_entries_with_options() {
244        let content = r#"  dir=/tmp
245  out=file1.dat
246http://example.com/a.dat
247
248  dir=/opt
249  out=file2.dat
250http://example.com/b.dat
251"#;
252        let mut file = UriListFile::new();
253        file.parse(content).unwrap();
254        assert_eq!(file.len(), 2);
255        assert_eq!(
256            file.entries()[0].option("dir").map(|s| s.as_str()),
257            Some("/tmp")
258        );
259        assert_eq!(
260            file.entries()[1].option("dir").map(|s| s.as_str()),
261            Some("/opt")
262        );
263    }
264
265    #[test]
266    fn test_skip_comments_and_blanks() {
267        let content = r#"# Download list
268# Generated by aria2
269
270http://example.com/valid.iso
271
272# This is ignored
273http://example.com/another.iso
274"#;
275        let mut file = UriListFile::new();
276        file.parse(content).unwrap();
277        assert_eq!(file.len(), 2);
278    }
279
280    #[test]
281    fn test_empty_file() {
282        let mut file = UriListFile::new();
283        file.parse("").unwrap();
284        assert!(file.is_empty());
285        assert_eq!(file.len(), 0);
286    }
287
288    #[test]
289    fn test_all_uris_collection() {
290        let content = "http://a.com/f1\nhttp://b.com/f2\thttp://c.com/f2\n";
291        let mut file = UriListFile::new();
292        file.parse(content).unwrap();
293        let all = file.all_uris();
294        assert_eq!(all.len(), 3);
295    }
296
297    #[test]
298    fn test_valid_entries_filter() {
299        let content = "\n  dir=/empty\n\n";
300        let mut file = UriListFile::new();
301        file.parse(content).unwrap();
302        assert!(file.valid_entries().is_empty());
303    }
304
305    #[test]
306    fn test_filter_by_option() {
307        let content = r#"  category=video
308http://example.com/video.mp4
309
310  category=audio
311http://example.com/audio.mp3
312"#;
313        let mut file = UriListFile::new();
314        file.parse(content).unwrap();
315        let videos = file.filter_by_option("category", "video");
316        assert_eq!(videos.len(), 1);
317    }
318
319    #[test]
320    fn test_parse_uri_list_function() {
321        let content = "http://example.com/file.iso\n";
322        let entries = parse_uri_list(content).unwrap();
323        assert_eq!(entries.len(), 1);
324    }
325
326    #[test]
327    fn test_parse_single_line_function() {
328        let uris = parse_single_line("http://a.com/f\thttp://b.com/f");
329        assert!(uris.is_some());
330        assert_eq!(uris.unwrap().len(), 2);
331
332        assert!(parse_single_line("# comment").is_none());
333        assert!(parse_single_line("").is_none());
334    }
335
336    #[test]
337    fn test_entry_is_valid() {
338        let valid = UriListEntry::new(vec!["http://x.com".into()]);
339        assert!(valid.is_valid());
340
341        let invalid = UriListEntry::new(vec![]);
342        assert!(!invalid.is_valid());
343    }
344
345    #[test]
346    fn test_entry_primary_uri() {
347        let entry = UriListEntry::new(vec!["http://first.com".into(), "http://second.com".into()]);
348        assert_eq!(entry.primary_uri().unwrap(), "http://first.com");
349        assert!(entry.primary_uri().is_some());
350    }
351
352    #[test]
353    fn test_error_display() {
354        let err = UriListError::FileNotFound("/missing.txt".into());
355        assert!(err.to_string().contains("not found"));
356
357        let err2 = UriListError::ParseError(42, "bad uri".into());
358        assert!(err2.to_string().contains("line 42"));
359    }
360
361    #[test]
362    fn test_complex_realistic_input() {
363        let content = r#"# My download list
364  dir=D:\Downloads
365  split=5
366  max-connection-per-server=3
367https://releases.ubuntu.com/22.04/ubuntu-22.04-desktop-amd64.iso	https://mirrors.tuna.tsinghua.edu.cn/ubuntu-releases/22.04/ubuntu-22.04-desktop-amd64.iso
368
369  dir=D:\Downloads\Music
370  out=song.mp3
371https://example.com/music/song.mp3
372"#;
373        let mut file = UriListFile::new();
374        file.parse(content).unwrap();
375        assert_eq!(file.len(), 2);
376        assert_eq!(file.entries()[0].uris.len(), 2);
377        assert_eq!(
378            file.entries()[0].option("split").map(|s| s.as_str()),
379            Some("5")
380        );
381        assert_eq!(
382            file.entries()[1].option("out").map(|s| s.as_str()),
383            Some("song.mp3")
384        );
385    }
386}