use crate::cli::{Cli, ProviderChoice};
use crate::error::{AppError, AppResult};
use crate::parse::video_id::extract_video_id;
use crate::provider::{Format, ProviderAttempt, ProviderChain, SubtitleInfo};
use crate::text::normalize_nfc;
use serde::Serialize;
use std::process::ExitCode;
pub mod batch;
pub mod config_cmd;
pub mod extract;
pub mod gen;
pub mod schema;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TargetSource {
Argv,
Stdin,
BatchFile,
}
impl TargetSource {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Argv => "argv",
Self::Stdin => "stdin",
Self::BatchFile => "batch-file",
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedTarget {
pub value: String,
pub source: TargetSource,
}
#[derive(Debug, Serialize)]
struct JsonSuccess {
provider: &'static str,
video_id: String,
target_resolved: String,
target_source: &'static str,
language: String,
#[serde(skip_serializing_if = "Option::is_none")]
delivered_language: Option<String>,
format: String,
content: String,
byte_size: u64,
duration_ms: u64,
source_url: String,
}
#[derive(Debug, Serialize)]
struct JsonError {
error: bool,
code: u8,
message: String,
kind: &'static str,
retryable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
retry_after_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
provider: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
video_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
target_resolved: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
target_source: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
requested_language: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
available_languages: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
diagnostic: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
attempts: Vec<ProviderAttempt>,
}
#[derive(Debug, Serialize)]
struct JsonDryRun {
event: &'static str,
video_id: String,
target_resolved: String,
target_source: &'static str,
language: String,
format: String,
would_fetch: bool,
}
#[tracing::instrument(level = "debug", err, skip(cli), fields(batch = cli.batch, url = ?cli.url, json = cli.json, verbose = cli.verbose, provider = ?cli.provider))]
pub async fn run(cli: Cli) -> AppResult<ExitCode> {
crate::i18n::init(cli.effective_ui_language());
let mut effective = toml::Table::new();
effective.insert("offline".to_string(), toml::Value::Boolean(cli.offline));
if cli.jobs > 0 {
if let Ok(jobs) = i64::try_from(cli.jobs) {
effective.insert("jobs".to_string(), toml::Value::Integer(jobs));
}
}
effective.insert(
"user_agent".to_string(),
toml::Value::String(cli.effective_user_agent()),
);
effective.insert("yes".to_string(), toml::Value::Boolean(cli.yes));
effective.insert("no_input".to_string(), toml::Value::Boolean(cli.no_input));
crate::config::install_flag_overrides(effective);
if cli.print_schema {
return schema::print_schema().await;
}
match &cli.command {
Some(crate::cli::Command::Completions { shell }) => {
return gen::run_completions(*shell).await;
}
Some(crate::cli::Command::Man) => {
return gen::run_man().await;
}
_ => {}
}
if let Some(crate::cli::Command::Config { action }) = &cli.command {
return match config_cmd::run(&cli, action).await {
Ok(code) => Ok(code),
Err(e) => {
output_error(&cli, &e, None, None).await.ok();
eprintln!("{e}");
Ok(ExitCode::from(e.exit_code()))
}
};
}
if let Err(e) = cli.validate() {
output_error(&cli, &e, None, None).await.ok();
if !cli.json {
eprintln!("{e}");
}
return Ok(ExitCode::from(e.exit_code()));
}
let chain = build_provider_chain(&cli);
if cli.batch {
batch::run(&cli, &chain).await
} else {
extract::run(&cli, &chain).await
}
}
#[tracing::instrument(level = "debug", skip(cli), fields(provider = ?cli.provider))]
fn build_provider_chain(cli: &Cli) -> ProviderChain {
let mut providers: Vec<Box<dyn crate::provider::Provider>> = Vec::new();
let selection = cli.provider.unwrap_or(ProviderChoice::Auto);
let lang = language_to_str(cli.lang);
let _ = lang;
let use_decopy = matches!(
selection,
ProviderChoice::Auto | ProviderChoice::ProviderDecopy
);
let use_noiz = matches!(
selection,
ProviderChoice::Auto | ProviderChoice::ProviderNoiz
);
if use_decopy {
providers.push(Box::new(crate::provider::ProviderDecopy::new()));
}
if use_noiz {
providers.push(Box::new(crate::provider::ProviderNoiz::new()));
}
ProviderChain::new(providers)
}
pub fn format_to_provider_format(arg: crate::cli::FormatArg) -> Format {
match arg {
crate::cli::FormatArg::Txt => Format::Txt,
crate::cli::FormatArg::Srt | crate::cli::FormatArg::Vtt => Format::Srt,
}
}
pub fn language_to_str(arg: crate::cli::LanguageArg) -> &'static str {
arg.as_str()
}
pub fn convert_format(
content: &[u8],
format: crate::cli::FormatArg,
format_hint: crate::provider::SubtitleFormat,
) -> AppResult<String> {
use crate::cli::FormatArg;
use crate::provider::SubtitleFormat;
match (format, format_hint) {
(FormatArg::Srt, SubtitleFormat::Srt) => srt_body(content),
(FormatArg::Vtt, SubtitleFormat::Srt) => Ok(srt_to_vtt(&srt_body(content)?)),
(FormatArg::Txt, SubtitleFormat::Srt) => crate::parse::srt_to_text(&srt_body(content)?),
(FormatArg::Txt, SubtitleFormat::NoteeyTranscript) => {
let raw = String::from_utf8(content.to_vec()).map_err(|e| {
AppError::Internal(format!("cached transcript body not valid utf-8: {e}"))
})?;
crate::parse::noteey_to_text(&raw)
}
(FormatArg::Srt | FormatArg::Vtt, SubtitleFormat::NoteeyTranscript) => {
Err(AppError::InvalidUsage(format!(
"--format {} is not available for this cached body: it is a plain \
transcript with no cue framing; use --format txt (default), or \
re-fetch with --no-cache to get a timed body from a live provider",
format_to_str(format)
)))
}
}
}
fn srt_body(content: &[u8]) -> AppResult<String> {
String::from_utf8(content.to_vec())
.map_err(|e| AppError::Internal(format!("srt is not valid utf-8: {e}")))
}
fn is_cue_index(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty() && trimmed.bytes().all(|b| b.is_ascii_digit())
}
fn is_cue_timing(line: &str) -> bool {
line.contains("-->")
}
#[must_use]
pub fn srt_to_vtt(srt: &str) -> String {
let mut out = String::with_capacity(srt.len() + 8);
out.push_str("WEBVTT\n\n");
let mut lines = srt.lines().peekable();
while let Some(line) = lines.next() {
if is_cue_index(line) && lines.peek().is_some_and(|next| is_cue_timing(next)) {
continue;
}
if is_cue_timing(line) {
out.push_str(&line.replace(',', "."));
} else {
out.push_str(line);
}
out.push('\n');
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeliveredLanguage(Option<String>);
impl DeliveredLanguage {
#[must_use]
pub fn unknown() -> Self {
Self(None)
}
#[must_use]
pub fn observed(info: &SubtitleInfo) -> Self {
Self(info.delivered_language.clone())
}
#[must_use]
pub fn as_deref(&self) -> Option<&str> {
self.0.as_deref()
}
}
#[allow(clippy::too_many_arguments)]
pub async fn output_success(
cli: &Cli,
provider: &'static str,
video_id: &str,
target: &ResolvedTarget,
content: &str,
source_url: &str,
duration_ms: u64,
delivered_language: &DeliveredLanguage,
) -> AppResult<()> {
let nfc = normalize_nfc(content);
if cli.json {
let payload = success_payload(
cli,
provider,
video_id,
target,
nfc,
source_url,
duration_ms,
delivered_language.as_deref(),
);
emit_envelope(cli, &payload).await?;
} else {
crate::io::write_subtitle_to_stdout(nfc.as_bytes()).await?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn success_payload(
cli: &Cli,
provider: &'static str,
video_id: &str,
target: &ResolvedTarget,
nfc: String,
source_url: &str,
duration_ms: u64,
delivered_language: Option<&str>,
) -> JsonSuccess {
JsonSuccess {
provider,
video_id: video_id.to_string(),
target_resolved: target.value.clone(),
target_source: target.source.as_str(),
language: language_to_str(cli.lang).to_string(),
delivered_language: delivered_language.map(str::to_string),
format: format_to_str(cli.format).to_string(),
byte_size: nfc.len() as u64,
content: nfc,
duration_ms,
source_url: source_url.to_string(),
}
}
async fn emit_envelope<T: Serialize>(cli: &Cli, payload: &T) -> AppResult<()> {
let options = cli.surface_options()?;
let mut json = if options.is_active() {
let value = serde_json::to_value(payload).map_err(AppError::Serde)?;
let (reduced, _) = options.apply(value);
serde_json::to_string(&reduced).map_err(AppError::Serde)?
} else {
serde_json::to_string(payload).map_err(AppError::Serde)?
};
json.push('\n');
crate::io::write_subtitle_to_stdout(json.as_bytes()).await
}
fn provider_is_persistently_broken(err: &AppError) -> bool {
let provider = match err {
AppError::CaptchaChallenge { provider, .. }
| AppError::ProviderUnavailable { provider }
| AppError::RateLimited { provider, .. }
| AppError::ProviderProtocolError { provider, .. } => *provider,
_ => return false,
};
crate::provider::health::is_persistently_broken(provider)
}
pub async fn output_error(
cli: &Cli,
err: &AppError,
target: Option<&ResolvedTarget>,
video_id: Option<&str>,
) -> AppResult<()> {
output_error_traced(cli, err, target, video_id, &[]).await
}
pub async fn output_error_traced(
cli: &Cli,
err: &AppError,
target: Option<&ResolvedTarget>,
video_id: Option<&str>,
attempts: &[ProviderAttempt],
) -> AppResult<()> {
if cli.json {
let payload = error_envelope(cli, err, target, video_id, attempts);
if let Ok(mut json) = serde_json::to_string(&payload) {
json.push('\n');
let _ = crate::io::write_subtitle_to_stdout(json.as_bytes()).await;
}
}
Ok(())
}
fn error_envelope(
cli: &Cli,
err: &AppError,
target: Option<&ResolvedTarget>,
video_id: Option<&str>,
attempts: &[ProviderAttempt],
) -> JsonError {
JsonError {
error: true,
code: err.exit_code(),
message: err.to_string(),
kind: err.kind(),
retryable: err.retryable() && !provider_is_persistently_broken(err) && !cli.offline,
retry_after_ms: err.retry_after_ms(),
provider: match err {
AppError::CaptchaChallenge { provider, .. }
| AppError::ProviderUnavailable { provider }
| AppError::RateLimited { provider, .. }
| AppError::ProviderProtocolError { provider, .. } => Some(*provider),
_ => None,
},
video_id: video_id.map(str::to_string),
target_resolved: target.map(|t| t.value.clone()),
target_source: target.map(|t| t.source.as_str()),
requested_language: Some(language_to_str(cli.lang)),
available_languages: match err {
AppError::LanguageUnavailable { available } => Some(available.clone()),
AppError::CaptionsAsrOnly { asr_languages } => Some(asr_languages.clone()),
_ => None,
},
diagnostic: attempts
.iter()
.filter_map(|a| a.diagnostic.as_deref())
.max_by_key(|d| d.chars().count())
.map(str::to_string),
attempts: attempts.to_vec(),
}
}
pub async fn output_dry_run(
cli: &Cli,
video_id: &str,
target: &ResolvedTarget,
would_fetch: bool,
) -> AppResult<()> {
if cli.json {
let payload = JsonDryRun {
event: if would_fetch {
"dry_run_cache_miss"
} else {
"dry_run_cache_hit"
},
video_id: video_id.to_string(),
target_resolved: target.value.clone(),
target_source: target.source.as_str(),
language: language_to_str(cli.lang).to_string(),
format: format_to_str(cli.format).to_string(),
would_fetch,
};
emit_envelope(cli, &payload).await?;
} else if would_fetch {
crate::io::write_to_stderr(&format!("dry_run_cache_miss {video_id}\n"))?;
} else {
crate::io::write_to_stderr(&format!("dry_run_cache_hit {video_id}\n"))?;
}
Ok(())
}
fn format_to_str(arg: crate::cli::FormatArg) -> &'static str {
match arg {
crate::cli::FormatArg::Txt => "txt",
crate::cli::FormatArg::Srt => "srt",
crate::cli::FormatArg::Vtt => "vtt",
}
}
pub async fn extract_url_from_input(cli: &Cli) -> AppResult<ResolvedTarget> {
if let Some(url) = &cli.url {
return Ok(ResolvedTarget {
value: url.clone(),
source: TargetSource::Argv,
});
}
if cli.batch {
return Err(AppError::InvalidUsage(
"extract cannot be called with --batch".to_string(),
));
}
let value = crate::io::read_url_from_stdin(cli.no_input).await?;
Ok(ResolvedTarget {
value,
source: TargetSource::Stdin,
})
}
pub async fn with_deadline<T, F>(cli: &Cli, fut: F) -> AppResult<T>
where
F: std::future::Future<Output = AppResult<T>>,
{
let budget = cli.timeout_duration();
match tokio::time::timeout(budget, fut).await {
Ok(result) => result,
Err(_) => Err(AppError::Timeout(format!(
"exceeded --timeout of {}s",
budget.as_secs()
))),
}
}
pub fn parse_video_id_from_url(cli: &Cli, url: &str) -> AppResult<String> {
let id = extract_video_id(url)?;
if cli.verbose && !cli.quiet {
tracing::info!(target: "events", event = "video_id_extracted", video_id = %id);
}
Ok(id)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn cli_from<const N: usize>(args: [&str; N]) -> Cli {
Cli::parse_from(args)
}
#[tokio::test]
async fn argv_wins_over_stdin_and_reports_argv() {
let cli = cli_from(["youtube-legend-cli", "https://youtu.be/FROM_ARGV"]);
let target = extract_url_from_input(&cli)
.await
.expect("argv target resolves");
assert_eq!(target.value, "https://youtu.be/FROM_ARGV");
assert_eq!(target.source, TargetSource::Argv);
assert_eq!(target.source.as_str(), "argv");
}
#[tokio::test]
async fn flag_position_never_changes_which_token_is_the_target() {
let expected = "https://youtu.be/dQw4w9WgXcQ";
for args in [
vec!["youtube-legend-cli", expected],
vec!["youtube-legend-cli", "--lang", "pt-BR", expected],
vec!["youtube-legend-cli", expected, "--lang", "pt-BR"],
vec!["youtube-legend-cli", "--json", expected, "--timeout", "45"],
] {
let cli = Cli::parse_from(args.clone());
let target = extract_url_from_input(&cli)
.await
.unwrap_or_else(|e| panic!("{args:?} must resolve: {e}"));
assert_eq!(target.value, expected, "argv shape: {args:?}");
assert_eq!(target.source, TargetSource::Argv, "argv shape: {args:?}");
}
}
#[test]
fn no_input_without_a_positional_url_is_a_usage_error() {
let cli = cli_from(["youtube-legend-cli", "--no-input"]);
let err = cli.validate().unwrap_err();
assert!(matches!(err, AppError::InvalidUsage(_)));
assert_eq!(err.exit_code(), 64);
}
const SAMPLE_SRT: &str =
"1\n00:00:01,000 --> 00:00:02,500\nhello\n\n2\n00:00:03,250 --> 00:00:04,000\n42\n";
#[test]
fn srt_to_vtt_reframes_the_body_without_touching_the_timings() {
let vtt = srt_to_vtt(SAMPLE_SRT);
assert!(vtt.starts_with("WEBVTT\n\n"), "missing signature: {vtt}");
assert!(
vtt.contains("00:00:01.000 --> 00:00:02.500"),
"the comma must become a dot: {vtt}"
);
assert!(!vtt.contains(','), "no SubRip comma may survive: {vtt}");
assert!(vtt.contains("hello"), "the cue text must survive: {vtt}");
assert!(
!vtt.contains("\n1\n00:00:01"),
"the cue index must be dropped: {vtt}"
);
assert!(vtt.contains("\n42\n"), "numeric cue text survives: {vtt}");
}
#[test]
fn convert_format_produces_webvtt_for_the_vtt_flag() {
let out = convert_format(
SAMPLE_SRT.as_bytes(),
crate::cli::FormatArg::Vtt,
crate::provider::SubtitleFormat::Srt,
)
.expect("vtt conversion succeeds");
assert_eq!(out, srt_to_vtt(SAMPLE_SRT));
assert_eq!(
format_to_provider_format(crate::cli::FormatArg::Vtt),
Format::Srt
);
}
#[test]
fn vtt_is_refused_on_a_noteey_transcript() {
let err = convert_format(
b"whatever",
crate::cli::FormatArg::Vtt,
crate::provider::SubtitleFormat::NoteeyTranscript,
)
.expect_err("noteey cannot produce webvtt");
assert!(matches!(err, AppError::InvalidUsage(_)), "got {err:?}");
assert!(err.to_string().contains("--format vtt"), "{err}");
}
#[test]
fn target_source_wire_spellings_are_stable() {
assert_eq!(TargetSource::Argv.as_str(), "argv");
assert_eq!(TargetSource::Stdin.as_str(), "stdin");
assert_eq!(TargetSource::BatchFile.as_str(), "batch-file");
}
#[test]
fn success_envelope_declares_provenance_and_drops_the_dead_field() {
let payload = JsonSuccess {
provider: "cache",
video_id: "dQw4w9WgXcQ".to_string(),
target_resolved: "https://youtu.be/dQw4w9WgXcQ".to_string(),
target_source: TargetSource::Argv.as_str(),
language: "en".to_string(),
delivered_language: None,
format: "txt".to_string(),
content: "hello".to_string(),
byte_size: 5,
duration_ms: 1,
source_url: "cache".to_string(),
};
let value = serde_json::to_value(&payload).expect("serialises");
assert_eq!(value["target_source"], serde_json::json!("argv"));
assert_eq!(
value["target_resolved"],
serde_json::json!("https://youtu.be/dQw4w9WgXcQ")
);
assert!(
value.get("language_detected").is_none(),
"the always-false field must be gone: {value}"
);
}
#[test]
fn delivered_language_is_never_sourced_from_the_request() {
let cli = cli_from([
"youtube-legend-cli",
"--json",
"--lang",
"en",
"https://youtu.be/dQw4w9WgXcQ",
]);
let target = ResolvedTarget {
value: "https://youtu.be/dQw4w9WgXcQ".to_string(),
source: TargetSource::Argv,
};
let mut checked = 0_usize;
let known = success_payload(
&cli,
"getsubs",
"dQw4w9WgXcQ",
&target,
"hello".to_string(),
"getsubs://pt-BR/srt",
7,
Some("pt-BR"),
);
let value = serde_json::to_value(&known).expect("serialises");
assert_eq!(value["language"], serde_json::json!("en"));
checked += 1;
assert_eq!(value["delivered_language"], serde_json::json!("pt-BR"));
checked += 1;
assert_ne!(
value["language"], value["delivered_language"],
"delivered_language repeated the request instead of the \
delivery: {value}"
);
checked += 1;
let unknown = success_payload(
&cli,
"provider-noiz",
"dQw4w9WgXcQ",
&target,
"hello".to_string(),
"noteey://dQw4w9WgXcQ/en/txt",
7,
None,
);
let value = serde_json::to_value(&unknown).expect("serialises");
assert!(
value.get("delivered_language").is_none(),
"an unknown delivery must OMIT the field, never guess the \
request: {value}"
);
checked += 1;
assert_eq!(value["language"], serde_json::json!("en"));
checked += 1;
assert!(
checked >= 5,
"GAP-2026-157 floor: expected at least 5 verified facts, ran {checked}"
);
}
fn info_with(language: &str, delivered: Option<&str>) -> SubtitleInfo {
SubtitleInfo {
video_id: "dQw4w9WgXcQ".to_string(),
language: language.to_string(),
delivered_language: delivered.map(str::to_string),
format: Format::Srt,
source_url: "https://example.invalid/track".to_string(),
byte_size: 0,
format_hint: crate::provider::SubtitleFormat::Srt,
provider: "noteey",
}
}
#[test]
fn a_subtitle_info_reaches_the_envelope_without_the_request_echoing_into_it() {
let cli = cli_from([
"youtube-legend-cli",
"--json",
"--lang",
"en",
"https://youtu.be/dQw4w9WgXcQ",
]);
let target = ResolvedTarget {
value: "https://youtu.be/dQw4w9WgXcQ".to_string(),
source: TargetSource::Argv,
};
let envelope_for = |info: &SubtitleInfo| {
let delivered = DeliveredLanguage::observed(info);
serde_json::to_value(success_payload(
&cli,
info.provider,
&info.video_id,
&target,
"hello".to_string(),
&info.source_url,
7,
delivered.as_deref(),
))
.expect("serialises")
};
let echoed = info_with("xx", None);
assert_eq!(echoed.language, "xx", "precondition: request echoed");
assert!(
echoed.delivered_language.is_none(),
"precondition: the upstream named no track"
);
let value = envelope_for(&echoed);
assert!(
value.get("delivered_language").is_none(),
"an unobserved track must stay undeclared all the way to the \
wire; emitting one here echoes the request back at the \
operator as if it were evidence: {value}"
);
let observed = info_with("xx", Some("pt"));
assert_eq!(
observed.language, "xx",
"precondition: the two fields must disagree, or the assertion \
below could not tell them apart"
);
let value = envelope_for(&observed);
assert_eq!(
value["delivered_language"],
serde_json::json!("pt"),
"an upstream observation must reach the envelope unchanged: {value}"
);
assert_eq!(
value["language"],
serde_json::json!("en"),
"`language` is the REQUEST, and it comes from the CLI rather \
than from the provider's echo: {value}"
);
}
#[test]
fn a_cache_hit_declares_an_unknown_delivered_language() {
assert_eq!(
DeliveredLanguage::unknown().as_deref(),
None,
"a cache hit stored no track identity, so it must not name one"
);
}
#[test]
fn dry_run_envelope_can_report_a_cache_hit() {
for (would_fetch, event) in [(true, "dry_run_cache_miss"), (false, "dry_run_cache_hit")] {
let payload = JsonDryRun {
event,
video_id: "dQw4w9WgXcQ".to_string(),
target_resolved: "https://youtu.be/dQw4w9WgXcQ".to_string(),
target_source: TargetSource::Argv.as_str(),
language: "en".to_string(),
format: "txt".to_string(),
would_fetch,
};
let value = serde_json::to_value(&payload).expect("serialises");
assert_eq!(value["would_fetch"], serde_json::json!(would_fetch));
assert_eq!(value["event"], serde_json::json!(event));
}
}
const ERROR_ENVELOPE_PROPERTIES: [(&str, bool); 14] = [
("error", true),
("code", true),
("message", true),
("kind", true),
("retryable", true),
("retry_after_ms", true),
("provider", true),
("video_id", true),
("target_resolved", true),
("target_source", true),
("requested_language", true),
("available_languages", true),
("diagnostic", true),
("attempts", true),
];
fn emitted_error_envelope_keys() -> std::collections::BTreeSet<String> {
let cli = cli_from([
"youtube-legend-cli",
"https://youtu.be/dQw4w9WgXcQ",
"--json",
]);
let target = ResolvedTarget {
value: "https://youtu.be/dQw4w9WgXcQ".to_string(),
source: TargetSource::Argv,
};
let errors = [
AppError::RateLimited {
provider: "provider-noiz",
retry_after_secs: Some(30),
},
AppError::CaptchaChallenge {
provider: "provider-decopy",
kind: "cf-turnstile",
},
AppError::LanguageUnavailable {
available: vec!["en".to_string(), "pt".to_string()],
},
];
let attempts = [crate::provider::ProviderAttempt {
provider: "provider-decopy",
outcome: crate::provider::AttemptOutcome::Unavailable,
elapsed_ms: Some(12),
http_status: None,
body_len: None,
diagnostic: Some("track list did not render within poll limit".to_string()),
}];
let mut keys = std::collections::BTreeSet::new();
for err in errors {
let payload = error_envelope(&cli, &err, Some(&target), Some("dQw4w9WgXcQ"), &attempts);
let value = serde_json::to_value(&payload).expect("envelope serialises");
let object = value.as_object().expect("envelope is an object");
keys.extend(object.keys().cloned());
}
keys
}
#[test]
fn every_error_kind_exists_in_the_published_enum() {
use crate::error::{AppError, NoSubtitleReason};
let samples = [
AppError::NoSubtitle(NoSubtitleReason::NotPublished),
AppError::InvalidUsage("x".into()),
AppError::StdinEmpty,
AppError::InvalidInput("x".into()),
AppError::ProviderUnavailable {
provider: "provider-noiz",
},
AppError::RateLimited {
provider: "provider-noiz",
retry_after_secs: None,
},
AppError::CaptchaChallenge {
provider: "provider-decopy",
kind: "cf-turnstile",
},
AppError::ProviderProtocolError {
provider: "provider-decopy",
detail: "x".into(),
},
AppError::BrowserNotFound("x".into()),
AppError::Timeout("x".into()),
AppError::Config("x".into()),
AppError::Io(std::io::Error::other("x")),
AppError::Internal("x".into()),
AppError::LanguageUnavailable {
available: vec!["pt-BR".into()],
},
AppError::CaptionsAsrOnly {
asr_languages: vec!["pt".into()],
},
];
let text = crate::commands::schema::SCHEMAS
.iter()
.find(|(id, _)| *id == "error-envelope")
.map(|(_, text)| *text)
.expect("the catalogue must carry the error envelope");
let document: serde_json::Value = serde_json::from_str(text).expect("schema parses");
let published: std::collections::BTreeSet<&str> = document["properties"]["kind"]["enum"]
.as_array()
.expect("the schema enumerates kinds")
.iter()
.map(|v| v.as_str().expect("each kind is a string"))
.collect();
for err in &samples {
let kind = err.kind();
assert!(
published.contains(kind),
"`{kind}` is emitted by the code and missing from the published enum"
);
}
let emitted: std::collections::BTreeSet<&str> =
samples.iter().map(crate::error::AppError::kind).collect();
let orphans: Vec<&str> = published.difference(&emitted).copied().collect();
assert!(
orphans.is_empty(),
"the schema publishes kinds no sampled variant produces: {orphans:?}. \
Either a variant is missing from the sample above, or the enum is \
stale, or the schema is publishing a promise nothing keeps."
);
}
#[test]
fn every_attempt_outcome_is_published_and_every_published_one_is_reachable() {
use crate::provider::AttemptOutcome as O;
let variants = [
O::Delivered,
O::NoCaptions,
O::AsrRefused,
O::LanguageUnavailable,
O::Unavailable,
O::RateLimited,
O::Captcha,
O::DomTimeout,
O::BrowserMissing,
O::SkippedDegraded,
O::SkippedDisabled,
];
let text = crate::commands::schema::SCHEMAS
.iter()
.find(|(id, _)| *id == "error-envelope")
.map(|(_, text)| *text)
.expect("the catalogue must carry the error envelope");
let document: serde_json::Value = serde_json::from_str(text).expect("schema parses");
let published: std::collections::BTreeSet<String> = document["properties"]["attempts"]
["items"]["properties"]["outcome"]["enum"]
.as_array()
.expect("the schema enumerates outcomes")
.iter()
.map(|v| v.as_str().expect("each outcome is a string").to_string())
.collect();
assert!(
published.len() >= 11,
"the schema publishes only {} outcomes, which is too few to be real",
published.len()
);
let emitted: std::collections::BTreeSet<String> = variants
.iter()
.map(|outcome| {
let json = serde_json::to_value(outcome).expect("outcome serialises");
json.as_str().expect("outcome is a string").to_string()
})
.collect();
let unpublished: Vec<&String> = emitted.difference(&published).collect();
assert!(
unpublished.is_empty(),
"the ledger emits outcomes the schema does not publish: {unpublished:?}"
);
let orphans: Vec<&String> = published.difference(&emitted).collect();
assert!(
orphans.is_empty(),
"the schema publishes outcomes no variant produces: {orphans:?}. \
Either a variant is missing from the list above, or the schema is \
publishing a branch the caller can never be handed."
);
}
#[test]
fn error_envelope_matches_the_published_schema() {
let text = crate::commands::schema::SCHEMAS
.iter()
.find(|(id, _)| *id == "error-envelope")
.map(|(_, text)| *text)
.expect("the catalogue must carry the error envelope");
let document: serde_json::Value = serde_json::from_str(text).expect("schema parses");
let declared: std::collections::BTreeSet<String> = document["properties"]
.as_object()
.expect("schema declares properties")
.keys()
.cloned()
.collect();
let emitted = emitted_error_envelope_keys();
for key in &emitted {
assert!(
declared.contains(key),
"the envelope emits `{key}`, which the schema does not declare"
);
}
let accounted: std::collections::BTreeSet<String> = ERROR_ENVELOPE_PROPERTIES
.iter()
.map(|(name, _)| (*name).to_string())
.collect();
assert_eq!(
declared, accounted,
"the schema and the accounting list must name the same properties"
);
for (name, expected) in ERROR_ENVELOPE_PROPERTIES {
assert_eq!(
emitted.contains(name),
expected,
"`{name}` is classified as emitted={expected} but serialisation disagrees"
);
}
}
#[test]
fn the_minimal_error_envelope_still_carries_the_required_fields() {
let cli = cli_from(["youtube-legend-cli", "--json", "--no-input"]);
let payload = error_envelope(&cli, &AppError::StdinEmpty, None, None, &[]);
let value = serde_json::to_value(&payload).expect("serialises");
let object = value.as_object().expect("object");
for required in ["error", "code", "message"] {
assert!(object.contains_key(required), "missing {required}: {value}");
}
for absent in ["target_resolved", "target_source", "video_id"] {
assert!(
!object.contains_key(absent),
"{absent} must be omitted, not emitted as null: {value}"
);
}
}
#[test]
fn an_offline_run_never_tells_the_caller_to_try_again() {
let err = AppError::ProviderUnavailable {
provider: "provider-noiz",
};
assert!(
err.retryable(),
"precondition: this error must be retryable BY TYPE, or the \
offline half below would pass without narrowing anything"
);
let online = cli_from([
"youtube-legend-cli",
"--json",
"https://youtu.be/dQw4w9WgXcQ",
]);
let value = serde_json::to_value(error_envelope(&online, &err, None, None, &[]))
.expect("serialises");
assert_eq!(
value["retryable"],
serde_json::json!(true),
"without the flag the envelope must keep describing the world: {value}"
);
let offline = cli_from([
"youtube-legend-cli",
"--json",
"--offline",
"https://youtu.be/dQw4w9WgXcQ",
]);
let value = serde_json::to_value(error_envelope(&offline, &err, None, None, &[]))
.expect("serialises");
assert_eq!(
value["retryable"],
serde_json::json!(false),
"under --offline the refusal is this process's own, so repeating \
the same command line cannot change it: {value}"
);
}
#[tokio::test(start_paused = true)]
async fn timeout_flag_bounds_the_operation() {
let cli = cli_from([
"youtube-legend-cli",
"https://youtu.be/dQw4w9WgXcQ",
"--timeout",
"1",
]);
let result: AppResult<()> = with_deadline(&cli, async {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
Ok(())
})
.await;
let err = result.unwrap_err();
assert!(matches!(err, AppError::Timeout(_)), "got {err:?}");
assert!(err.to_string().contains("--timeout"));
}
#[tokio::test]
async fn a_fast_operation_is_not_cut_by_the_deadline() {
let cli = cli_from(["youtube-legend-cli", "https://youtu.be/dQw4w9WgXcQ"]);
let result: AppResult<u8> = with_deadline(&cli, async { Ok(7) }).await;
assert_eq!(result.ok(), Some(7));
}
#[test]
fn reduction_flags_require_json() {
let cli = cli_from([
"youtube-legend-cli",
"https://youtu.be/dQw4w9WgXcQ",
"--limit",
"1",
]);
let err = cli.validate().unwrap_err();
assert!(matches!(err, AppError::InvalidUsage(_)));
assert!(err.to_string().contains("--json"));
}
#[test]
fn a_malformed_filter_is_rejected_at_validation_time() {
let cli = cli_from([
"youtube-legend-cli",
"https://youtu.be/dQw4w9WgXcQ",
"--json",
"--filter",
"no_operator",
]);
assert!(matches!(cli.validate(), Err(AppError::InvalidUsage(_))));
}
#[test]
fn provider_selection_is_read_rather_than_discarded() {
for flag in ["auto", "provider-decopy", "provider-noiz"] {
let cli = cli_from([
"youtube-legend-cli",
"https://youtu.be/dQw4w9WgXcQ",
"--provider",
flag,
]);
assert_eq!(
cli.provider,
Some(match flag {
"provider-decopy" => ProviderChoice::ProviderDecopy,
"provider-noiz" => ProviderChoice::ProviderNoiz,
_ => ProviderChoice::Auto,
})
);
let _chain = build_provider_chain(&cli);
}
}
#[test]
fn the_error_envelope_struct_and_its_published_schema_agree_on_every_key() {
let envelope = JsonError {
error: true,
code: 69,
message: "provedor indisponível".to_string(),
kind: "provider_unavailable",
retryable: true,
retry_after_ms: Some(60_000),
provider: Some("provider-decopy"),
video_id: Some("dQw4w9WgXcQ".to_string()),
target_resolved: Some("https://youtu.be/dQw4w9WgXcQ".to_string()),
target_source: Some("argv"),
requested_language: Some("pt"),
available_languages: Some(vec!["en".to_string(), "pt".to_string()]),
diagnostic: Some("checkbox-grid".to_string()),
attempts: vec![crate::provider::ProviderAttempt {
provider: "provider-decopy",
outcome: crate::provider::AttemptOutcome::Unavailable,
elapsed_ms: Some(1),
http_status: None,
body_len: None,
diagnostic: None,
}],
};
let serde_json::Value::Object(emitido) =
serde_json::to_value(&envelope).expect("o envelope serializa")
else {
panic!("o envelope de erro é um objeto JSON");
};
let schema: serde_json::Value = serde_json::from_str(include_str!(
"../../docs/schemas/error-envelope.schema.json"
))
.expect("o schema publicado é JSON válido");
let declarado = schema
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("o schema de erro declara `properties`");
let mut emitidas: Vec<&str> = emitido.keys().map(String::as_str).collect();
let mut declaradas: Vec<&str> = declarado.keys().map(String::as_str).collect();
emitidas.sort_unstable();
declaradas.sort_unstable();
assert_eq!(
emitidas, declaradas,
"a struct e o schema divergiram: com `additionalProperties: false`, chave emitida a mais quebra o consumidor e chave declarada a menos é contrato que ninguém cumpre"
);
}
}