aria2_core/config/
uri_list.rs1use 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}