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 #[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}