use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use crate::cli::drive::format::{output_as, sanitize_for_terminal, OutputFormat};
use crate::drive::client::DriveClient;
use crate::drive::sheets::api::{SheetsApi, ValueRenderOption};
use crate::drive::sheets::client::SheetsClient;
use crate::drive::sheets::read::{read, ReadOptions, ReadOutcome};
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub enum RenderArg {
#[default]
Formatted,
Unformatted,
Formula,
}
impl From<RenderArg> for ValueRenderOption {
fn from(arg: RenderArg) -> Self {
match arg {
RenderArg::Formatted => Self::Formatted,
RenderArg::Unformatted => Self::Unformatted,
RenderArg::Formula => Self::Formula,
}
}
}
#[derive(Parser)]
pub struct ReadCommand {
pub spreadsheet_id: String,
#[arg(long, value_name = "A1")]
pub range: Option<String>,
#[arg(long, value_name = "NAME")]
pub sheet: Option<String>,
#[arg(long, value_enum, default_value_t = RenderArg::Formatted)]
pub render: RenderArg,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl ReadCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let sheets = SheetsClient::from_drive_client(client)?;
let opts = ReadOptions {
spreadsheet_id: self.spreadsheet_id,
range: self.range,
sheet: self.sheet,
render: self.render.into(),
};
run_read(&sheets, &opts, &self.output).await
}
}
async fn run_read(client: &SheetsClient, opts: &ReadOptions, output: &OutputFormat) -> Result<()> {
let outcome = read(&SheetsApi::new(client), opts).await?;
if output_as(&outcome, output)? {
return Ok(());
}
let stdout = std::io::stdout();
let mut handle = stdout.lock();
render_csv(&outcome, &mut handle)
}
fn render_csv(outcome: &ReadOutcome, out: &mut dyn std::io::Write) -> Result<()> {
let multi = outcome.sheets.len() > 1;
for (idx, sheet) in outcome.sheets.iter().enumerate() {
if multi {
if idx > 0 {
writeln!(out).context("Failed to write CSV separator")?;
}
let title = sheet.title.as_deref().unwrap_or("(untitled)");
writeln!(out, "# {}", sanitize_for_terminal(title))
.context("Failed to write CSV sheet header")?;
}
write_sheet_csv(&sheet.values, out)?;
}
Ok(())
}
fn write_sheet_csv(values: &[Vec<serde_json::Value>], out: &mut dyn std::io::Write) -> Result<()> {
let width = values.iter().map(Vec::len).max().unwrap_or(0);
if width == 0 {
return Ok(());
}
let mut writer = csv::WriterBuilder::new().from_writer(out);
for row in values {
let mut record: Vec<String> = row.iter().map(cell_to_string).collect();
record.resize(width, String::new());
writer
.write_record(&record)
.context("Failed to write a CSV row")?;
}
writer.flush().context("Failed to flush CSV output")
}
fn cell_to_string(cell: &serde_json::Value) -> String {
match cell {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::sheets::read::SheetValues;
fn sheet(title: Option<&str>, values: &[&[serde_json::Value]]) -> SheetValues {
SheetValues {
title: title.map(str::to_string),
range: None,
values: values.iter().map(|row| row.to_vec()).collect(),
}
}
fn outcome(sheets: Vec<SheetValues>) -> ReadOutcome {
ReadOutcome {
spreadsheet_id: "s".to_string(),
spreadsheet_title: None,
sheets,
}
}
fn render(o: &ReadOutcome) -> String {
let mut buf = Vec::new();
render_csv(o, &mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
#[test]
fn cell_to_string_unwraps_strings_rather_than_requoting_them() {
assert_eq!(cell_to_string(&serde_json::json!("hi")), "hi");
}
#[test]
fn cell_to_string_renders_typed_values_and_null() {
assert_eq!(cell_to_string(&serde_json::json!(1234.5)), "1234.5");
assert_eq!(cell_to_string(&serde_json::json!(true)), "true");
assert_eq!(cell_to_string(&serde_json::Value::Null), "");
}
#[test]
fn single_sheet_renders_without_a_header_line() {
let text = render(&outcome(vec![sheet(
Some("Q1"),
&[&[serde_json::json!("a"), serde_json::json!("b")]],
)]));
assert_eq!(text, "a,b\n");
}
#[test]
fn multiple_sheets_get_a_commented_title_and_a_blank_separator() {
let text = render(&outcome(vec![
sheet(Some("Q1"), &[&[serde_json::json!("a")]]),
sheet(Some("My Sheet"), &[&[serde_json::json!("b")]]),
]));
assert_eq!(text, "# Q1\na\n\n# My Sheet\nb\n");
}
#[test]
fn ragged_rows_are_padded_to_the_widest_row() {
let text = render(&outcome(vec![sheet(
Some("S"),
&[
&[
serde_json::json!("a"),
serde_json::json!("b"),
serde_json::json!("c"),
],
&[serde_json::json!("d")],
],
)]));
assert_eq!(text, "a,b,c\nd,,\n");
}
#[test]
fn an_empty_sheet_renders_as_nothing_but_its_header() {
let text = render(&outcome(vec![
sheet(Some("Blank"), &[]),
sheet(Some("Full"), &[&[serde_json::json!("x")]]),
]));
assert_eq!(text, "# Blank\n\n# Full\nx\n");
}
#[test]
fn a_single_empty_sheet_renders_as_empty_output() {
assert_eq!(render(&outcome(vec![sheet(Some("Blank"), &[])])), "");
}
#[test]
fn cells_containing_commas_quotes_and_newlines_are_csv_quoted() {
let text = render(&outcome(vec![sheet(
Some("S"),
&[&[
serde_json::json!("a,b"),
serde_json::json!("say \"hi\""),
serde_json::json!("line1\nline2"),
]],
)]));
let mut reader = csv::ReaderBuilder::new()
.has_headers(false)
.from_reader(text.as_bytes());
let record = reader.records().next().unwrap().unwrap();
assert_eq!(&record[0], "a,b");
assert_eq!(&record[1], "say \"hi\"");
assert_eq!(
&record[2], "line1\nline2",
"a multi-line cell must survive intact"
);
}
#[test]
fn a_sheet_with_no_title_still_renders_under_a_placeholder_header() {
let text = render(&outcome(vec![
sheet(None, &[&[serde_json::json!("a")]]),
sheet(Some("B"), &[&[serde_json::json!("b")]]),
]));
assert!(text.contains("# (untitled)"), "{text}");
assert!(text.contains('a'), "{text}");
}
#[test]
fn a_control_sequence_in_a_sheet_title_is_stripped_from_the_header() {
let text = render(&outcome(vec![
sheet(Some("evil\x1b[31mtab"), &[&[serde_json::json!("a")]]),
sheet(Some("B"), &[&[serde_json::json!("b")]]),
]));
assert!(text.contains("# evil[31mtab"), "{text:?}");
}
#[test]
fn cell_content_is_emitted_verbatim_not_sanitized() {
let text = render(&outcome(vec![sheet(
Some("S"),
&[&[serde_json::json!("multi\nline")]],
)]));
assert!(text.contains("multi\nline"), "{text:?}");
}
#[test]
fn render_arg_maps_onto_the_engine_option() {
assert_eq!(
ValueRenderOption::from(RenderArg::Formatted),
ValueRenderOption::Formatted
);
assert_eq!(
ValueRenderOption::from(RenderArg::Unformatted),
ValueRenderOption::Unformatted
);
assert_eq!(
ValueRenderOption::from(RenderArg::Formula),
ValueRenderOption::Formula
);
}
#[test]
fn render_arg_defaults_to_formatted() {
assert_eq!(
ValueRenderOption::from(RenderArg::default()),
ValueRenderOption::Formatted
);
}
}