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.
//! `--print-schema`: emit the JSON Schema of every output surface.
//!
//! The schemas are compiled into the binary from `docs/schemas/`, so the
//! document an agent reads at run time is byte-identical to the one the
//! repository ships and cannot drift from it.

use crate::error::{AppError, AppResult};
use serde_json::Value;
use std::process::ExitCode;

/// One compiled schema: its stable id and its document text.
///
/// `pub(crate)` so the envelope conformance tests read the very bytes
/// the binary publishes instead of re-opening the file at test time,
/// which is what keeps the test honest about drift.
pub(crate) const SCHEMAS: [(&str, &str); 6] = [
    (
        "success-envelope",
        include_str!("../../docs/schemas/success-envelope.schema.json"),
    ),
    (
        "error-envelope",
        include_str!("../../docs/schemas/error-envelope.schema.json"),
    ),
    (
        "dry-run-envelope",
        include_str!("../../docs/schemas/dry-run-envelope.schema.json"),
    ),
    (
        "agent-surface",
        include_str!("../../docs/schemas/agent-surface.schema.json"),
    ),
    (
        "config-envelope",
        include_str!("../../docs/schemas/config-envelope.schema.json"),
    ),
    (
        "caption-track",
        include_str!("../../docs/schemas/caption-track.schema.json"),
    ),
];

/// Write the schema catalogue to stdout as a single JSON object.
///
/// The shape is `{"schemas": [{"id": …, "schema": …}, …]}`, so a caller
/// can pick one out by id without a second invocation.
///
/// # Errors
///
/// - [`AppError::Serde`] when a compiled schema is not valid JSON, which
///   would mean the repository shipped a broken document.
/// - [`AppError::Io`] on stdout write failure.
pub async fn print_schema() -> AppResult<ExitCode> {
    let mut entries = Vec::with_capacity(SCHEMAS.len());
    for (id, text) in SCHEMAS {
        let document: Value = serde_json::from_str(text).map_err(AppError::Serde)?;
        entries.push(serde_json::json!({ "id": id, "schema": document }));
    }
    let mut out = serde_json::to_string(&serde_json::json!({ "schemas": entries }))
        .map_err(AppError::Serde)?;
    out.push('\n');
    crate::io::write_subtitle_to_stdout(out.as_bytes()).await?;
    Ok(ExitCode::SUCCESS)
}

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

    #[test]
    fn every_compiled_schema_parses_and_declares_its_title() {
        for (id, text) in SCHEMAS {
            let document: Value =
                serde_json::from_str(text).unwrap_or_else(|e| panic!("{id} must parse: {e}"));
            assert_eq!(
                document.get("title").and_then(Value::as_str),
                Some(id),
                "{id} must declare a matching title"
            );
            assert!(
                document.get("$schema").is_some(),
                "{id} must declare its meta-schema"
            );
        }
    }

    /// The catalogue must cover every schema the repository ships.
    ///
    /// A document that exists in `docs/schemas/` but is missing from
    /// `SCHEMAS` is never compiled into the binary, so `--print-schema`
    /// silently omits a contract the repository publishes. That is
    /// exactly how `caption-track.schema.json` stayed orphaned; this
    /// test is what stops the next one.
    #[test]
    fn the_catalogue_covers_every_shipped_schema() {
        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/schemas");
        let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
            .unwrap_or_else(|e| panic!("{} must be readable: {e}", dir.display()))
            .filter_map(|entry| {
                let name = entry.ok()?.file_name().to_string_lossy().into_owned();
                // Only the schema documents; the directory also holds
                // its own README in two languages.
                name.strip_suffix(".schema.json").map(str::to_string)
            })
            .collect();
        on_disk.sort_unstable();

        let mut catalogued: Vec<String> = SCHEMAS.iter().map(|(id, _)| (*id).to_string()).collect();
        catalogued.sort_unstable();

        assert_eq!(
            on_disk, catalogued,
            "docs/schemas/ and the compiled catalogue must name the same documents"
        );
    }

    /// Every published `$id` must share one base and name its own file.
    ///
    /// Two bases for one catalogue give a consumer that resolves `$id`
    /// by base two namespaces for the same contracts. This gate lives
    /// beside the catalogue instead of in the integration tests because
    /// it reads the COMPILED documents, which is exactly what the
    /// consumer receives from `--print-schema`.
    #[test]
    fn every_schema_id_shares_the_published_base() {
        const ID_BASE: &str = "https://docs.rs/youtube-legend-cli/schemas/";
        // A floor, because an empty catalogue would pass this test while
        // measuring nothing at all (GAP-2026-157).
        const MINIMUM_SCHEMAS: usize = 6;

        let mut checked = 0usize;
        for (id, text) in SCHEMAS {
            let document: Value =
                serde_json::from_str(text).unwrap_or_else(|e| panic!("{id} must parse: {e}"));
            let schema_id = document
                .get("$id")
                .and_then(Value::as_str)
                .unwrap_or_else(|| panic!("{id} must declare a string $id"));
            assert_eq!(
                schema_id,
                format!("{ID_BASE}{id}.schema.json"),
                "{id} must publish its $id under {ID_BASE} with a matching file name"
            );
            checked += 1;
        }

        assert!(
            checked >= MINIMUM_SCHEMAS,
            "the catalogue must carry at least {MINIMUM_SCHEMAS} schemas, found {checked}"
        );
    }

    #[test]
    fn schema_ids_are_unique() {
        let mut ids: Vec<&str> = SCHEMAS.iter().map(|(id, _)| *id).collect();
        let total = ids.len();
        ids.sort_unstable();
        ids.dedup();
        assert_eq!(ids.len(), total);
    }
}