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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
//! `youtube-legend-cli` binary entry point.

use std::process::ExitCode;

use std::sync::atomic::{AtomicU8, Ordering};

use youtube_legend_cli::cli::{load_config, parse_with_overrides};
use youtube_legend_cli::config;
use youtube_legend_cli::error::AppError;
use youtube_legend_cli::i18n::{t, Message};
use youtube_legend_cli::io::is_broken_pipe;
use youtube_legend_cli::logging::init_tracing;
use youtube_legend_cli::run;

/// Exit code for a run cut short by SIGINT: `128 + SIGINT`.
const EXIT_SIGINT: u8 = 130;
/// Exit code for a run cut short by SIGTERM: `128 + SIGTERM`.
const EXIT_SIGTERM: u8 = 143;
/// Exit code for a run whose stdout reader closed the pipe:
/// `128 + SIGPIPE`. A shell reports this for `cmd | head -1`, and an
/// agent branches on it to tell "the reader left" apart from "the
/// program failed".
const EXIT_BROKEN_PIPE: u8 = 141;
/// Exit code for a command line `clap` refused. Mirrored here so the
/// envelope and the process agree even if `clap` ever stops publishing
/// a value that fits in a `u8`.
const EXIT_USAGE: u8 = 2;

/// Compiled default of `cli.worker_threads_min`.
const DEFAULT_WORKER_THREADS_MIN: usize = 2;
/// Compiled default of `cli.worker_threads_max`.
const DEFAULT_WORKER_THREADS_MAX: usize = 8;
/// Worker-thread count assumed when the platform reports no
/// parallelism figure at all.
const WORKER_THREADS_FALLBACK: usize = 4;
/// Widest bound either worker-thread key may carry. A value beyond it
/// is discarded in favour of the compiled default, the same way every
/// other tuning key in this crate is validated.
const WORKER_THREADS_HARD_MAX: usize = 1_024;

/// Which signal cancelled the run, as a raw exit code.
///
/// `0` means no signal was seen. The watcher records the signal it
/// observed so the exit code distinguishes SIGTERM from SIGINT; the
/// previous implementation collapsed both onto 130, which made an
/// orchestrator's `kill` indistinguishable from an operator's Ctrl-C.
static SIGNAL_EXIT: AtomicU8 = AtomicU8::new(0);

use mimalloc::MiMalloc;

/// Drop-in mimalloc global allocator. Reduces allocation overhead in
/// the subtitle-fetching hot path (HTTP body buffers, URL parsing).
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;

fn main() -> ExitCode {
    // GAP-E2E-016: `parse_with_overrides` returns both the parsed
    // `Cli` and a `CliOverrideFlags` bitmask describing which flags
    // the operator actually typed (vs which the parser filled from
    // `default_value`). The bitmask is the only reliable input to
    // `apply_config_overrides` because it does not rely on a
    // sentinel comparison against a literal default value.
    //
    // `parse_with_overrides` cannot return a `Cli` directly because
    // clap's `Command::get_matches_from_mut` consumes the args and
    // we need to inspect `value_source` before building `Cli`. The
    // trade-off is one extra allocation for the `matches` builder,
    // which is negligible against the per-extraction HTTP cost.
    let (mut cli, cli_overrides) = match parse_with_overrides() {
        Ok(pair) => pair,
        Err(e) => return report_clap_error(&e),
    };

    // 1. Apply config-file overrides BEFORE we propagate env vars, so
    //    the effective level/format take config + CLI into account.
    //
    //    `--config` names a file explicitly; without it the XDG file is
    //    auto-discovered, which is what `docs/CROSS_PLATFORM.md` has
    //    always promised. An absent file is not an error: running with
    //    no configuration at all is the normal case.
    let config_path = cli.config.clone().or_else(config::discover);
    if let Some(path) = config_path {
        // Publish the raw table first so the tuning accessors can read
        // the dotted namespaces that never map onto a CLI flag.
        match youtube_legend_cli::config::ConfigStore::load_from(&path) {
            Ok(store) => config::install_tuning(store.table().clone()),
            Err(e) => {
                eprintln!("{e}");
                return ExitCode::from(e.exit_code());
            }
        }
        match load_config(&path) {
            Ok(overrides) => cli.apply_config_overrides(overrides, &cli_overrides),
            Err(e) => {
                // GAP-E2E-013: `AppError::Config`'s Display already
                // includes the `config error: ` prefix (see
                // src/error.rs:181). The previous code duplicated it
                // via this `eprintln!("config error: {e}")`, producing
                // `config error: config error: <path>` to the operator.
                // Emit the Display directly and rely on the
                // `Termination` impl to route the matching exit code.
                eprintln!("{e}");
                return ExitCode::from(e.exit_code());
            }
        }
    }

    // 2. Propagate effective values into env vars so downstream
    //    crates (`tracing`, progress bars) see the chosen config.
    cli.apply_overrides();

    // 3. Initialise tracing with the effective level/format/color.
    //    These arguments arrive already resolved: step 1 above merged
    //    the `log_level` and `log_format` config keys into the `Cli`
    //    respecting `value_source`, so the flag beats the key and
    //    `init_tracing` reads no configuration of its own. No
    //    environment variable takes
    //    part any more: `YT_LOG_LEVEL` and `YT_LOG_FORMAT` never
    //    existed, and `RUST_LOG` stopped being consulted on 2026-08-31.
    //    Product settings live in the config file, not the environment.
    if let Err(e) = init_tracing(
        cli.effective_log_level(),
        cli.effective_log_format(),
        cli.color,
        cli.quiet,
        cli.json,
    ) {
        eprintln!("{}: {e}", t(Message::ErrTracingInit));
        return ExitCode::from(e.exit_code());
    }

    // 4. SIGINT and SIGTERM are honoured cooperatively: a dedicated
    //    watcher task inside the runtime arms a CancellationToken on
    //    the first signal, which the in-flight HTTP requests observe
    //    at their next await point for clean abort. A second signal
    //    during shutdown is forwarded to the runtime's shutdown
    //    handle to force an immediate exit with code 130
    //    (conventional SIGINT). On Windows, only SIGINT is delivered
    //    via `tokio::signal::ctrl_c`; SIGTERM has no portable
    //    equivalent there and is silently ignored.
    let shutdown_token = tokio_util::sync::CancellationToken::new();

    let worker_threads =
        resolve_worker_threads(std::thread::available_parallelism().ok().map(|n| n.get()));

    let runtime = match tokio::runtime::Builder::new_multi_thread()
        .worker_threads(worker_threads)
        .enable_all()
        .build()
    {
        Ok(rt) => rt,
        Err(e) => {
            tracing::error!(error = %e, "failed to start tokio runtime");
            return ExitCode::from(AppError::Internal(format!("tokio runtime: {e}")).exit_code());
        }
    };

    let exit_code = runtime.block_on(async move {
        let signal_watcher = tokio::spawn(install_signal_handler(shutdown_token.clone()));
        let result = tokio::select! {
            biased;
            result = run(cli) => result,
            _ = shutdown_token.cancelled() => {
                tracing::warn!("cancellation requested before completion");
                let code = match SIGNAL_EXIT.load(Ordering::SeqCst) {
                    0 => EXIT_SIGINT,
                    seen => seen,
                };
                Ok(ExitCode::from(code))
            }
        };
        // Stop the watcher. Dropping a `JoinHandle` *detaches* the task
        // — it keeps running, and its `loop` never terminates, so the
        // runtime would refuse to shut down until the process exits.
        // `abort()` is what actually cancels it at the next await point.
        signal_watcher.abort();
        result
    });
    match exit_code {
        Ok(code) => code,
        // A reader that closed the pipe is not a program failure. The
        // `ErrorKind` survives all the way from `io::write_subtitle_to_stdout`
        // precisely so this branch can tell the two apart.
        Err(e) if is_broken_pipe(&e) => ExitCode::from(EXIT_BROKEN_PIPE),
        Err(e) => ExitCode::from(e.exit_code()),
    }
}

/// Whether `--json` was typed on the command line.
///
/// The parse already failed when this is asked, so the flag cannot be
/// read off a `Cli` that was never built. `--json` takes no value, so an
/// exact match is the whole test.
fn json_requested() -> bool {
    std::env::args_os().any(|arg| arg == "--json")
}

/// Render a `clap` parse outcome and map it onto an exit code.
///
/// `--help` and `--version` arrive here as `Err` as well, carrying
/// [`ErrorKind::DisplayHelp`] and [`ErrorKind::DisplayVersion`]; both
/// are successful outcomes whose text belongs on stdout with exit 0.
/// Branching on the kind before anything else is what keeps `--help`
/// working — intercepting every `clap::Error` alike would turn the help
/// screen into a usage failure.
///
/// Everything else is a usage error. Under `--json` the envelope goes to
/// stdout, the destination `docs/schemas/error-envelope.schema.json`
/// publishes, so `cmd --json | jaq .` parses without the caller having to
/// know whether the run died parsing arguments or walking providers; the
/// exit code stays the 2 `clap` publishes. `clap::Error::exit` used to
/// end the process through `std::process::exit`, which skipped every
/// `Drop` on the way out; returning an `ExitCode` unwinds normally.
fn report_clap_error(err: &clap::Error) -> ExitCode {
    use clap::error::ErrorKind;
    if matches!(
        err.kind(),
        ErrorKind::DisplayHelp
            | ErrorKind::DisplayVersion
            | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
    ) {
        // `print` is clap's own writer: it picks stdout or stderr the
        // way the kind requires and keeps the colouring.
        let _ = err.print();
        return ExitCode::SUCCESS;
    }

    let code = u8::try_from(err.exit_code()).unwrap_or(EXIT_USAGE);
    if json_requested() {
        // Only the properties `docs/schemas/error-envelope.schema.json`
        // declares: the document forbids additional ones, so a field
        // invented here would fail every consumer's validation.
        let envelope = serde_json::json!({
            "error": true,
            "code": code,
            "message": err.render().to_string().trim_end(),
            "kind": "invalid_usage",
            "retryable": false,
        });
        println!("{envelope}");
    } else {
        let _ = err.print();
    }
    ExitCode::from(code)
}

/// Order a pair of worker-thread bounds so it is safe to clamp with.
///
/// [`Ord::clamp`] panics when its low end is above its high end, and an
/// operator is free to persist `cli.worker_threads_min` above
/// `cli.worker_threads_max` — nothing in the config store couples the
/// two keys. Swapping an inverted pair keeps the range the operator
/// described and keeps the process alive; aborting at startup over a
/// typo in a tuning file would be the worse of the two outcomes, and a
/// panic on the production path is not an option here.
fn order_worker_thread_bounds(low: usize, high: usize) -> (usize, usize) {
    if low > high {
        tracing::warn!(
            low,
            high,
            "cli.worker_threads_min is above cli.worker_threads_max; treating the pair as swapped"
        );
        return (high, low);
    }
    (low, high)
}

/// Resolve the tokio worker-thread count from the platform parallelism.
///
/// `available` is `None` when the platform exposes no parallelism
/// figure; [`WORKER_THREADS_FALLBACK`] stands in for it and is then
/// bounded like any measured value, so an operator who raised the floor
/// never gets a runtime below it.
fn resolve_worker_threads(available: Option<usize>) -> usize {
    let low = config::tuning_usize_in_range(
        "cli.worker_threads_min",
        DEFAULT_WORKER_THREADS_MIN,
        1,
        WORKER_THREADS_HARD_MAX,
    );
    let high = config::tuning_usize_in_range(
        "cli.worker_threads_max",
        DEFAULT_WORKER_THREADS_MAX,
        1,
        WORKER_THREADS_HARD_MAX,
    );
    let (low, high) = order_worker_thread_bounds(low, high);
    available
        .unwrap_or(WORKER_THREADS_FALLBACK)
        .clamp(low, high)
}

/// Watch SIGINT (Ctrl-C) and SIGTERM (Unix only) and cancel `token` on
/// the first signal observed. A second SIGINT is a hard-exit signal;
/// the runtime's shutdown task is not given a chance to drain.
#[cfg(unix)]
async fn install_signal_handler(token: tokio_util::sync::CancellationToken) {
    use tokio::signal::unix::{signal, SignalKind};
    let mut sigterm = match signal(SignalKind::terminate()) {
        Ok(s) => s,
        Err(e) => {
            tracing::warn!(error = %e, "could not install SIGTERM handler");
            return;
        }
    };
    let mut sigint = match signal(SignalKind::interrupt()) {
        Ok(s) => s,
        Err(e) => {
            tracing::warn!(error = %e, "could not install SIGINT handler");
            return;
        }
    };
    // Both dispositions are now installed, and this is the first instant
    // at which a signal produces the cooperative exit rather than the
    // default kill.
    //
    // The line exists so a caller can OBSERVE readiness instead of
    // guessing at it. MEASURED on 2026-09-04: the signal tests slept a
    // fixed 500 ms before signalling, passed one at a time and failed
    // three-of-four when run together, every failure reporting a status
    // with no exit code — that is the kernel's default disposition
    // acting on a process that had not reached this point yet. A fixed
    // sleep measures the host's load, not the program, which is the
    // defect GAP-2026-110 names.
    tracing::info!(
        target: "events",
        event = "signal_handler_installed",
        "signal handlers installed"
    );
    let mut first = true;
    loop {
        // Record which signal arrived: SIGTERM must exit 143 and SIGINT
        // 130, and the only place that distinction is still available is
        // right here, at the receive.
        let code = tokio::select! {
            biased;
            _ = sigterm.recv() => EXIT_SIGTERM,
            _ = sigint.recv() => EXIT_SIGINT,
        };
        if first {
            SIGNAL_EXIT.store(code, Ordering::SeqCst);
            tracing::info!(target: "events", event = "signal", exit_code = code, "shutdown requested");
            token.cancel();
            first = false;
        } else {
            // Skipping every `Drop` here is the requested semantics, not
            // a leak. The first signal already asked for a cooperative
            // shutdown; a second one means the operator is no longer
            // willing to wait for it, so `XvfbGuard` and `ProfileGuard`
            // are traded away on purpose. Every other exit from this
            // binary returns `ExitCode` up the normal stack and does run
            // them. MEASURED 2026-08-31: these are the only two
            // `std::process::exit` sites left in the crate.
            tracing::warn!(target: "events", event = "signal", "second signal received; forcing immediate exit");
            std::process::exit(i32::from(code));
        }
    }
}

#[cfg(not(unix))]
async fn install_signal_handler(token: tokio_util::sync::CancellationToken) {
    let mut first = true;
    loop {
        if let Err(e) = tokio::signal::ctrl_c().await {
            tracing::warn!(error = %e, "could not install SIGINT handler");
            return;
        }
        if first {
            // Windows delivers no SIGTERM equivalent through
            // `ctrl_c`, so the only signal reachable here is SIGINT.
            SIGNAL_EXIT.store(EXIT_SIGINT, Ordering::SeqCst);
            tracing::info!(target: "events", event = "signal", "shutdown requested");
            token.cancel();
            first = false;
        } else {
            // Same trade as the Unix branch: a second signal forfeits
            // guard cleanup because the operator declined to wait.
            tracing::warn!(target: "events", event = "signal", "second signal received; forcing immediate exit");
            std::process::exit(i32::from(EXIT_SIGINT));
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// `cli.worker_threads_min` above `cli.worker_threads_max` is the
    /// pair that makes [`Ord::clamp`] panic. Install exactly that pair
    /// and prove the resolver still returns a usable count.
    ///
    /// The tuning table is process-wide and write-once, so this test
    /// owns it for the whole binary; it is the only test here.
    #[test]
    fn inverted_worker_thread_bounds_do_not_panic() {
        let mut cli_table = toml::Table::new();
        cli_table.insert("worker_threads_min".to_string(), toml::Value::Integer(8));
        cli_table.insert("worker_threads_max".to_string(), toml::Value::Integer(2));
        let mut table = toml::Table::new();
        table.insert("cli".to_string(), toml::Value::Table(cli_table));
        config::install_tuning(table);

        assert_eq!(order_worker_thread_bounds(8, 2), (2, 8));
        assert_eq!(order_worker_thread_bounds(2, 8), (2, 8));

        // The swapped pair is honoured as the range 2..=8.
        assert_eq!(resolve_worker_threads(Some(1)), 2);
        assert_eq!(resolve_worker_threads(Some(4)), 4);
        assert_eq!(resolve_worker_threads(Some(64)), 8);
        assert_eq!(resolve_worker_threads(None), WORKER_THREADS_FALLBACK);
    }
}