use std::{fs, io::Write as _, path::Path, path::PathBuf};
use anyhow::{Result, anyhow, bail};
use crate::cli::OutputFormat;
use crate::clipboard;
use crate::input_spec::split_explicit_format;
pub(crate) fn parse_output_path_spec(raw: Option<&Path>) -> (Option<PathBuf>, Option<String>) {
let Some(raw_path) = raw else {
return (None, None);
};
let path_str = raw_path.to_string_lossy();
let (path_part, fmt) = split_explicit_format(&path_str);
if let Some(fmt) = fmt {
(
Some(PathBuf::from(path_part)),
Some(fmt.to_ascii_lowercase()),
)
} else {
(Some(raw_path.to_path_buf()), None)
}
}
pub(crate) fn write_query_result(
result: &query_forge::QueryResult,
output_path: Option<&Path>,
explicit_format: Option<OutputFormat>,
path_format_hint: Option<&str>,
) -> Result<()> {
if let Some(path) = output_path {
if clipboard::is_clipboard_path(path) && path.extension().is_some() {
let ext = path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_ascii_lowercase();
bail!(
"clipboard output must not use a file extension for format selection; \
use '@clipboard|{ext}' instead"
);
}
}
match resolve_output_format(output_path, explicit_format, path_format_hint)? {
OutputFormat::Table | OutputFormat::Text => {
write_streamed_text_output(output_path, |writer| {
query_forge::write_text(result, writer)
})?;
}
OutputFormat::Csv => {
write_streamed_text_output(output_path, |writer| {
query_forge::write_csv(result, writer)
})?;
}
OutputFormat::Json => {
let rendered = query_forge::render_json(result);
write_or_print(&rendered, output_path)?;
}
OutputFormat::Jsonl => {
write_streamed_text_output(output_path, |writer| {
query_forge::write_jsonl(result, writer)
})?;
}
OutputFormat::Markdown => {
let rendered = query_forge::render_markdown(result);
write_or_print(&rendered, output_path)?;
}
OutputFormat::Html => {
let rendered = query_forge::render_html(result);
write_or_print(&rendered, output_path)?;
}
OutputFormat::Xml => {
let rendered = query_forge::render_xml(result);
write_or_print(&rendered, output_path)?;
}
OutputFormat::Xlsx => {
let output_path = output_path
.ok_or_else(|| anyhow!("--output is required when --format xlsx is selected"))?;
if clipboard::is_clipboard_path(output_path) {
bail!("clipboard output does not support xlsx; write to a file instead");
}
query_forge::write_xlsx(result, output_path)?;
}
OutputFormat::Feather => {
let output_path = output_path
.ok_or_else(|| anyhow!("--output is required when --format feather is selected"))?;
if clipboard::is_clipboard_path(output_path) {
bail!("clipboard output does not support feather; write to a file instead");
}
query_forge::write_feather(result, output_path)?;
}
OutputFormat::Parquet => {
let output_path = output_path
.ok_or_else(|| anyhow!("--output is required when --format parquet is selected"))?;
if clipboard::is_clipboard_path(output_path) {
bail!("clipboard output does not support parquet; write to a file instead");
}
query_forge::write_parquet(result, output_path)?;
}
}
Ok(())
}
fn write_streamed_text_output<F>(output_path: Option<&Path>, mut render: F) -> Result<()>
where
F: FnMut(&mut dyn std::io::Write) -> std::io::Result<()>,
{
if let Some(output_path) = output_path {
if clipboard::is_clipboard_path(output_path) {
clipboard::ensure_supported_clipboard_output(output_path)?;
let mut rendered = Vec::new();
render(&mut rendered)?;
let rendered = String::from_utf8(rendered)
.map_err(|error| anyhow!("rendered output was not valid UTF-8: {error}"))?;
clipboard::write_text(output_path, &rendered)?;
} else {
let file = fs::File::create(output_path)?;
let mut writer = std::io::BufWriter::new(file);
render(&mut writer)?;
writer.flush()?;
}
} else {
let stdout = std::io::stdout();
let mut stdout_lock = stdout.lock();
render(&mut stdout_lock)?;
stdout_lock.write_all(b"\n")?;
stdout_lock.flush()?;
}
Ok(())
}
fn write_or_print(rendered: &str, output_path: Option<&Path>) -> Result<()> {
if let Some(output_path) = output_path {
if clipboard::is_clipboard_path(output_path) {
clipboard::ensure_supported_clipboard_output(output_path)?;
clipboard::write_text(output_path, rendered)?;
} else {
fs::write(output_path, rendered)?;
}
} else {
println!("{rendered}");
}
Ok(())
}
pub(crate) fn resolve_output_format(
output: Option<&Path>,
format: Option<OutputFormat>,
path_format_hint: Option<&str>,
) -> Result<OutputFormat> {
if let Some(format) = format {
if matches!(
format,
OutputFormat::Xlsx | OutputFormat::Feather | OutputFormat::Parquet
) && output.is_none()
{
let name = if matches!(format, OutputFormat::Xlsx) {
"xlsx"
} else if matches!(format, OutputFormat::Feather) {
"feather"
} else {
"parquet"
};
bail!("--output is required when --format {name} is selected");
}
return Ok(format);
}
let ext = path_format_hint
.or_else(|| output.and_then(|p| p.extension()).and_then(|e| e.to_str()))
.unwrap_or("");
if ext.eq_ignore_ascii_case("xlsx") {
return Ok(OutputFormat::Xlsx);
}
if ext.eq_ignore_ascii_case("feather") {
return Ok(OutputFormat::Feather);
}
if ext.eq_ignore_ascii_case("parquet") {
return Ok(OutputFormat::Parquet);
}
if ext.eq_ignore_ascii_case("csv") {
return Ok(OutputFormat::Csv);
}
if ext.eq_ignore_ascii_case("json") {
return Ok(OutputFormat::Json);
}
if ext.eq_ignore_ascii_case("jsonl") || ext.eq_ignore_ascii_case("ndjson") {
return Ok(OutputFormat::Jsonl);
}
if ext.eq_ignore_ascii_case("md") || ext.eq_ignore_ascii_case("markdown") {
return Ok(OutputFormat::Markdown);
}
if ext.eq_ignore_ascii_case("xml") {
return Ok(OutputFormat::Xml);
}
if ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm") {
return Ok(OutputFormat::Html);
}
Ok(OutputFormat::Table)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use crate::cli::OutputFormat;
use crate::clipboard;
use super::{parse_output_path_spec, resolve_output_format, write_query_result};
#[test]
fn infers_output_format_from_extension() {
assert!(matches!(
resolve_output_format(Some(Path::new("result.xlsx")), None, None).expect("xlsx format"),
OutputFormat::Xlsx
));
assert!(matches!(
resolve_output_format(Some(Path::new("result.feather")), None, None)
.expect("feather format"),
OutputFormat::Feather
));
assert!(matches!(
resolve_output_format(Some(Path::new("result.parquet")), None, None)
.expect("parquet format"),
OutputFormat::Parquet
));
assert!(matches!(
resolve_output_format(Some(Path::new("result.csv")), None, None).expect("csv format"),
OutputFormat::Csv
));
assert!(matches!(
resolve_output_format(Some(Path::new("result.jsonl")), None, None)
.expect("jsonl format"),
OutputFormat::Jsonl
));
assert!(matches!(
resolve_output_format(Some(Path::new("result.json")), None, None).expect("json format"),
OutputFormat::Json
));
assert!(matches!(
resolve_output_format(Some(Path::new("result.md")), None, None).expect("md format"),
OutputFormat::Markdown
));
assert!(matches!(
resolve_output_format(Some(Path::new("result.html")), None, None).expect("html format"),
OutputFormat::Html
));
assert!(matches!(
resolve_output_format(Some(Path::new("result.htm")), None, None).expect("htm format"),
OutputFormat::Html
));
assert!(matches!(
resolve_output_format(Some(Path::new("result.txt")), None, None).expect("text format"),
OutputFormat::Table
));
assert!(matches!(
resolve_output_format(None, None, None).expect("default format"),
OutputFormat::Table
));
}
#[test]
fn explicit_path_hint_overrides_extension() {
assert!(matches!(
resolve_output_format(Some(Path::new("result.txt")), None, Some("json"))
.expect("json hint overrides txt extension"),
OutputFormat::Json
));
assert!(matches!(
resolve_output_format(Some(Path::new("result.txt")), None, Some("CSV"))
.expect("CSV hint case-insensitive"),
OutputFormat::Csv
));
}
#[test]
fn explicit_format_flag_overrides_path_hint() {
assert!(matches!(
resolve_output_format(
Some(Path::new("result.txt")),
Some(OutputFormat::Json),
Some("csv")
)
.expect("--format wins over |type hint"),
OutputFormat::Json
));
}
#[test]
fn requires_output_path_for_feather_format() {
let error = resolve_output_format(None, Some(OutputFormat::Feather), None)
.expect_err("feather format should require output");
assert!(
error
.to_string()
.contains("--output is required when --format feather is selected")
);
}
#[test]
fn parse_output_path_spec_strips_pipe_type() {
let (path, hint) = parse_output_path_spec(Some(Path::new("result.txt|json")));
assert_eq!(path.as_deref().and_then(|p| p.to_str()), Some("result.txt"));
assert_eq!(hint.as_deref(), Some("json"));
}
#[test]
fn parse_output_path_spec_no_pipe_returns_original() {
let (path, hint) = parse_output_path_spec(Some(Path::new("result.csv")));
assert_eq!(path.as_deref().and_then(|p| p.to_str()), Some("result.csv"));
assert_eq!(hint, None);
}
#[test]
fn parse_output_path_spec_none_returns_none() {
let (path, hint) = parse_output_path_spec(None);
assert!(path.is_none());
assert!(hint.is_none());
}
#[test]
fn writes_text_output_to_clipboard() {
clipboard::set_test_text("");
let result = query_forge::QueryResult {
columns: vec!["product".to_owned()],
rows: vec![vec![query_forge::QueryValue::Text("Keyboard".to_owned())]],
};
write_query_result(
&result,
Some(Path::new("@clipboard")),
Some(OutputFormat::Csv),
None,
)
.expect("clipboard output should succeed");
let clipboard_text = clipboard::get_test_text();
assert!(clipboard_text.contains("product"));
assert!(clipboard_text.contains("Keyboard"));
}
#[test]
fn clipboard_with_extension_is_rejected_on_output() {
let result = query_forge::QueryResult {
columns: vec!["product".to_owned()],
rows: vec![vec![query_forge::QueryValue::Text("Keyboard".to_owned())]],
};
let error = write_query_result(
&result,
Some(Path::new("@clipboard.csv")),
Some(OutputFormat::Csv),
None,
)
.expect_err("clipboard extension syntax should be rejected");
assert!(error.to_string().contains("@clipboard|csv"));
}
#[test]
fn rejects_binary_output_to_clipboard() {
let result = query_forge::QueryResult {
columns: vec!["product".to_owned()],
rows: vec![vec![query_forge::QueryValue::Text("Keyboard".to_owned())]],
};
let error = write_query_result(
&result,
Some(Path::new("@clipboard")),
None,
Some("parquet"),
)
.expect_err("clipboard parquet output should fail");
assert!(
error
.to_string()
.contains("clipboard output does not support parquet")
);
}
}