use crate::error::{AppError, AppResult};
use std::io::{IsTerminal, Write};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
const DEFAULT_MAX_STDIN_BYTES: usize = 50 * 1024 * 1024;
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,
)
}
pub fn is_stdin_tty() -> bool {
std::io::stdin().is_terminal()
}
pub fn is_stdout_tty() -> bool {
std::io::stdout().is_terminal()
}
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(())
}
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)
}
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()
}
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();
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)
}
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(())
}
#[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)
}
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() {
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() {
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() {
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");
}
}