youtube-legend-cli 0.4.0

Non-interactive Rust CLI that downloads YouTube subtitles through third-party providers, using a native Unix stdin/stdout interface.
//! Reading `config.toml` and rejecting what it must not contain.
//!
//! Split out of `cli.rs` on 2026-09-04 under GAP-2026-074. The parent
//! module declares the command line; this one answers the separate
//! question of what a file on disk is allowed to say, and the two were
//! only in the same file because the file grew rather than because the
//! responsibilities are one.

use super::*;

/// Load CLI defaults from a TOML config file. The file is a flat
/// table whose keys mirror the long-form CLI flag names (without the
/// leading `--`). Unknown keys are rejected with a clear error
/// message so typos surface immediately. CLI flags always override
/// config values, so the precedence is CLI > config > built-in
/// default.
///
/// Supported keys: `url`, `lang`, `ui_lang`, `format`, `timeout`, `cache_ttl`,
/// `user_agent`. Booleans `verbose`, `quiet`, `json`, `batch`,
/// `no_cache`, `dry_run`, `no_progress`, `yes`. Optional `log_level`,
/// `log_format`, `color`.
///
/// # Errors
///
/// - [`crate::error::AppError::Config`] when the file is missing,
///   unreadable, or contains malformed TOML or an unknown key. This
///   maps to sysexits `EX_CONFIG = 78`, distinct from
///   `AppError::InvalidUsage` (exit 64) which is reserved for
///   post-parse CLI argument validation failures.
pub fn load_config(path: &std::path::Path) -> AppResult<ConfigOverrides> {
    use crate::error::AppError;
    use std::fs;

    let text = fs::read_to_string(path).map_err(|e| {
        AppError::Config(format!(
            "{} {}: {e}",
            t(Message::ConfigCouldNotRead),
            path.display()
        ))
    })?;
    let table: toml::Table = text.parse().map_err(|e| {
        AppError::Config(format!(
            "{} {}: {e}",
            path.display(),
            t(Message::ConfigNotValidToml)
        ))
    })?;

    let mut out = ConfigOverrides::default();
    for (key, value) in &table {
        if absorb_config_key(key, value, &mut out)? {
            continue;
        }
        // A dotted tuning namespace is not a CLI flag; it is read later
        // by `crate::config`, so it is not rejected here. Its leaves are
        // checked against the registry once the loop ends.
        if TUNING_NAMESPACES.contains(&key.as_str()) {
            continue;
        }
        return Err(unknown_config_key(key));
    }
    validate_tuning_keys(path)?;
    Ok(out)
}

/// Reject every leaf under a tuning namespace that the registry does not
/// declare.
///
/// A tuning namespace is read lazily by [`crate::config`], so a
/// misspelled key such as `net.foo` used to be dropped in silence: no
/// error, no warning, and no reader. Checking the flattened tree here
/// turns that silence into a failure at load time, and it covers the
/// operator who edits `config.toml` by hand — a path `config set` never
/// sees.
///
/// The tree is flattened by [`crate::config::ConfigStore::flattened`],
/// the same routine that backs `config show`, so the dotted form
/// compared here is the one the registry stores. Membership is decided
/// by [`crate::config::spec`], never by a second copy of the registry.
///
/// # Errors
///
/// Returns [`crate::error::AppError::Config`] — sysexits
/// `EX_CONFIG = 78` — for the first leaf absent from
/// [`crate::config::KEYS`].
fn validate_tuning_keys(path: &std::path::Path) -> AppResult<()> {
    let store = crate::config::ConfigStore::load_from(path)?;
    for dotted in store.flattened().keys() {
        let root = dotted.split('.').next().unwrap_or(dotted.as_str());
        if !TUNING_NAMESPACES.contains(&root) {
            continue;
        }
        if crate::config::spec(dotted).is_none() {
            return Err(unknown_config_key(dotted));
        }
    }
    Ok(())
}

/// Re-file [`crate::config::unknown_key`] under
/// [`crate::error::AppError::Config`].
///
/// The wording, and its twelve translations, belongs to
/// [`crate::config`], which raises the condition as an
/// [`crate::error::AppError::InvalidUsage`] because its own callers are
/// command-line arguments. Reaching the same condition through the
/// configuration *file* is a configuration fault, so the variant changes
/// and the exit becomes 78 while the sentence stays the single one.
fn unknown_config_key(key: &str) -> crate::error::AppError {
    match crate::config::unknown_key(key) {
        crate::error::AppError::InvalidUsage(detail) => crate::error::AppError::Config(detail),
        other => other,
    }
}

pub(super) fn invalid_type(key: &str, expected: &str) -> crate::error::AppError {
    crate::error::AppError::Config(format!(
        "{} `{key}` {} {expected}",
        t(Message::ConfigKeyPrefix),
        t(Message::ConfigWrongType)
    ))
}