use crate::error::{AppError, AppResult};
use crate::i18n::{t, Message};
use directories::ProjectDirs;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
const PROJECT_ORG: &str = "youtube-legend-cli";
const PROJECT_APP: &str = "youtube-legend-cli";
const PROJECT_QUALIFIER: &str = "com";
const CONFIG_FILE_NAME: &str = "config.toml";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum KeyKind {
Str,
Int,
Bool,
StrList,
}
impl KeyKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Str => "string",
Self::Int => "integer",
Self::Bool => "boolean",
Self::StrList => "string-list",
}
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct KeySpec {
pub key: &'static str,
pub kind: KeyKind,
pub doc: &'static str,
pub secret: bool,
}
macro_rules! key {
($name:literal, $kind:ident, $doc:literal) => {
KeySpec {
key: $name,
kind: KeyKind::$kind,
doc: $doc,
secret: false,
}
};
}
pub const KEYS: &[KeySpec] = &[
key!("url", Str, "Default YouTube URL when none is supplied"),
key!("lang", Str, "Preferred subtitle language (BCP 47)"),
key!("ui_lang", Str, "Interface language for stderr messages"),
key!("format", Str, "Output format: txt, srt or vtt"),
key!(
"timeout",
Int,
"Whole-operation timeout in seconds (not per HTTP request)"
),
key!("cache_ttl", Int, "Local cache TTL in hours"),
key!("user_agent", Str, "User-Agent header for HTTP requests"),
key!("verbose", Bool, "Emit tracing events to stderr"),
key!("quiet", Bool, "Suppress stderr output except errors"),
key!("json", Bool, "Emit the structured JSON envelope"),
key!("batch", Bool, "Read multiple URLs from stdin"),
key!("no_cache", Bool, "Disable reads from the local cache"),
key!("dry_run", Bool, "Skip network I/O entirely"),
key!("no_progress", Bool, "Suppress progress bars on stderr"),
key!("yes", Bool, "Assume yes for any confirmation prompt"),
key!("no_input", Bool, "Refuse to read stdin"),
key!(
"log_level",
Str,
"Log level: error, warn, info, debug, trace"
),
key!("log_format", Str, "Log format: text or json"),
key!("color", Str, "Colour output: auto, always, never"),
key!(
"provider",
Str,
"Provider selection: auto or a pinned provider"
),
key!(
"offline",
Bool,
"Refuse every outbound request; serve cache only"
),
key!("jobs", Int, "Batch items processed concurrently"),
key!("cli.max_url_chars", Int, "Longest positional URL accepted"),
key!(
"cli.worker_threads_min",
Int,
"Lower bound of the tokio worker-thread count derived from available parallelism"
),
key!(
"cli.worker_threads_max",
Int,
"Upper bound of the tokio worker-thread count derived from available parallelism"
),
key!(
"cli.max_jobs",
Int,
"Ceiling applied to --jobs regardless of what is requested"
),
key!(
"i18n.windows_console_code_page",
Int,
"Console code page forced on Windows"
),
key!(
"i18n.max_untranslated_messages",
Int,
"Ceiling on untranslated catalogue entries"
),
key!(
"cache.qualifier",
Str,
"Qualifier segment of the platform cache directory"
),
key!(
"net.watch_probe_timeout_secs",
Int,
"Timeout for the watch-page probe that names the real cause after the chain fails"
),
key!(
"net.verify_delivered_language",
Bool,
"Confirm on the watch page that the delivered track is the language that was asked for"
),
key!(
"net.health.failure_budget",
Int,
"Consecutive failures before a provider is called broken"
),
key!(
"net.health.persistence_window_secs",
Int,
"Seconds a failure run must span before it counts as persistent"
),
key!(
"net.per_host_concurrency",
Int,
"Concurrent in-flight requests allowed against one upstream host"
),
key!(
"net.throttle_interval_ms",
Int,
"Minimum interval between two provider calls, in milliseconds"
),
key!(
"net.max_body_bytes",
Int,
"Ceiling on a subtitle payload accepted by the parser, in bytes"
),
key!(
"net.retry.max_attempts",
Int,
"How many times a provider call is attempted before the error is returned"
),
key!(
"net.retry.backoff_base_ms",
Int,
"First back-off delay; each further attempt doubles it"
),
key!(
"net.retry.backoff_max_ms",
Int,
"Ceiling applied to the doubling back-off delay"
),
key!(
"net.retry.rate_limit_default_secs",
Int,
"Wait applied on HTTP 429 when the upstream sends no Retry-After"
),
key!(
"net.retry.rate_limit_cap_secs",
Int,
"Ceiling applied to an upstream Retry-After value"
),
key!(
"net.robots.honor",
Bool,
"Obey robots.txt before scraping (off by default: this CLI acts on behalf of a user request, not as a crawler)"
),
key!(
"net.robots.fetch_timeout_secs",
Int,
"Seconds allowed for the robots.txt fetch itself, when honoring is on"
),
key!(
"net.observe.redacted_headers",
StrList,
"Header names redacted before a capture is written"
),
key!(
"net.observe.redacted_header_substrings",
StrList,
"Header-name substrings that trigger redaction"
),
key!(
"net.observe.binary_media_prefixes",
StrList,
"Content-Type prefixes treated as binary media"
),
key!(
"net.observe.top_hosts_limit",
Int,
"How many hosts the traffic summary reports"
),
key!(
"net.intercept.blocked_hosts",
StrList,
"Hosts refused outright by the interceptor"
),
key!(
"net.intercept.url_patterns",
StrList,
"URL patterns matched by the interceptor"
),
key!(
"net.intercept.stub_status",
Int,
"HTTP status returned for a stubbed request"
),
key!(
"net.intercept.block_media",
Bool,
"Refuse image, font and media subresources"
),
key!(
"net.intercept.stub_trackers",
Bool,
"Answer tracker requests with a stub instead of the network"
),
key!(
"net.endpoints.decopy.host",
Str,
"Host of the decopy provider API"
),
key!(
"net.endpoints.decopy.base",
Str,
"Scheme and authority of the decopy provider API"
),
key!(
"net.endpoints.decopy.create_job_path",
Str,
"Path of the decopy create-job endpoint"
),
key!(
"net.endpoints.decopy.product_code",
Str,
"Product code the decopy API requires"
),
key!("net.endpoints.noiz.host", Str, "Host of the noiz provider API"),
key!(
"net.endpoints.noiz.base",
Str,
"Scheme and authority of the noiz provider API"
),
key!(
"net.endpoints.noiz.subtitles_path",
Str,
"Path of the noiz subtitles endpoint"
),
key!("net.session.cookie_file", Str, "Path of the cookie jar"),
key!(
"net.session.cookie_file_mode",
Int,
"Unix permission bits of the cookie jar"
),
key!(
"net.session.chrome_full_version",
Str,
"Chrome full version advertised in client hints"
),
key!(
"net.session.header_order",
StrList,
"Order request headers are emitted in"
),
key!(
"net.session.accept_navigation",
Str,
"Accept header for navigation requests"
),
key!(
"net.session.accept_encoding",
Str,
"Accept-Encoding header value"
),
key!(
"net.session.accept_language",
Str,
"Accept-Language header value"
),
key!(
"net.waf.max_consecutive_failures",
Int,
"Failures tolerated before the circuit opens"
),
key!(
"net.waf.header_signatures",
StrList,
"Exact header names that identify a WAF"
),
key!(
"net.waf.header_prefix_signatures",
StrList,
"Header-name prefixes that identify a WAF"
),
key!(
"net.waf.cookie_signatures",
StrList,
"Exact cookie names that identify a WAF"
),
key!(
"net.waf.cookie_prefix_signatures",
StrList,
"Cookie-name prefixes that identify a WAF"
),
key!(
"net.waf.challenge_cookies",
StrList,
"Cookies that mark an interactive challenge"
),
key!(
"input.max_stdin_bytes",
Int,
"Ceiling on a single stdin read, in bytes"
),
key!(
"input.move_steps_min",
Int,
"Minimum synthesised pointer steps per move"
),
key!(
"input.move_steps_max",
Int,
"Maximum synthesised pointer steps per move"
),
key!("input.move_gap_min_ms", Int, "Minimum gap between steps"),
key!("input.move_gap_max_ms", Int, "Maximum gap between steps"),
key!(
"input.bezier_deviation_px",
Int,
"Curve deviation of a synthesised pointer path"
),
key!("input.click_jitter_min_px", Int, "Minimum click jitter"),
key!("input.click_jitter_max_px", Int, "Maximum click jitter"),
key!(
"input.click_settle_min_ms",
Int,
"Minimum post-click settle"
),
key!(
"input.click_settle_max_ms",
Int,
"Maximum post-click settle"
),
key!("input.type_delay_min_ms", Int, "Minimum inter-key delay"),
key!("input.type_delay_max_ms", Int, "Maximum inter-key delay"),
key!("input.scroll_chunk_min_px", Int, "Minimum scroll chunk"),
key!("input.scroll_chunk_max_px", Int, "Maximum scroll chunk"),
key!(
"input.scroll_pause_min_ms",
Int,
"Minimum pause between chunks"
),
key!(
"input.scroll_pause_max_ms",
Int,
"Maximum pause between chunks"
),
key!("input.scroll_total_px", Int, "Total distance scrolled"),
key!(
"input.delay_sigma_milli",
Int,
"Log-space standard deviation of the delays, in thousandths"
),
key!(
"input.long_pause_permille",
Int,
"Chance per thousand that a word boundary earns a long pause"
),
key!(
"input.long_pause_min_ms",
Int,
"Minimum long pause at a word boundary"
),
key!(
"input.long_pause_max_ms",
Int,
"Maximum long pause at a word boundary"
),
key!(
"stealth.seed",
Int,
"Fixed root seed for this invocation; unset draws from the system"
),
key!(
"providers.decopy.request_timeout_secs",
Int,
"Per-request timeout"
),
key!(
"providers.decopy.max_body_bytes",
Int,
"Largest response body accepted"
),
key!(
"providers.decopy.serial_hex_len",
Int,
"Length in hex characters of the request serial"
),
key!(
"providers.noiz.request_timeout_secs",
Int,
"Per-request timeout"
),
key!(
"providers.noiz.max_body_bytes",
Int,
"Largest response body accepted"
),
key!(
"providers.cue.min_cue_millis",
Int,
"Shortest cue duration kept when normalising timings"
),
];
#[must_use]
pub fn spec(key: &str) -> Option<&'static KeySpec> {
KEYS.iter().find(|s| s.key == key)
}
#[must_use]
pub fn project_dirs() -> Option<ProjectDirs> {
ProjectDirs::from(PROJECT_QUALIFIER, PROJECT_ORG, PROJECT_APP)
}
pub fn config_dir() -> AppResult<PathBuf> {
project_dirs()
.map(|d| d.config_dir().to_path_buf())
.ok_or_else(|| AppError::Internal(t(Message::ConfigDirUnavailable).to_string()))
}
pub fn state_dir() -> AppResult<PathBuf> {
project_dirs()
.map(|d| {
d.state_dir()
.unwrap_or_else(|| d.data_local_dir())
.to_path_buf()
})
.ok_or_else(|| AppError::Internal(t(Message::ConfigDirUnavailable).to_string()))
}
pub fn config_file_path() -> AppResult<PathBuf> {
Ok(config_dir()?.join(CONFIG_FILE_NAME))
}
#[must_use]
pub fn discover() -> Option<PathBuf> {
let path = config_file_path().ok()?;
path.is_file().then_some(path)
}
#[derive(Debug, Clone)]
pub struct ConfigStore {
path: PathBuf,
table: toml::Table,
}
impl ConfigStore {
pub fn load() -> AppResult<Self> {
let path = config_file_path()?;
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> AppResult<Self> {
let table = if path.is_file() {
let text = std::fs::read_to_string(path).map_err(|e| {
AppError::Config(format!(
"{} {}: {e}",
t(Message::ConfigCouldNotRead),
path.display()
))
})?;
text.parse::<toml::Table>().map_err(|e| {
AppError::Config(format!(
"{} {}: {e}",
path.display(),
t(Message::ConfigNotValidToml)
))
})?
} else {
toml::Table::new()
};
Ok(Self {
path: path.to_path_buf(),
table,
})
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn table(&self) -> &toml::Table {
&self.table
}
#[must_use]
pub fn flattened(&self) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
flatten_into(&self.table, String::new(), &mut out);
out
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&toml::Value> {
let mut cursor: Option<&toml::Value> = None;
for (idx, segment) in key.split('.').enumerate() {
cursor = if idx == 0 {
self.table.get(segment)
} else {
cursor?.as_table()?.get(segment)
};
cursor?;
}
cursor
}
pub fn set(&mut self, key: &str, raw: &str) -> AppResult<()> {
let spec = spec(key).ok_or_else(|| unknown_key(key))?;
let value = parse_value(spec, raw)?;
let mut segments: Vec<&str> = key.split('.').collect();
let leaf = segments.pop().unwrap_or(key);
let mut cursor = &mut self.table;
for segment in segments {
let entry = cursor
.entry(segment.to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
if !entry.is_table() {
*entry = toml::Value::Table(toml::Table::new());
}
match entry.as_table_mut() {
Some(t) => cursor = t,
None => return Err(unknown_key(key)),
}
}
cursor.insert(leaf.to_string(), value);
Ok(())
}
pub fn unset(&mut self, key: &str) -> AppResult<()> {
if spec(key).is_none() {
return Err(unknown_key(key));
}
let mut segments: Vec<&str> = key.split('.').collect();
let leaf = segments.pop().unwrap_or(key);
let mut cursor = &mut self.table;
for segment in segments {
match cursor.get_mut(segment).and_then(toml::Value::as_table_mut) {
Some(t) => cursor = t,
None => return Ok(()),
}
}
cursor.remove(leaf);
Ok(())
}
pub fn save(&self) -> AppResult<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
AppError::Io(std::io::Error::other(format!(
"creating {}: {e}",
parent.display()
)))
})?;
}
let text = toml::to_string_pretty(&self.table)
.map_err(|e| AppError::Config(format!("could not serialise config: {e}")))?;
std::fs::write(&self.path, text.as_bytes()).map_err(|e| {
AppError::Io(std::io::Error::other(format!(
"writing {}: {e}",
self.path.display()
)))
})?;
restrict_permissions(&self.path)
}
}
#[cfg(unix)]
fn restrict_permissions(path: &Path) -> AppResult<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| {
AppError::Io(std::io::Error::other(format!(
"restricting permissions on {}: {e}",
path.display()
)))
})
}
#[cfg(not(unix))]
fn restrict_permissions(_path: &Path) -> AppResult<()> {
Ok(())
}
#[must_use]
pub fn unknown_key(key: &str) -> AppError {
AppError::InvalidUsage(format!(
"{} `{key}`; {}",
t(Message::ConfigUnknownKey),
t(Message::ConfigUnknownKeyHint)
))
}
fn parse_value(spec: &KeySpec, raw: &str) -> AppResult<toml::Value> {
match spec.kind {
KeyKind::Str => Ok(toml::Value::String(raw.to_string())),
KeyKind::Int => raw
.trim()
.parse::<i64>()
.map(toml::Value::Integer)
.map_err(|e| {
AppError::InvalidUsage(format!(
"`{}` {}: {e}",
spec.key,
t(Message::ConfigExpectsInteger)
))
}),
KeyKind::Bool => match raw.trim() {
"true" | "1" | "yes" => Ok(toml::Value::Boolean(true)),
"false" | "0" | "no" => Ok(toml::Value::Boolean(false)),
other => Err(AppError::InvalidUsage(format!(
"`{}` {} `{other}`",
spec.key,
t(Message::ConfigExpectsBoolean)
))),
},
KeyKind::StrList => Ok(toml::Value::Array(
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| toml::Value::String(s.to_string()))
.collect(),
)),
}
}
fn flatten_into(table: &toml::Table, prefix: String, out: &mut BTreeMap<String, String>) {
for (key, value) in table {
let dotted = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
match value {
toml::Value::Table(inner) => flatten_into(inner, dotted, out),
other => {
out.insert(dotted, render_toml(other));
}
}
}
}
fn render_toml(value: &toml::Value) -> String {
match value {
toml::Value::String(s) => s.clone(),
toml::Value::Array(items) => items.iter().map(render_toml).collect::<Vec<_>>().join(","),
other => other.to_string(),
}
}
static TUNING: OnceLock<toml::Table> = OnceLock::new();
pub fn install_tuning(table: toml::Table) {
let _ = TUNING.set(table);
}
#[must_use]
pub fn tuning_u64(key: &str) -> Option<u64> {
lookup_tuning(key)?.as_integer()?.try_into().ok()
}
#[must_use]
pub fn tuning_bool(key: &str) -> Option<bool> {
lookup_tuning(key)?.as_bool()
}
#[must_use]
pub fn tuning_string(key: &str) -> Option<String> {
Some(lookup_tuning(key)?.as_str()?.to_string())
}
#[must_use]
pub fn tuning_str_list(key: &str) -> Option<Vec<String>> {
let array = lookup_tuning(key)?.as_array()?;
Some(
array
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
)
}
#[must_use]
pub fn tuning_u64_in_range(key: &str, default: u64, min: u64, max: u64) -> u64 {
match tuning_u64(key) {
Some(value) if value >= min && value <= max => value,
Some(value) => {
tracing::warn!(
key,
value,
min,
max,
default,
"configured value is out of range; keeping the compiled default"
);
default
}
None => default,
}
}
#[must_use]
pub fn tuning_usize_in_range(key: &str, default: usize, min: usize, max: usize) -> usize {
let resolved = tuning_u64_in_range(key, default as u64, min as u64, max as u64);
usize::try_from(resolved).unwrap_or(default)
}
#[must_use]
pub fn tuning_u32_in_range(key: &str, default: u32, min: u32, max: u32) -> u32 {
let resolved = tuning_u64_in_range(key, u64::from(default), u64::from(min), u64::from(max));
u32::try_from(resolved).unwrap_or(default)
}
#[must_use]
pub fn tuning_bool_or(key: &str, default: bool) -> bool {
tuning_bool(key).unwrap_or(default)
}
#[must_use]
pub fn tuning_string_or(key: &str, default: &str) -> String {
match tuning_string(key) {
Some(value) if !value.trim().is_empty() => value,
Some(_) => {
tracing::warn!(
key,
"configured value is empty; keeping the compiled default"
);
default.to_string()
}
None => default.to_string(),
}
}
#[must_use]
pub fn tuning_str_list_or(key: &str, default: &[&str]) -> Vec<String> {
match tuning_str_list(key) {
Some(list) if !list.is_empty() => list,
Some(_) => {
tracing::warn!(
key,
"configured list is empty; keeping the compiled default"
);
default.iter().map(|s| (*s).to_string()).collect()
}
None => default.iter().map(|s| (*s).to_string()).collect(),
}
}
#[must_use]
pub fn tuning_pairs_or(key: &str, default: &[(&str, &str)]) -> Vec<(String, String)> {
let compiled = || -> Vec<(String, String)> {
default
.iter()
.map(|(l, r)| ((*l).to_string(), (*r).to_string()))
.collect()
};
let Some(list) = tuning_str_list(key) else {
return compiled();
};
let mut out = Vec::with_capacity(list.len());
for entry in &list {
match entry.split_once('=') {
Some((left, right)) if !left.trim().is_empty() && !right.trim().is_empty() => {
out.push((left.trim().to_string(), right.trim().to_string()));
}
_ => tracing::warn!(key, entry, "ignoring an entry that is not `left=right`"),
}
}
if out.is_empty() {
tracing::warn!(
key,
"configured list yielded no usable pair; keeping the compiled default"
);
return compiled();
}
out
}
static FLAG_OVERRIDES: OnceLock<toml::Table> = OnceLock::new();
pub fn install_flag_overrides(table: toml::Table) {
let _ = FLAG_OVERRIDES.set(table);
}
fn lookup_tuning(key: &str) -> Option<&'static toml::Value> {
if let Some(value) = FLAG_OVERRIDES.get().and_then(|t| walk(t, key)) {
return Some(value);
}
walk(TUNING.get()?, key)
}
fn walk(table: &'static toml::Table, key: &str) -> Option<&'static toml::Value> {
let mut cursor: Option<&toml::Value> = None;
for (idx, segment) in key.split('.').enumerate() {
cursor = if idx == 0 {
table.get(segment)
} else {
cursor?.as_table()?.get(segment)
};
cursor?;
}
cursor
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("ylc_config_test_{name}.toml"))
}
#[test]
fn registry_has_no_duplicate_keys() {
let mut seen: Vec<&str> = KEYS.iter().map(|s| s.key).collect();
let total = seen.len();
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), total, "the registry must not repeat a key");
}
fn doc_for(key: &str) -> &'static str {
KEYS.iter()
.find(|spec| spec.key == key)
.unwrap_or_else(|| panic!("the registry must carry a `{key}` key"))
.doc
}
#[test]
fn the_format_key_description_names_every_accepted_spelling() {
use clap::ValueEnum;
let doc = doc_for("format");
let mut checked = 0_usize;
for variant in crate::cli::FormatArg::value_variants() {
let spelling = variant
.to_possible_value()
.expect("no FormatArg variant is skipped")
.get_name()
.to_string();
assert!(
doc.contains(&spelling),
"`config list-keys` describes `format` as {doc:?}, which never \
mentions the accepted spelling {spelling:?}"
);
checked += 1;
}
assert!(
checked >= 2,
"only {checked} spelling(s) were compared, so this test proved nothing"
);
}
#[test]
fn the_timeout_key_and_its_flag_tell_the_same_story() {
use clap::CommandFactory;
let command = crate::cli::Cli::command();
let flag = command
.get_arguments()
.find(|a| a.get_id() == "timeout")
.expect("the CLI must carry a --timeout flag");
let help = flag
.get_help()
.expect("--timeout must carry help text")
.to_string();
assert_eq!(
doc_for("timeout"),
help,
"the `timeout` config key and the `--timeout` flag describe the \
same ceiling and must not word it differently"
);
}
#[test]
fn every_registry_key_resolves_through_spec() {
for entry in KEYS {
assert!(spec(entry.key).is_some(), "{} must resolve", entry.key);
}
assert!(spec("definitely.not.a.key").is_none());
}
#[test]
fn set_get_and_unset_round_trip_a_dotted_key() {
let mut store = ConfigStore {
path: tmp_path("roundtrip"),
table: toml::Table::new(),
};
store
.set("net.waf.max_consecutive_failures", "7")
.expect("set succeeds");
assert_eq!(
store
.get("net.waf.max_consecutive_failures")
.and_then(toml::Value::as_integer),
Some(7)
);
store
.unset("net.waf.max_consecutive_failures")
.expect("unset succeeds");
assert!(store.get("net.waf.max_consecutive_failures").is_none());
}
#[test]
fn unsetting_an_absent_key_succeeds() {
let mut store = ConfigStore {
path: tmp_path("absent"),
table: toml::Table::new(),
};
assert!(store.unset("cli.max_url_chars").is_ok());
}
#[test]
fn unknown_key_is_rejected_on_set_and_unset() {
let mut store = ConfigStore {
path: tmp_path("unknown"),
table: toml::Table::new(),
};
assert!(matches!(
store.set("nope", "1"),
Err(AppError::InvalidUsage(_))
));
assert!(matches!(
store.unset("nope"),
Err(AppError::InvalidUsage(_))
));
}
#[test]
fn integer_key_rejects_non_numeric_input() {
let mut store = ConfigStore {
path: tmp_path("badint"),
table: toml::Table::new(),
};
assert!(store.set("timeout", "abc").is_err());
assert!(store.set("timeout", "45").is_ok());
}
#[test]
fn string_list_key_splits_on_commas() {
let mut store = ConfigStore {
path: tmp_path("list"),
table: toml::Table::new(),
};
store
.set("net.session.header_order", "host, accept ,user-agent")
.expect("set succeeds");
let flat = store.flattened();
assert_eq!(
flat.get("net.session.header_order").map(String::as_str),
Some("host,accept,user-agent")
);
}
#[test]
fn save_then_load_preserves_the_table() {
let path = tmp_path("persist");
std::fs::remove_file(&path).ok();
let mut store = ConfigStore {
path: path.clone(),
table: toml::Table::new(),
};
store.set("timeout", "45").expect("set succeeds");
store.set("stealth.seed", "1366").expect("set");
store.save().expect("save succeeds");
let reloaded = ConfigStore::load_from(&path).expect("load succeeds");
assert_eq!(
reloaded.get("timeout").and_then(toml::Value::as_integer),
Some(45)
);
assert_eq!(
reloaded
.get("stealth.seed")
.and_then(toml::Value::as_integer),
Some(1366)
);
std::fs::remove_file(&path).ok();
}
#[cfg(unix)]
#[test]
fn saved_config_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let path = tmp_path("perms");
std::fs::remove_file(&path).ok();
let mut store = ConfigStore {
path: path.clone(),
table: toml::Table::new(),
};
store.set("timeout", "10").expect("set succeeds");
store.save().expect("save succeeds");
let mode = std::fs::metadata(&path)
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "config must not be world-readable");
std::fs::remove_file(&path).ok();
}
#[test]
fn concurrency_and_offline_keys_are_registered() {
for key in [
"offline",
"jobs",
"cli.max_jobs",
"net.per_host_concurrency",
"net.throttle_interval_ms",
] {
assert!(spec(key).is_some(), "{key} must be in the registry");
}
}
#[test]
fn out_of_range_and_absent_values_both_yield_the_default() {
assert_eq!(tuning_u64_in_range("definitely.not.a.key", 7, 1, 10), 7);
assert_eq!(tuning_usize_in_range("definitely.not.a.key", 3, 1, 10), 3);
assert_eq!(tuning_u32_in_range("definitely.not.a.key", 5, 1, 10), 5);
assert!(!tuning_bool_or("definitely.not.a.key", false));
assert_eq!(tuning_string_or("definitely.not.a.key", "x"), "x");
assert_eq!(
tuning_str_list_or("definitely.not.a.key", &["a"]),
vec!["a"]
);
assert_eq!(
tuning_pairs_or("definitely.not.a.key", &[("a", "b")]),
vec![("a".to_string(), "b".to_string())]
);
}
#[test]
fn tuning_accessors_return_none_without_an_installed_table() {
assert_eq!(tuning_u64("definitely.not.a.key"), None);
assert_eq!(tuning_bool("definitely.not.a.key"), None);
assert_eq!(tuning_string("definitely.not.a.key"), None);
assert_eq!(tuning_str_list("definitely.not.a.key"), None);
}
#[test]
fn state_resolves_wherever_configuration_resolves() {
assert_eq!(
config_dir().is_ok(),
state_dir().is_ok(),
"state_dir must not fail on a platform where config_dir succeeds"
);
if let Ok(dir) = state_dir() {
assert!(
dir.is_absolute(),
"a relative state directory would follow the working directory: {}",
dir.display()
);
}
}
}