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.
//! `tracing` subscriber initialiser.
//!
//! Every event this subscriber emits goes to stderr. stdout carries
//! only the payload, so a caller can pipe the payload into a parser
//! while still reading the diagnosis on the terminal.
//!
//! Sources, highest priority first:
//! - the `log_level` and `log_format` configuration keys, settable
//!   with `config set` and discoverable with `config list-keys`
//! - the `--log-level` and `--log-format` flags
//!
//! ANSI is gated by the `color` key and the `--color` flag, whose
//! resolved value arrives here as the `cli_color` argument. No
//! environment variable takes part in either decision.

use crate::cli::{ColorArg, LogFormatArg, LogLevelArg};
use crate::error::{AppError, AppResult};
use std::io::IsTerminal;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};

/// Initialise the global `tracing` subscriber.
///
/// Precedence (highest first):
/// 1. The `--log-level` flag, passed here as `cli_log_level`.
/// 2. The `log_level` configuration key.
/// 3. Default `error`.
///
/// The flag wins, which is what `Cli::config` documents and what every
/// other setting in this binary already did. Until 2026-08-31 the order
/// was inverted here and only here: the key was read first, so
/// `--log-level error` lost to a `log_level = "trace"` sitting in the
/// config file. That is the same invisible precedence for which
/// `RUST_LOG` was removed from this function, reproduced by the
/// replacement — see GAP-2026-121.
///
/// This function no longer reads the `log_level` or `log_format`
/// configuration keys at all. It does not need to: `apply_config_overrides`
/// has already merged them into the `Cli`, respecting `value_source`, so
/// the two arguments below arrive fully resolved.
///
/// A first attempt at this fix kept the key read and merely skipped it
/// when the flag was present, on the belief that the key accepted
/// `EnvFilter` directives such as `some_crate=warn` that the
/// five-variant flag cannot express. MEASURED on 2026-08-31: it does
/// not. The config loader validates `log_level` as the enum, so
/// `events=trace` and outright garbage both fail identically, before
/// this function is ever reached. That capability is claimed in
/// GAP-2026-122 and does not exist.
///
/// Dropping the read also makes the `--json` silencing unconditional.
/// It used to be escapable by setting the key, which was the one way to
/// put prose back on the channel `cmd --json 2>&1 | jaq .` has to parse.
///
/// # Errors
///
/// - [`AppError::Internal`] when the global subscriber cannot be
///   installed (typically because another test already installed one).
pub fn init_tracing(
    cli_log_level: LogLevelArg,
    cli_log_format: LogFormatArg,
    cli_color: ColorArg,
    quiet: bool,
    json: bool,
) -> AppResult<()> {
    // `YT_LOG_LEVEL` used to sit at the head of this chain. It was a
    // product setting expressed as an environment variable, which this
    // project forbids: an exported value follows every invocation in
    // the shell, including the ones the operator did not mean to
    // change, and it never shows up in `config list-keys`. The
    // replacement is the `log_level` key, which is discoverable and
    // scoped to the config file.
    //
    // `RUST_LOG` was read here until 2026-08-31 on the argument that it
    // is an ecosystem convention rather than a setting of this product.
    // The owner's rule for this project is stricter than that
    // distinction: runtime configuration is resolved from XDG through
    // the CLI, and the environment is consulted only by the discovery
    // verb that writes those paths. An inherited `RUST_LOG` also
    // silently overrode the `--log-level` flag, which is the kind of
    // invisible precedence the rule exists to remove. Set the
    // `log_level` key, or pass `--log-level`.
    let filter = if json {
        // Under `--json` the error envelope on stderr IS the structured
        // report of the failure, so a tracing line saying the same thing
        // in prose is a second, unparseable copy sharing one channel:
        // `cmd --json 2>&1 | jaq .` chokes on the timestamp before it
        // reaches the JSON. Silencing tracing here keeps the envelope on
        // stderr, which the project's CLI rule requires, and gives the
        // consumer a channel it can parse without filtering lines.
        //
        // This is not a `--quiet` change. The same rule says `--quiet`
        // suppresses stderr only down to ERROR, so without `--json` an
        // ERROR line still prints under `--quiet`, exactly as before.
        //
        // This arm is now UNCONDITIONAL: nothing re-enables tracing
        // under `--json`. Until 2026-08-31 an arm above read the
        // `log_level` key directly and ran first, so a key set in the
        // config file put prose back on the channel and broke
        // `cmd --json 2>&1 | jaq .` from configuration alone. Neither
        // the flag nor the key can do that any more, which is what
        // makes the clean-channel guarantee a guarantee.
        EnvFilter::new("off")
    } else if quiet {
        EnvFilter::new("error")
    } else {
        EnvFilter::new(cli_log_level.as_str())
    };

    // A pin held `chromiumoxide` and `chromiumoxide_fetcher` at `error`
    // here, because the CDP client logged a warning for every message
    // its `Message` enum did not recognise, and one of those lines broke
    // `--json` consumers by landing raw on stderr beside the envelope.
    //
    // Both crates left the dependency tree on 2026-09-04 with the two
    // browser providers, so the directives named targets that can no
    // longer emit anything. A filter that silences a crate nobody links
    // is not harmless: it reads as evidence that the noise still exists,
    // and the next person spends the afternoon looking for it.
    //
    // What survives the removal is the REASONING, and it is written down
    // because the trap is not obvious: an `EnvFilter` resolves by
    // SPECIFICITY and never by the order directives were added, so a
    // directive naming a target added after a base level of `off` does
    // not lower that target — it RE-ENABLES it. Any future pin against a
    // chatty dependency has to be skipped under `--json` for that reason,
    // exactly as this one was. Tracked as GAP-2026-122, which also
    // records that such a pin cannot be lifted from configuration:
    // `log_level` is validated against a five-variant enum and rejects
    // an `EnvFilter` directive before this function runs.

    let registry = tracing_subscriber::registry().with(filter);

    // Same reasoning as `log_level` above: `YT_LOG_FORMAT` was a
    // product setting hidden in the environment, and is now the
    // `log_format` key. That key reaches this function already merged
    // into `cli_log_format`, so reading it again here would be the
    // second, unordered resolution that GAP-2026-121 was about.
    let use_json = matches!(cli_log_format, LogFormatArg::Json);

    if use_json {
        let layer = fmt::layer()
            .json()
            .with_writer(std::io::stderr)
            .with_target(false)
            .with_current_span(false)
            .with_ansi(false);
        registry
            .with(layer)
            .try_init()
            .map_err(|e| AppError::Internal(format!("tracing init failed: {e}")))?;
    } else {
        let ansi = match cli_color {
            ColorArg::Never => false,
            ColorArg::Always => true,
            ColorArg::Auto => std::io::stderr().is_terminal(),
        };
        let layer = fmt::layer()
            .with_writer(std::io::stderr)
            .with_target(false)
            .with_ansi(ansi);
        registry
            .with(layer)
            .try_init()
            .map_err(|e| AppError::Internal(format!("tracing init failed: {e}")))?;
    }

    Ok(())
}

#[cfg(test)]
mod tests {

    /// GAP-2026-122. The `log_level` key does NOT accept an `EnvFilter`
    /// directive, and this test exists to keep that fact measured.
    ///
    /// What stood here until 2026-09-01 built an `EnvFilter` by hand,
    /// merged it, and asserted the directive survived. It passed, and
    /// it always would have: it proved the merge machinery works on an
    /// input it fabricated itself. It never asked the question that
    /// mattered — whether such an input can REACH the merge through the
    /// configuration surface. It cannot, and a green test asserting the
    /// opposite is what sustained the belief that it could.
    ///
    /// The surface is `LogLevelArg::from_config_str`, which validates
    /// against a closed set of five before `init_tracing` is ever
    /// reached. Asking it directly is the only way this test can fail
    /// on the day somebody widens the domain, which is exactly the day
    /// the promise would become true.
    #[test]
    fn the_config_surface_refuses_an_env_filter_directive() {
        use crate::cli::ConfigValue;

        for accepted in ["error", "warn", "info", "debug", "trace"] {
            assert!(
                crate::cli::LogLevelArg::from_config_str(accepted).is_ok(),
                "`{accepted}` is one of the five and must be accepted"
            );
        }

        // A real directive and plain garbage are indistinguishable here,
        // which is the measured behaviour and the reason the promise was
        // withdrawn from the documentation.
        for refused in ["some_crate=warn", "events=trace", "lixo==invalido"] {
            assert!(
                crate::cli::LogLevelArg::from_config_str(refused).is_err(),
                "`{refused}` must be refused; if this fails the key gained \
                 directive support and GAP-2026-122 can be closed"
            );
        }
    }
}