Skip to main content

aria2_core/option/
option_handler.rs

1//! Centralized OptionHandler with config file parser (.aria2rc format).
2//!
3//! This module provides [`OptionHandler`], a self-contained option management
4//! struct that holds all aria2 configuration values with built-in defaults,
5//! supports loading from `.aria2rc` config files, applying CLI argument overrides,
6//! and converting to [`DownloadOptions`] for use by download commands.
7//!
8//! The design mirrors C++ aria2's `OptionHandler` class for reference.
9//!
10//! # Example
11//!
12//! ```rust,no_run
13//! use aria2_core::option::{OptionHandler, OptionValue};
14//! use std::path::Path;
15//!
16//! let mut handler = OptionHandler::new();
17//! handler.set("dir", OptionValue::Str("/downloads".into()));
18//! handler.load_config_file(Path::new("~/.aria2rc")).ok();
19//! let opts = handler.to_download_options();
20//! ```
21
22use std::collections::HashMap;
23use std::path::Path;
24
25use crate::config::option::OptionValue;
26use crate::request::request_group::DownloadOptions;
27
28// OptionValue is now defined in crate::config::option and re-exported
29// from crate::option for backward compatibility.
30
31// ---------------------------------------------------------------------------
32// Built-in defaults (C++ aria2 compatible)
33// ---------------------------------------------------------------------------
34
35/// Built-in default option values matching C++ aria2 behavior.
36///
37/// These are populated into every new [`OptionHandler`] instance on construction.
38/// Defined as a function returning owned values to avoid const-eval limitations
39/// with `String::from` in Rust constants.
40fn built_in_defaults() -> Vec<(&'static str, OptionValue)> {
41    vec![
42        ("dir", OptionValue::Str(String::from("."))),
43        ("max-concurrent-downloads", OptionValue::Usize(5)),
44        ("max-connection-per-server", OptionValue::Usize(16)),
45        ("min-split-size", OptionValue::Usize(1_048_576)), // 1 MiB
46        ("split", OptionValue::Usize(5)),
47        ("max-overall-download-limit", OptionValue::Usize(0)), // unlimited
48        ("max-download-limit", OptionValue::Usize(0)),
49        ("max-upload-limit", OptionValue::Usize(0)),
50        ("continue", OptionValue::Bool(true)),
51        ("remote-time", OptionValue::Bool(true)),
52        ("reuse-uri", OptionValue::Bool(true)),
53        ("allow-overwrite", OptionValue::Bool(true)),
54        (
55            "file-allocation",
56            OptionValue::Str(String::from("falloc")), // falloc / none / prealloc / trunc / mmap
57        ),
58        (
59            "mmap-threshold",
60            OptionValue::Usize(256 * 1024 * 1024), // 256 MiB
61        ),
62        ("auto-save-interval", OptionValue::Usize(60)),
63        ("check-certificate", OptionValue::Bool(true)),
64        ("bt-max-peers", OptionValue::Usize(128)),
65        ("bt-request-peer-speed-limit", OptionValue::Usize(0)),
66        ("seed-time", OptionValue::Usize(0)),
67        ("seed-ratio", OptionValue::Float(0.0)),
68        ("rpc-listen-port", OptionValue::Usize(6800)),
69        ("rpc-secret", OptionValue::Str(String::new())),
70        ("quiet", OptionValue::Bool(false)),
71        (
72            "console-log-level",
73            OptionValue::Str(String::from("notice")),
74        ),
75    ]
76}
77
78// ---------------------------------------------------------------------------
79// OptionHandler struct
80// ---------------------------------------------------------------------------
81
82/// Centralized option handler with built-in defaults, config file parsing,
83/// CLI argument override support, and DownloadOptions conversion.
84///
85/// # Priority Order (lowest to highest)
86///
87/// 1. Built-in defaults (from [`DEFAULTS`])
88/// 2. Config file values (via [`load_config_file`])
89/// 3. Command-line arguments (via [`apply_args`])
90/// 4. Explicit [`set`] calls
91///
92/// # Example
93///
94/// ```no_run
95/// use aria2_core::option::{OptionHandler, OptionValue};
96///
97/// let mut h = OptionHandler::new();
98/// assert_eq!(h.get("split").as_usize(), 5); // default
99///
100/// h.set("split", OptionValue::Usize(10));
101/// assert_eq!(h.get("split").as_usize(), 10);
102/// ```
103pub struct OptionHandler {
104    /// Current option values (overrides + config + args).
105    options: HashMap<String, OptionValue>,
106    /// Original built-in defaults (never modified after construction).
107    defaults: HashMap<String, OptionValue>,
108}
109
110impl OptionHandler {
111    /// Create a new `OptionHandler` pre-populated with all built-in defaults.
112    ///
113    /// Every default from [`built_in_defaults`] is copied into both `options` and
114    /// `defaults`. Subsequent mutations only affect `options`; `defaults`
115    /// remains immutable so fallback lookups always work.
116    pub fn new() -> Self {
117        let defaults = built_in_defaults();
118        let mut options = HashMap::with_capacity(defaults.len());
119        let mut defaults_map = HashMap::with_capacity(defaults.len());
120
121        for (key, value) in defaults {
122            options.insert(key.to_string(), value.clone());
123            defaults_map.insert(key.to_string(), value);
124        }
125
126        Self {
127            options,
128            defaults: defaults_map,
129        }
130    }
131
132    /// Set an option value. Overwrites any existing value.
133    ///
134    /// After calling `set`, subsequent calls to [`get`] will return the new
135    /// value instead of the default.
136    pub fn set(&mut self, key: &str, value: OptionValue) {
137        self.options.insert(key.to_string(), value);
138    }
139
140    /// Get the current value for `key`.
141    ///
142    /// Falls back to the built-in default if the key was never explicitly set
143    /// (or was removed). Returns [`OptionValue::None`] for completely unknown keys.
144    pub fn get(&self, key: &str) -> &OptionValue {
145        self.options
146            .get(key)
147            .unwrap_or_else(|| self.defaults.get(key).unwrap_or(&OptionValue::None))
148    }
149
150    /// Override options from raw command-line arguments.
151    ///
152    /// Parses common CLI patterns:
153    /// - `--key=value` / `--key:value`
154    /// - `--key value` (value in next arg)
155    /// - `--no-key` sets boolean to false
156    /// - `-o key=value` (GNU style)
157    ///
158    /// CLI arguments take precedence over config file values but can be
159    /// overridden by explicit [`set`] calls.
160    pub fn apply_args(&mut self, args: &[String]) {
161        let mut i = 0;
162        while i < args.len() {
163            let arg = &args[i];
164
165            // Skip non-option arguments (e.g., URLs, positional args)
166            if !arg.starts_with('-') || arg == "--" {
167                i += 1;
168                continue;
169            }
170
171            // Parse --key=value or --key:value
172            if let Some((key, value)) = Self::parse_kv_arg(arg) {
173                if let Some(parsed) = Self::detect_value_type(value.trim()) {
174                    tracing::debug!(key, value = ?parsed, "CLI arg applied");
175                    self.set(key, parsed);
176                }
177                i += 1;
178                continue;
179            }
180
181            // Parse --no-key (boolean false)
182            if let Some(key) = arg.strip_prefix("--no-") {
183                self.set(key, OptionValue::Bool(false));
184                i += 1;
185                continue;
186            }
187
188            // Parse --key <next-arg> (value in next argument)
189            if let Some(key) = arg.strip_prefix("--") {
190                if i + 1 < args.len() && !args[i + 1].starts_with('-') {
191                    let value = &args[i + 1];
192                    if let Some(parsed) = Self::detect_value_type(value) {
193                        self.set(key, parsed);
194                    }
195                    i += 2;
196                    continue;
197                } else {
198                    // Flag without value: treat as boolean true
199                    self.set(key, OptionValue::Bool(true));
200                    i += 1;
201                    continue;
202                }
203            }
204
205            // Parse -o key=value
206            if arg == "-o" && i + 1 < args.len() {
207                let next = &args[i + 1];
208                if let Some((key, value)) = next.split_once('=')
209                    && let Some(parsed) = Self::detect_value_type(value.trim())
210                {
211                    self.set(key, parsed);
212                }
213                i += 2;
214                continue;
215            }
216
217            i += 1;
218        }
219    }
220
221    /// Parse a `--key=value` or `--key:value` argument into `(key, value)`.
222    fn parse_kv_arg(arg: &str) -> Option<(&str, &str)> {
223        let stripped = arg.strip_prefix("--")?;
224        if let Some((k, v)) = stripped.split_once('=') {
225            return Some((k, v));
226        }
227        if let Some((k, v)) = stripped.split_once(':') {
228            return Some((k, v));
229        }
230        None
231    }
232
233    /// Auto-detect the type of a raw string value and wrap it in [`OptionValue`].
234    ///
235    /// Detection rules:
236    /// - `"true"` / `"false"` → [`OptionValue::Bool`]
237    /// - Numeric string without `.` → [`OptionValue::Usize`] (or [`OptionValue::Int`] if negative)
238    /// - Numeric string with `.` → [`OptionValue::Float`]
239    /// - `[...]` bracket notation → [`OptionValue::List`]
240    /// - Quoted string → [`OptionValue::Str`] (quotes stripped)
241    /// - Anything else → [`OptionValue::Str`]
242    fn detect_value_type(value: &str) -> Option<OptionValue> {
243        let trimmed = value.trim();
244
245        // Empty string → None
246        if trimmed.is_empty() {
247            return Some(OptionValue::None);
248        }
249
250        // Boolean literals
251        if trimmed == "true" || trimmed == "yes" || trimmed == "on" {
252            return Some(OptionValue::Bool(true));
253        }
254        if trimmed == "false" || trimmed == "no" || trimmed == "off" {
255            return Some(OptionValue::Bool(false));
256        }
257
258        // Bracket notation: ['val1', 'val2'] or ["val1", "val2"]
259        if trimmed.starts_with('[') && trimmed.ends_with(']') {
260            let inner = &trimmed[1..trimmed.len() - 1];
261            let items: Vec<String> = inner
262                .split(',')
263                .map(|s| {
264                    let item = s.trim();
265                    // Strip quotes if present
266                    if (item.starts_with('\'') && item.ends_with('\''))
267                        || (item.starts_with('"') && item.ends_with('"'))
268                    {
269                        &item[1..item.len() - 1]
270                    } else {
271                        item
272                    }
273                    .to_string()
274                })
275                .filter(|s| !s.is_empty())
276                .collect();
277            return Some(OptionValue::List(items));
278        }
279
280        // Quoted string: "value" or 'value'
281        if (trimmed.starts_with('"') && trimmed.ends_with('"'))
282            || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
283        {
284            return Some(OptionValue::Str(trimmed[1..trimmed.len() - 1].to_string()));
285        }
286
287        // Negative integer
288        if let Some(neg) = trimmed.strip_prefix('-')
289            && neg.parse::<i64>().is_ok()
290        {
291            return Some(OptionValue::Int(-neg.parse::<i64>().unwrap()));
292        }
293
294        // Unsigned integer
295        if trimmed.parse::<usize>().is_ok() {
296            return Some(OptionValue::Usize(trimmed.parse::<usize>().unwrap()));
297        }
298
299        // Float
300        if trimmed.parse::<f64>().is_ok() {
301            return Some(OptionValue::Float(trimmed.parse::<f64>().unwrap()));
302        }
303
304        // Default: plain string
305        Some(OptionValue::Str(trimmed.to_string()))
306    }
307
308    /// Load options from a `.aria2rc` config file.
309    ///
310    /// File format:
311    /// ```text
312    /// # Comment lines start with #
313    /// key=value
314    /// key="value with spaces"
315    /// key=['val1', 'val2']
316    /// bool-key=true
317    /// number-key=42
318    /// float-key=3.14
319    /// ```
320    ///
321    /// # Parse Rules
322    ///
323    /// - Lines starting with `#` are comments and skipped.
324    /// - Blank lines are skipped.
325    /// - `key=value`: auto-detect type (see [`detect_value_type`]).
326    /// - Invalid lines produce warnings via `tracing::warn` but do **not**
327    ///   cause an error return.
328    ///
329    /// # Errors
330    ///
331    /// Returns an error only if the file cannot be read (IO failure).
332    pub fn load_config_file(&mut self, path: &Path) -> Result<(), String> {
333        let content = std::fs::read_to_string(path)
334            .map_err(|e| format!("Failed to read config file '{}': {}", path.display(), e))?;
335
336        for (line_num, raw_line) in content.lines().enumerate() {
337            let line = raw_line.trim();
338
339            // Skip blank lines and comments
340            if line.is_empty() || line.starts_with('#') {
341                continue;
342            }
343
344            // Split on first '='
345            let Some((key, value_str)) = line.split_once('=') else {
346                tracing::warn!(
347                    path = %path.display(),
348                    line = line_num + 1,
349                    content = raw_line,
350                    "Skipping invalid config line (no '=' found)"
351                );
352                continue;
353            };
354
355            let key = key.trim();
356            let value_str = value_str.trim();
357
358            if key.is_empty() {
359                tracing::warn!(
360                    path = %path.display(),
361                    line = line_num + 1,
362                    "Skipping config line with empty key"
363                );
364                continue;
365            }
366
367            // Auto-detect type and set
368            match Self::detect_value_type(value_str) {
369                Some(parsed) => {
370                    tracing::debug!(
371                        key,
372                        value = ?parsed,
373                        source = %path.display(),
374                        "Config option loaded"
375                    );
376                    self.set(key, parsed);
377                }
378                None => {
379                    tracing::warn!(
380                        key,
381                        line = line_num + 1,
382                        source = %path.display(),
383                        "Failed to parse config value"
384                    );
385                }
386            }
387        }
388
389        Ok(())
390    }
391
392    /// Convert current options to a [`DownloadOptions`] struct suitable for
393    /// creating a download task.
394    ///
395    /// Maps well-known option keys to their corresponding fields in
396    /// `DownloadOptions`. Unknown or unmapped keys are silently ignored.
397    pub fn to_download_options(&self) -> DownloadOptions {
398        let get_usize = |key: &str| -> Option<u16> {
399            let v = self.get(key).as_usize();
400            if v > 0 { Some(v as u16) } else { None }
401        };
402        let get_u64 = |key: &str| -> Option<u64> {
403            let v = self.get(key).as_usize();
404            if v > 0 { Some(v as u64) } else { None }
405        };
406        let get_str = |key: &str| -> Option<String> {
407            self.get(key)
408                .as_str()
409                .map(|s| s.to_string())
410                .filter(|s| !s.is_empty())
411        };
412
413        DownloadOptions {
414            split: get_usize("split"),
415            max_connection_per_server: get_usize("max-connection-per-server"),
416            max_download_limit: get_u64("max-download-limit"),
417            max_upload_limit: get_u64("max-upload-limit"),
418            dir: get_str("dir"),
419            out: get_str("out"),
420            seed_time: get_u64("seed-time"),
421            seed_ratio: {
422                let r = self.get("seed-ratio").as_f64().unwrap_or(0.0);
423                if r > 0.0 { Some(r) } else { None }
424            },
425            checksum: None,
426            cookie_file: get_str("cookie-file"),
427            cookies: get_str("cookies"),
428            bt_force_encrypt: self.get("bt-force-encrypt").as_bool().unwrap_or(false),
429            bt_require_crypto: self.get("bt-require-crypto").as_bool().unwrap_or(false),
430            enable_dht: self.get("enable-dht").as_bool().unwrap_or(true),
431            dht_listen_port: get_usize("dht-listen-port"),
432            dht_entry_point: {
433                let v = self.get("dht-entry-point").as_str().unwrap_or("");
434                if v.is_empty() {
435                    None
436                } else {
437                    Some(
438                        v.split(',')
439                            .map(|s| s.trim().to_string())
440                            .filter(|s| !s.is_empty())
441                            .collect(),
442                    )
443                }
444            },
445            enable_public_trackers: self.get("enable-public-trackers").as_bool().unwrap_or(true),
446            bt_piece_selection_strategy: self
447                .get("bt-piece-selection-strategy")
448                .as_str()
449                .unwrap_or("")
450                .to_string(),
451            bt_endgame_threshold: self.get("bt-endgame-threshold").as_usize() as u32,
452            max_retries: self.get("max-tries").as_usize() as u32,
453            retry_wait: self.get("retry-wait").as_usize() as u64,
454            http_proxy: get_str("http-proxy"),
455            all_proxy: get_str("all-proxy"),
456            https_proxy: get_str("https-proxy"),
457            ftp_proxy: get_str("ftp-proxy"),
458            no_proxy: get_str("no-proxy"),
459            dht_file_path: get_str("dht-file-path"),
460            bt_max_upload_slots: {
461                let v = self.get("bt-max-upload-slots").as_usize();
462                if v > 0 { Some(v as u32) } else { None }
463            },
464            bt_optimistic_unchoke_interval: {
465                let v = self.get("bt-optimistic-unchoke-interval").as_usize();
466                if v > 0 { Some(v as u64) } else { None }
467            },
468            bt_snubbed_timeout: {
469                let v = self.get("bt-snubbed-timeout").as_usize();
470                if v > 0 { Some(v as u64) } else { None }
471            },
472            bt_prioritize_piece: self
473                .get("bt-prioritize-piece")
474                .as_str()
475                .unwrap_or("")
476                .to_string(),
477            enable_utp: self.get("enable-utp").as_bool().unwrap_or(false),
478            utp_listen_port: get_usize("utp-listen-port"),
479            header: {
480                // C++ aria2 allows repeated `--header NAME: VALUE`; the config
481                // parser joins them with newlines. Split on newlines here.
482                self.get("header")
483                    .as_str()
484                    .unwrap_or("")
485                    .split('\n')
486                    .map(|s| s.trim().to_string())
487                    .filter(|s| !s.is_empty())
488                    .collect()
489            },
490            user_agent: get_str("user-agent"),
491            referer: get_str("referer"),
492            file_allocation: get_str("file-allocation"),
493            mmap_threshold: get_u64("mmap-threshold"),
494            secure_falloc: self.get("secure-falloc").as_bool().unwrap_or(false),
495        }
496    }
497
498    /// Export all current options as a key-value map.
499    ///
500    /// Useful for RPC responses and serialization. Returns a clone of the
501    /// internal options map (including defaults for unset keys).
502    pub fn to_map(&self) -> HashMap<String, OptionValue> {
503        // Merge: start with defaults, overlay with current options
504        let mut map = self.defaults.clone();
505        for (k, v) in &self.options {
506            map.insert(k.clone(), v.clone());
507        }
508        map
509    }
510
511    /// Return the number of built-in defaults.
512    pub fn default_count(&self) -> usize {
513        self.defaults.len()
514    }
515
516    /// Check whether a specific key has been explicitly set (vs using default).
517    pub fn is_explicitly_set(&self, key: &str) -> bool {
518        self.options.contains_key(key)
519    }
520
521    /// Remove an explicitly-set option, reverting it to its default value.
522    ///
523    /// After removal, [`get`] will return the built-in default again.
524    pub fn reset_to_default(&mut self, key: &str) {
525        self.options.remove(key);
526    }
527}
528
529impl Default for OptionHandler {
530    fn default() -> Self {
531        Self::new()
532    }
533}
534
535// ---------------------------------------------------------------------------
536// Tests
537// ---------------------------------------------------------------------------
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn test_defaults_populated() {
545        // All defaults should be present after new()
546        let handler = OptionHandler::new();
547        let expected_count = built_in_defaults().len();
548        assert_eq!(handler.default_count(), expected_count);
549        assert!(handler.default_count() > 0);
550
551        // Verify specific known defaults
552        assert_eq!(handler.get("dir").as_str().unwrap_or(""), ".");
553        assert_eq!(handler.get("split").as_usize(), 5);
554        assert_eq!(handler.get("max-concurrent-downloads").as_usize(), 5);
555        assert_eq!(handler.get("max-connection-per-server").as_usize(), 16);
556        assert_eq!(handler.get("min-split-size").as_usize(), 1_048_576);
557        assert!(handler.get("continue").as_bool().unwrap_or(false));
558        assert!(!handler.get("quiet").as_bool().unwrap_or(false));
559        assert_eq!(handler.get("seed-ratio").as_f64().unwrap_or(0.0), 0.0);
560        assert_eq!(handler.get("rpc-listen-port").as_usize(), 6800);
561        assert_eq!(
562            handler.get("console-log-level").as_str().unwrap_or(""),
563            "notice"
564        );
565    }
566
567    #[test]
568    fn test_set_get_roundtrip() {
569        let mut handler = OptionHandler::new();
570
571        // Set and retrieve various types
572        handler.set("dir", OptionValue::Str("/tmp/downloads".into()));
573        assert_eq!(handler.get("dir").as_str().unwrap_or(""), "/tmp/downloads");
574
575        handler.set("split", OptionValue::Usize(16));
576        assert_eq!(handler.get("split").as_usize(), 16);
577
578        handler.set("seed-ratio", OptionValue::Float(2.5));
579        assert!((handler.get("seed-ratio").as_f64().unwrap_or(0.0) - 2.5).abs() < f64::EPSILON);
580
581        handler.set("quiet", OptionValue::Bool(true));
582        assert!(handler.get("quiet").as_bool().unwrap_or(false));
583
584        handler.set(
585            "header",
586            OptionValue::List(vec!["X-Custom: foo".into(), "X-Bar: baz".into()]),
587        );
588        assert_eq!(handler.get("header").as_str_vec().len(), 2);
589
590        // Overwrite: second set wins
591        handler.set("split", OptionValue::Usize(32));
592        assert_eq!(handler.get("split").as_usize(), 32);
593
594        // Unknown key returns None variant
595        assert!(handler.get("nonexistent-key").is_none());
596    }
597
598    #[test]
599    #[allow(clippy::approx_constant)]
600    fn test_load_config_file() {
601        let mut handler = OptionHandler::new();
602
603        // Build sample .aria2rc content
604        let config_content = r#"
605# This is a comment
606dir="/home/user/downloads"
607split=16
608max-connection-per-server=8
609quiet=true
610seed-ratio=1.5
611custom-list=['header1', 'header2', 'header3']
612bool-flag=yes
613number-key=42
614float-key=3.14
615
616# Another comment
617allow-overwrite=false
618"#;
619
620        // Write to temp file
621        let tmp_dir = std::env::temp_dir();
622        let config_path = tmp_dir.join(format!("aria2_test_config_{}.aria2rc", std::process::id()));
623        std::fs::write(&config_path, config_content).expect("Failed to write temp config");
624
625        // Load config file
626        let result = handler.load_config_file(&config_path);
627        assert!(
628            result.is_ok(),
629            "load_config_file should succeed: {:?}",
630            result.err()
631        );
632
633        // Verify loaded values override defaults
634        assert_eq!(
635            handler.get("dir").as_str().unwrap_or(""),
636            "/home/user/downloads"
637        );
638        assert_eq!(handler.get("split").as_usize(), 16);
639        assert_eq!(handler.get("max-connection-per-server").as_usize(), 8);
640        assert!(handler.get("quiet").as_bool().unwrap_or(false));
641        assert!((handler.get("seed-ratio").as_f64().unwrap_or(0.0) - 1.5).abs() < f64::EPSILON);
642        assert!(!handler.get("allow-overwrite").as_bool().unwrap_or(true));
643
644        // Verify list parsing
645        let list_val = handler.get("custom-list");
646        assert_eq!(list_val.as_str_vec().len(), 3);
647        assert_eq!(list_val.as_str_vec()[0], "header1");
648
649        // Verify auto-detected types
650        assert!(handler.get("bool-flag").as_bool().unwrap_or(false)); // yes -> true
651        assert_eq!(handler.get("number-key").as_usize(), 42);
652        let float_val = handler.get("float-key").as_f64().unwrap_or(0.0);
653        assert!((float_val - 3.14).abs() < f64::EPSILON);
654
655        // Defaults should still be intact for unmentioned keys
656        assert_eq!(handler.get("rpc-listen-port").as_usize(), 6800);
657
658        // Cleanup
659        let _ = std::fs::remove_file(&config_path);
660    }
661
662    #[test]
663    fn test_apply_args_overrides_config() {
664        let mut handler = OptionHandler::new();
665
666        // First load config file with some values
667        let config_content = r#"
668dir=/config/dir
669split=4
670quiet=false
671"#;
672        let tmp_dir = std::env::temp_dir();
673        let config_path = tmp_dir.join(format!(
674            "aria2_test_override_{}.aria2rc",
675            std::process::id()
676        ));
677        std::fs::write(&config_path, config_content).expect("Failed to write config");
678        handler
679            .load_config_file(&config_path)
680            .expect("Should load config");
681
682        // Verify config values loaded
683        assert_eq!(handler.get("dir").as_str().unwrap_or(""), "/config/dir");
684        assert_eq!(handler.get("split").as_usize(), 4);
685        assert!(!handler.get("quiet").as_bool().unwrap_or(false));
686
687        // Now apply CLI args (should override config)
688        let cli_args: Vec<String> = vec![
689            "--dir=/cli/dir".to_string(),
690            "--split=12".to_string(),
691            "--quiet".to_string(), // flag without value -> bool true
692            "--max-connection-per-server=8".to_string(),
693            "--seed-ratio=2.0".to_string(),
694            "--no-continue".to_string(), // --no-key pattern -> bool false
695        ];
696        handler.apply_args(&cli_args);
697
698        // CLI args should win over config
699        assert_eq!(handler.get("dir").as_str().unwrap_or(""), "/cli/dir");
700        assert_eq!(handler.get("split").as_usize(), 12);
701        assert!(handler.get("quiet").as_bool().unwrap_or(false)); // CLI flag overrides config
702        assert_eq!(handler.get("max-connection-per-server").as_usize(), 8);
703        assert!((handler.get("seed-ratio").as_f64().unwrap_or(0.0) - 2.0).abs() < f64::EPSILON);
704        assert!(!handler.get("continue").as_bool().unwrap_or(true)); // --no-continue
705
706        // Cleanup
707        let _ = std::fs::remove_file(&config_path);
708    }
709
710    #[test]
711    fn test_to_download_options() {
712        let mut handler = OptionHandler::new();
713
714        // Set values that map to DownloadOptions fields
715        handler.set("split", OptionValue::Usize(8));
716        handler.set("max-connection-per-server", OptionValue::Usize(4));
717        handler.set("max-download-limit", OptionValue::Usize(102400));
718        handler.set("max-upload-limit", OptionValue::Usize(51200));
719        handler.set("dir", OptionValue::Str("/data".to_string()));
720        handler.set("out", OptionValue::Str("output.bin".to_string()));
721        handler.set("seed-time", OptionValue::Usize(300));
722        handler.set("seed-ratio", OptionValue::Float(2.0));
723
724        let opts = handler.to_download_options();
725
726        // Verify conversion produced correct struct
727        assert_eq!(opts.split, Some(8));
728        assert_eq!(opts.max_connection_per_server, Some(4));
729        assert_eq!(opts.max_download_limit, Some(102400));
730        assert_eq!(opts.max_upload_limit, Some(51200));
731        assert_eq!(opts.dir, Some("/data".to_string()));
732        assert_eq!(opts.out, Some("output.bin".to_string()));
733        assert_eq!(opts.seed_time, Some(300));
734        assert_eq!(opts.seed_ratio, Some(2.0));
735
736        // Default values (non-zero) should be preserved in DownloadOptions
737        let handler2 = OptionHandler::new();
738        let opts2 = handler2.to_download_options();
739        assert_eq!(opts2.split, Some(5)); // default split=5 which is > 0
740        assert_eq!(opts2.max_connection_per_server, Some(16)); // default is 16
741        assert_eq!(opts2.dir, Some(".".to_string())); // default dir is "."
742        assert_eq!(opts2.out, None); // "out" has no default -> None
743
744        // Verify reset_to_default works
745        handler.reset_to_default("split");
746        assert_eq!(handler.get("split").as_usize(), 5); // back to default
747        assert!(!handler.is_explicitly_set("split"));
748    }
749
750    #[test]
751    #[allow(clippy::approx_constant)]
752    fn test_detect_value_type_edge_cases() {
753        // Test various auto-detection scenarios
754        assert_eq!(
755            OptionHandler::detect_value_type("true"),
756            Some(OptionValue::Bool(true))
757        );
758        assert_eq!(
759            OptionHandler::detect_value_type("false"),
760            Some(OptionValue::Bool(false))
761        );
762        assert_eq!(
763            OptionHandler::detect_value_type("yes"),
764            Some(OptionValue::Bool(true))
765        );
766        assert_eq!(
767            OptionHandler::detect_value_type("no"),
768            Some(OptionValue::Bool(false))
769        );
770        assert_eq!(
771            OptionHandler::detect_value_type("42"),
772            Some(OptionValue::Usize(42))
773        );
774        assert_eq!(
775            OptionHandler::detect_value_type("-10"),
776            Some(OptionValue::Int(-10))
777        );
778        let detected = OptionHandler::detect_value_type("3.14159")
779            .unwrap()
780            .as_f64()
781            .unwrap_or(0.0);
782        assert!((detected - 3.14159).abs() < 0.001); // use full precision to avoid lint
783        assert_eq!(
784            OptionHandler::detect_value_type("\"quoted string\""),
785            Some(OptionValue::Str("quoted string".into()))
786        );
787        assert_eq!(
788            OptionHandler::detect_value_type("['a','b','c']"),
789            Some(OptionValue::List(vec!["a".into(), "b".into(), "c".into()]))
790        );
791        assert_eq!(
792            OptionHandler::detect_value_type(""),
793            Some(OptionValue::None)
794        );
795        assert_eq!(
796            OptionHandler::detect_value_type("plain_text"),
797            Some(OptionValue::Str("plain_text".into()))
798        );
799    }
800
801    #[test]
802    fn test_option_value_display() {
803        assert_eq!(OptionValue::Bool(true).to_string(), "true");
804        assert_eq!(OptionValue::Usize(42).to_string(), "42");
805        assert_eq!(OptionValue::Int(-10).to_string(), "-10");
806        assert_eq!(
807            format!("{:.2}", {
808                #[allow(clippy::approx_constant)]
809                OptionValue::Float(3.14).to_string().parse::<f64>().unwrap()
810            }),
811            "3.14"
812        ); // approximate
813        assert_eq!(OptionValue::Str("hello".to_string()).to_string(), "hello");
814        assert_eq!(
815            OptionValue::List(vec!["a".into(), "b".into()]).to_string(),
816            "a,b"
817        );
818        assert_eq!(OptionValue::None.to_string(), "");
819    }
820
821    #[test]
822    fn test_to_map_includes_all() {
823        let mut handler = OptionHandler::new();
824        handler.set("custom-key", OptionValue::Str("custom-value".into()));
825
826        let map = handler.to_map();
827        // Should include all defaults plus custom key
828        assert!(map.contains_key("dir"));
829        assert!(map.contains_key("split"));
830        assert!(map.contains_key("custom-key"));
831        assert_eq!(
832            map.get("custom-key").unwrap().as_str().unwrap_or(""),
833            "custom-value"
834        );
835        // Map size >= defaults count
836        assert!(map.len() >= built_in_defaults().len());
837    }
838}