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.
//! Stdin / stdout helpers for the CLI.

use crate::error::{AppError, AppResult};
use std::io::{IsTerminal, Write};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};

/// Hard cap on stdin payload size, enforced before allocation. Matches
/// the subtitle ceiling in `parse::srt_to_text` so a malicious or
/// accidental large stream cannot OOM the process.
///
/// Compiled default behind `input.max_stdin_bytes`.
const DEFAULT_MAX_STDIN_BYTES: usize = 50 * 1024 * 1024;

/// Hard cap on stdin payload size, as the operator configured it.
///
/// Resolves `input.max_stdin_bytes`.
fn max_stdin_bytes() -> usize {
    crate::config::tuning_usize_in_range(
        "input.max_stdin_bytes",
        DEFAULT_MAX_STDIN_BYTES,
        1_024,
        1_073_741_824,
    )
}

/// Return `true` when stdin is attached to a TTY (interactive terminal).
pub fn is_stdin_tty() -> bool {
    std::io::stdin().is_terminal()
}

/// Return `true` when stdout is attached to a TTY.
pub fn is_stdout_tty() -> bool {
    std::io::stdout().is_terminal()
}

/// Refuse stdin declaratively when `--no-input` is engaged.
///
/// The refusal happens before the handle is touched, so an agent that
/// forgot to redirect stdin fails immediately instead of blocking on a
/// read that will never complete.
///
/// # Errors
///
/// Returns [`AppError::InvalidUsage`] when `no_input` is `true`.
fn reject_stdin_when_no_input(no_input: bool) -> AppResult<()> {
    if no_input {
        return Err(AppError::InvalidUsage(
            "--no-input refuses stdin; pass the url as a positional argument".to_string(),
        ));
    }
    Ok(())
}

/// Read exactly one URL from stdin.
///
/// # Errors
///
/// - [`AppError::InvalidUsage`] when `no_input` is set, or when stdin is
///   a TTY (the caller must pass the URL as a positional argument
///   instead).
/// - [`AppError::StdinEmpty`] when stdin is closed without data or contains
///   only whitespace.
/// - [`AppError::SubtitleTooLarge`] when the first line exceeds
///   `input.max_stdin_bytes`.
/// - [`AppError::InvalidInput`] on I/O errors.
pub async fn read_url_from_stdin(no_input: bool) -> AppResult<String> {
    reject_stdin_when_no_input(no_input)?;
    if is_stdin_tty() {
        return Err(AppError::InvalidUsage(
            "stdin is a tty; pass the url as a positional argument".to_string(),
        ));
    }

    let stdin = tokio::io::stdin();
    let mut reader = BufReader::new(stdin);
    let mut line = String::new();
    let n = reader
        .read_line(&mut line)
        .await
        .map_err(|e| AppError::InvalidInput(format!("reading stdin: {e}")))?;

    if n == 0 {
        return Err(AppError::StdinEmpty);
    }

    if line.len() > max_stdin_bytes() {
        return Err(AppError::SubtitleTooLarge(line.len()));
    }

    let url = line.trim().to_string();
    if url.is_empty() {
        return Err(AppError::StdinEmpty);
    }

    Ok(url)
}

/// Parse a block of text into a list of URLs, one per line.
///
/// Skips blank lines and lines whose first non-whitespace character is
/// `#` (treated as comments). The trimmed contents of every kept line
/// are returned in their original order, preserving the input shape so
/// the caller can pass it on unchanged.
///
/// This pure helper is split out from [`read_urls_from_stdin`] so the
/// shell-pipeline contract (blank + comment skipping) is unit-testable
/// without mocking the OS stdin handle.
pub fn parse_url_lines(text: &str) -> Vec<String> {
    text.lines()
        .map(str::trim)
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
        .map(str::to_string)
        .collect()
}

/// Read multiple URLs from stdin, one per line, ignoring blank lines and
/// comment lines that start with `#`.
///
/// # Errors
///
/// - [`AppError::InvalidUsage`] when `no_input` is set or stdin is a TTY.
/// - [`AppError::StdinEmpty`] when no usable URL was found.
/// - [`AppError::SubtitleTooLarge`] when the stream exceeds
///   `input.max_stdin_bytes`.
/// - [`AppError::InvalidInput`] on I/O errors.
pub async fn read_urls_from_stdin(no_input: bool) -> AppResult<Vec<String>> {
    reject_stdin_when_no_input(no_input)?;
    if is_stdin_tty() {
        return Err(AppError::InvalidUsage(
            "stdin is a tty; pass urls as a positional argument or redirect a file".to_string(),
        ));
    }

    let stdin = tokio::io::stdin();
    let reader = BufReader::new(stdin);
    let mut buffer = String::new();
    use tokio::io::AsyncReadExt;
    let limit = max_stdin_bytes();
    // Bounded read: take at most `limit + 1` bytes. The `+ 1` is
    // deliberate. Reading exactly `limit` cannot tell a stream that
    // ends at the ceiling apart from one the `take` cut off there, so
    // the extra byte is what turns a silent truncation into the
    // explicit `SubtitleTooLarge` rejection below, and it costs one
    // byte of memory rather than the unbounded stream itself.
    let cap = (limit + 1) as u64;
    let n = reader
        .take(cap)
        .read_to_string(&mut buffer)
        .await
        .map_err(|e| AppError::InvalidInput(format!("reading stdin: {e}")))?;

    if n as usize > limit {
        return Err(AppError::SubtitleTooLarge(n as usize));
    }

    let urls = parse_url_lines(&buffer);
    if urls.is_empty() {
        return Err(AppError::StdinEmpty);
    }

    Ok(urls)
}

/// Write the subtitle body to stdout and flush.
///
/// The [`std::io::ErrorKind`] is preserved rather than flattened into
/// [`std::io::ErrorKind::Other`]. That distinction is load-bearing:
/// `main` maps a [`std::io::ErrorKind::BrokenPipe`] to exit code 141
/// (`128 + SIGPIPE`), which is what a reader closing the pipe early —
/// `youtube-legend-cli … | head -1` — must produce. Flattening the kind
/// made every such run look like a generic I/O failure with exit 70.
///
/// # Errors
///
/// - [`AppError::Io`] on write or flush failure.
pub async fn write_subtitle_to_stdout(content: &[u8]) -> AppResult<()> {
    let mut stdout = tokio::io::stdout();
    stdout.write_all(content).await.map_err(AppError::Io)?;
    stdout.flush().await.map_err(AppError::Io)?;
    Ok(())
}

/// `true` when `err` is a closed-pipe failure on stdout.
///
/// Both the direct kind and a wrapped inner error are inspected, because
/// an [`AppError::Io`] may carry either.
#[must_use]
pub fn is_broken_pipe(err: &AppError) -> bool {
    let AppError::Io(io_err) = err else {
        return false;
    };
    if io_err.kind() == std::io::ErrorKind::BrokenPipe {
        return true;
    }
    io_err
        .get_ref()
        .and_then(|inner| inner.downcast_ref::<std::io::Error>())
        .is_some_and(|inner| inner.kind() == std::io::ErrorKind::BrokenPipe)
}

/// Write a single message to stderr (blocking, best-effort).
///
/// # Errors
///
/// - [`AppError::Io`] on write or flush failure.
pub fn write_to_stderr(msg: &str) -> AppResult<()> {
    let mut stderr = std::io::stderr();
    stderr.write_all(msg.as_bytes()).map_err(AppError::Io)?;
    stderr.flush().map_err(AppError::Io)?;
    Ok(())
}

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

    #[test]
    fn parse_url_lines_empty() {
        assert!(parse_url_lines("").is_empty());
        assert!(parse_url_lines("\n\n   \n").is_empty());
    }

    #[test]
    fn parse_url_lines_only_comments() {
        let input = "# a comment\n#another comment\n   # indented\n";
        assert!(parse_url_lines(input).is_empty());
    }

    #[test]
    fn parse_url_lines_mixed() {
        let input = "\
            https://example.com/a\n\
            \n\
            # this is a comment\n\
            https://example.com/b\n\
              \n\
            https://example.com/c\n";
        let urls = parse_url_lines(input);
        assert_eq!(
            urls,
            vec![
                "https://example.com/a".to_string(),
                "https://example.com/b".to_string(),
                "https://example.com/c".to_string(),
            ]
        );
    }

    #[test]
    fn parse_url_lines_trims_whitespace() {
        let input = "  https://example.com/trimmed  \n";
        let urls = parse_url_lines(input);
        assert_eq!(urls, vec!["https://example.com/trimmed".to_string()]);
    }

    #[test]
    fn parse_url_lines_preserves_order() {
        let input = "z://x\n# comment\na://b\nm://n\n";
        let urls = parse_url_lines(input);
        assert_eq!(
            urls,
            vec![
                "z://x".to_string(),
                "a://b".to_string(),
                "m://n".to_string(),
            ]
        );
    }

    #[test]
    fn read_urls_from_stdin_skips_blank_and_comment_lines() {
        // White-box check that the public helper routes through parse_url_lines.
        // We exercise the pure parser here because reading from the real stdin
        // is not safe inside a unit test process; the public async path is
        // covered by  integration tests that pipe real fixtures.
        let input = "https://a\n\n# comment\nhttps://b\n";
        assert_eq!(
            parse_url_lines(input),
            vec!["https://a".to_string(), "https://b".to_string()],
        );
    }

    #[test]
    fn no_input_refuses_stdin_before_touching_the_handle() {
        let err = reject_stdin_when_no_input(true).unwrap_err();
        assert!(matches!(err, AppError::InvalidUsage(_)));
        assert!(err.to_string().contains("--no-input"));
        assert!(reject_stdin_when_no_input(false).is_ok());
    }

    #[test]
    fn broken_pipe_is_recognised_through_app_error() {
        let err = AppError::Io(std::io::Error::new(
            std::io::ErrorKind::BrokenPipe,
            "closed",
        ));
        assert!(is_broken_pipe(&err));
    }

    #[test]
    fn a_plain_io_error_is_not_a_broken_pipe() {
        let err = AppError::Io(std::io::Error::other("something else"));
        assert!(!is_broken_pipe(&err));
        assert!(!is_broken_pipe(&AppError::StdinEmpty));
    }

    #[test]
    fn broken_pipe_survives_being_wrapped() {
        // `std::io::Error::other` boxes its argument; the wrapper must
        // still be recognised, otherwise a pipe closed inside a nested
        // writer would exit 70 instead of 141.
        let inner = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "closed");
        let err = AppError::Io(std::io::Error::other(inner));
        assert!(is_broken_pipe(&err));
    }

    #[test]
    fn stdout_write_does_not_interleave_bytes() {
        // Validates that taking the Stdout lock and writing two distinct
        // patterns (0xAA then 0x55) does not panic and completes a flush.
        // tokio::io::stdout is already a single handle, so bytes from a
        // single task are never interleaved. The real correctness proof
        // is exercised at runtime by integration tests; this unit test
        // exists as living evidence that the call path is reachable
        // and that the lock API is the one used by production code.
        use std::io::{stdout, Write};
        let mut handle = stdout().lock();
        let chunk1 = vec![0xAA; 16];
        let chunk2 = vec![0x55; 16];
        handle.write_all(&chunk1).expect("write chunk1");
        handle.write_all(&chunk2).expect("write chunk2");
        handle.flush().expect("flush");
    }
}