//! Config parsing and normalization helpers for aria2-style option files. use crate::options::{OptionScope, option_spec, reserved_option_names}; /// Where a compat config document originated. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ConfigLocationKind { /// The document was loaded from a file on disk. File, /// The document was provided inline as raw text. Inline, /// The document originated from an RPC payload. Rpc, /// The document originated from environment variables. Env, /// The document originated from CLI arguments. Cli, } /// Scope inferred for a config document or directive set. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ConfigScope { /// Only global options are expected. Global, /// Only per-download options are expected. PerDownload, /// The document can contain both global and per-download options. Mixed, } /// Source category for parsed config data. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ConfigSource { /// User-authored config file such as `aria2.conf`. UserConfig, /// Persisted session file content. SessionFile, /// Input-file content that expands downloads. InputFile, /// Runtime overrides originating from transient inputs. RuntimeOverride, /// Named profile content. Profile, } /// A single `name=value` config directive. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ConfigDirective { /// Directive name after canonicalization. pub name: String, /// Optional directive value. pub value: Option, } /// Parsed config directives with source metadata. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ConfigAst { /// Ordered directives found in the source input. pub directives: Vec, /// Origin category for the parsed directives. pub source: ConfigSource, } /// Parsed config document with location metadata. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ConfigDocument { /// Ordered directives found in the document. pub directives: Vec, /// Where the document was loaded from. pub location: ConfigLocationKind, /// Scope classification for the document. pub scope: ConfigScope, } /// Session save/load compatibility metadata. #[derive(Clone, Debug, Eq, PartialEq)] pub struct SessionFileModel { /// Path where the session file is stored. pub session_path: String, /// Optional input file associated with the session. pub input_path: Option, /// Autosave interval for session persistence. pub autosave_interval_secs: u64, } /// Named profile loaded through compat config handling. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ConfigProfile { /// Profile name. pub name: String, /// Source category for the profile. pub source: ConfigSource, /// Parsed profile document. pub document: ConfigDocument, } /// Errors produced while parsing compat config input. #[derive(Clone, Debug, Eq, PartialEq)] pub enum ConfigParseError { /// A line could not be parsed as a valid directive. InvalidDirective(String), /// A directive that requires a value omitted one. MissingValue(String), /// A directive named an unknown option. UnknownOption(String), /// A directive used an option name reserved for internal use. ReservedOption(String), } impl std::fmt::Display for ConfigParseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::InvalidDirective(line) => write!(f, "invalid config directive: {line}"), Self::MissingValue(name) => write!(f, "missing value for option: {name}"), Self::UnknownOption(name) => write!(f, "unknown option: {name}"), Self::ReservedOption(name) => write!(f, "reserved option name: {name}"), } } } impl std::error::Error for ConfigParseError {} /// Canonicalizes a config option name through the option registry. fn canonicalize_option_name(name: &str) -> String { let name = normalize_option_token(name); option_spec(name).map_or_else(|| name.to_owned(), |spec| spec.name.to_owned()) } /// Removes a UTF-8 byte-order mark when one prefixes a text fragment. fn strip_utf8_bom(text: &str) -> &str { text.strip_prefix('\u{feff}').unwrap_or(text) } /// Normalizes a CLI- or config-style option token to its raw name form. fn normalize_option_token(name: &str) -> &str { let name = strip_utf8_bom(name.trim()); name.strip_prefix("--").unwrap_or_else(|| { name.strip_prefix('-') .filter(|value| !value.is_empty()) .map_or(name, |name| name) }) } /// Removes trailing comments that are safely separated from a value. fn strip_safe_trailing_comment(value: &str) -> &str { for (index, ch) in value.char_indices() { let Some(prefix) = value.get(..index) else { continue; }; if (ch == '#' || ch == ';') && prefix.chars().next_back().is_some_and(char::is_whitespace) { return prefix.trim_end(); } } value.trim() } /// Parses a single aria2-style config line, ignoring blank lines and comments. /// /// # Errors /// /// Returns [`ConfigParseError`] when the line is malformed or omits a required value. pub fn parse_config_line(line: &str) -> Result, ConfigParseError> { let trimmed = strip_utf8_bom(line).trim(); if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') { return Ok(None); } let (name, value) = trimmed .split_once('=') .ok_or_else(|| ConfigParseError::InvalidDirective(trimmed.to_owned()))?; let name = normalize_option_token(name); if name.is_empty() { return Err(ConfigParseError::InvalidDirective(trimmed.to_owned())); } let value = strip_safe_trailing_comment(value); if value.is_empty() { return Err(ConfigParseError::MissingValue(name.to_owned())); } Ok(Some(ConfigDirective { name: canonicalize_option_name(name), value: Some(value.to_owned()), })) } /// Parses a single config line and enforces the known-option / non-reserved subset. /// /// # Errors /// /// Returns [`ConfigParseError`] when the line is malformed, reserved, or names an unknown option. pub fn parse_config_line_strict(line: &str) -> Result, ConfigParseError> { let directive = parse_config_line(line)?; if let Some(ref d) = directive { if reserved_option_names().iter().any(|r| r.name == d.name) { return Err(ConfigParseError::ReservedOption(d.name.clone())); } if option_spec(d.name.as_str()).is_none() { return Err(ConfigParseError::UnknownOption(d.name.clone())); } } Ok(directive) } /// Parses a full config document using strict option validation. /// /// # Errors /// /// Returns [`ConfigParseError`] when any non-comment line is malformed, reserved, or unknown. pub fn parse_config(text: &str) -> Result, ConfigParseError> { let mut directives = Vec::new(); for line in text.lines() { if let Some(d) = parse_config_line_strict(line)? { directives.push(d); } } Ok(directives) } /// Parses a full config document while tolerating unknown options. /// /// # Errors /// /// Returns [`ConfigParseError`] when any non-comment line is malformed or omits a required value. pub fn parse_config_lenient(text: &str) -> Result, ConfigParseError> { let mut directives = Vec::new(); for line in text.lines() { if let Some(d) = parse_config_line(line)? { directives.push(d); } } Ok(directives) } /// Infers config scope for a named option when the option is known. #[must_use] pub fn infer_scope(name: &str) -> Option { option_spec(name).map(|s| match s.metadata.scope { OptionScope::Global => ConfigScope::Global, OptionScope::PerDownload => ConfigScope::PerDownload, OptionScope::Both => ConfigScope::Mixed, }) } /// Returns a document whose directive names have been canonicalized. #[must_use] pub fn normalize_document(mut document: ConfigDocument) -> ConfigDocument { for directive in &mut document.directives { directive.name = canonicalize_option_name(&directive.name); } document } /// Loads an inline named profile using lenient parsing semantics. /// /// # Errors /// /// Returns [`ConfigParseError`] when the profile text contains malformed directives. pub fn load_profile(name: &str, text: &str) -> Result { let directives = parse_config_lenient(text)?; Ok(ConfigProfile { name: name.to_owned(), source: ConfigSource::Profile, document: ConfigDocument { directives, location: ConfigLocationKind::Inline, scope: ConfigScope::Mixed, }, }) } #[cfg(test)] mod tests { use super::{ ConfigDirective, ConfigDocument, ConfigLocationKind, ConfigParseError, ConfigScope, ConfigSource, load_profile, normalize_document, parse_config_line, parse_config_line_strict, }; #[test] fn parse_config_line_strict_canonicalizes_known_alias_names() { let directive = parse_config_line_strict("http-want-digest=true") .expect("strict alias parse should succeed") .expect("directive should be present"); assert_eq!(directive.name, "no-want-digest-header"); assert_eq!(directive.value.as_deref(), Some("true")); } #[test] fn parse_config_line_lenient_canonicalizes_known_alias_names() { let directive = parse_config_line("http-want-digest=true") .expect("lenient alias parse should succeed") .expect("directive should be present"); assert_eq!(directive.name, "no-want-digest-header"); assert_eq!(directive.value.as_deref(), Some("true")); } #[test] fn load_profile_keeps_profile_metadata_while_canonicalizing_known_aliases() { let profile = load_profile( "demo", "# comment\nhttp-want-digest=true\nrpc-listen-all=true\n", ) .expect("profile should load"); assert_eq!(profile.name, "demo"); assert_eq!(profile.source, ConfigSource::Profile); assert_eq!(profile.document.location, ConfigLocationKind::Inline); assert_eq!(profile.document.scope, ConfigScope::Mixed); let [first, second] = profile.document.directives.as_slice() else { panic!("profile should contain exactly two directives"); }; assert_eq!(first.name, "no-want-digest-header"); assert_eq!(second.name, "rpc-listen-all"); } #[test] fn parse_config_line_strict_still_rejects_unknown_options() { let error = parse_config_line_strict("not-a-real-option=true") .expect_err("unknown option should still be rejected"); assert_eq!( error, ConfigParseError::UnknownOption("not-a-real-option".to_owned()) ); } #[test] fn parse_config_line_accepts_bom_and_cli_style_option_spellings() { let parameterized = parse_config_line_strict("\u{feff}--parameterized-uri=true") .expect("long CLI spelling should parse") .expect("directive should be present"); assert_eq!(parameterized.name, "parameterized-uri"); assert_eq!(parameterized.value.as_deref(), Some("true")); let remote_time = parse_config_line_strict("-R=true") .expect("short CLI alias should parse") .expect("directive should be present"); assert_eq!(remote_time.name, "remote-time"); assert_eq!(remote_time.value.as_deref(), Some("true")); } #[test] fn parse_config_line_strips_safe_trailing_comments_but_preserves_uri_fragments() { let referer = parse_config_line_strict( "referer=http://example.invalid/download#frag # copied note", ) .expect("referer with fragment should parse") .expect("directive should be present"); assert_eq!(referer.name, "referer"); assert_eq!( referer.value.as_deref(), Some("http://example.invalid/download#frag") ); let tracker = parse_config_line("bt-tracker=udp://tracker.invalid:80/announce ; mirror") .expect("tracker line should parse") .expect("directive should be present"); assert_eq!(tracker.name, "bt-tracker"); assert_eq!( tracker.value.as_deref(), Some("udp://tracker.invalid:80/announce") ); } #[test] fn load_profile_canonicalizes_extended_cli_spellings_and_comments() { let profile = load_profile( "compat", "\u{feff}--select-file=1-3,5 # keep files\n-R=true\n", ) .expect("profile should load"); let [first, second] = profile.document.directives.as_slice() else { panic!("profile should contain exactly two directives"); }; assert_eq!(first.name, "select-file"); assert_eq!(first.value.as_deref(), Some("1-3,5")); assert_eq!(second.name, "remote-time"); assert_eq!(second.value.as_deref(), Some("true")); } #[test] fn normalize_document_canonicalizes_cli_style_and_bom_prefixed_names() { let normalized = normalize_document(ConfigDocument { directives: vec![ ConfigDirective { name: "\u{feff}--parameterized-uri".to_owned(), value: Some("true".to_owned()), }, ConfigDirective { name: "-R".to_owned(), value: Some("true".to_owned()), }, ], location: ConfigLocationKind::Inline, scope: ConfigScope::Mixed, }); let [first, second] = normalized.directives.as_slice() else { panic!("normalized document should contain exactly two directives"); }; assert_eq!(first.name, "parameterized-uri"); assert_eq!(second.name, "remote-time"); } }