Skip to main content

aria2_core/config/
parser.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use super::option::{OptionRegistry, OptionType, OptionValue};
5
6#[derive(Debug, Clone)]
7pub enum ConfigSource {
8    Defaults,
9    Environment,
10    ConfigFile,
11    CommandLine,
12}
13
14impl fmt::Display for ConfigSource {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16        match self {
17            Self::Defaults => write!(f, "defaults"),
18            Self::Environment => write!(f, "environment"),
19            Self::ConfigFile => write!(f, "config-file"),
20            Self::CommandLine => write!(f, "command-line"),
21        }
22    }
23}
24
25#[derive(Debug, Clone)]
26pub struct ConfigError {
27    pub source: ConfigSource,
28    pub option: String,
29    pub message: String,
30}
31
32impl fmt::Display for ConfigError {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        write!(f, "[{}] {}: {}", self.source, self.option, self.message)
35    }
36}
37
38pub struct ConfigParser {
39    options: HashMap<String, OptionValue>,
40    sources: Vec<ConfigSource>,
41    errors: Vec<ConfigError>,
42    registry: OptionRegistry,
43}
44
45impl ConfigParser {
46    pub fn new() -> Self {
47        Self {
48            options: HashMap::new(),
49            sources: Vec::new(),
50            errors: Vec::new(),
51            registry: OptionRegistry::new(),
52        }
53    }
54
55    pub fn with_registry(registry: OptionRegistry) -> Self {
56        Self {
57            registry,
58            ..Self::new()
59        }
60    }
61
62    pub fn set(&mut self, name: impl Into<String>, value: OptionValue) {
63        let key = name.into();
64        if let Some(def) = self.registry.get(key.as_str()) {
65            match def.parse_value(&value.to_string()) {
66                Ok(v) => {
67                    self.options.insert(key, v);
68                }
69                Err(e) => self.errors.push(ConfigError {
70                    source: ConfigSource::CommandLine,
71                    option: key.clone(),
72                    message: e,
73                }),
74            }
75        } else {
76            self.options.insert(key, value);
77        }
78    }
79
80    pub fn set_raw(&mut self, name: impl Into<String>, value: impl Into<String>) {
81        let key = name.into();
82        if let Some(def) = self.registry.get(key.as_str()) {
83            match def.parse_value(&value.into()) {
84                Ok(v) => {
85                    self.options.insert(key, v);
86                }
87                Err(e) => self.errors.push(ConfigError {
88                    source: ConfigSource::CommandLine,
89                    option: key.clone(),
90                    message: e,
91                }),
92            }
93        } else {
94            self.options.insert(key, OptionValue::Str(value.into()));
95        }
96    }
97
98    pub fn get(&self, name: &str) -> Option<&OptionValue> {
99        self.options.get(name)
100    }
101    pub fn get_str(&self, name: &str) -> Option<&str> {
102        self.options.get(name).and_then(|v| v.as_str())
103    }
104    pub fn get_i64(&self, name: &str) -> Option<i64> {
105        self.options.get(name).and_then(|v| v.as_i64())
106    }
107    pub fn get_bool(&self, name: &str) -> Option<bool> {
108        self.options.get(name).and_then(|v| v.as_bool())
109    }
110    pub fn contains(&self, name: &str) -> bool {
111        self.options.contains_key(name)
112    }
113
114    pub fn parse_cli_args(&mut self, args: &[&str]) {
115        self.sources.push(ConfigSource::CommandLine);
116        let mut i = 0;
117        while i < args.len() {
118            let arg = &args[i];
119            if let Some(opt_name) = arg.strip_prefix("--") {
120                if opt_name.starts_with("no-") && opt_name.len() > 3 {
121                    let real_name = &opt_name[3..];
122                    self.set(real_name, OptionValue::Bool(false));
123                } else if opt_name.contains('=') {
124                    let parts: Vec<&str> = opt_name.splitn(2, '=').collect();
125                    if parts.len() == 2 {
126                        self.set_raw(parts[0], parts[1]);
127                    }
128                } else if i + 1 < args.len() && !args[i + 1].starts_with('-') {
129                    self.set_raw(opt_name, args[i + 1]);
130                    i += 1;
131                } else {
132                    if let Some(def) = self.registry.get(opt_name) {
133                        if def.opt_type() == OptionType::Boolean {
134                            self.set(opt_name, OptionValue::Bool(true));
135                        } else {
136                            self.set_raw(opt_name, "");
137                        }
138                    } else {
139                        self.set_raw(opt_name, "");
140                    }
141                }
142            } else if arg.starts_with('-') && arg.len() == 2 {
143                let c = arg.chars().nth(1).unwrap();
144                let opt_name = self
145                    .registry
146                    .all()
147                    .values()
148                    .find(|def| def.short_name() == Some(c))
149                    .map(|def| def.name().to_string());
150                if let Some(name) = opt_name {
151                    if i + 1 < args.len() && !args[i + 1].starts_with('-') {
152                        self.set_raw(&name, args[i + 1]);
153                        i += 1;
154                    } else {
155                        self.set(&name, OptionValue::Bool(true));
156                    }
157                }
158            } else if let Some(rest) = arg.strip_prefix('@') {
159                self.parse_file(rest);
160            } else {
161                i += 1;
162            }
163            i += 1;
164        }
165    }
166
167    pub fn parse_file(&mut self, path: &str) {
168        self.sources.push(ConfigSource::ConfigFile);
169        if let Ok(content) = std::fs::read_to_string(path) {
170            for line in content.lines() {
171                let line = line.trim();
172                if line.is_empty()
173                    || line.starts_with('#')
174                    || line.starts_with('[')
175                    || line.starts_with(';')
176                {
177                    continue;
178                }
179                if let Some(eq_pos) = line.find('=') {
180                    let name = line[..eq_pos].trim();
181                    let value = line[eq_pos + 1..].trim();
182                    if !name.is_empty() {
183                        self.set_raw(name, value);
184                    }
185                }
186            }
187        }
188    }
189
190    pub fn parse_env_vars(&mut self) {
191        self.sources.push(ConfigSource::Environment);
192        for (key, value) in std::env::vars() {
193            if let Some(rest) = key.strip_prefix("ARIA2_") {
194                let opt_name = rest.to_lowercase().replace('_', "-");
195                self.set_raw(opt_name, &value);
196            }
197        }
198    }
199
200    pub fn apply_defaults(&mut self) {
201        self.sources.push(ConfigSource::Defaults);
202        for def in self.registry.all().values() {
203            if !matches!(def.default_value(), OptionValue::None) {
204                self.options
205                    .entry(def.name().to_string())
206                    .or_insert_with(|| def.default_value().clone());
207            }
208        }
209    }
210
211    pub fn load_defaults_first(&mut self) {
212        self.apply_defaults();
213        self.parse_env_vars();
214        let conf_path = self
215            .get_str("conf-path")
216            .map(|s| s.to_string())
217            .unwrap_or_default();
218        if !conf_path.is_empty() && std::path::Path::new(&conf_path).exists() {
219            self.parse_file(&conf_path);
220        }
221    }
222
223    pub fn options(&self) -> &HashMap<String, OptionValue> {
224        &self.options
225    }
226    pub fn errors(&self) -> &[ConfigError] {
227        &self.errors
228    }
229    pub fn has_errors(&self) -> bool {
230        !self.errors.is_empty()
231    }
232    pub fn source_count(&self) -> usize {
233        self.sources.len()
234    }
235
236    pub fn to_json_map(&self) -> serde_json::Value {
237        let mut map = serde_json::Map::new();
238        for (k, v) in &self.options {
239            map.insert(
240                k.clone(),
241                <&OptionValue as Into<serde_json::Value>>::into(v),
242            );
243        }
244        serde_json::Value::Object(map)
245    }
246}
247
248impl Default for ConfigParser {
249    fn default() -> Self {
250        Self::new()
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_config_source_display() {
260        assert_eq!(ConfigSource::CommandLine.to_string(), "command-line");
261        assert_eq!(ConfigSource::Defaults.to_string(), "defaults");
262    }
263
264    #[test]
265    fn test_parser_creation() {
266        let p = ConfigParser::new();
267        assert_eq!(p.source_count(), 0);
268        assert!(!p.has_errors());
269    }
270
271    #[test]
272    fn test_set_and_get() {
273        let mut p = ConfigParser::new();
274        p.set("dir", OptionValue::Str("/downloads".into()));
275        assert_eq!(p.get_str("dir").unwrap(), "/downloads");
276        assert!(p.contains("dir"));
277        assert!(!p.contains("nonexistent"));
278    }
279
280    #[test]
281    fn test_set_raw_string() {
282        let mut p = ConfigParser::new();
283        p.set_raw("split", "8");
284        assert_eq!(p.get_i64("split").unwrap(), 8);
285    }
286
287    #[test]
288    fn test_set_raw_bool() {
289        let mut p = ConfigParser::new();
290        p.set_raw("check-certificate", "false");
291        assert!(!p.get_bool("check-certificate").unwrap());
292    }
293
294    #[test]
295    fn test_parse_cli_args_basic() {
296        let mut p = ConfigParser::new();
297        p.parse_cli_args(&["--dir=/tmp", "--split=3", "--quiet"]);
298        assert_eq!(p.get_str("dir").unwrap(), "/tmp");
299        assert_eq!(p.get_i64("split").unwrap(), 3);
300        assert!(p.get_bool("quiet").unwrap());
301    }
302
303    #[test]
304    fn test_parse_cli_args_no_prefix() {
305        let mut p = ConfigParser::new();
306        p.parse_cli_args(&["--no-check-certificate", "--no-continue"]);
307        assert!(!p.get_bool("check-certificate").unwrap());
308        assert!(!p.get_bool("continue").unwrap());
309    }
310
311    #[test]
312    fn test_parse_cli_args_space_separated() {
313        let mut p = ConfigParser::new();
314        p.parse_cli_args(&["--dir", "/opt/downloads", "--out", "file.iso"]);
315        assert_eq!(p.get_str("dir").unwrap(), "/opt/downloads");
316        assert_eq!(p.get_str("out").unwrap(), "file.iso");
317    }
318
319    #[test]
320    fn test_parse_cli_boolean_flag() {
321        let mut p = ConfigParser::new();
322        p.parse_cli_args(&["-q", "--dry-run"]);
323        assert!(p.get_bool("quiet").unwrap());
324        assert!(p.get_bool("dry-run").unwrap());
325    }
326
327    #[test]
328    fn test_apply_defaults() {
329        let mut p = ConfigParser::new();
330        p.apply_defaults();
331        assert_eq!(p.get_str("dir").unwrap(), ".");
332        assert_eq!(p.get_i64("split").unwrap(), 5);
333        assert!(p.get_bool("enable-color").unwrap());
334    }
335
336    #[test]
337    fn test_load_order() {
338        let mut p = ConfigParser::new();
339        p.load_defaults_first();
340        p.set("dir", OptionValue::Str("/override".into()));
341        assert_eq!(p.get_str("dir").unwrap(), "/override");
342    }
343
344    #[test]
345    fn test_to_json_map() {
346        let mut p = ConfigParser::new();
347        p.set("dir", OptionValue::Str("/tmp".into()));
348        p.set("split", OptionValue::Int(10));
349        let map = p.to_json_map();
350        assert!(map.get("dir").is_some());
351        assert!(map.get("split").is_some());
352    }
353
354    #[test]
355    fn test_error_on_invalid_integer() {
356        let mut p = ConfigParser::new();
357        p.set_raw("split", "not_a_number");
358        assert!(p.has_errors());
359        assert_eq!(p.errors()[0].option, "split");
360    }
361
362    #[test]
363    fn test_error_on_out_of_range() {
364        let mut p = ConfigParser::new();
365        p.set_raw("split", "100");
366        assert!(p.has_errors());
367    }
368
369    #[test]
370    fn test_error_display() {
371        let err = ConfigError {
372            source: ConfigSource::CommandLine,
373            option: "split".into(),
374            message: "value 100 exceeds maximum 16".into(),
375        };
376        let s = format!("{}", err);
377        assert!(s.contains("split"));
378        assert!(s.contains("command-line"));
379    }
380
381    #[test]
382    fn test_default_parser() {
383        let p = ConfigParser::default();
384        assert_eq!(p.source_count(), 0);
385    }
386
387    // --- Phase 13 / Wave D — Task D6: 10 CLI Option Parsing Tests ---
388
389    #[test]
390    fn test_d6_01_short_option_dir_maps_to_tmp() {
391        let mut p = ConfigParser::new();
392        p.parse_cli_args(&["-d", "/tmp"]);
393        assert_eq!(p.get_str("dir").unwrap(), "/tmp");
394    }
395
396    #[test]
397    fn test_d6_02_short_option_split_maps_to_8() {
398        let mut p = ConfigParser::new();
399        p.parse_cli_args(&["-s", "8"]);
400        assert_eq!(p.get_i64("split").unwrap(), 8);
401    }
402
403    #[test]
404    fn test_d6_03_long_option_timeout_equals_30() {
405        let mut p = ConfigParser::new();
406        p.parse_cli_args(&["--timeout=30"]);
407        assert_eq!(p.get_i64("timeout").unwrap(), 30);
408    }
409
410    #[test]
411    fn test_d6_04_boolean_flag_quiet_sets_true() {
412        let mut p = ConfigParser::new();
413        p.parse_cli_args(&["--quiet"]);
414        assert!(p.get_bool("quiet").unwrap());
415    }
416
417    #[test]
418    fn test_d6_05_list_option_header_parses_correctly() {
419        let mut p = ConfigParser::new();
420        p.parse_cli_args(&["--header=X:Y,Z:W"]);
421        let val = p.get("header").unwrap();
422        let list = val.as_list().unwrap();
423        assert_eq!(list.len(), 2);
424        assert_eq!(list[0], "X:Y");
425        assert_eq!(list[1], "Z:W");
426    }
427
428    #[test]
429    fn test_d6_06_unknown_option_returns_error() {
430        let mut p = ConfigParser::new();
431        p.parse_cli_args(&["--totally-nonexistent-option"]);
432        assert!(p.contains("totally-nonexistent-option"));
433        let val = p.get("totally-nonexistent-option").unwrap();
434        assert_eq!(val.as_str().unwrap(), "");
435    }
436
437    #[test]
438    fn test_d6_07_out_of_range_value_rejected() {
439        let mut p = ConfigParser::new();
440        p.set_raw("split", "0");
441        assert!(p.has_errors());
442        assert!(p.errors()[0].option == "split");
443    }
444
445    #[test]
446    fn test_d6_08_default_values_applied_when_not_specified() {
447        let mut p = ConfigParser::new();
448        p.apply_defaults();
449        assert_eq!(p.get_str("dir").unwrap(), ".");
450        assert_eq!(p.get_i64("split").unwrap(), 5);
451        assert_eq!(p.get_i64("timeout").unwrap(), 60);
452        assert!(!p.get_bool("quiet").unwrap());
453    }
454
455    #[test]
456    fn test_d6_09_multiple_options_parsed_together() {
457        let mut p = ConfigParser::new();
458        p.parse_cli_args(&[
459            "--dir=/downloads",
460            "--split=4",
461            "--timeout=30",
462            "--quiet",
463            "--out=output.bin",
464        ]);
465        assert_eq!(p.get_str("dir").unwrap(), "/downloads");
466        assert_eq!(p.get_i64("split").unwrap(), 4);
467        assert_eq!(p.get_i64("timeout").unwrap(), 30);
468        assert!(p.get_bool("quiet").unwrap());
469        assert_eq!(p.get_str("out").unwrap(), "output.bin");
470    }
471
472    #[test]
473    fn test_d6_10_help_flag_skipped_without_error() {
474        let mut p = ConfigParser::new();
475        p.parse_cli_args(&["--help", "--version", "-h"]);
476        assert_eq!(p.source_count(), 1);
477        assert!(!p.has_errors());
478    }
479}