use crate::errors::AppError;
use std::io::{IsTerminal, Read};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
static NO_INPUT: AtomicBool = AtomicBool::new(false);
pub fn install_no_input(enabled: bool) {
NO_INPUT.store(enabled, Ordering::Release);
}
pub fn no_input() -> bool {
NO_INPUT.load(Ordering::Acquire)
}
pub fn read_stdin() -> Result<String, AppError> {
read_stdin_with_timeout(crate::runtime_config::stdin_timeout_secs())
}
pub fn read_stdin_with_timeout(secs: u64) -> Result<String, AppError> {
if no_input() {
return Err(AppError::Validation(
crate::i18n::validation::no_input_blocks_stdin(),
));
}
if std::io::stdin().is_terminal() {
return Err(AppError::Internal(anyhow::anyhow!(
"stdin is attached to a terminal; pipe data via stdin \
(e.g. `echo ... | sqlite-graphrag ...` or `... < file`) \
or use --body instead of the stdin body flag"
)));
}
let (tx, rx) = mpsc::channel::<std::io::Result<String>>();
thread::spawn(move || {
let mut buf = String::new();
let result = std::io::stdin().read_to_string(&mut buf).map(|_| buf);
let _ = tx.send(result);
});
match rx.recv_timeout(Duration::from_secs(secs)) {
Ok(Ok(buf)) => Ok(buf),
Ok(Err(e)) => Err(AppError::Io(e)),
Err(mpsc::RecvTimeoutError::Timeout) => Err(AppError::Internal(anyhow::anyhow!(
"stdin read timed out after {secs}s; pipe must close within timeout window"
))),
Err(mpsc::RecvTimeoutError::Disconnected) => Err(AppError::Internal(anyhow::anyhow!(
"stdin reader thread disconnected unexpectedly"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
static NO_INPUT_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn read_stdin_with_timeout_returns_internal_error_on_timeout() {
let _guard = NO_INPUT_GUARD.lock().unwrap_or_else(|e| e.into_inner());
install_no_input(false);
let start = Instant::now();
let result = read_stdin_with_timeout(1);
let elapsed = start.elapsed();
match result {
Err(AppError::Internal(e)) => {
let msg = e.to_string();
assert!(
msg.contains("timed out") || msg.contains("terminal"),
"unexpected internal error: {msg}"
);
assert!(elapsed.as_secs_f64() < 2.5);
}
Ok(_) | Err(AppError::Io(_)) => {
}
Err(other) => unreachable!("stdin test: expected Internal/Io, got {other:?}"),
}
}
#[test]
fn no_input_refuses_before_the_read_is_attempted() {
let _guard = NO_INPUT_GUARD.lock().unwrap_or_else(|e| e.into_inner());
install_no_input(true);
let start = Instant::now();
let result = read_stdin_with_timeout(600);
let elapsed = start.elapsed();
install_no_input(false);
match result {
Err(AppError::Validation(msg)) => {
assert!(msg.contains("--no-input"), "unexpected message: {msg}");
}
other => unreachable!("expected Validation under --no-input, got {other:?}"),
}
assert!(
elapsed.as_secs_f64() < 1.0,
"refusal must precede the read, took {elapsed:?}"
);
}
#[test]
fn install_no_input_round_trips() {
let _guard = NO_INPUT_GUARD.lock().unwrap_or_else(|e| e.into_inner());
install_no_input(true);
assert!(no_input());
install_no_input(false);
assert!(!no_input());
}
}