use std::io::Read as _;
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use crate::cli::drive::format::{output_as, sanitize_for_terminal, OutputFormat};
use crate::cli::drive::helpers;
use crate::cli::drive::sheets::values::{self, ValuesFormat};
use crate::drive::client::DriveClient;
use crate::drive::sheets::api::ValueInputOption;
use crate::drive::sheets::client::SheetsClient;
use crate::drive::sheets::write::{describe, write, WriteOptions, WriteVerb};
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub enum InputArg {
#[default]
UserEntered,
Raw,
}
impl From<InputArg> for ValueInputOption {
fn from(arg: InputArg) -> Self {
match arg {
InputArg::UserEntered => Self::UserEntered,
InputArg::Raw => Self::Raw,
}
}
}
#[derive(Parser)]
pub struct WriteCommand {
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_name = "PATH|-")]
pub values: String,
#[arg(long = "values-format", value_enum, default_value_t = ValuesFormat::Auto)]
pub values_format: ValuesFormat,
#[arg(long, value_enum, default_value_t = InputArg::UserEntered)]
pub input: InputArg,
#[arg(long)]
pub dry_run: bool,
#[command(flatten)]
pub lease: crate::cli::drive::helpers::LeaseTokenArg,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
#[derive(Parser)]
pub struct AppendCommand {
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_name = "PATH|-")]
pub values: String,
#[arg(long = "values-format", value_enum, default_value_t = ValuesFormat::Auto)]
pub values_format: ValuesFormat,
#[arg(long, value_enum, default_value_t = InputArg::UserEntered)]
pub input: InputArg,
#[arg(long)]
pub dry_run: bool,
#[command(flatten)]
pub lease: crate::cli::drive::helpers::LeaseTokenArg,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
#[derive(Parser)]
pub struct ClearCommand {
pub spreadsheet_id: String,
#[arg(long, value_name = "A1")]
pub range: Option<String>,
#[arg(long, value_name = "NAME")]
pub sheet: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[command(flatten)]
pub lease: crate::cli::drive::helpers::LeaseTokenArg,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl WriteCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let values = read_values(&self.values, self.values_format)?;
let opts = WriteOptions {
spreadsheet_id: self.spreadsheet_id,
verb: WriteVerb::Write,
range: self.range,
sheet: self.sheet,
values,
input: self.input.into(),
dry_run: self.dry_run,
lease_token: self.lease.lease,
ledger_path: helpers::resolve_ledger_path(self.dry_run)?,
};
run_write(client, &opts, &self.output).await
}
}
impl AppendCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let values = read_values(&self.values, self.values_format)?;
let opts = WriteOptions {
spreadsheet_id: self.spreadsheet_id,
verb: WriteVerb::Append,
range: self.range,
sheet: self.sheet,
values,
input: self.input.into(),
dry_run: self.dry_run,
lease_token: self.lease.lease,
ledger_path: helpers::resolve_ledger_path(self.dry_run)?,
};
run_write(client, &opts, &self.output).await
}
}
impl ClearCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let opts = WriteOptions {
spreadsheet_id: self.spreadsheet_id,
verb: WriteVerb::Clear,
range: self.range,
sheet: self.sheet,
values: Vec::new(),
input: ValueInputOption::default(),
dry_run: self.dry_run,
lease_token: self.lease.lease,
ledger_path: helpers::resolve_ledger_path(self.dry_run)?,
};
run_write(client, &opts, &self.output).await
}
}
pub(crate) fn read_values(source: &str, format: ValuesFormat) -> Result<Vec<Vec<String>>> {
let content = if source == "-" {
let mut buf = Vec::new();
std::io::stdin()
.take(crate::drive::files_api::MAX_UPLOAD_BYTES + 1)
.read_to_end(&mut buf)
.context("Failed to read --values from stdin")?;
anyhow::ensure!(
buf.len() as u64 <= crate::drive::files_api::MAX_UPLOAD_BYTES,
"--values from stdin is over the {} byte cap",
crate::drive::files_api::MAX_UPLOAD_BYTES
);
String::from_utf8(buf).context("--values from stdin is not valid UTF-8")?
} else {
let metadata = std::fs::metadata(source)
.with_context(|| format!("Failed to stat --values file {source}"))?;
anyhow::ensure!(
metadata.len() <= crate::drive::files_api::MAX_UPLOAD_BYTES,
"--values file {source} is {} bytes, over the {} byte cap",
metadata.len(),
crate::drive::files_api::MAX_UPLOAD_BYTES
);
std::fs::read_to_string(source)
.with_context(|| format!("Failed to read --values file {source}"))?
};
values::parse(&content, format.resolve(source))
}
async fn run_write(client: &DriveClient, opts: &WriteOptions, output: &OutputFormat) -> Result<()> {
let sheets = SheetsClient::from_drive_client(client)?;
let rules = helpers::active_account_rules()?;
let outcome = write(client, &sheets, opts, &rules).await;
if output_as(&outcome, output)? {
return Ok(());
}
println!("{}", sanitize_for_terminal(&describe(&outcome)));
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn input_arg_maps_onto_the_engine_option() {
assert_eq!(
ValueInputOption::from(InputArg::UserEntered),
ValueInputOption::UserEntered
);
assert_eq!(ValueInputOption::from(InputArg::Raw), ValueInputOption::Raw);
}
#[test]
fn input_arg_defaults_to_user_entered() {
assert_eq!(
ValueInputOption::from(InputArg::default()),
ValueInputOption::UserEntered
);
}
#[test]
fn read_values_parses_a_csv_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cells.csv");
std::fs::write(&path, "a,b\nc,d\n").unwrap();
let rows = read_values(path.to_str().unwrap(), ValuesFormat::Auto).unwrap();
assert_eq!(rows, vec![vec!["a", "b"], vec!["c", "d"]]);
}
#[test]
fn read_values_infers_json_from_the_extension() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cells.json");
std::fs::write(&path, r#"[["a",1]]"#).unwrap();
let rows = read_values(path.to_str().unwrap(), ValuesFormat::Auto).unwrap();
assert_eq!(rows, vec![vec!["a", "1"]]);
}
#[test]
fn read_values_reports_a_missing_file_clearly() {
let err = read_values("/definitely/not/here.csv", ValuesFormat::Auto).unwrap_err();
assert!(err.to_string().contains("Failed to stat"), "{err}");
}
#[test]
fn read_values_refuses_a_file_over_the_upload_cap() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("huge.csv");
let oversize = vec![b'a'; (crate::drive::files_api::MAX_UPLOAD_BYTES + 1) as usize];
std::fs::write(&path, oversize).unwrap();
let err = read_values(path.to_str().unwrap(), ValuesFormat::Auto).unwrap_err();
assert!(err.to_string().contains("over the"), "{err}");
}
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::sheets::client::SHEETS_API_URL;
use crate::utils::secret::Secret;
fn test_credentials() -> DriveCredentials {
DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
}
}
async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token",
"expires_in": 3600,
})),
)
.mount(server)
.await;
let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
crate::drive::client::test_support::replace_session(
&mut client,
&test_credentials(),
&format!("{}/token", server.uri()),
);
client
}
fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
let parents: Vec<&str> = parents.to_vec();
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": id, "name": id, "mimeType": mime_type, "parents": parents,
"version": "1",
})),
)
}
fn seed_ledger_lease(spreadsheet_id: &str, version: &str) -> String {
let ledger_path = crate::drive::lease::ledger::ledger_path().unwrap();
crate::drive::test_support::seed_lease(&ledger_path, spreadsheet_id, version)
}
fn mount_folder(id: &str) -> wiremock::Mock {
mount_file(id, "application/vnd.google-apps.folder", &[])
}
fn allow_write_rule_settings() -> serde_json::Value {
serde_json::json!({
"rules": [{
"folder_id": "parent-1",
"recursive": true,
"allow": ["sheets-write"],
}],
})
}
fn write_opts(dry_run: bool) -> WriteOptions {
WriteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: WriteVerb::Write,
range: Some("A1:B2".to_string()),
sheet: None,
values: vec![vec!["a".to_string(), "b".to_string()]],
input: InputArg::UserEntered.into(),
dry_run,
lease_token: None,
ledger_path: std::path::PathBuf::from("/nonexistent/lease-ledger.jsonl"),
}
}
#[tokio::test]
async fn run_write_reports_blocked_by_default_policy() {
let guard = crate::drive::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
std::env::set_var(SHEETS_API_URL, server.uri());
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["parent-1"],
)
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
run_write(&client, &write_opts(false), &OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn run_write_json_output_short_circuits_before_the_table_line() {
let guard = crate::drive::test_support::EnvGuard::take();
let _dir = guard.clear_credentials();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
std::env::set_var(SHEETS_API_URL, server.uri());
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["parent-1"],
)
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
run_write(&client, &write_opts(false), &OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn write_command_execute_calls_values_update() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
crate::utils::settings::Settings::upsert_drive_account(
&settings_path,
"work",
&[("write_permissions", allow_write_rule_settings())],
)
.unwrap();
let values_path = dir.path().join("cells.csv");
std::fs::write(&values_path, "a,b\n").unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
std::env::set_var(SHEETS_API_URL, server.uri());
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["parent-1"],
)
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("PUT"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"updatedCells": 2})),
)
.mount(&server)
.await;
let lease = Some(seed_ledger_lease("sheet-1", "1"));
let cmd = WriteCommand {
spreadsheet_id: "sheet-1".to_string(),
range: Some("A1:B2".to_string()),
sheet: None,
values: values_path.to_str().unwrap().to_string(),
values_format: ValuesFormat::Auto,
input: InputArg::UserEntered,
dry_run: false,
lease: crate::cli::drive::helpers::LeaseTokenArg { lease },
output: OutputFormat::Table,
};
cmd.execute(&client).await.unwrap();
}
#[tokio::test]
async fn append_command_execute_calls_values_append() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
crate::utils::settings::Settings::upsert_drive_account(
&settings_path,
"work",
&[("write_permissions", allow_write_rule_settings())],
)
.unwrap();
let values_path = dir.path().join("cells.csv");
std::fs::write(&values_path, "a,b\n").unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
std::env::set_var(SHEETS_API_URL, server.uri());
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["parent-1"],
)
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/A1:B2:append",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"updates": {"updatedRange": "A1:B2", "updatedCells": 2},
})),
)
.mount(&server)
.await;
let lease = Some(seed_ledger_lease("sheet-1", "1"));
let cmd = AppendCommand {
spreadsheet_id: "sheet-1".to_string(),
range: Some("A1:B2".to_string()),
sheet: None,
values: values_path.to_str().unwrap().to_string(),
values_format: ValuesFormat::Auto,
input: InputArg::UserEntered,
dry_run: false,
lease: crate::cli::drive::helpers::LeaseTokenArg { lease },
output: OutputFormat::Table,
};
cmd.execute(&client).await.unwrap();
}
#[tokio::test]
async fn clear_command_execute_calls_values_clear() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
let settings_path = dir.path().join(".omni-dev").join("settings.json");
crate::utils::settings::Settings::upsert_drive_account(
&settings_path,
"work",
&[("write_permissions", allow_write_rule_settings())],
)
.unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
std::env::set_var(SHEETS_API_URL, server.uri());
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["parent-1"],
)
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/A1:B2:clear",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"clearedRange": "A1:B2"})),
)
.mount(&server)
.await;
let lease = Some(seed_ledger_lease("sheet-1", "1"));
let cmd = ClearCommand {
spreadsheet_id: "sheet-1".to_string(),
range: Some("A1:B2".to_string()),
sheet: None,
dry_run: false,
lease: crate::cli::drive::helpers::LeaseTokenArg { lease },
output: OutputFormat::Table,
};
cmd.execute(&client).await.unwrap();
}
}