From 55fffefe1bd41a8e2211427c70f8882275967c90 Mon Sep 17 00:00:00 2001 From: Giuseppe Mazzotta Date: Wed, 21 Jan 2026 13:49:25 +0100 Subject: [PATCH] fix: make sure that only parseable output is on stdout Better detection of interactive terminals --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 15 +++++++++++---- src/query.rs | 7 +++---- src/utils.rs | 14 +++++++------- tests/cli.rs | 30 ++++++++++++++++++++++++++++++ 6 files changed, 53 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea545d1..77a3582 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,7 +233,7 @@ checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" [[package]] name = "fb" -version = "0.2.1" +version = "0.2.2" dependencies = [ "dirs", "gumdrop", diff --git a/Cargo.toml b/Cargo.toml index 477d648..1137f45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fb" -version = "0.2.1" +version = "0.2.2" edition = "2021" license = "Apache-2.0" diff --git a/src/main.rs b/src/main.rs index 70edc9b..c07d990 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use rustyline::{config::Configurer, error::ReadlineError, Cmd, DefaultEditor, EventHandler, KeyCode, KeyEvent, Modifiers}; +use std::io::IsTerminal; mod args; mod auth; @@ -41,24 +42,30 @@ async fn main() -> Result<(), Box> { return Ok(()); } + let is_tty = std::io::stdout().is_terminal() && std::io::stdin().is_terminal(); + let mut rl = DefaultEditor::new()?; let history_path = history_path()?; rl.set_max_history_size(10_000)?; if rl.load_history(&history_path).is_err() { - eprintln!("No previous history"); + if is_tty { + eprintln!("No previous history"); + } } else if context.args.verbose { eprintln!("Loaded history from {:?} and set max_history_size = 10'000", history_path) } rl.bind_sequence(KeyEvent(KeyCode::Char('o'), Modifiers::CTRL), EventHandler::Simple(Cmd::Newline)); - if !context.args.concise { + if is_tty { eprintln!("Press Ctrl+D to exit."); } - let mut buffer: String = String::new(); loop { - let prompt = if !buffer.trim_start().is_empty() { + let prompt = if !is_tty { + // No prompt when stdout is not a terminal (e.g., piped) + "" + } else if !buffer.trim_start().is_empty() { // Continuation prompt (PROMPT2) if let Some(custom_prompt) = &context.prompt2 { custom_prompt.as_str() diff --git a/src/query.rs b/src/query.rs index a7719d5..efc7a9a 100644 --- a/src/query.rs +++ b/src/query.rs @@ -203,12 +203,11 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< if !context.args.concise { let elapsed = format!("{:?}", elapsed / 100000 * 100000); - print!("Time: {elapsed}\n"); + eprintln!("Time: {elapsed}"); if let Some(request_id) = maybe_request_id { - print!("Request Id: {request_id}\n"); + eprintln!("Request Id: {request_id}"); } - // on stdout, on purpose - println!("") + eprintln!("") } } }; diff --git a/src/utils.rs b/src/utils.rs index cf90baf..0a7c785 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,5 @@ use std::fs; -use std::io::stdout; +use std::io::stderr; use std::io::Write; use std::path::PathBuf; use std::time::SystemTime; @@ -46,20 +46,20 @@ pub fn format_remaining_time(time: SystemTime, maybe_more: String) -> Result { - print!("\x08 \x08"); - let _ = stdout().flush(); + eprint!("\x08 \x08"); + let _ = stderr().flush(); return; } _ = tokio::time::sleep(std::time::Duration::from_millis(200)) => { - print!("\x08{}", spins[it]); + eprint!("\x08{}", spins[it]); it = (it + 1) % spins.len(); - let _ = stdout().flush(); + let _ = stderr().flush(); } }; } diff --git a/tests/cli.rs b/tests/cli.rs index 07a366f..5ec85c7 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,5 +1,6 @@ use std::io::Write; use std::process::Command; +use serde_json; fn run_fb(args: &[&str]) -> (bool, String, String) { let output = Command::new(env!("CARGO_BIN_EXE_fb")) @@ -205,3 +206,32 @@ fn test_exiting() { assert_eq!(lines.next().unwrap(), "42"); lines.next(); } + +#[test] +fn test_json_output_fully_parseable() { + // Test that JSON output on stdout is fully parseable, even when stats are printed to stderr + let (success, stdout, stderr) = run_fb(&["--core", "-f", "JSONLines_Compact", "SELECT 42 AS value"]); + + assert!(success); + + // stderr should contain stats + assert!(stderr.contains("Time:"), "stderr should contain Time:"); + + // stdout should be valid JSON Lines - each non-empty line should be valid JSON + let trimmed_stdout = stdout.trim(); + assert!(!trimmed_stdout.is_empty(), "stdout should not be empty"); + + for line in trimmed_stdout.lines() { + if line.trim().is_empty() { + continue; + } + let parsed: Result = serde_json::from_str(line); + assert!( + parsed.is_ok(), + "Each line of stdout should be valid JSON, but got parse error: {:?}\nline was: {}\nfull stdout was: {}", + parsed.err(), + line, + stdout + ); + } +}