use anyhow::Result;
use clap::Parser;
use crate::cli::drive::format::{output_as, sanitize_for_terminal, OutputFormat};
use crate::cli::drive::helpers;
use crate::drive::client::DriveClient;
use crate::drive::sheets::api::SheetsApi;
use crate::drive::sheets::client::SheetsClient;
use crate::drive::sheets::named_range::{
describe_lines, named_range, NamedRangeOptions, NamedRangeVerb,
};
use crate::drive::sheets::{render_grid_range, sheet_title_by_id};
#[derive(Parser)]
pub struct AddNamedRangeCommand {
pub spreadsheet_id: String,
#[arg(long, value_name = "NAME")]
pub name: String,
#[arg(long, value_name = "A1")]
pub range: Option<String>,
#[arg(long, value_name = "NAME")]
pub sheet: Option<String>,
#[arg(long)]
pub whole_sheet: bool,
#[command(flatten)]
pub write: helpers::StructureWriteArgs,
}
impl AddNamedRangeCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let opts = NamedRangeOptions {
spreadsheet_id: self.spreadsheet_id,
verb: NamedRangeVerb::AddNamedRange {
name: self.name,
sheet: self.sheet,
range: self.range,
whole_sheet: self.whole_sheet,
},
dry_run: self.write.dry_run,
lease_token: self.write.lease.lease,
ledger_path: helpers::resolve_ledger_path(self.write.dry_run)?,
};
run_named_range(client, &opts, &self.write.output).await
}
}
#[derive(Parser)]
#[command(group(clap::ArgGroup::new("target").args(["name", "id"]).required(true)))]
pub struct UpdateNamedRangeCommand {
pub spreadsheet_id: String,
#[arg(long, value_name = "NAME")]
pub name: Option<String>,
#[arg(long, value_name = "ID")]
pub id: Option<String>,
#[arg(long, value_name = "NAME")]
pub new_name: Option<String>,
#[arg(long, value_name = "A1")]
pub range: Option<String>,
#[arg(long, value_name = "NAME")]
pub sheet: Option<String>,
#[arg(long)]
pub whole_sheet: bool,
#[command(flatten)]
pub write: helpers::StructureWriteArgs,
}
impl UpdateNamedRangeCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let opts = NamedRangeOptions {
spreadsheet_id: self.spreadsheet_id,
verb: NamedRangeVerb::UpdateNamedRange {
name: self.name,
id: self.id,
new_name: self.new_name,
sheet: self.sheet,
range: self.range,
whole_sheet: self.whole_sheet,
},
dry_run: self.write.dry_run,
lease_token: self.write.lease.lease,
ledger_path: helpers::resolve_ledger_path(self.write.dry_run)?,
};
run_named_range(client, &opts, &self.write.output).await
}
}
#[derive(Parser)]
#[command(group(clap::ArgGroup::new("target").args(["name", "id"]).required(true)))]
pub struct DeleteNamedRangeCommand {
pub spreadsheet_id: String,
#[arg(long, value_name = "NAME")]
pub name: Option<String>,
#[arg(long, value_name = "ID")]
pub id: 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 DeleteNamedRangeCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let opts = NamedRangeOptions {
spreadsheet_id: self.spreadsheet_id,
verb: NamedRangeVerb::DeleteNamedRange {
name: self.name,
id: self.id,
},
dry_run: self.dry_run,
lease_token: self.lease.lease,
ledger_path: helpers::resolve_ledger_path(self.dry_run)?,
};
run_named_range(client, &opts, &self.output).await
}
}
#[derive(Parser)]
pub struct ListNamedRangesCommand {
pub spreadsheet_id: String,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl ListNamedRangesCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let sheets = SheetsClient::from_drive_client(client)?;
let api = SheetsApi::new(&sheets);
let workbook = api
.get_spreadsheet_with_named_ranges(&self.spreadsheet_id)
.await?;
if output_as(&workbook, &self.output)? {
return Ok(());
}
let mut rows = Vec::new();
for named in &workbook.named_ranges {
let id = named.named_range_id.as_deref().unwrap_or("?");
let sheet = sheet_title_by_id(&workbook, named.range.sheet_id)
.unwrap_or_else(|| "?".to_string());
rows.push(format!(
"id {id}: {} {} sheet={sheet:?}",
named.name,
render_grid_range(&named.range)
));
}
helpers::print_list(&rows, "No named ranges.");
Ok(())
}
}
async fn run_named_range(
client: &DriveClient,
opts: &NamedRangeOptions,
output: &OutputFormat,
) -> Result<()> {
let sheets = SheetsClient::from_drive_client(client)?;
let rules = helpers::active_account_rules()?;
let outcome = named_range(client, &sheets, opts, &rules).await;
if output_as(&outcome, output)? {
return Ok(());
}
let lines: Vec<String> = describe_lines(&outcome)
.into_iter()
.map(|line| sanitize_for_terminal(&line))
.collect();
println!("{}", lines.join("\n"));
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
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
}
#[tokio::test]
async fn list_named_ranges_prints_every_named_range() {
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());
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": "sheet-1",
"properties": {"title": "Budget"},
"sheets": [{"properties": {"sheetId": 0, "title": "Sheet1"}}],
"namedRanges": [
{
"namedRangeId": "id-1",
"name": "Foo",
"range": {
"sheetId": 0,
"startRowIndex": 0,
"endRowIndex": 5,
"startColumnIndex": 0,
"endColumnIndex": 2,
},
},
{"namedRangeId": "id-2", "name": "Bar", "range": {"sheetId": 0}},
],
})),
)
.mount(&server)
.await;
let cmd = ListNamedRangesCommand {
spreadsheet_id: "sheet-1".to_string(),
output: crate::cli::drive::format::OutputFormat::Table,
};
assert!(cmd.execute(&client).await.is_ok());
}
#[tokio::test]
async fn list_named_ranges_yaml_output_short_circuits_before_printing_lines() {
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());
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": "sheet-1",
"properties": {"title": "Budget"},
"sheets": [{"properties": {"sheetId": 0, "title": "Sheet1"}}],
"namedRanges": [],
})),
)
.mount(&server)
.await;
let cmd = ListNamedRangesCommand {
spreadsheet_id: "sheet-1".to_string(),
output: crate::cli::drive::format::OutputFormat::Yaml,
};
assert!(cmd.execute(&client).await.is_ok());
}
async fn mount_ungated_target(server: &wiremock::MockServer) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "sheet-1",
"name": "Budget",
"mimeType": crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
"parents": ["folder-1"],
})),
)
.mount(server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/folder-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "folder-1",
"name": "folder-1",
"mimeType": "application/vnd.google-apps.folder",
"parents": [],
})),
)
.mount(server)
.await;
}
#[tokio::test]
async fn add_named_range_command_runs_end_to_end() {
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_ungated_target(&server).await;
let cmd = AddNamedRangeCommand {
spreadsheet_id: "sheet-1".to_string(),
name: "Foo".to_string(),
range: Some("A1:A5".to_string()),
sheet: Some("Q1".to_string()),
whole_sheet: false,
write: helpers::StructureWriteArgs {
dry_run: true,
lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
output: crate::cli::drive::format::OutputFormat::Table,
},
};
assert!(cmd.execute(&client).await.is_ok());
}
#[tokio::test]
async fn update_named_range_command_runs_end_to_end() {
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_ungated_target(&server).await;
let cmd = UpdateNamedRangeCommand {
spreadsheet_id: "sheet-1".to_string(),
name: Some("Foo".to_string()),
id: None,
new_name: Some("Bar".to_string()),
range: None,
sheet: None,
whole_sheet: false,
write: helpers::StructureWriteArgs {
dry_run: true,
lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
output: crate::cli::drive::format::OutputFormat::Table,
},
};
assert!(cmd.execute(&client).await.is_ok());
}
#[tokio::test]
async fn update_named_range_command_by_id_runs_end_to_end() {
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_ungated_target(&server).await;
let cmd = UpdateNamedRangeCommand {
spreadsheet_id: "sheet-1".to_string(),
name: None,
id: Some("id-1".to_string()),
new_name: Some("Bar".to_string()),
range: None,
sheet: None,
whole_sheet: false,
write: helpers::StructureWriteArgs {
dry_run: true,
lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
output: crate::cli::drive::format::OutputFormat::Table,
},
};
assert!(cmd.execute(&client).await.is_ok());
}
#[tokio::test]
async fn delete_named_range_command_runs_end_to_end() {
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_ungated_target(&server).await;
let cmd = DeleteNamedRangeCommand {
spreadsheet_id: "sheet-1".to_string(),
name: Some("Foo".to_string()),
id: None,
dry_run: true,
lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
output: crate::cli::drive::format::OutputFormat::Yaml,
};
assert!(cmd.execute(&client).await.is_ok());
}
#[tokio::test]
async fn delete_named_range_command_by_id_runs_end_to_end() {
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_ungated_target(&server).await;
let cmd = DeleteNamedRangeCommand {
spreadsheet_id: "sheet-1".to_string(),
name: None,
id: Some("id-1".to_string()),
dry_run: true,
lease: crate::cli::drive::helpers::LeaseTokenArg { lease: None },
output: crate::cli::drive::format::OutputFormat::Yaml,
};
assert!(cmd.execute(&client).await.is_ok());
}
#[test]
fn update_and_delete_named_range_require_exactly_one_of_name_or_id() {
let err = UpdateNamedRangeCommand::try_parse_from(["update-named-range", "sheet-1"])
.map(|_| ())
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
let err = UpdateNamedRangeCommand::try_parse_from([
"update-named-range",
"sheet-1",
"--name",
"Foo",
"--id",
"id-1",
"--new-name",
"Bar",
])
.map(|_| ())
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
let err = DeleteNamedRangeCommand::try_parse_from(["delete-named-range", "sheet-1"])
.map(|_| ())
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
let err = DeleteNamedRangeCommand::try_parse_from([
"delete-named-range",
"sheet-1",
"--name",
"Foo",
"--id",
"id-1",
])
.map(|_| ())
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
}