use crate::error::{AppError, AppResult};
use crate::i18n::{t, Language, Message};
use clap::{ArgAction, Parser, ValueEnum};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::Duration;
use unic_langid::subtags::{Language as LanguageSubtag, Region, Script};
use unic_langid::LanguageIdentifier;
mod config_file;
mod output_args;
mod value;
use config_file::invalid_type;
pub use config_file::load_config;
pub use output_args::{ColorArg, LogFormatArg, LogLevelArg};
pub use value::ConfigValue;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[non_exhaustive]
pub enum FormatArg {
Txt,
Srt,
Vtt,
}
const LEGACY_YOUTUBE_CODES: [(&str, &str); 3] = [("he", "iw"), ("id", "in"), ("yi", "ji")];
#[must_use]
fn modern_from_legacy(primary: &str) -> Option<&'static str> {
LEGACY_YOUTUBE_CODES
.iter()
.find(|(_, legacy)| *legacy == primary)
.map(|(modern, _)| *modern)
}
#[must_use]
fn legacy_from_modern(primary: &str) -> Option<&'static str> {
LEGACY_YOUTUBE_CODES
.iter()
.find(|(modern, _)| *modern == primary)
.map(|(_, legacy)| *legacy)
}
static TAG_INTERNER: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
fn intern_tag(tag: &str) -> &'static str {
let set = TAG_INTERNER.get_or_init(|| Mutex::new(HashSet::new()));
let mut guard = set.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(existing) = guard.get(tag) {
return existing;
}
let leaked: &'static str = Box::leak(tag.to_string().into_boxed_str());
guard.insert(leaked);
leaked
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LanguageArg {
tag: &'static str,
youtube_tag: &'static str,
language: LanguageSubtag,
script: Option<Script>,
region: Option<Region>,
}
impl LanguageArg {
pub fn parse(raw: &str) -> AppResult<Self> {
let normalised = crate::i18n::normalise_locale_string(raw);
if normalised.is_empty() {
return Err(AppError::LanguageParseError(format!(
"{raw:?}: {}",
t(Message::LangInvalidTag)
)));
}
let langid = LanguageIdentifier::from_str(&normalised).map_err(|e| {
AppError::LanguageParseError(format!("{raw}: {} ({e})", t(Message::LangInvalidTag)))
})?;
Ok(Self::from_langid(&langid))
}
fn from_langid(langid: &LanguageIdentifier) -> Self {
let raw_primary = langid.language.as_str().to_ascii_lowercase();
let modern_primary = modern_from_legacy(&raw_primary).unwrap_or(raw_primary.as_str());
let language = LanguageSubtag::from_str(modern_primary).unwrap_or(langid.language);
let script = langid.script;
let region = langid.region;
let canonical = LanguageIdentifier::from_parts(language, script, region, &[]);
let tag = intern_tag(&canonical.to_string());
let youtube_tag = match legacy_from_modern(modern_primary) {
Some(legacy) => {
let mut rebuilt = String::from(legacy);
if let Some(s) = script {
rebuilt.push('-');
rebuilt.push_str(s.as_str());
}
if let Some(r) = region {
rebuilt.push('-');
rebuilt.push_str(r.as_str());
}
intern_tag(&rebuilt)
}
None => tag,
};
Self {
tag,
youtube_tag,
language,
script,
region,
}
}
#[must_use]
pub fn english() -> Self {
Self::from_langid(&LanguageIdentifier::from_parts(
LanguageSubtag::from_str("en").unwrap_or_default(),
None,
None,
&[],
))
}
#[must_use]
pub fn as_str(self) -> &'static str {
self.tag
}
#[must_use]
pub fn youtube_code(self) -> &'static str {
self.youtube_tag
}
#[must_use]
pub fn primary(self) -> String {
self.language.as_str().to_string()
}
#[must_use]
pub fn script(self) -> Option<Script> {
self.script
}
#[must_use]
pub fn region(self) -> Option<Region> {
self.region
}
#[must_use]
pub fn to_langid(self) -> LanguageIdentifier {
LanguageIdentifier::from_parts(self.language, self.script, self.region, &[])
}
}
impl std::fmt::Display for LanguageArg {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.tag)
}
}
impl FromStr for LanguageArg {
type Err = AppError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ProviderChoice {
Auto,
ProviderDecopy,
ProviderNoiz,
}
impl ProviderChoice {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::ProviderDecopy => "provider-decopy",
Self::ProviderNoiz => "provider-noiz",
}
}
}
#[doc(alias = "Args")]
#[doc(alias = "arguments")]
#[doc(alias = "parser")]
#[doc(alias = "CLI")]
#[doc(alias = "command-line")]
#[doc(alias = "clap")]
#[doc(alias = "argument parser")]
#[derive(Debug, Parser, Clone)]
#[command(
name = "youtube-legend-cli",
version,
about = "Non-interactive Rust CLI that downloads YouTube subtitles via third-party providers using a native Unix stdin/stdout interface.",
long_about = None,
propagate_version = true,
disable_help_subcommand = true,
// The positional URL is optional, so a subcommand never has to
// compete with a required argument. Declaring it keeps `config …`
// parsing unchanged if a future flag becomes required.
subcommand_negates_reqs = true,
after_help = "Examples:\n youtube-legend-cli https://youtu.be/dQw4w9WgXcQ\n echo \"https://youtu.be/dQw4w9WgXcQ\" | youtube-legend-cli --format srt\n cat urls.txt | youtube-legend-cli --batch --json\n youtube-legend-cli --lang pt --timeout 60 https://youtu.be/dQw4w9WgXcQ",
)]
pub struct Cli {
#[arg(
value_name = "URL",
help = "YouTube URL (watch, shorts, embed, or youtu.be)"
)]
pub url: Option<String>,
#[arg(
long,
value_name = "LANG",
help = "Preferred subtitle language, ISO 639-1 or BCP 47 (en, pt-BR, pt-PT, zh-Hans, zh-Hant)",
default_value = "en",
value_parser = parse_language
)]
pub lang: LanguageArg,
#[arg(
long,
value_name = "LANG",
help = "Interface language for messages on stderr (see --help for the compiled list)",
value_parser = parse_ui_language
)]
pub ui_lang: Option<Language>,
#[arg(
long,
value_name = "FORMAT",
help = "Output format: txt (default), srt or vtt",
default_value = "txt"
)]
pub format: FormatArg,
#[arg(
long,
value_name = "SECONDS",
help = "Whole-operation timeout in seconds (not per HTTP request)",
default_value_t = 300
)]
pub timeout: u64,
#[arg(
long,
action = ArgAction::SetTrue,
help = "Emit tracing events to stderr"
)]
pub verbose: bool,
#[arg(
long,
action = ArgAction::SetTrue,
help = "Suppress all stderr output except errors"
)]
pub quiet: bool,
#[arg(long, value_name = "PATH", help = "Path to a TOML config file")]
pub config: Option<PathBuf>,
#[arg(
long,
value_name = "LEVEL",
help = "Log level: error, warn, info, debug, trace",
default_value = "warn",
value_enum
)]
pub log_level: LogLevelArg,
#[arg(
long,
value_name = "FORMAT",
help = "Log format: text (default) or json",
default_value = "text",
value_enum
)]
pub log_format: LogFormatArg,
#[arg(
long,
value_name = "WHEN",
help = "Colour output: auto, always, never",
default_value = "auto",
value_enum
)]
pub color: ColorArg,
#[arg(
long,
action = ArgAction::SetTrue,
help = "Suppress progress bars on stderr"
)]
pub no_progress: bool,
#[arg(
long,
action = ArgAction::SetTrue,
help = "Skip network I/O and serve reads from cache only"
)]
pub dry_run: bool,
#[arg(
long,
action = ArgAction::SetTrue,
help = "Assume yes for any confirmation prompt"
)]
pub yes: bool,
#[arg(
long,
action = ArgAction::SetTrue,
help = "Emit structured JSON to stdout"
)]
pub json: bool,
#[arg(
long,
action = ArgAction::SetTrue,
help = "Read multiple URLs from stdin, one per line"
)]
pub batch: bool,
#[arg(
long,
action = ArgAction::SetTrue,
requires = "batch",
help = "Skip URLs already completed by a previous --batch run"
)]
pub resume: bool,
#[arg(
long,
action = ArgAction::SetTrue,
help = "Refuse every outbound request; serve cache only"
)]
pub offline: bool,
#[arg(
long,
value_name = "N",
help = "Batch items processed concurrently (0 = derive from the host)",
default_value_t = 0
)]
pub jobs: u64,
#[arg(
long,
value_name = "STRING",
help = "Custom User-Agent for HTTP requests"
)]
pub user_agent: Option<String>,
#[arg(
long,
value_name = "HOURS",
help = "Local cache TTL in hours",
default_value_t = crate::cache::DEFAULT_TTL_HOURS
)]
pub cache_ttl: u64,
#[arg(
long,
action = ArgAction::SetTrue,
help = "Disable reads from the local cache"
)]
pub no_cache: bool,
#[arg(
long,
value_name = "PROVIDER",
// Do not enumerate the variants here. `value_enum` already
// prints all five with their own doc comments right below this
// line, and a hand-written list is a second copy that drifts:
// this one still advertised two providers after the enum grew
// to five.
help = "Which provider to use; see the values below",
default_value = "auto",
value_enum
)]
pub provider: Option<ProviderChoice>,
#[arg(
long,
action = ArgAction::SetTrue,
help = "Refuse stdin; fail fast instead of blocking"
)]
pub no_input: bool,
#[arg(
long,
action = ArgAction::SetTrue,
help = "Print the JSON Schema of every output surface and exit"
)]
pub print_schema: bool,
#[arg(
long,
visible_alias = "fields",
value_name = "KEYS",
value_delimiter = ',',
help = "Keep only these dotted keys (comma-separated)"
)]
pub select: Vec<String>,
#[arg(
long,
value_name = "EXPR",
help = "Filter elements: key=value, key!=value, key~substring"
)]
pub filter: Vec<String>,
#[arg(long, value_name = "N", help = "Emit at most N elements")]
pub limit: Option<usize>,
#[arg(long, value_name = "KEY", help = "Sort ascending by a dotted key")]
pub sort: Option<String>,
#[arg(
long,
value_name = "KEY",
help = "Drop later elements repeating this key's value"
)]
pub dedupe_by: Option<String>,
#[arg(
long,
action = ArgAction::SetTrue,
help = "Replace the payload with a count"
)]
pub count_only: bool,
#[arg(
long,
value_name = "N",
help = "Shorten strings above N characters (never bytes)"
)]
pub truncate_content: Option<usize>,
#[arg(
long,
value_name = "N",
help = "Cap the envelope at N bytes by dropping whole elements"
)]
pub max_output_bytes: Option<usize>,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Clone, clap::Subcommand)]
#[non_exhaustive]
pub enum Command {
Config {
#[command(subcommand)]
action: ConfigAction,
},
Completions {
shell: clap_complete::Shell,
},
Man,
}
#[derive(Debug, Clone, clap::Subcommand)]
#[non_exhaustive]
pub enum ConfigAction {
Path,
Show,
Get {
key: String,
},
Set {
key: String,
value: Option<String>,
#[arg(long, action = ArgAction::SetTrue)]
from_stdin: bool,
},
Unset {
key: String,
},
ListKeys,
}
impl Cli {
pub fn timeout_duration(&self) -> Duration {
Duration::from_secs(self.timeout)
}
pub fn cache_ttl_duration(&self) -> Duration {
crate::cache::ttl_from_hours(self.cache_ttl)
}
pub fn effective_user_agent(&self) -> String {
self.user_agent
.clone()
.unwrap_or_else(|| crate::net::DEFAULT_USER_AGENT.to_string())
}
pub fn effective_log_level(&self) -> LogLevelArg {
if self.log_level != LogLevelArg::Warn {
return self.log_level;
}
if self.verbose {
return LogLevelArg::Info;
}
self.log_level
}
pub fn effective_log_format(&self) -> LogFormatArg {
self.log_format
}
pub fn effective_color(&self) -> ColorArg {
self.color
}
pub fn apply_overrides(&self) {}
pub fn surface_options(&self) -> AppResult<crate::surface::SurfaceOptions> {
let mut filters = Vec::with_capacity(self.filter.len());
for raw in &self.filter {
filters.push(crate::surface::Filter::parse(raw)?);
}
Ok(crate::surface::SurfaceOptions {
select: self.select.clone(),
filters,
limit: self.limit,
sort: self.sort.clone(),
dedupe_by: self.dedupe_by.clone(),
count_only: self.count_only,
truncate_content: self.truncate_content,
max_output_bytes: self.max_output_bytes,
})
}
}
macro_rules! config_schema {
(
opt_str { $($os:ident),* $(,)? }
int { $($n:ident),* $(,)? }
flag { $($bl:ident),* $(,)? }
val { $($vp:ident : $vpt:ty),* $(,)? }
opt_val { $($vo:ident : $vot:ty),* $(,)? }
) => {
#[derive(Debug, Default, Clone)]
#[non_exhaustive]
#[allow(missing_docs)]
pub struct ConfigOverrides {
$(pub $os: Option<String>,)*
$(pub $n: Option<u64>,)*
$(pub $bl: Option<bool>,)*
$(pub $vp: Option<$vpt>,)*
$(pub $vo: Option<$vot>,)*
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
#[allow(missing_docs)]
pub struct CliOverrideFlags {
$(pub $os: bool,)*
$(pub $n: bool,)*
$(pub $bl: bool,)*
$(pub $vp: bool,)*
$(pub $vo: bool,)*
}
impl CliOverrideFlags {
#[must_use]
pub fn from_matches(matches: &clap::ArgMatches) -> Self {
use clap::parser::ValueSource;
let src = |id: &str| matches.value_source(id) == Some(ValueSource::CommandLine);
Self {
$($os: src(stringify!($os)),)*
$($n: src(stringify!($n)),)*
$($bl: src(stringify!($bl)),)*
$($vp: src(stringify!($vp)),)*
$($vo: src(stringify!($vo)),)*
}
}
}
#[must_use]
pub fn config_flag_keys() -> Vec<&'static str> {
let mut keys = vec![
$(stringify!($os),)*
$(stringify!($n),)*
$(stringify!($bl),)*
$(stringify!($vp),)*
$(stringify!($vo),)*
];
keys.sort_unstable();
keys
}
fn absorb_config_key(
key: &str,
value: &toml::Value,
out: &mut ConfigOverrides,
) -> AppResult<bool> {
match key {
$(stringify!($os) => {
out.$os = Some(
value.as_str().ok_or_else(|| invalid_type(key, "string"))?.to_string(),
);
})*
$(stringify!($n) => {
let raw = value.as_integer().ok_or_else(|| invalid_type(key, "integer"))?;
out.$n = Some(u64::try_from(raw).map_err(|_| invalid_type(key, "integer"))?);
})*
$(stringify!($bl) => {
out.$bl = Some(value.as_bool().ok_or_else(|| invalid_type(key, "boolean"))?);
})*
$(stringify!($vp) => {
let raw = value.as_str().ok_or_else(|| invalid_type(key, "string"))?;
out.$vp = Some(<$vpt as ConfigValue>::from_config_str(raw)?);
})*
$(stringify!($vo) => {
let raw = value.as_str().ok_or_else(|| invalid_type(key, "string"))?;
out.$vo = Some(<$vot as ConfigValue>::from_config_str(raw)?);
})*
_ => return Ok(false),
}
Ok(true)
}
impl Cli {
pub fn apply_config_overrides(
&mut self,
cfg: ConfigOverrides,
flags: &CliOverrideFlags,
) {
$(if !flags.$os {
if let Some(v) = cfg.$os { self.$os = Some(v); }
})*
$(if !flags.$n {
if let Some(v) = cfg.$n { self.$n = v; }
})*
$(if !flags.$bl {
if let Some(v) = cfg.$bl { self.$bl = v; }
})*
$(if !flags.$vp {
if let Some(v) = cfg.$vp { self.$vp = v; }
})*
$(if !flags.$vo {
if let Some(v) = cfg.$vo { self.$vo = Some(v); }
})*
}
}
};
}
config_schema! {
opt_str { url, user_agent }
int { timeout, cache_ttl, jobs }
flag {
verbose, quiet, json, batch, no_cache, dry_run, no_progress, yes, no_input,
offline
}
val {
lang: LanguageArg,
format: FormatArg,
log_level: LogLevelArg,
log_format: LogFormatArg,
color: ColorArg
}
opt_val { ui_lang: Language, provider: ProviderChoice }
}
const TUNING_NAMESPACES: [&str; 8] = [
"cli",
"cache",
"i18n",
"net",
"browser",
"input",
"providers",
"stealth",
];
pub fn parse_with_overrides() -> Result<(Cli, CliOverrideFlags), clap::Error> {
let cmd = <Cli as clap::CommandFactory>::command();
let matches = cmd.try_get_matches_from(std::env::args_os())?;
let flags = CliOverrideFlags::from_matches(&matches);
let cli = <Cli as clap::FromArgMatches>::from_arg_matches(&matches)?;
Ok((cli, flags))
}
impl Cli {
pub fn validate(&self) -> AppResult<()> {
if self.batch && self.url.is_some() {
return Err(usage(Message::UsageBatchWithUrl));
}
if self.url.is_none() && is_stdin_tty_or_blocked() && !self.batch {
return Err(usage(Message::UsageNoUrl));
}
if self.url.as_ref().is_some_and(|u| u.len() > max_url_chars()) {
return Err(usage(Message::UsageUrlTooLong));
}
if self.no_input && self.url.is_none() {
return Err(AppError::InvalidUsage(
"--no-input requires the url as a positional argument".to_string(),
));
}
let surface = self.surface_options()?;
if surface.is_active() && !self.json {
return Err(AppError::InvalidUsage(
"the agent-native reduction flags require --json".to_string(),
));
}
if self.quiet && self.verbose {
return Err(usage(Message::UsageQuietWithVerbose));
}
if self.timeout == 0 {
return Err(usage(Message::UsageTimeoutZero));
}
if self.cache_ttl == 0 {
return Err(usage(Message::UsageCacheTtlZero));
}
if self.dry_run && self.batch {
return Err(usage(Message::UsageDryRunWithBatch));
}
Ok(())
}
#[must_use]
pub fn effective_ui_language(&self) -> Option<Language> {
self.ui_lang
}
}
const DEFAULT_MAX_URL_CHARS: usize = 2048;
#[must_use]
pub fn max_url_chars() -> usize {
crate::config::tuning_u64("cli.max_url_chars")
.and_then(|v| usize::try_from(v).ok())
.filter(|v| *v > 0)
.unwrap_or(DEFAULT_MAX_URL_CHARS)
}
fn usage(msg: Message) -> AppError {
AppError::InvalidUsage(t(msg).to_string())
}
fn parse_language(raw: &str) -> Result<LanguageArg, String> {
LanguageArg::parse(raw).map_err(|e| e.to_string())
}
fn parse_ui_language(raw: &str) -> Result<Language, String> {
Language::from_tag(raw).ok_or_else(|| {
format!(
"{raw}: {} ({})",
t(Message::LangUnsupportedUi),
Language::compiled_tags()
)
})
}
fn is_stdin_tty_or_blocked() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::{Cli, FormatArg, LanguageArg};
use crate::cli::{ColorArg, LogFormatArg, LogLevelArg};
use crate::error::AppError;
use clap::Parser;
#[test]
fn no_tuning_namespace_shadows_a_flat_key() {
for namespace in TUNING_NAMESPACES {
assert!(
crate::config::spec(namespace).is_none(),
"`{namespace}` is both a tuning namespace and a flat registry key; \
every `{namespace}.*` key is unreachable from config.toml"
);
}
}
#[test]
fn every_dotted_registry_key_has_a_declared_namespace() {
for entry in crate::config::KEYS {
let Some((root, _)) = entry.key.split_once('.') else {
continue;
};
assert!(
TUNING_NAMESPACES.contains(&root),
"`{}` has root `{root}`, which is not a tuning namespace",
entry.key
);
}
}
fn parse_with_overrides_from<const N: usize>(args: [&str; N]) -> (Cli, CliOverrideFlags) {
let cmd = <Cli as clap::CommandFactory>::command();
let matches = cmd.get_matches_from(args);
let flags = CliOverrideFlags::from_matches(&matches);
let cli =
<Cli as clap::FromArgMatches>::from_arg_matches(&matches).expect("test argv is valid");
(cli, flags)
}
fn make_cli(url: Option<&str>, batch: bool) -> Cli {
let mut args = vec!["youtube-legend-cli".to_string()];
if let Some(u) = url {
args.push(u.to_string());
}
if batch {
args.push("--batch".to_string());
}
Cli::parse_from(args)
}
#[test]
fn validate_accepts_url_only() {
let cli = make_cli(Some("https://youtu.be/dQw4w9WgXcQ"), false);
assert!(cli.validate().is_ok());
}
#[test]
fn clap_rejects_invalid_language_via_try_parse_from() {
use clap::Parser;
let result = Cli::try_parse_from([
"youtube-legend-cli",
"--lang",
"not a language tag",
"https://youtu.be/dQw4w9WgXcQ",
]);
assert!(
result.is_err(),
"clap must reject a malformed --lang before reaching AppError"
);
let err = result.unwrap_err();
assert!(matches!(
err.kind(),
clap::error::ErrorKind::ValueValidation | clap::error::ErrorKind::InvalidValue
));
}
#[test]
fn validate_accepts_batch_with_stdin() {
let cli = make_cli(None, true);
assert!(cli.validate().is_ok());
}
#[test]
fn validate_rejects_url_and_batch_together() {
let cli = make_cli(Some("https://youtu.be/dQw4w9WgXcQ"), true);
assert_usage(&cli.validate().unwrap_err(), Message::UsageBatchWithUrl);
}
#[test]
fn validate_rejects_url_too_long() {
let long = "a".repeat(max_url_chars() + 1);
let cli = make_cli(Some(&long), false);
assert_usage(&cli.validate().unwrap_err(), Message::UsageUrlTooLong);
}
fn assert_usage(err: &AppError, msg: Message) {
match err {
AppError::InvalidUsage(payload) => assert_eq!(payload, t(msg)),
other => panic!("expected InvalidUsage, got {other:?}"),
}
}
#[test]
fn validate_rejects_quiet_with_verbose() {
let cli = Cli::parse_from([
"youtube-legend-cli",
"https://youtu.be/dQw4w9WgXcQ",
"--quiet",
"--verbose",
]);
let err = cli.validate().unwrap_err();
assert!(matches!(err, AppError::InvalidUsage(_)));
assert!(err.to_string().contains("--quiet"));
}
#[test]
fn validate_rejects_zero_timeout() {
let cli = Cli::parse_from([
"youtube-legend-cli",
"https://youtu.be/dQw4w9WgXcQ",
"--timeout",
"0",
]);
let err = cli.validate().unwrap_err();
assert!(matches!(err, AppError::InvalidUsage(_)));
assert!(err.to_string().contains("--timeout"));
}
#[test]
fn validate_rejects_zero_cache_ttl() {
let cli = Cli::parse_from([
"youtube-legend-cli",
"https://youtu.be/dQw4w9WgXcQ",
"--cache-ttl",
"0",
]);
let err = cli.validate().unwrap_err();
assert!(matches!(err, AppError::InvalidUsage(_)));
assert!(err.to_string().contains("--cache-ttl"));
}
#[test]
fn validate_accepts_stdin_pipe_path_semantically() {
let cli = make_cli(None, false);
let res = cli.validate();
let is_tty = is_stdin_tty_or_blocked();
if is_tty {
assert!(matches!(res, Err(AppError::InvalidUsage(_))));
} else {
assert!(res.is_ok());
}
}
#[test]
fn parse_language_normalises_without_truncating() {
assert_eq!(lang_tag("pt-BR"), "pt-BR");
assert_eq!(lang_tag("pt_BR.UTF-8"), "pt-BR");
assert_eq!(lang_tag("EN-us"), "en-US");
assert_eq!(lang_tag("es-AR"), "es-AR");
assert_eq!(lang_tag("zh-Hans-CN"), "zh-Hans-CN");
assert_eq!(lang_tag("zh-hant-tw"), "zh-Hant-TW");
}
#[test]
fn parse_language_keeps_region_and_script_distinct() {
assert_ne!(lang_tag("pt-BR"), lang_tag("pt-PT"));
assert_ne!(lang_tag("zh-Hans"), lang_tag("zh-Hant"));
}
#[test]
fn parse_language_maps_youtube_legacy_codes() {
for (modern, legacy) in [("he", "iw"), ("id", "in"), ("yi", "ji")] {
let from_modern = LanguageArg::parse(modern).expect("modern code parses");
let from_legacy = LanguageArg::parse(legacy).expect("legacy code parses");
assert_eq!(from_modern.as_str(), modern);
assert_eq!(from_legacy.as_str(), modern);
assert_eq!(from_modern.youtube_code(), legacy);
assert_eq!(from_legacy.youtube_code(), legacy);
}
}
#[test]
fn youtube_legacy_mapping_preserves_region() {
let tag = LanguageArg::parse("he-IL").expect("he-IL parses");
assert_eq!(tag.as_str(), "he-IL");
assert_eq!(tag.youtube_code(), "iw-IL");
}
#[test]
fn youtube_code_equals_tag_for_non_legacy_languages() {
let tag = LanguageArg::parse("pt-BR").expect("pt-BR parses");
assert_eq!(tag.youtube_code(), tag.as_str());
}
#[test]
fn parse_language_rejects_malformed_tag() {
let err = LanguageArg::parse("not a tag").unwrap_err();
assert!(
matches!(err, AppError::LanguageParseError(_)),
"expected LanguageParseError, got {err:?}"
);
assert!(LanguageArg::parse("").is_err());
assert!(LanguageArg::parse(" ").is_err());
}
#[test]
fn interning_returns_the_same_pointer_for_equal_tags() {
let a = LanguageArg::parse("pt-BR").expect("parses");
let b = LanguageArg::parse("pt_BR.UTF-8").expect("parses");
assert!(std::ptr::eq(a.as_str(), b.as_str()));
}
#[test]
fn lang_flag_accepts_bcp47_from_argv() {
let cli = Cli::parse_from([
"youtube-legend-cli",
"https://youtu.be/dQw4w9WgXcQ",
"--lang",
"pt-BR",
]);
assert_eq!(cli.lang.as_str(), "pt-BR");
}
#[test]
fn lang_flag_defaults_to_english() {
let cli = Cli::parse_from(["youtube-legend-cli", "https://youtu.be/dQw4w9WgXcQ"]);
assert_eq!(cli.lang.as_str(), "en");
assert_eq!(cli.lang, LanguageArg::english());
}
#[test]
fn language_arg_exposes_its_subtags() {
let tag = LanguageArg::parse("zh-Hant-TW").expect("parses");
assert_eq!(tag.primary(), "zh");
assert_eq!(tag.script().map(|s| s.to_string()).as_deref(), Some("Hant"));
assert_eq!(tag.region().map(|r| r.to_string()).as_deref(), Some("TW"));
assert_eq!(tag.to_langid().to_string(), "zh-Hant-TW");
}
#[test]
fn ui_lang_flag_accepts_compiled_locale() {
let cli = Cli::parse_from([
"youtube-legend-cli",
"https://youtu.be/dQw4w9WgXcQ",
"--ui-lang",
"pt-BR",
]);
assert_eq!(cli.ui_lang, Some(Language::PtBr));
assert_eq!(cli.effective_ui_language(), Some(Language::PtBr));
}
#[test]
fn ui_lang_flag_rejects_locale_absent_from_this_build() {
let result = Cli::try_parse_from([
"youtube-legend-cli",
"https://youtu.be/dQw4w9WgXcQ",
"--ui-lang",
"xx",
]);
assert!(result.is_err(), "clap must reject an uncompiled ui locale");
}
#[test]
fn ui_lang_defaults_to_none_so_the_system_locale_decides() {
let cli = Cli::parse_from(["youtube-legend-cli", "https://youtu.be/dQw4w9WgXcQ"]);
assert_eq!(cli.effective_ui_language(), None);
}
#[test]
fn format_arg_maps_to_extensions() {
assert_eq!(format_fmt(FormatArg::Txt), "txt");
assert_eq!(format_fmt(FormatArg::Srt), "srt");
assert_eq!(format_fmt(FormatArg::Vtt), "vtt");
}
#[test]
fn every_format_variant_is_reachable_from_argv_and_from_config() {
use clap::ValueEnum;
for variant in FormatArg::value_variants() {
let spelling = variant
.to_possible_value()
.expect("every variant is selectable")
.get_name()
.to_string();
let cli = Cli::parse_from([
"youtube-legend-cli",
"https://youtu.be/dQw4w9WgXcQ",
"--format",
&spelling,
]);
assert_eq!(cli.format, *variant, "argv rejected `{spelling}`");
assert_eq!(
FormatArg::from_config_str(&spelling).expect("config accepts it"),
*variant,
"the config file rejected `{spelling}`"
);
assert_eq!(format_fmt(*variant), spelling);
}
}
fn lang_tag(raw: &str) -> &'static str {
LanguageArg::parse(raw)
.unwrap_or_else(|e| panic!("`{raw}` must parse: {e}"))
.as_str()
}
#[test]
fn cli_accepts_all_global_flags() {
let cli = Cli::parse_from([
"youtube-legend-cli",
"https://youtu.be/dQw4w9WgXcQ",
"--config",
"/tmp/cfg.toml",
"--log-level",
"debug",
"--log-format",
"json",
"--color",
"never",
"--no-progress",
"--dry-run",
"--yes",
]);
assert_eq!(cli.log_level, LogLevelArg::Debug);
assert_eq!(cli.log_format, LogFormatArg::Json);
assert_eq!(cli.color, ColorArg::Never);
assert!(cli.no_progress);
assert!(cli.dry_run);
assert!(cli.yes);
assert_eq!(cli.config, Some(std::path::PathBuf::from("/tmp/cfg.toml")));
}
#[test]
fn log_level_enum_maps_to_tracing() {
assert_eq!(LogLevelArg::Error.as_str(), "error");
assert_eq!(LogLevelArg::Warn.as_str(), "warn");
assert_eq!(LogLevelArg::Info.as_str(), "info");
assert_eq!(LogLevelArg::Debug.as_str(), "debug");
assert_eq!(LogLevelArg::Trace.as_str(), "trace");
}
#[test]
fn color_defaults_to_auto_and_ignores_the_environment() {
let cli = Cli::parse_from(["youtube-legend-cli", "https://youtu.be/dQw4w9WgXcQ"]);
assert_eq!(cli.color, ColorArg::Auto);
assert_eq!(cli.effective_color(), ColorArg::Auto);
}
#[test]
fn dry_run_rejects_batch() {
let cli = Cli::parse_from(["youtube-legend-cli", "--dry-run", "--batch"]);
let err = cli.validate().unwrap_err();
assert!(matches!(err, AppError::InvalidUsage(_)));
assert!(err.to_string().contains("--dry-run"));
assert!(err.to_string().contains("--batch"));
}
#[test]
fn apply_overrides_exports_no_env_var_at_all() {
let prev_no_color = std::env::var("NO_COLOR").ok();
let prev_force = std::env::var("CLICOLOR_FORCE").ok();
for key in [
"YT_LOG_LEVEL",
"YT_LOG_FORMAT",
"YT_DRY_RUN",
"YT_NO_PROGRESS",
"NO_COLOR",
"CLICOLOR_FORCE",
] {
std::env::remove_var(key);
}
let cli = Cli::parse_from([
"youtube-legend-cli",
"https://youtu.be/dQw4w9WgXcQ",
"--log-level",
"trace",
"--log-format",
"json",
"--color",
"never",
"--no-progress",
"--dry-run",
]);
cli.apply_overrides();
for key in [
"YT_LOG_LEVEL",
"YT_LOG_FORMAT",
"YT_DRY_RUN",
"YT_NO_PROGRESS",
"NO_COLOR",
"CLICOLOR_FORCE",
] {
assert!(
std::env::var(key).is_err(),
"{key} must not be exported by this product"
);
}
assert_eq!(cli.effective_color(), ColorArg::Never);
restore("NO_COLOR", prev_no_color);
restore("CLICOLOR_FORCE", prev_force);
}
fn restore(key: &str, prev: Option<String>) {
match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
#[test]
fn load_config_reads_valid_toml() {
let dir = std::env::temp_dir();
let path = dir.join("yt_legend_config_test_valid.toml");
std::fs::write(
&path,
r#"
url = "https://youtu.be/dQw4w9WgXcQ"
lang = "pt"
timeout = 12
cache_ttl = 6
verbose = true
json = false
dry_run = true
log_level = "debug"
log_format = "json"
color = "never"
"#,
)
.expect("write tmp config");
let cfg = load_config(&path).expect("load config");
assert_eq!(cfg.url.as_deref(), Some("https://youtu.be/dQw4w9WgXcQ"));
assert_eq!(cfg.lang.map(LanguageArg::as_str), Some("pt"));
assert_eq!(cfg.timeout, Some(12));
assert_eq!(cfg.cache_ttl, Some(6));
assert_eq!(cfg.verbose, Some(true));
assert_eq!(cfg.dry_run, Some(true));
assert!(matches!(cfg.log_level, Some(LogLevelArg::Debug)));
assert!(matches!(cfg.log_format, Some(LogFormatArg::Json)));
assert!(matches!(cfg.color, Some(ColorArg::Never)));
std::fs::remove_file(&path).ok();
}
#[test]
fn load_config_rejects_invalid_toml() {
let dir = std::env::temp_dir();
let path = dir.join("yt_legend_config_test_bad.toml");
std::fs::write(&path, "this is not = toml [[[").expect("write tmp");
let err = load_config(&path).unwrap_err();
assert!(matches!(err, AppError::Config(_)));
assert_eq!(err.exit_code(), 78);
let msg = err.to_string();
assert!(msg.contains(&path.display().to_string()), "actual: {msg}");
std::fs::remove_file(&path).ok();
}
#[test]
fn load_config_rejects_unknown_key() {
let dir = std::env::temp_dir();
let path = dir.join("yt_legend_config_test_unknown.toml");
std::fs::write(&path, "definitely_not_a_flag = 1\n").expect("write tmp");
let err = load_config(&path).unwrap_err();
assert!(matches!(err, AppError::Config(_)));
assert_eq!(err.exit_code(), 78);
let msg = err.to_string();
assert!(msg.contains("`definitely_not_a_flag`"), "actual: {msg}");
std::fs::remove_file(&path).ok();
}
#[test]
fn load_config_rejects_unknown_key_inside_tuning_namespace() {
let path = std::env::temp_dir().join("yt_legend_config_test_tuning_unknown.toml");
std::fs::write(&path, "[net]\nfoo = 1\n").expect("write tmp");
let err = load_config(&path).unwrap_err();
assert!(matches!(err, AppError::Config(_)));
assert_eq!(err.exit_code(), 78);
let msg = err.to_string();
assert!(msg.contains("`net.foo`"), "actual: {msg}");
std::fs::remove_file(&path).ok();
}
#[test]
fn load_config_accepts_known_tuning_keys() {
let path = std::env::temp_dir().join("yt_legend_config_test_tuning_known.toml");
std::fs::write(
&path,
r#"
[net.retry]
max_attempts = 5
[cache]
qualifier = "com"
[cli]
worker_threads_max = 8
[i18n]
max_untranslated_messages = 3
"#,
)
.expect("write tmp");
load_config(&path).expect("registered tuning keys must load");
std::fs::remove_file(&path).ok();
}
#[test]
fn load_config_accepts_every_registered_tuning_key() {
let mut text = String::new();
let mut written = 0_usize;
for spec in crate::config::KEYS {
if !spec.key.contains('.') || spec.key.starts_with("batch.") {
continue;
}
text.push_str(&format!("{} = \"placeholder\"\n", spec.key));
written += 1;
}
assert!(written > 50, "the registry looks empty: {written} keys");
let path = std::env::temp_dir().join("yt_legend_config_test_all_tuning.toml");
std::fs::write(&path, &text).expect("write tmp");
load_config(&path).expect("every registered tuning key must load");
std::fs::remove_file(&path).ok();
}
#[test]
fn load_config_rejects_missing_file() {
let path = std::path::Path::new("/nonexistent/path/yt_legend.toml");
let err = load_config(path).unwrap_err();
assert!(matches!(err, AppError::Config(_)));
assert_eq!(err.exit_code(), 78);
}
#[test]
fn apply_config_overrides_cli_wins() {
let (mut cli, flags) = parse_with_overrides_from([
"youtube-legend-cli",
"https://youtu.be/from_cli",
"--lang",
"es",
]);
let cfg = ConfigOverrides {
url: Some("https://youtu.be/from_config".to_string()),
lang: Some(LanguageArg::parse("pt-BR").expect("pt-BR parses")),
timeout: Some(99),
..Default::default()
};
cli.apply_config_overrides(cfg, &flags);
assert_eq!(cli.url.as_deref(), Some("https://youtu.be/from_cli"));
assert_eq!(cli.lang.as_str(), "es");
assert_eq!(cli.timeout, 99);
}
#[test]
fn apply_config_overrides_config_fills_defaults() {
let (mut cli, flags) = parse_with_overrides_from(["youtube-legend-cli"]);
let cfg = ConfigOverrides {
timeout: Some(45),
cache_ttl: Some(2),
log_level: Some(LogLevelArg::Trace),
color: Some(ColorArg::Always),
..Default::default()
};
cli.apply_config_overrides(cfg, &flags);
assert_eq!(cli.timeout, 45);
assert_eq!(cli.cache_ttl, 2);
assert!(matches!(cli.log_level, LogLevelArg::Trace));
assert!(matches!(cli.color, ColorArg::Always));
}
#[test]
fn apply_config_overrides_explicit_default_does_not_get_overridden() {
let (mut cli, flags) = parse_with_overrides_from(["youtube-legend-cli", "--timeout", "30"]);
let cfg = ConfigOverrides {
timeout: Some(99),
..Default::default()
};
cli.apply_config_overrides(cfg, &flags);
assert!(
flags.timeout,
"flags.timeout must report explicit CLI usage"
);
assert_eq!(
cli.timeout, 30,
"explicit CLI default value must NOT be overridden by config"
);
}
fn format_fmt(f: FormatArg) -> &'static str {
match f {
FormatArg::Txt => "txt",
FormatArg::Srt => "srt",
FormatArg::Vtt => "vtt",
}
}
#[test]
fn provider_choice_parses_all_variants() {
use crate::cli::ProviderChoice;
let cases = [
("auto", ProviderChoice::Auto),
("provider-decopy", ProviderChoice::ProviderDecopy),
("provider-noiz", ProviderChoice::ProviderNoiz),
];
for (flag, expected) in cases {
let cli = Cli::parse_from([
"youtube-legend-cli",
"https://youtu.be/dQw4w9WgXcQ",
"--provider",
flag,
]);
assert_eq!(cli.provider, Some(expected), "failed for {flag}");
}
}
#[test]
fn every_provider_the_command_line_accepts_the_config_file_accepts_too() {
use crate::cli::ConfigValue;
use clap::ValueEnum;
for variant in ProviderChoice::value_variants() {
let wire = variant.as_str();
let parsed = ProviderChoice::from_config_str(wire).unwrap_or_else(|e| {
panic!(
"`--provider {wire}` parses on the command line but the config file \
refuses it: {e}"
)
});
assert_eq!(
parsed, *variant,
"`{wire}` round-trips to a different variant through the config file"
);
}
}
}