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::conditional_format::{
conditional_format, describe_existing_rule, describe_lines, ConditionalFormatOptions,
ConditionalFormatVerb, FormatCondition, FormatEffect, FormatRule, GradientMidpoint,
GradientPointType, GradientSpec,
};
use crate::drive::sheets::date_value::DateValue;
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum GradientMidTypeArg {
Number,
Percent,
Percentile,
}
impl From<GradientMidTypeArg> for GradientPointType {
fn from(value: GradientMidTypeArg) -> Self {
match value {
GradientMidTypeArg::Number => Self::Number,
GradientMidTypeArg::Percent => Self::Percent,
GradientMidTypeArg::Percentile => Self::Percentile,
}
}
}
#[derive(Parser)]
pub struct ConditionalFormatRuleArgs {
#[arg(long, num_args = 2, value_names = ["MIN", "MAX"], allow_hyphen_values = true)]
pub number_between: Option<Vec<f64>>,
#[arg(long, num_args = 2, value_names = ["MIN", "MAX"], allow_hyphen_values = true)]
pub number_not_between: Option<Vec<f64>>,
#[arg(long, value_name = "N", allow_hyphen_values = true)]
pub number_greater: Option<f64>,
#[arg(long, value_name = "N", allow_hyphen_values = true)]
pub number_greater_eq: Option<f64>,
#[arg(long, value_name = "N", allow_hyphen_values = true)]
pub number_less: Option<f64>,
#[arg(long, value_name = "N", allow_hyphen_values = true)]
pub number_less_eq: Option<f64>,
#[arg(long, value_name = "N", allow_hyphen_values = true)]
pub number_eq: Option<f64>,
#[arg(long, value_name = "N", allow_hyphen_values = true)]
pub number_not_eq: Option<f64>,
#[arg(long, value_name = "TEXT")]
pub text_contains: Option<String>,
#[arg(long, value_name = "TEXT")]
pub text_not_contains: Option<String>,
#[arg(long, value_name = "TEXT")]
pub text_starts_with: Option<String>,
#[arg(long, value_name = "TEXT")]
pub text_ends_with: Option<String>,
#[arg(long, value_name = "TEXT")]
pub text_eq: Option<String>,
#[arg(long, value_name = "DATE")]
pub date_after: Option<String>,
#[arg(long, value_name = "DATE")]
pub date_before: Option<String>,
#[arg(long, value_name = "DATE")]
pub date_on: Option<String>,
#[arg(long, num_args = 2, value_names = ["START", "END"])]
pub date_between: Option<Vec<String>>,
#[arg(long)]
pub cell_empty: bool,
#[arg(long)]
pub cell_not_empty: bool,
#[arg(long, value_name = "FORMULA")]
pub custom_formula: Option<String>,
#[arg(long, value_name = "HEX")]
pub background: Option<String>,
#[arg(long, value_name = "HEX")]
pub text_color: Option<String>,
#[arg(long, value_name = "BOOL")]
pub bold: Option<bool>,
#[arg(long, value_name = "HEX", requires = "gradient_max_color")]
pub gradient_min_color: Option<String>,
#[arg(long, value_name = "HEX", requires = "gradient_min_color")]
pub gradient_max_color: Option<String>,
#[arg(
long,
value_name = "HEX",
requires_all = ["gradient_mid_type", "gradient_mid_value"]
)]
pub gradient_mid_color: Option<String>,
#[arg(long, value_enum, value_name = "TYPE", requires = "gradient_mid_color")]
pub gradient_mid_type: Option<GradientMidTypeArg>,
#[arg(long, value_name = "VALUE", requires = "gradient_mid_color")]
pub gradient_mid_value: Option<String>,
}
impl ConditionalFormatRuleArgs {
const RULE_KIND_ARGS: &'static [&'static str] = &[
"number_between",
"number_not_between",
"number_greater",
"number_greater_eq",
"number_less",
"number_less_eq",
"number_eq",
"number_not_eq",
"text_contains",
"text_not_contains",
"text_starts_with",
"text_ends_with",
"text_eq",
"date_after",
"date_before",
"date_on",
"date_between",
"cell_empty",
"cell_not_empty",
"custom_formula",
"gradient_min_color",
];
fn select_rule(&self) -> FormatRule {
if let Some(min) = &self.gradient_min_color {
let mid = self
.gradient_mid_color
.as_ref()
.map(|color| GradientMidpoint {
color: color.clone(),
point_type: self
.gradient_mid_type
.map_or(GradientPointType::Number, GradientPointType::from),
value: self.gradient_mid_value.clone().unwrap_or_default(),
});
return FormatRule::Gradient(GradientSpec {
min_color: min.clone(),
max_color: self.gradient_max_color.clone().unwrap_or_default(),
mid,
});
}
let condition = if let Some(v) = &self.number_between {
FormatCondition::NumberBetween(v[0], v[1])
} else if let Some(v) = &self.number_not_between {
FormatCondition::NumberNotBetween(v[0], v[1])
} else if let Some(n) = self.number_greater {
FormatCondition::NumberGreater(n)
} else if let Some(n) = self.number_greater_eq {
FormatCondition::NumberGreaterEq(n)
} else if let Some(n) = self.number_less {
FormatCondition::NumberLess(n)
} else if let Some(n) = self.number_less_eq {
FormatCondition::NumberLessEq(n)
} else if let Some(n) = self.number_eq {
FormatCondition::NumberEq(n)
} else if let Some(n) = self.number_not_eq {
FormatCondition::NumberNotEq(n)
} else if let Some(text) = &self.text_contains {
FormatCondition::TextContains(text.clone())
} else if let Some(text) = &self.text_not_contains {
FormatCondition::TextNotContains(text.clone())
} else if let Some(text) = &self.text_starts_with {
FormatCondition::TextStartsWith(text.clone())
} else if let Some(text) = &self.text_ends_with {
FormatCondition::TextEndsWith(text.clone())
} else if let Some(text) = &self.text_eq {
FormatCondition::TextEq(text.clone())
} else if let Some(raw) = &self.date_after {
FormatCondition::DateAfter(DateValue::parse(raw.clone()))
} else if let Some(raw) = &self.date_before {
FormatCondition::DateBefore(DateValue::parse(raw.clone()))
} else if let Some(raw) = &self.date_on {
FormatCondition::DateOn(DateValue::parse(raw.clone()))
} else if let Some(v) = &self.date_between {
FormatCondition::DateBetween(v[0].clone(), v[1].clone())
} else if self.cell_empty {
FormatCondition::CellEmpty
} else if self.cell_not_empty {
FormatCondition::CellNotEmpty
} else {
FormatCondition::CustomFormula(self.custom_formula.clone().unwrap_or_default())
};
FormatRule::Boolean {
condition,
format: FormatEffect {
background: self.background.clone(),
text_color: self.text_color.clone(),
bold: self.bold,
},
}
}
}
#[derive(Parser)]
#[command(group(clap::ArgGroup::new("rule_kind")
.args(ConditionalFormatRuleArgs::RULE_KIND_ARGS)
.required(true)))]
pub struct AddConditionalFormatCommand {
pub spreadsheet_id: String,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[arg(long = "range", value_name = "A1", required = true)]
pub ranges: Vec<String>,
#[arg(long, value_name = "N")]
pub index: Option<i64>,
#[command(flatten)]
pub rule: ConditionalFormatRuleArgs,
#[command(flatten)]
pub write: helpers::StructureWriteArgs,
}
impl AddConditionalFormatCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let opts = ConditionalFormatOptions {
spreadsheet_id: self.spreadsheet_id,
verb: ConditionalFormatVerb::AddConditionalFormat {
sheet: self.sheet,
ranges: self.ranges,
index: self.index,
rule: self.rule.select_rule(),
},
dry_run: self.write.dry_run,
lease_token: self.write.lease.lease,
ledger_path: helpers::resolve_ledger_path(self.write.dry_run)?,
};
run_conditional_format(client, &opts, &self.write.output).await
}
}
#[derive(Parser)]
#[command(group(clap::ArgGroup::new("rule_kind")
.args(ConditionalFormatRuleArgs::RULE_KIND_ARGS)
.required(true)))]
pub struct UpdateConditionalFormatCommand {
pub spreadsheet_id: String,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[arg(long, value_name = "N")]
pub index: usize,
#[arg(long = "range", value_name = "A1", required = true)]
pub ranges: Vec<String>,
#[command(flatten)]
pub rule: ConditionalFormatRuleArgs,
#[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 UpdateConditionalFormatCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let opts = ConditionalFormatOptions {
spreadsheet_id: self.spreadsheet_id,
verb: ConditionalFormatVerb::UpdateConditionalFormat {
sheet: self.sheet,
ranges: self.ranges,
index: self.index,
rule: self.rule.select_rule(),
},
dry_run: self.dry_run,
lease_token: self.lease.lease,
ledger_path: helpers::resolve_ledger_path(self.dry_run)?,
};
run_conditional_format(client, &opts, &self.output).await
}
}
#[derive(Parser)]
pub struct DeleteConditionalFormatCommand {
pub spreadsheet_id: String,
#[arg(long, value_name = "NAME")]
pub sheet: String,
#[arg(long, value_name = "N")]
pub index: usize,
#[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 DeleteConditionalFormatCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let opts = ConditionalFormatOptions {
spreadsheet_id: self.spreadsheet_id,
verb: ConditionalFormatVerb::DeleteConditionalFormat {
sheet: self.sheet,
index: self.index,
},
dry_run: self.dry_run,
lease_token: self.lease.lease,
ledger_path: helpers::resolve_ledger_path(self.dry_run)?,
};
run_conditional_format(client, &opts, &self.output).await
}
}
#[derive(Parser)]
pub struct ListConditionalFormatsCommand {
pub spreadsheet_id: String,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl ListConditionalFormatsCommand {
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_conditional_formats(&self.spreadsheet_id)
.await?;
if output_as(&workbook, &self.output)? {
return Ok(());
}
let mut rows = Vec::new();
for sheet in &workbook.sheets {
for (index, rule) in sheet.conditional_formats.iter().enumerate() {
rows.push(format!(
"index {index}: {} sheet={}",
describe_existing_rule(rule),
sheet.title()
));
}
}
helpers::print_list(&rows, "No conditional formats.");
Ok(())
}
}
async fn run_conditional_format(
client: &DriveClient,
opts: &ConditionalFormatOptions,
output: &OutputFormat,
) -> Result<()> {
let sheets = SheetsClient::from_drive_client(client)?;
let rules = helpers::active_account_rules()?;
let outcome = conditional_format(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 clap::CommandFactory;
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::sheets::client::SHEETS_API_URL;
use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
use crate::utils::secret::Secret;
use crate::utils::settings::Settings;
fn parse_add(args: &[&str]) -> AddConditionalFormatCommand {
let mut full = vec!["add-conditional-format"];
full.extend_from_slice(args);
AddConditionalFormatCommand::parse_from(full)
}
fn parse_update(args: &[&str]) -> UpdateConditionalFormatCommand {
let mut full = vec!["update-conditional-format"];
full.extend_from_slice(args);
UpdateConditionalFormatCommand::parse_from(full)
}
fn boolean_condition(rule: FormatRule) -> FormatCondition {
match rule {
FormatRule::Boolean { condition, .. } => condition,
other @ FormatRule::Gradient(_) => panic!("expected Boolean, got {other:?}"),
}
}
#[test]
fn add_command_debug_asserts_its_clap_structure() {
AddConditionalFormatCommand::command().debug_assert();
UpdateConditionalFormatCommand::command().debug_assert();
DeleteConditionalFormatCommand::command().debug_assert();
ListConditionalFormatsCommand::command().debug_assert();
}
#[test]
fn select_rule_picks_a_boolean_condition() {
let cmd = parse_add(&[
"sheet-1",
"--sheet",
"Q1",
"--range",
"A1:A10",
"--number-greater",
"100",
"--background",
"#00FF00",
]);
match cmd.rule.select_rule() {
FormatRule::Boolean { condition, format } => {
match condition {
FormatCondition::NumberGreater(n) => assert!((n - 100.0).abs() < f64::EPSILON),
other => panic!("expected NumberGreater, got {other:?}"),
}
assert_eq!(format.background, Some("#00FF00".to_string()));
}
other @ FormatRule::Gradient(_) => panic!("expected Boolean, got {other:?}"),
}
}
#[test]
fn select_rule_picks_cell_empty() {
let cmd = parse_add(&[
"sheet-1",
"--sheet",
"Q1",
"--range",
"A1:A10",
"--cell-empty",
"--bold",
"true",
]);
match cmd.rule.select_rule() {
FormatRule::Boolean { condition, format } => {
assert!(matches!(condition, FormatCondition::CellEmpty));
assert_eq!(format.bold, Some(true));
}
other @ FormatRule::Gradient(_) => panic!("expected Boolean, got {other:?}"),
}
}
#[test]
fn select_rule_picks_number_conditions() {
let cases: Vec<(Vec<&str>, FormatCondition)> = vec![
(
vec!["--number-between", "1", "10"],
FormatCondition::NumberBetween(1.0, 10.0),
),
(
vec!["--number-not-between", "1", "10"],
FormatCondition::NumberNotBetween(1.0, 10.0),
),
(
vec!["--number-greater-eq", "5"],
FormatCondition::NumberGreaterEq(5.0),
),
(vec!["--number-less", "5"], FormatCondition::NumberLess(5.0)),
(
vec!["--number-less-eq", "5"],
FormatCondition::NumberLessEq(5.0),
),
(vec!["--number-eq", "5"], FormatCondition::NumberEq(5.0)),
(
vec!["--number-not-eq", "5"],
FormatCondition::NumberNotEq(5.0),
),
];
for (flags, expected) in cases {
let mut args = vec!["sheet-1", "--sheet", "Q1", "--range", "A1:A10"];
args.extend(flags);
args.extend(["--background", "#00FF00"]);
let cmd = parse_add(&args);
assert_eq!(boolean_condition(cmd.rule.select_rule()), expected);
}
}
#[test]
fn select_rule_picks_text_conditions() {
let cases: Vec<(Vec<&str>, FormatCondition)> = vec![
(
vec!["--text-contains", "abc"],
FormatCondition::TextContains("abc".to_string()),
),
(
vec!["--text-not-contains", "abc"],
FormatCondition::TextNotContains("abc".to_string()),
),
(
vec!["--text-starts-with", "abc"],
FormatCondition::TextStartsWith("abc".to_string()),
),
(
vec!["--text-ends-with", "abc"],
FormatCondition::TextEndsWith("abc".to_string()),
),
(
vec!["--text-eq", "abc"],
FormatCondition::TextEq("abc".to_string()),
),
];
for (flags, expected) in cases {
let mut args = vec!["sheet-1", "--sheet", "Q1", "--range", "A1:A10"];
args.extend(flags);
args.extend(["--background", "#00FF00"]);
let cmd = parse_add(&args);
assert_eq!(boolean_condition(cmd.rule.select_rule()), expected);
}
}
#[test]
fn select_rule_picks_date_conditions() {
let cases: Vec<(Vec<&str>, FormatCondition)> = vec![
(
vec!["--date-after", "2024-01-01"],
FormatCondition::DateAfter(DateValue::parse("2024-01-01".to_string())),
),
(
vec!["--date-before", "2024-01-01"],
FormatCondition::DateBefore(DateValue::parse("2024-01-01".to_string())),
),
(
vec!["--date-on", "2024-01-01"],
FormatCondition::DateOn(DateValue::parse("2024-01-01".to_string())),
),
(
vec!["--date-between", "2024-01-01", "2024-01-31"],
FormatCondition::DateBetween("2024-01-01".to_string(), "2024-01-31".to_string()),
),
];
for (flags, expected) in cases {
let mut args = vec!["sheet-1", "--sheet", "Q1", "--range", "A1:A10"];
args.extend(flags);
args.extend(["--background", "#00FF00"]);
let cmd = parse_add(&args);
assert_eq!(boolean_condition(cmd.rule.select_rule()), expected);
}
}
#[test]
fn select_rule_picks_cell_not_empty_and_falls_back_to_custom_formula() {
let cmd = parse_add(&[
"sheet-1",
"--sheet",
"Q1",
"--range",
"A1:A10",
"--cell-not-empty",
"--bold",
"true",
]);
match cmd.rule.select_rule() {
FormatRule::Boolean { condition, format } => {
assert!(matches!(condition, FormatCondition::CellNotEmpty));
assert_eq!(format.bold, Some(true));
}
other @ FormatRule::Gradient(_) => panic!("expected Boolean, got {other:?}"),
}
let cmd = parse_add(&[
"sheet-1",
"--sheet",
"Q1",
"--range",
"A1:A10",
"--custom-formula",
"=A1>0",
"--background",
"#00FF00",
]);
assert_eq!(
boolean_condition(cmd.rule.select_rule()),
FormatCondition::CustomFormula("=A1>0".to_string())
);
}
#[test]
fn select_rule_picks_a_gradient_with_a_midpoint() {
let cmd = parse_add(&[
"sheet-1",
"--sheet",
"Q1",
"--range",
"A1:A10",
"--gradient-min-color",
"#FFFFFF",
"--gradient-max-color",
"#00FF00",
"--gradient-mid-color",
"#FFFF00",
"--gradient-mid-type",
"percent",
"--gradient-mid-value",
"50",
]);
match cmd.rule.select_rule() {
FormatRule::Gradient(spec) => {
assert_eq!(spec.min_color, "#FFFFFF");
assert_eq!(spec.max_color, "#00FF00");
let mid = spec.mid.unwrap();
assert_eq!(mid.color, "#FFFF00");
assert!(matches!(mid.point_type, GradientPointType::Percent));
assert_eq!(mid.value, "50");
}
other @ FormatRule::Boolean { .. } => panic!("expected Gradient, got {other:?}"),
}
}
#[test]
fn add_requires_exactly_one_rule_kind_flag() {
let err = AddConditionalFormatCommand::try_parse_from([
"add-conditional-format",
"sheet-1",
"--sheet",
"Q1",
"--range",
"A1:A10",
])
.map(|_| ())
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn add_rejects_both_a_condition_and_a_gradient_flag() {
let err = AddConditionalFormatCommand::try_parse_from([
"add-conditional-format",
"sheet-1",
"--sheet",
"Q1",
"--range",
"A1:A10",
"--cell-empty",
"--gradient-min-color",
"#FFFFFF",
"--gradient-max-color",
"#00FF00",
])
.map(|_| ())
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn add_requires_at_least_one_range() {
let err = AddConditionalFormatCommand::try_parse_from([
"add-conditional-format",
"sheet-1",
"--sheet",
"Q1",
"--cell-empty",
])
.map(|_| ())
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn gradient_min_color_requires_max_color() {
let err = AddConditionalFormatCommand::try_parse_from([
"add-conditional-format",
"sheet-1",
"--sheet",
"Q1",
"--range",
"A1:A10",
"--gradient-min-color",
"#FFFFFF",
])
.map(|_| ())
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn delete_command_needs_no_rule_kind_flag() {
let cmd = DeleteConditionalFormatCommand::try_parse_from([
"delete-conditional-format",
"sheet-1",
"--sheet",
"Q1",
"--index",
"0",
])
.unwrap();
assert_eq!(cmd.index, 0);
}
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 mount_folder(id: &str) -> wiremock::Mock {
mount_file(id, "application/vnd.google-apps.folder", &[])
}
fn mount_workbook() -> wiremock::Mock {
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": "Q1", "index": 0},
"conditionalFormats": [
{
"ranges": [{"sheetId": 0, "startRowIndex": 0, "endRowIndex": 1}],
"booleanRule": {
"condition": {"type": "BLANK", "values": []},
"format": {"backgroundColorStyle": {"rgbColor": {"red": 1.0, "green": 0.0, "blue": 0.0}}},
},
},
{
"ranges": [{"sheetId": 0, "startRowIndex": 1, "endRowIndex": 2}],
"gradientRule": {
"minpoint": {"colorStyle": {"rgbColor": {"red": 1.0, "green": 1.0, "blue": 1.0}}, "type": "MIN"},
"maxpoint": {"colorStyle": {"rgbColor": {"red": 0.0, "green": 1.0, "blue": 0.0}}, "type": "MAX"},
},
},
],
},
],
})),
)
}
fn mount_batch_update() -> wiremock::Mock {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"replies": [{}]})),
)
}
fn allow_sheets_structure_on_folder_1(dir: &tempfile::TempDir) {
let settings_path = dir.path().join(".omni-dev").join("settings.json");
Settings::upsert_drive_account(
&settings_path,
"work",
&[(
"write_permissions",
serde_json::json!({
"rules": [{
"folder_id": "folder-1",
"recursive": true,
"allow": ["sheets-structure"],
"require_lease": false,
}],
}),
)],
)
.unwrap();
}
#[tokio::test]
async fn execute_add_conditional_format_reaches_batch_update() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
allow_sheets_structure_on_folder_1(&dir);
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", GOOGLE_SHEET_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update().mount(&server).await;
let cmd = parse_add(&[
"sheet-1",
"--sheet",
"Q1",
"--range",
"A1:A10",
"--number-greater",
"100",
"--background",
"#00FF00",
]);
assert!(cmd.execute(&client).await.is_ok());
let requests = server.received_requests().await.unwrap();
assert!(requests
.iter()
.any(|r| r.url.path().ends_with(":batchUpdate")));
}
#[tokio::test]
async fn execute_update_conditional_format_reaches_batch_update() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
allow_sheets_structure_on_folder_1(&dir);
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", GOOGLE_SHEET_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update().mount(&server).await;
let cmd = parse_update(&[
"sheet-1",
"--sheet",
"Q1",
"--index",
"0",
"--range",
"A1:A10",
"--cell-not-empty",
"--text-color",
"#112233",
]);
assert!(cmd.execute(&client).await.is_ok());
let requests = server.received_requests().await.unwrap();
let batch = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
assert_eq!(
body["requests"][0]["updateConditionalFormatRule"]["index"],
0
);
}
#[tokio::test]
async fn execute_delete_conditional_format_reaches_batch_update() {
let guard = crate::drive::test_support::EnvGuard::take();
let dir = guard.clear_credentials();
allow_sheets_structure_on_folder_1(&dir);
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", GOOGLE_SHEET_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update().mount(&server).await;
let cmd = DeleteConditionalFormatCommand::try_parse_from([
"delete-conditional-format",
"sheet-1",
"--sheet",
"Q1",
"--index",
"1",
"-o",
"json",
])
.unwrap();
assert!(cmd.execute(&client).await.is_ok());
let requests = server.received_requests().await.unwrap();
let batch = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
let deleted = &body["requests"][0]["deleteConditionalFormatRule"];
assert_eq!(deleted["sheetId"], 0);
assert_eq!(deleted["index"], 1);
}
#[tokio::test]
async fn execute_list_conditional_formats_prints_the_human_readable_table() {
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_workbook().mount(&server).await;
let cmd = ListConditionalFormatsCommand {
spreadsheet_id: "sheet-1".to_string(),
output: OutputFormat::Table,
};
assert!(cmd.execute(&client).await.is_ok());
}
#[tokio::test]
async fn execute_list_conditional_formats_json_output_short_circuits() {
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_workbook().mount(&server).await;
let cmd = ListConditionalFormatsCommand {
spreadsheet_id: "sheet-1".to_string(),
output: OutputFormat::Json,
};
assert!(cmd.execute(&client).await.is_ok());
}
#[tokio::test]
async fn execute_list_conditional_formats_reads_a_pure_red_rule_with_channels_omitted() {
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": {"title": "Q1"},
"conditionalFormats": [
{
"ranges": [{"endRowIndex": 1}],
"booleanRule": {
"condition": {"type": "BLANK"},
"format": {"backgroundColorStyle": {"rgbColor": {"red": 1.0}}},
},
},
],
},
],
})),
)
.mount(&server)
.await;
let cmd = ListConditionalFormatsCommand {
spreadsheet_id: "sheet-1".to_string(),
output: OutputFormat::Table,
};
assert!(cmd.execute(&client).await.is_ok());
}
}