use crate::error::{AppError, AppResult};
use std::io::{IsTerminal, Write};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
const MAX_STDIN_BYTES: usize = 50 * 1024 * 1024;
pub fn is_stdin_tty() -> bool {
std::io::stdin().is_terminal()
}
pub fn is_stdout_tty() -> bool {
std::io::stdout().is_terminal()
}
pub async fn read_url_from_stdin() -> AppResult<String> {
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() -> AppResult<Vec<String>> {
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 cap = (MAX_STDIN_BYTES + 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 > MAX_STDIN_BYTES {
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(|e| AppError::Io(std::io::Error::other(e.to_string())))?;
stdout
.flush()
.await
.map_err(|e| AppError::Io(std::io::Error::other(e.to_string())))?;
Ok(())
}
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 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");
}
}