Skip to main content

aria2_core/config/
mod.rs

1pub mod netrc;
2pub mod option;
3pub mod option_definitions;
4pub mod parser;
5pub mod uri_list;
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10pub use netrc::{NetRcEntry, NetRcError, NetRcFile};
11pub use option::{
12    ChoiceValidator, DependencyChecker, OptionCategory, OptionDef, OptionDefinition, OptionError,
13    OptionRegistry, OptionType, OptionValidator, OptionValue, PathValidator, RangeValidator,
14    RegexValidator, UrlValidator,
15};
16pub use parser::{ConfigError, ConfigParser, ConfigSource};
17pub use uri_list::{UriListEntry, UriListError, UriListFile};
18
19/// Emitted when a global option value changes via `set_global_option`.
20///
21/// Subscribers can listen for these events via `ConfigManager::subscribe_changes()`.
22#[derive(Debug, Clone)]
23pub struct ConfigChangeEvent {
24    /// The option name that was changed (e.g., "split", "dir").
25    pub key: String,
26    /// The previous value before the change.
27    pub old_value: OptionValue,
28    /// The new value after the change.
29    pub new_value: OptionValue,
30}
31
32/// Unified runtime configuration manager for aria2-rust.
33///
34/// `ConfigManager` provides a two-tier option storage system:
35/// - **Global options**: Shared across all download tasks
36/// - **Task-level options**: Per-task overrides that inherit from globals
37///
38/// Options are loaded from four sources in priority order:
39/// 1. Built-in defaults (from `OptionRegistry`)
40/// 2. Environment variables (`ARIA2_*` prefix)
41/// 3. Configuration file (`~/.aria2/aria2.conf`)
42/// 4. Command-line arguments (highest priority)
43///
44/// # Example
45///
46/// ```rust,no_run
47/// use aria2_core::config::ConfigManager;
48/// use aria2_core::config::OptionValue;
49///
50/// #[tokio::main]
51/// async fn main() {
52///     let mut mgr = ConfigManager::new();
53///     mgr.set_global_option("split", OptionValue::Int(8)).await.unwrap();
54///     assert_eq!(mgr.get_global_i64("split").await, Some(8));
55/// }
56/// ```
57pub struct ConfigManager {
58    global_opts: Arc<tokio::sync::RwLock<HashMap<String, OptionValue>>>,
59    task_defaults: Arc<tokio::sync::RwLock<HashMap<String, HashMap<String, OptionValue>>>>,
60    parser: ConfigParser,
61    registry: OptionRegistry,
62    change_tx: tokio::sync::broadcast::Sender<ConfigChangeEvent>,
63}
64
65impl ConfigManager {
66    /// Create a new `ConfigManager` with the built-in `OptionRegistry`
67    /// containing ~95 core aria2 options.
68    pub fn new() -> Self {
69        let (change_tx, _) = tokio::sync::broadcast::channel(64);
70        let registry = OptionRegistry::new();
71        let mut parser = ConfigParser::with_registry(registry.clone());
72        parser.load_defaults_first();
73        Self {
74            global_opts: Arc::new(tokio::sync::RwLock::new(parser.options().clone())),
75            task_defaults: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
76            parser,
77            registry,
78            change_tx,
79        }
80    }
81
82    /// Create a `ConfigManager` with a custom `OptionRegistry`.
83    ///
84    /// Use this when you need to register custom options beyond the
85    /// built-in set (e.g., application-specific configuration).
86    pub fn new_with_registry(registry: OptionRegistry) -> Self {
87        let (change_tx, _) = tokio::sync::broadcast::channel(64);
88        let mut parser = ConfigParser::with_registry(registry.clone());
89        parser.load_defaults_first();
90        Self {
91            global_opts: Arc::new(tokio::sync::RwLock::new(parser.options().clone())),
92            task_defaults: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
93            parser,
94            registry,
95            change_tx,
96        }
97    }
98
99    /// Parse and load command-line arguments into global options.
100    ///
101    /// Supports formats: `--opt=val`, `--opt val`, `-o val`, `--no-opt`,
102    /// and `@file` for URI list file references.
103    pub async fn load_cli(&mut self, args: &[String]) {
104        let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
105        self.parser.parse_cli_args(&args_ref);
106        self.sync_global().await;
107    }
108
109    /// Load options from an aria2.conf-format configuration file.
110    pub async fn load_file(&mut self, path: &str) {
111        self.parser.parse_file(path);
112        self.sync_global().await;
113    }
114
115    /// Load options from environment variables with `ARIA2_` prefix.
116    ///
117    /// Maps `ARIA2_SPLIT` → `split`, `ARIA2_DIR` → `dir`, etc.
118    pub async fn load_env(&mut self) {
119        self.parser.parse_env_vars();
120        self.sync_global().await;
121    }
122
123    async fn sync_global(&self) {
124        let mut opts = self.global_opts.write().await;
125        for (k, v) in self.parser.options() {
126            opts.insert(k.clone(), v.clone());
127        }
128    }
129
130    /// Get a global option value by name.
131    pub async fn get_global_option(&self, name: &str) -> Option<OptionValue> {
132        self.global_opts.read().await.get(name).cloned()
133    }
134
135    /// Convenience: get a global option as a `String`.
136    ///
137    /// Returns `None` if the option doesn't exist or is not a string type.
138    pub async fn get_global_str(&self, name: &str) -> Option<String> {
139        self.global_opts
140            .read()
141            .await
142            .get(name)
143            .and_then(|v| match v {
144                OptionValue::Str(s) => Some(s.clone()),
145                _ => None,
146            })
147    }
148
149    /// Convenience: get a global option as an `i64` integer.
150    ///
151    /// Returns `None` if the option doesn't exist or is not an integer type.
152    pub async fn get_global_i64(&self, name: &str) -> Option<i64> {
153        self.global_opts
154            .read()
155            .await
156            .get(name)
157            .and_then(|v| match v {
158                OptionValue::Int(n) => Some(*n),
159                _ => None,
160            })
161    }
162
163    /// Convenience: get a global option as a `bool`.
164    ///
165    /// Returns `None` if the option doesn't exist or is not a boolean type.
166    pub async fn get_global_bool(&self, name: &str) -> Option<bool> {
167        self.global_opts
168            .read()
169            .await
170            .get(name)
171            .and_then(|v| match v {
172                OptionValue::Bool(b) => Some(*b),
173                _ => None,
174            })
175    }
176
177    /// Set a global option value with validation.
178    ///
179    /// Validates against the `OptionRegistry` (type checking, range validation).
180    /// Emits a `ConfigChangeEvent` on success. Returns an error for unknown
181    /// options or validation failures.
182    pub async fn set_global_option(
183        &mut self,
184        name: &str,
185        value: OptionValue,
186    ) -> Result<(), String> {
187        if !self.registry.contains(name) {
188            return Err(format!("unknown option '{}'", name));
189        }
190        let def = self.registry.get(name).unwrap();
191        let parsed = def.parse_value(&value.to_string())?;
192        let old = self.global_opts.read().await.get(name).cloned();
193        {
194            let mut opts = self.global_opts.write().await;
195            opts.insert(name.to_string(), parsed.clone());
196        }
197        self.parser.set(name, value);
198        let _ = self.change_tx.send(ConfigChangeEvent {
199            key: name.to_string(),
200            old_value: old.unwrap_or(OptionValue::None),
201            new_value: parsed,
202        });
203        Ok(())
204    }
205
206    /// Batch-set multiple global options (RPC `changeGlobalOption` compatible).
207    ///
208    /// Returns a list of error messages for each failed option.
209    /// Options that succeed are applied immediately.
210    pub async fn change_global_options(&mut self, options: HashMap<String, String>) -> Vec<String> {
211        let mut errors = Vec::new();
212        for (key, value) in options {
213            if let Err(e) = self.set_global_option(&key, OptionValue::Str(value)).await {
214                errors.push(e);
215            }
216        }
217        errors
218    }
219
220    pub async fn get_all_global_options(&self) -> HashMap<String, OptionValue> {
221        self.global_opts.read().await.clone()
222    }
223
224    pub async fn get_all_global_options_json(&self) -> serde_json::Value {
225        let opts = self.global_opts.read().await;
226        let mut map = serde_json::Map::new();
227        for (k, v) in opts.iter() {
228            map.insert(
229                k.clone(),
230                <&OptionValue as Into<serde_json::Value>>::into(v),
231            );
232        }
233        serde_json::Value::Object(map)
234    }
235
236    pub async fn get_task_default(&self, gid: &str, name: &str) -> Option<OptionValue> {
237        let tasks = self.task_defaults.read().await;
238        let task_val = tasks.get(gid).and_then(|m| m.get(name)).cloned();
239        if task_val.is_some() {
240            return task_val;
241        }
242        drop(tasks);
243        self.global_opts.read().await.get(name).cloned()
244    }
245
246    pub async fn set_task_option(
247        &mut self,
248        gid: &str,
249        name: &str,
250        value: OptionValue,
251    ) -> Result<(), String> {
252        if !self.registry.contains(name) {
253            return Err(format!("unknown option '{}'", name));
254        }
255        let def = self.registry.get(name).unwrap();
256        let parsed = def.parse_value(&value.to_string())?;
257        let mut tasks = self.task_defaults.write().await;
258        let entry = tasks.entry(gid.to_string()).or_insert_with(HashMap::new);
259        entry.insert(name.to_string(), parsed);
260        Ok(())
261    }
262
263    pub async fn change_task_options(
264        &mut self,
265        gid: &str,
266        options: HashMap<String, String>,
267    ) -> Vec<String> {
268        let mut errors = Vec::new();
269        for (key, value) in options {
270            if let Err(e) = self
271                .set_task_option(gid, &key, OptionValue::Str(value))
272                .await
273            {
274                errors.push(e);
275            }
276        }
277        errors
278    }
279
280    pub async fn get_task_options(&self, gid: &str) -> HashMap<String, OptionValue> {
281        let tasks = self.task_defaults.read().await;
282        let global = self.global_opts.read().await;
283        if let Some(task_opts) = tasks.get(gid) {
284            let mut merged = global.clone();
285            for (k, v) in task_opts {
286                merged.insert(k.clone(), v.clone());
287            }
288            merged
289        } else {
290            global.clone()
291        }
292    }
293
294    pub async fn remove_task(&mut self, gid: &str) {
295        let mut tasks = self.task_defaults.write().await;
296        tasks.remove(gid);
297    }
298
299    /// Subscribe to configuration change events.
300    ///
301    /// Returns a `broadcast::Receiver` that receives `ConfigChangeEvent`
302    /// whenever `set_global_option` is called.
303    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ConfigChangeEvent> {
304        self.change_tx.subscribe()
305    }
306
307    pub fn registry(&self) -> &OptionRegistry {
308        &self.registry
309    }
310    pub fn parser(&self) -> &ConfigParser {
311        &self.parser
312    }
313    pub fn has_errors(&self) -> bool {
314        self.parser.has_errors()
315    }
316    pub fn errors(&self) -> &[ConfigError] {
317        self.parser.errors()
318    }
319
320    pub async fn save_session(&self, path: &str) -> Result<(), String> {
321        let opts = self.global_opts.read().await;
322        let content = opts
323            .iter()
324            .filter_map(|(k, v)| {
325                if matches!(v, OptionValue::None) {
326                    None
327                } else {
328                    Some(format!("{}={}", k, v))
329                }
330            })
331            .collect::<Vec<_>>()
332            .join("\n");
333        std::fs::write(path, content).map_err(|e| format!("failed to save session: {}", e))
334    }
335
336    pub async fn load_session(&mut self, path: &str) -> Result<(), String> {
337        self.parser.parse_file(path);
338        self.sync_global().await;
339        Ok(())
340    }
341
342    pub async fn create_task_config(
343        &self,
344        overrides: HashMap<String, OptionValue>,
345    ) -> HashMap<String, OptionValue> {
346        let global = self.global_opts.read().await;
347        let mut config = global.clone();
348        for (k, v) in overrides {
349            config.insert(k, v);
350        }
351        config
352    }
353}
354
355impl Default for ConfigManager {
356    fn default() -> Self {
357        Self::new()
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[tokio::test]
366    async fn test_manager_creation() {
367        let mgr = ConfigManager::new();
368        assert!(!mgr.has_errors());
369        let dir = mgr.get_global_str("dir").await;
370        assert!(dir.is_some());
371    }
372
373    #[tokio::test]
374    async fn test_get_and_set_global() {
375        let mut mgr = ConfigManager::new();
376        let result = mgr.set_global_option("split", OptionValue::Int(8)).await;
377        assert!(result.is_ok());
378        let val = mgr.get_global_i64("split").await;
379        assert_eq!(val, Some(8));
380    }
381
382    #[tokio::test]
383    async fn test_set_unknown_option_fails() {
384        let mut mgr = ConfigManager::new();
385        let result = mgr
386            .set_global_option("nonexistent-option", OptionValue::Str("value".into()))
387            .await;
388        assert!(result.is_err());
389    }
390
391    #[tokio::test]
392    async fn test_change_global_options_batch() {
393        let mut mgr = ConfigManager::new();
394        let mut opts = HashMap::new();
395        opts.insert("split".to_string(), "10".to_string());
396        opts.insert("quiet".to_string(), "true".to_string());
397        let errors = mgr.change_global_options(opts).await;
398        assert!(errors.is_empty());
399        assert_eq!(mgr.get_global_i64("split").await, Some(10));
400        assert_eq!(mgr.get_global_bool("quiet").await, Some(true));
401    }
402
403    #[tokio::test]
404    async fn test_get_all_global_options() {
405        let mgr = ConfigManager::new();
406        let all = mgr.get_all_global_options().await;
407        assert!(!all.is_empty());
408        assert!(all.contains_key("dir"));
409    }
410
411    #[tokio::test]
412    async fn test_get_all_global_options_json() {
413        let mgr = ConfigManager::new();
414        let json = mgr.get_all_global_options_json().await;
415        assert!(json.is_object());
416        let obj = json.as_object().unwrap();
417        assert!(obj.contains_key("dir"));
418    }
419
420    #[tokio::test]
421    async fn test_task_options_inherit_global() {
422        let mut mgr = ConfigManager::new();
423        mgr.set_global_option("split", OptionValue::Int(8))
424            .await
425            .unwrap();
426        let task_val = mgr.get_task_default("gid-001", "split").await;
427        assert_eq!(task_val.as_ref().and_then(|v| v.as_i64()), Some(8));
428    }
429
430    #[tokio::test]
431    async fn test_task_options_override_global() {
432        let mut mgr = ConfigManager::new();
433        mgr.set_global_option("split", OptionValue::Int(5))
434            .await
435            .unwrap();
436        mgr.set_task_option("gid-001", "split", OptionValue::Int(12))
437            .await
438            .unwrap();
439        let task_val = mgr.get_task_default("gid-001", "split").await;
440        assert_eq!(task_val.and_then(|v| v.as_i64()), Some(12));
441    }
442
443    #[tokio::test]
444    async fn test_change_task_options_batch() {
445        let mut mgr = ConfigManager::new();
446        let mut opts = HashMap::new();
447        opts.insert("out".to_string(), "special.txt".to_string());
448        let errors = mgr.change_task_options("gid-002", opts).await;
449        assert!(errors.is_empty());
450    }
451
452    #[tokio::test]
453    async fn test_remove_task() {
454        let mut mgr = ConfigManager::new();
455        mgr.set_task_option("gid-003", "out", OptionValue::Str("file.txt".into()))
456            .await
457            .unwrap();
458        mgr.remove_task("gid-003").await;
459        let val = mgr.get_task_default("gid-003", "out").await;
460        assert_eq!(val.map(|v| v.as_str().map(|s| s.to_string())), None);
461    }
462
463    #[tokio::test]
464    async fn test_change_event_broadcast() {
465        let mut mgr = ConfigManager::new();
466        let mut rx = mgr.subscribe_changes();
467        mgr.set_global_option("quiet", OptionValue::Bool(true))
468            .await
469            .unwrap();
470        let event = rx.recv().await;
471        assert!(event.is_ok());
472        let evt = event.unwrap();
473        assert_eq!(evt.key, "quiet");
474    }
475
476    #[tokio::test]
477    async fn test_create_task_config_merges_overrides() {
478        let mut mgr = ConfigManager::new();
479        mgr.set_global_option("dir", OptionValue::Str("/global".into()))
480            .await
481            .unwrap();
482        let mut overrides = HashMap::new();
483        overrides.insert("dir".into(), OptionValue::Str("/local".into()));
484        overrides.insert("out".into(), OptionValue::Str("file.txt".into()));
485        let config = mgr.create_task_config(overrides).await;
486        assert_eq!(config.get("dir").and_then(|v| v.as_str()), Some("/local"));
487        assert_eq!(config.get("out").and_then(|v| v.as_str()), Some("file.txt"));
488    }
489
490    #[tokio::test]
491    async fn test_save_and_load_session() {
492        let mut mgr = ConfigManager::new();
493        mgr.set_global_option("split", OptionValue::Int(7))
494            .await
495            .unwrap();
496        let tmp_dir = std::env::temp_dir();
497        let session_path = format!(
498            "{}/aria2_test_session_{}.txt",
499            tmp_dir.display(),
500            std::process::id()
501        );
502        mgr.save_session(&session_path).await.unwrap();
503
504        let mut mgr2 = ConfigManager::new();
505        mgr2.load_session(&session_path).await.unwrap();
506        let val = mgr2.get_global_i64("split").await;
507        assert_eq!(val, Some(7));
508
509        let _ = std::fs::remove_file(&session_path);
510    }
511
512    #[tokio::test]
513    async fn test_load_cli_args() {
514        let mut mgr = ConfigManager::new();
515        mgr.load_cli(&["--dir=/custom/path".to_string(), "--split=12".to_string()])
516            .await;
517        assert_eq!(mgr.get_global_str("dir").await, Some("/custom/path".into()));
518        assert_eq!(mgr.get_global_i64("split").await, Some(12));
519    }
520
521    #[tokio::test]
522    async fn test_registry_access() {
523        let mgr = ConfigManager::new();
524        assert!(mgr.registry().contains("split"));
525        assert!(mgr.registry().get("dir").is_some());
526        assert!(mgr.registry().count() >= 60);
527    }
528}