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::protection::{
describe_lines, protection, ProtectionOptions, ProtectionVerb,
};
use crate::drive::sheets::render_grid_range;
#[derive(Parser)]
pub struct ProtectRangeCommand {
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 whole_sheet: bool,
#[arg(long, value_name = "TEXT")]
pub description: Option<String>,
#[arg(long)]
pub warning_only: bool,
#[arg(long = "editor", value_name = "EMAIL")]
pub editors: Vec<String>,
#[command(flatten)]
pub write: helpers::StructureWriteArgs,
}
impl ProtectRangeCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let opts = ProtectionOptions {
spreadsheet_id: self.spreadsheet_id,
verb: ProtectionVerb::ProtectRange {
sheet: self.sheet,
range: self.range,
whole_sheet: self.whole_sheet,
description: self.description,
warning_only: self.warning_only,
editors: self.editors,
},
dry_run: self.write.dry_run,
lease_token: self.write.lease.lease,
ledger_path: helpers::resolve_ledger_path(self.write.dry_run)?,
};
run_protection(client, &opts, &self.write.output).await
}
}
#[derive(Parser)]
pub struct UpdateProtectionCommand {
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 whole_sheet: bool,
#[arg(long, value_name = "TEXT")]
pub description: Option<String>,
#[arg(long, value_name = "BOOL")]
pub warning_only: Option<bool>,
#[arg(long = "add-editor", value_name = "EMAIL")]
pub add_editors: Vec<String>,
#[arg(long = "remove-editor", value_name = "EMAIL")]
pub remove_editors: Vec<String>,
#[command(flatten)]
pub write: helpers::StructureWriteArgs,
}
impl UpdateProtectionCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let opts = ProtectionOptions {
spreadsheet_id: self.spreadsheet_id,
verb: ProtectionVerb::UpdateProtection {
sheet: self.sheet,
range: self.range,
whole_sheet: self.whole_sheet,
description: self.description,
warning_only: self.warning_only,
add_editors: self.add_editors,
remove_editors: self.remove_editors,
},
dry_run: self.write.dry_run,
lease_token: self.write.lease.lease,
ledger_path: helpers::resolve_ledger_path(self.write.dry_run)?,
};
run_protection(client, &opts, &self.write.output).await
}
}
#[derive(Parser)]
pub struct UnprotectRangeCommand {
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 whole_sheet: bool,
#[command(flatten)]
pub write: helpers::StructureWriteArgs,
}
impl UnprotectRangeCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let opts = ProtectionOptions {
spreadsheet_id: self.spreadsheet_id,
verb: ProtectionVerb::UnprotectRange {
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_protection(client, &opts, &self.write.output).await
}
}
#[derive(Parser)]
pub struct ListProtectionsCommand {
pub spreadsheet_id: String,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl ListProtectionsCommand {
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_protections(&self.spreadsheet_id)
.await?;
if output_as(&workbook, &self.output)? {
return Ok(());
}
let mut rows = Vec::new();
for sheet in &workbook.sheets {
for protected in &sheet.protected_ranges {
let id = protected
.protected_range_id
.map_or_else(|| "?".to_string(), |id| id.to_string());
let range = protected
.range
.as_ref()
.map_or_else(|| "(unresolvable)".to_string(), render_grid_range);
let description = protected.description.as_deref().unwrap_or("");
let warning_only = protected.warning_only.unwrap_or(false);
let editors = protected
.editors
.as_ref()
.map(|e| e.users.join(", "))
.unwrap_or_default();
rows.push(format!(
"id {id}: {range} description={description:?} \
warning_only={warning_only} editors=[{editors}] \
sheet={}",
sheet.title()
));
}
}
helpers::print_list(&rows, "No protected ranges.");
Ok(())
}
}
async fn run_protection(
client: &DriveClient,
opts: &ProtectionOptions,
output: &OutputFormat,
) -> Result<()> {
let sheets = SheetsClient::from_drive_client(client)?;
let rules = helpers::active_account_rules()?;
let outcome = protection(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_protections_prints_every_protected_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"},
"protectedRanges": [
{
"protectedRangeId": 1,
"range": {
"sheetId": 0,
"startRowIndex": 0,
"endRowIndex": 5,
"startColumnIndex": 0,
"endColumnIndex": 2,
},
"description": "locked",
"warningOnly": true,
"editors": {"users": ["a@example.com", "b@example.com"]},
},
{"protectedRangeId": 2},
],
}],
})),
)
.mount(&server)
.await;
let cmd = ListProtectionsCommand {
spreadsheet_id: "sheet-1".to_string(),
output: crate::cli::drive::format::OutputFormat::Table,
};
assert!(cmd.execute(&client).await.is_ok());
}
}