1use std::path::PathBuf;
40use std::time::{Duration, Instant};
41
42use serde::Serialize;
43
44use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
45use crate::drive::client::DriveClient;
46use crate::drive::files_api::FilesApi;
47use crate::drive::lease::check::{
48 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
49 LeasedWrite,
50};
51use crate::drive::sheets::a1;
52use crate::drive::sheets::api::SheetsApi;
53use crate::drive::sheets::client::SheetsClient;
54use crate::drive::sheets::date_value::{
55 reject_blank, reject_blank_date, reject_empty, reject_invalid_date_between, reject_nan,
56 reject_reversed_range, DateValue,
57};
58use crate::drive::sheets::format::parse_hex_color;
59use crate::drive::sheets::grid_range;
60use crate::drive::sheets::target_gate;
61use crate::drive::sheets::types::{
62 AddConditionalFormatRuleRequest, BatchUpdateRequestItem, BooleanCondition, BooleanRule,
63 CellFormat, ColorStyle, ConditionValue, ConditionalFormatRule,
64 DeleteConditionalFormatRuleRequest, GradientRule, GridRange, InterpolationPoint, Sheet,
65 Spreadsheet, TextFormat, UpdateConditionalFormatRuleRequest,
66};
67use crate::drive::types::SheetTargetRefusal;
68use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
69use crate::request_log::{self, DriveMutationOutcome};
70
71#[derive(Debug, Clone, PartialEq)]
76pub enum FormatCondition {
77 NumberBetween(f64, f64),
79 NumberNotBetween(f64, f64),
81 NumberGreater(f64),
83 NumberGreaterEq(f64),
85 NumberLess(f64),
87 NumberLessEq(f64),
89 NumberEq(f64),
91 NumberNotEq(f64),
93 TextContains(String),
95 TextNotContains(String),
97 TextStartsWith(String),
99 TextEndsWith(String),
101 TextEq(String),
103 DateAfter(DateValue),
105 DateBefore(DateValue),
107 DateOn(DateValue),
109 DateBetween(String, String),
111 CellEmpty,
113 CellNotEmpty,
115 CustomFormula(String),
117}
118
119impl FormatCondition {
120 const fn condition_type_str(&self) -> &'static str {
122 match self {
123 Self::NumberBetween(..) => "NUMBER_BETWEEN",
124 Self::NumberNotBetween(..) => "NUMBER_NOT_BETWEEN",
125 Self::NumberGreater(_) => "NUMBER_GREATER",
126 Self::NumberGreaterEq(_) => "NUMBER_GREATER_THAN_EQ",
127 Self::NumberLess(_) => "NUMBER_LESS",
128 Self::NumberLessEq(_) => "NUMBER_LESS_THAN_EQ",
129 Self::NumberEq(_) => "NUMBER_EQ",
130 Self::NumberNotEq(_) => "NUMBER_NOT_EQ",
131 Self::TextContains(_) => "TEXT_CONTAINS",
132 Self::TextNotContains(_) => "TEXT_NOT_CONTAINS",
133 Self::TextStartsWith(_) => "TEXT_STARTS_WITH",
134 Self::TextEndsWith(_) => "TEXT_ENDS_WITH",
135 Self::TextEq(_) => "TEXT_EQ",
136 Self::DateAfter(_) => "DATE_AFTER",
137 Self::DateBefore(_) => "DATE_BEFORE",
138 Self::DateOn(_) => "DATE_EQ",
139 Self::DateBetween(..) => "DATE_BETWEEN",
140 Self::CellEmpty => "BLANK",
141 Self::CellNotEmpty => "NOT_BLANK",
142 Self::CustomFormula(_) => "CUSTOM_FORMULA",
143 }
144 }
145
146 fn into_boolean_condition(self) -> BooleanCondition {
147 let value = |s: String| ConditionValue {
148 user_entered_value: Some(s),
149 relative_date: None,
150 };
151 let condition_type = self.condition_type_str().to_string();
152 let values = match self {
153 Self::NumberBetween(min, max) | Self::NumberNotBetween(min, max) => {
154 vec![value(min.to_string()), value(max.to_string())]
155 }
156 Self::NumberGreater(n)
157 | Self::NumberGreaterEq(n)
158 | Self::NumberLess(n)
159 | Self::NumberLessEq(n)
160 | Self::NumberEq(n)
161 | Self::NumberNotEq(n) => vec![value(n.to_string())],
162 Self::TextContains(text)
163 | Self::TextNotContains(text)
164 | Self::TextStartsWith(text)
165 | Self::TextEndsWith(text)
166 | Self::TextEq(text) => vec![value(text)],
167 Self::DateAfter(date) | Self::DateBefore(date) | Self::DateOn(date) => {
168 vec![date.into_condition_value()]
169 }
170 Self::DateBetween(start, end) => vec![value(start), value(end)],
171 Self::CellEmpty | Self::CellNotEmpty => Vec::new(),
172 Self::CustomFormula(formula) => vec![value(formula)],
173 };
174 BooleanCondition {
175 condition_type,
176 values,
177 }
178 }
179}
180
181#[derive(Debug, Clone, Default, PartialEq, Eq)]
186pub struct FormatEffect {
187 pub background: Option<String>,
189 pub text_color: Option<String>,
191 pub bold: Option<bool>,
193}
194
195impl FormatEffect {
196 fn into_cell_format(self) -> Result<CellFormat, String> {
197 let mut format = CellFormat::default();
198 if let Some(hex) = self.background {
199 format.background_color_style = Some(ColorStyle::rgb(
200 parse_hex_color(&hex).map_err(|e| format!("--background: {e}"))?,
201 ));
202 }
203 if self.text_color.is_some() || self.bold.is_some() {
204 let mut text_format = TextFormat::default();
205 if let Some(hex) = self.text_color {
206 text_format.foreground_color_style = Some(ColorStyle::rgb(
207 parse_hex_color(&hex).map_err(|e| format!("--text-color: {e}"))?,
208 ));
209 }
210 text_format.bold = self.bold;
211 format.text_format = Some(text_format);
212 }
213 Ok(format)
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum GradientPointType {
222 Number,
224 Percent,
226 Percentile,
228}
229
230impl GradientPointType {
231 const fn as_sheets_str(self) -> &'static str {
232 match self {
233 Self::Number => "NUMBER",
234 Self::Percent => "PERCENT",
235 Self::Percentile => "PERCENTILE",
236 }
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct GradientMidpoint {
243 pub color: String,
245 pub point_type: GradientPointType,
247 pub value: String,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
255pub struct GradientSpec {
256 pub min_color: String,
258 pub max_color: String,
260 pub mid: Option<GradientMidpoint>,
262}
263
264impl GradientSpec {
265 fn into_gradient_rule(self) -> Result<GradientRule, String> {
266 let min =
267 parse_hex_color(&self.min_color).map_err(|e| format!("--gradient-min-color: {e}"))?;
268 let max =
269 parse_hex_color(&self.max_color).map_err(|e| format!("--gradient-max-color: {e}"))?;
270 let midpoint = match self.mid {
271 Some(mid) => Some(InterpolationPoint {
272 color_style: ColorStyle::rgb(
273 parse_hex_color(&mid.color)
274 .map_err(|e| format!("--gradient-mid-color: {e}"))?,
275 ),
276 point_type: mid.point_type.as_sheets_str().to_string(),
277 value: Some(mid.value),
278 }),
279 None => None,
280 };
281 let endpoint = |rgb_color, point_type: &str| InterpolationPoint {
282 color_style: ColorStyle::rgb(rgb_color),
283 point_type: point_type.to_string(),
284 value: None,
285 };
286 Ok(GradientRule {
287 minpoint: Some(endpoint(min, "MIN")),
288 midpoint,
289 maxpoint: Some(endpoint(max, "MAX")),
290 })
291 }
292}
293
294#[derive(Debug, Clone, PartialEq)]
298pub enum FormatRule {
299 Boolean {
302 condition: FormatCondition,
304 format: FormatEffect,
306 },
307 Gradient(GradientSpec),
309}
310
311impl FormatRule {
312 fn into_conditional_format_rule(
313 self,
314 ranges: Vec<GridRange>,
315 ) -> Result<ConditionalFormatRule, String> {
316 match self {
317 Self::Boolean { condition, format } => Ok(ConditionalFormatRule {
318 ranges,
319 boolean_rule: Some(BooleanRule {
320 condition: condition.into_boolean_condition(),
321 format: format.into_cell_format()?,
322 }),
323 gradient_rule: None,
324 }),
325 Self::Gradient(spec) => Ok(ConditionalFormatRule {
326 ranges,
327 boolean_rule: None,
328 gradient_rule: Some(spec.into_gradient_rule()?),
329 }),
330 }
331 }
332}
333
334#[derive(Debug, Clone, PartialEq)]
336pub enum ConditionalFormatVerb {
337 AddConditionalFormat {
339 sheet: String,
341 ranges: Vec<String>,
343 index: Option<i64>,
346 rule: FormatRule,
348 },
349 UpdateConditionalFormat {
351 sheet: String,
353 ranges: Vec<String>,
355 index: usize,
357 rule: FormatRule,
359 },
360 DeleteConditionalFormat {
362 sheet: String,
364 index: usize,
366 },
367}
368
369impl ConditionalFormatVerb {
370 const fn log_operation(&self) -> &'static str {
371 match self {
372 Self::AddConditionalFormat { .. } => "sheets-add-conditional-format",
373 Self::UpdateConditionalFormat { .. } => "sheets-update-conditional-format",
374 Self::DeleteConditionalFormat { .. } => "sheets-delete-conditional-format",
375 }
376 }
377
378 const fn label(&self) -> &'static str {
379 match self {
380 Self::AddConditionalFormat { .. } => "add-conditional-format",
381 Self::UpdateConditionalFormat { .. } => "update-conditional-format",
382 Self::DeleteConditionalFormat { .. } => "delete-conditional-format",
383 }
384 }
385
386 fn sheet(&self) -> &str {
387 match self {
388 Self::AddConditionalFormat { sheet, .. }
389 | Self::UpdateConditionalFormat { sheet, .. }
390 | Self::DeleteConditionalFormat { sheet, .. } => sheet,
391 }
392 }
393
394 fn ranges(&self) -> &[String] {
395 match self {
396 Self::AddConditionalFormat { ranges, .. }
397 | Self::UpdateConditionalFormat { ranges, .. } => ranges,
398 Self::DeleteConditionalFormat { .. } => &[],
399 }
400 }
401
402 fn rule(&self) -> Option<&FormatRule> {
403 match self {
404 Self::AddConditionalFormat { rule, .. }
405 | Self::UpdateConditionalFormat { rule, .. } => Some(rule),
406 Self::DeleteConditionalFormat { .. } => None,
407 }
408 }
409}
410
411#[derive(Debug, Clone)]
413pub struct ConditionalFormatOptions {
414 pub spreadsheet_id: String,
416 pub verb: ConditionalFormatVerb,
418 pub dry_run: bool,
420 pub lease_token: Option<String>,
423 pub ledger_path: PathBuf,
425}
426
427#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
429#[serde(tag = "status", rename_all = "kebab-case")]
430pub enum ConditionalFormatResult {
431 WouldChange {
433 summary: String,
435 },
436 RefusedNotASpreadsheet {
438 mime_type: String,
440 },
441 RefusedShortcut,
443 RefusedNoVisibleParents,
445 RefusedSheetNotFound {
447 title: String,
449 available: Vec<String>,
451 },
452 RefusedInvalidRange {
455 detail: String,
457 },
458 RefusedIndexOutOfBounds {
464 sheet: String,
466 index: i64,
470 count: usize,
472 },
473 Blocked {
475 decided_by: Option<DecidingRule>,
477 },
478 RefusedNoLease,
480 RefusedLeaseExpired,
483 RefusedLeaseWrongFile,
485 RefusedLeaseStale,
487 Changed {
489 summary: String,
491 },
492 Failed {
494 detail: String,
496 },
497}
498
499impl FromLeaseRefusal for ConditionalFormatResult {
500 fn from_no_lease() -> Self {
501 Self::RefusedNoLease
502 }
503 fn from_lease_expired() -> Self {
504 Self::RefusedLeaseExpired
505 }
506 fn from_lease_wrong_file() -> Self {
507 Self::RefusedLeaseWrongFile
508 }
509 fn from_lease_stale() -> Self {
510 Self::RefusedLeaseStale
511 }
512 fn from_lease_failed(detail: String) -> Self {
513 Self::Failed { detail }
514 }
515}
516
517impl ConditionalFormatResult {
518 fn log_status(&self) -> &'static str {
519 match self {
520 Self::WouldChange { .. } => "would-change",
521 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
522 Self::RefusedShortcut => "refused-shortcut",
523 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
524 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
525 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
526 Self::RefusedIndexOutOfBounds { .. } => "refused-index-out-of-bounds",
527 Self::Blocked { .. } => "blocked",
528 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
529 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
530 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
531 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
532 Self::Changed { .. } => "changed",
533 Self::Failed { .. } => "failed",
534 }
535 }
536}
537
538#[derive(Debug, Clone, Serialize, PartialEq)]
540pub struct ConditionalFormatOutcome {
541 pub spreadsheet_id: String,
543 #[serde(skip_serializing_if = "Option::is_none")]
545 pub file_name: Option<String>,
546 #[serde(skip_serializing_if = "Option::is_none")]
548 pub resolved_folder_id: Option<String>,
549 #[serde(skip)]
551 pub verb: ConditionalFormatVerb,
552 pub result: ConditionalFormatResult,
554}
555
556impl JsonlSerialize for ConditionalFormatOutcome {
557 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
558 write_scalar_jsonl(self, out)
559 }
560}
561
562pub async fn conditional_format(
565 drive: &DriveClient,
566 sheets: &SheetsClient,
567 opts: &ConditionalFormatOptions,
568 rules: &[FolderPermissionRule],
569) -> ConditionalFormatOutcome {
570 let started = Instant::now();
571 let outcome = conditional_format_inner(drive, sheets, opts, rules).await;
572 if !opts.dry_run {
573 record_attempt(&outcome, opts, started.elapsed());
574 }
575 outcome
576}
577
578async fn conditional_format_inner(
579 drive: &DriveClient,
580 sheets: &SheetsClient,
581 opts: &ConditionalFormatOptions,
582 rules: &[FolderPermissionRule],
583) -> ConditionalFormatOutcome {
584 let bare = |result| ConditionalFormatOutcome {
585 spreadsheet_id: opts.spreadsheet_id.clone(),
586 file_name: None,
587 resolved_folder_id: None,
588 verb: opts.verb.clone(),
589 result,
590 };
591
592 if opts.verb.ranges().is_empty() && opts.verb.rule().is_some() {
593 return bare(ConditionalFormatResult::RefusedInvalidRange {
594 detail: format!("{} needs at least one --range", opts.verb.label()),
595 });
596 }
597
598 let composed_ranges: Vec<String> = match opts
599 .verb
600 .ranges()
601 .iter()
602 .map(|range| a1::compose(Some(opts.verb.sheet()), Some(range)))
603 .collect::<anyhow::Result<Vec<String>>>()
604 {
605 Ok(composed) => composed,
606 Err(err) => {
607 return bare(ConditionalFormatResult::RefusedInvalidRange {
608 detail: err.to_string(),
609 })
610 }
611 };
612
613 if let Some(rule) = opts.verb.rule() {
614 if let Err(detail) = validate_format_rule(rule) {
615 return bare(ConditionalFormatResult::RefusedInvalidRange { detail });
616 }
617 }
618
619 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
620 drive,
621 &opts.spreadsheet_id,
622 DriveOperation::SheetsStructure,
623 rules,
624 )
625 .await
626 {
627 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
628 return bare(ConditionalFormatResult::Failed { detail })
629 }
630 target_gate::TargetGateOutcome::Refused { target, refusal } => {
631 let result = match refusal {
632 SheetTargetRefusal::Shortcut => ConditionalFormatResult::RefusedShortcut,
633 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
634 ConditionalFormatResult::RefusedNotASpreadsheet { mime_type }
635 }
636 SheetTargetRefusal::NoVisibleParents => {
637 ConditionalFormatResult::RefusedNoVisibleParents
638 }
639 };
640 return ConditionalFormatOutcome {
641 spreadsheet_id: opts.spreadsheet_id.clone(),
642 file_name: Some(target.name),
643 resolved_folder_id: None,
644 verb: opts.verb.clone(),
645 result,
646 };
647 }
648 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
649 return ConditionalFormatOutcome {
650 spreadsheet_id: opts.spreadsheet_id.clone(),
651 file_name: Some(target.name),
652 resolved_folder_id: None,
653 verb: opts.verb.clone(),
654 result: ConditionalFormatResult::Failed { detail },
655 };
656 }
657 target_gate::TargetGateOutcome::Gated {
658 target,
659 decision,
660 resolved_folder_id,
661 requires_lease,
662 } => (target, decision, resolved_folder_id, requires_lease),
663 };
664
665 let gated = |result| ConditionalFormatOutcome {
666 spreadsheet_id: opts.spreadsheet_id.clone(),
667 file_name: Some(target.name.clone()),
668 resolved_folder_id: resolved_folder_id.clone(),
669 verb: opts.verb.clone(),
670 result,
671 };
672
673 if decision.verdict == write_gate::Verdict::Deny {
674 return gated(ConditionalFormatResult::Blocked {
675 decided_by: decision.decided_by,
676 });
677 }
678
679 let api = SheetsApi::new(sheets);
680 let workbook = match api
681 .get_spreadsheet_with_conditional_formats(&opts.spreadsheet_id)
682 .await
683 {
684 Ok(workbook) => workbook,
685 Err(err) => {
686 return gated(ConditionalFormatResult::Failed {
687 detail: format!("{err:#}"),
688 })
689 }
690 };
691
692 let sheet_id =
693 match grid_range::find_sheet_id(&workbook, opts.verb.sheet(), |title, available| {
694 ConditionalFormatResult::RefusedSheetNotFound { title, available }
695 }) {
696 Ok(id) => id,
697 Err(result) => return gated(result),
698 };
699
700 let mut grids = Vec::new();
701 for composed in &composed_ranges {
702 match grid_range::resolve_grid_range(
703 &workbook,
704 composed,
705 |detail| ConditionalFormatResult::RefusedInvalidRange { detail },
706 |title, available| ConditionalFormatResult::RefusedSheetNotFound { title, available },
707 ) {
708 Ok((_, grid)) => grids.push(grid),
709 Err(result) => return gated(result),
710 }
711 }
712
713 let sheet = find_sheet_by_id(&workbook, sheet_id);
714 let existing_count = sheet.map_or(0, |s| s.conditional_formats.len());
715
716 let (resolved_index, existing_summary) = match &opts.verb {
717 ConditionalFormatVerb::AddConditionalFormat { index, .. } => match index {
718 Some(explicit) => {
719 let in_bounds = usize::try_from(*explicit).is_ok_and(|i| i <= existing_count);
725 if !in_bounds {
726 return gated(ConditionalFormatResult::RefusedIndexOutOfBounds {
727 sheet: opts.verb.sheet().to_string(),
728 index: *explicit,
729 count: existing_count,
730 });
731 }
732 (*explicit, None)
733 }
734 None => (i64::try_from(existing_count).unwrap_or(i64::MAX), None),
735 },
736 ConditionalFormatVerb::UpdateConditionalFormat { index, .. }
737 | ConditionalFormatVerb::DeleteConditionalFormat { index, .. } => {
738 let idx = *index;
739 match sheet.and_then(|s| s.conditional_formats.get(idx)) {
740 Some(existing) => (
741 i64::try_from(idx).unwrap_or(i64::MAX),
742 Some(describe_existing_rule(existing)),
743 ),
744 None => {
745 return gated(ConditionalFormatResult::RefusedIndexOutOfBounds {
746 sheet: opts.verb.sheet().to_string(),
747 index: i64::try_from(idx).unwrap_or(i64::MAX),
748 count: existing_count,
749 })
750 }
751 }
752 }
753 };
754
755 let (summary, request) = match &opts.verb {
769 ConditionalFormatVerb::AddConditionalFormat { rule, .. } => {
770 let built = match rule.clone().into_conditional_format_rule(grids) {
771 Ok(built) => built,
772 Err(detail) => {
773 return gated(ConditionalFormatResult::RefusedInvalidRange { detail })
774 }
775 };
776 let summary = format!(
777 "add conditional format ({})",
778 describe_existing_rule(&built)
779 );
780 let request =
781 BatchUpdateRequestItem::AddConditionalFormatRule(AddConditionalFormatRuleRequest {
782 rule: built,
783 index: resolved_index,
784 });
785 (summary, request)
786 }
787 ConditionalFormatVerb::UpdateConditionalFormat { rule, .. } => {
788 let built = match rule.clone().into_conditional_format_rule(grids) {
789 Ok(built) => built,
790 Err(detail) => {
791 return gated(ConditionalFormatResult::RefusedInvalidRange { detail })
792 }
793 };
794 let current = existing_summary.as_deref().unwrap_or("unknown");
795 let summary = format!(
796 "update conditional format rule at index {resolved_index} (currently: \
797 {current}) to ({})",
798 describe_existing_rule(&built)
799 );
800 let request = BatchUpdateRequestItem::UpdateConditionalFormatRule(
801 UpdateConditionalFormatRuleRequest {
802 rule: built,
803 index: resolved_index,
804 },
805 );
806 (summary, request)
807 }
808 ConditionalFormatVerb::DeleteConditionalFormat { .. } => {
809 let current = existing_summary.as_deref().unwrap_or("unknown");
810 let summary = format!(
811 "delete conditional format rule at index {resolved_index} (currently: \
812 {current})"
813 );
814 let request = BatchUpdateRequestItem::DeleteConditionalFormatRule(
815 DeleteConditionalFormatRuleRequest {
816 sheet_id,
817 index: resolved_index,
818 },
819 );
820 (summary, request)
821 }
822 };
823
824 if opts.dry_run {
825 return gated(ConditionalFormatResult::WouldChange { summary });
826 }
827
828 let files_api = FilesApi::new(drive);
833 let leased = LeasedWrite {
834 log_prefix: "drive sheets conditional_format",
835 operation: opts.verb.log_operation(),
836 ledger_path: &opts.ledger_path,
837 file_id: &opts.spreadsheet_id,
838 };
839 let lease_grant = match gate_optional_leased_write(
840 leased,
841 &files_api,
842 requires_lease,
843 opts.lease_token.as_deref(),
844 )
845 .await
846 {
847 Ok(grant) => grant,
848 Err(err) => return gated(err.into_result()),
849 };
850
851 let result = match conclude_native_leased_write(
852 leased,
853 &lease_grant,
854 &files_api,
855 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
856 |err| format!("{err:#}"),
857 )
858 .await
859 {
860 Ok(_response) => ConditionalFormatResult::Changed { summary },
861 Err(err) => ConditionalFormatResult::Failed {
862 detail: format!("{err:#}"),
863 },
864 };
865 drop(lease_grant);
866 gated(result)
867}
868
869fn find_sheet_by_id(workbook: &Spreadsheet, sheet_id: i64) -> Option<&Sheet> {
870 workbook
871 .sheets
872 .iter()
873 .find(|s| s.sheet_id() == Some(sheet_id))
874}
875
876fn validate_format_rule(rule: &FormatRule) -> Result<(), String> {
877 match rule {
878 FormatRule::Boolean { condition, format } => {
879 validate_condition(condition)?;
880 validate_format_effect(format)
881 }
882 FormatRule::Gradient(spec) => validate_gradient(spec),
883 }
884}
885
886fn validate_condition(condition: &FormatCondition) -> Result<(), String> {
887 match condition {
890 FormatCondition::NumberBetween(min, max) => {
891 reject_reversed_range(*min, *max, "--number-between")
892 }
893 FormatCondition::NumberNotBetween(min, max) => {
894 reject_reversed_range(*min, *max, "--number-not-between")
895 }
896 FormatCondition::NumberGreater(n) => reject_nan(*n, "--number-greater"),
897 FormatCondition::NumberGreaterEq(n) => reject_nan(*n, "--number-greater-eq"),
898 FormatCondition::NumberLess(n) => reject_nan(*n, "--number-less"),
899 FormatCondition::NumberLessEq(n) => reject_nan(*n, "--number-less-eq"),
900 FormatCondition::NumberEq(n) => reject_nan(*n, "--number-eq"),
901 FormatCondition::NumberNotEq(n) => reject_nan(*n, "--number-not-eq"),
902 FormatCondition::TextContains(text) => reject_empty(text, "--text-contains"),
903 FormatCondition::TextNotContains(text) => reject_empty(text, "--text-not-contains"),
904 FormatCondition::TextStartsWith(text) => reject_empty(text, "--text-starts-with"),
905 FormatCondition::TextEndsWith(text) => reject_empty(text, "--text-ends-with"),
906 FormatCondition::TextEq(text) => reject_empty(text, "--text-eq"),
907 FormatCondition::DateAfter(date) => reject_blank_date(date, "--date-after"),
908 FormatCondition::DateBefore(date) => reject_blank_date(date, "--date-before"),
909 FormatCondition::DateOn(date) => reject_blank_date(date, "--date-on"),
910 FormatCondition::DateBetween(start, end) => reject_invalid_date_between(start, end),
911 FormatCondition::CellEmpty | FormatCondition::CellNotEmpty => Ok(()),
912 FormatCondition::CustomFormula(formula) => reject_blank(formula, "--custom-formula"),
913 }
914}
915
916fn validate_format_effect(effect: &FormatEffect) -> Result<(), String> {
917 if let Some(hex) = &effect.background {
918 parse_hex_color(hex).map_err(|e| format!("--background: {e}"))?;
919 }
920 if let Some(hex) = &effect.text_color {
921 parse_hex_color(hex).map_err(|e| format!("--text-color: {e}"))?;
922 }
923 if effect.background.is_none() && effect.text_color.is_none() && effect.bold.is_none() {
924 return Err(
925 "a boolean conditional format rule needs at least one of --background/\
926 --text-color/--bold"
927 .to_string(),
928 );
929 }
930 Ok(())
931}
932
933fn validate_gradient(spec: &GradientSpec) -> Result<(), String> {
934 parse_hex_color(&spec.min_color).map_err(|e| format!("--gradient-min-color: {e}"))?;
935 parse_hex_color(&spec.max_color).map_err(|e| format!("--gradient-max-color: {e}"))?;
936 if let Some(mid) = &spec.mid {
937 parse_hex_color(&mid.color).map_err(|e| format!("--gradient-mid-color: {e}"))?;
938 if mid.value.trim().is_empty() {
939 return Err("--gradient-mid-value must not be empty".to_string());
940 }
941 }
942 Ok(())
943}
944
945pub(crate) fn describe_existing_rule(rule: &ConditionalFormatRule) -> String {
953 if let Some(boolean_rule) = &rule.boolean_rule {
954 let cond = boolean_rule
955 .condition
956 .condition_type
957 .to_ascii_lowercase()
958 .replace('_', " ");
959 let mut parts = Vec::new();
960 if boolean_rule.format.background_color_style.is_some() {
961 parts.push("background");
962 }
963 if let Some(text_format) = &boolean_rule.format.text_format {
964 if text_format.foreground_color_style.is_some() {
965 parts.push("text color");
966 }
967 if text_format.bold == Some(true) {
968 parts.push("bold");
969 }
970 }
971 let effect = if parts.is_empty() {
972 "format".to_string()
973 } else {
974 parts.join("+")
975 };
976 format!("boolean rule ({cond} -> {effect})")
977 } else if let Some(gradient_rule) = &rule.gradient_rule {
978 let mut anchors = String::new();
982 for (name, point, builtin) in [
983 ("min", &gradient_rule.minpoint, Some("MIN")),
984 ("mid", &gradient_rule.midpoint, None),
985 ("max", &gradient_rule.maxpoint, Some("MAX")),
986 ] {
987 let Some(point) = point else { continue };
988 if builtin.is_some_and(|t| point.point_type == t && point.value.is_none()) {
989 continue;
990 }
991 let point_type = point.point_type.to_ascii_lowercase();
992 anchors.push_str(&match &point.value {
993 Some(value) => format!(", {name} {point_type} at {value}"),
994 None => format!(", {name} {point_type}"),
995 });
996 }
997 format!("gradient rule (min/max colors{anchors})")
998 } else {
999 "rule (unrecognized type)".to_string()
1000 }
1001}
1002
1003fn record_attempt(
1004 outcome: &ConditionalFormatOutcome,
1005 opts: &ConditionalFormatOptions,
1006 duration: Duration,
1007) {
1008 let error = match &outcome.result {
1009 ConditionalFormatResult::Failed { detail } => Some(detail.clone()),
1010 _ => None,
1011 };
1012 let decided_by = match &outcome.result {
1013 ConditionalFormatResult::Blocked { decided_by } => decided_by.as_ref(),
1014 _ => None,
1015 };
1016 let decided_by = write_gate::decided_by_log_fields(decided_by);
1017
1018 request_log::record_drive_mutation(DriveMutationOutcome {
1019 operation: opts.verb.log_operation(),
1020 file_id: outcome.spreadsheet_id.clone(),
1021 file_name: outcome.file_name.clone().unwrap_or_default(),
1022 status: outcome.result.log_status().to_string(),
1023 resolved_folder_id: outcome.resolved_folder_id.clone(),
1024 decided_by_folder_id: decided_by.folder_id,
1025 decided_by_depth: decided_by.depth,
1026 decided_by_file_id: decided_by.file_id,
1027 error,
1028 duration,
1029 ..Default::default()
1030 });
1031}
1032
1033#[must_use]
1035pub fn describe(outcome: &ConditionalFormatOutcome) -> String {
1036 describe_lines(outcome).join("\n")
1037}
1038
1039#[must_use]
1042pub fn describe_lines(outcome: &ConditionalFormatOutcome) -> Vec<String> {
1043 let verb = &outcome.verb;
1044 let book = outcome.file_name.as_deref().map_or_else(
1045 || format!("'{}'", outcome.spreadsheet_id),
1046 |n| format!("'{n}'"),
1047 );
1048 match &outcome.result {
1049 ConditionalFormatResult::WouldChange { summary } => {
1050 vec![format!("Would {summary} in {book}")]
1051 }
1052 ConditionalFormatResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1053 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
1054 `drive sheets {}` only works on spreadsheets",
1055 verb.label()
1056 )],
1057 ConditionalFormatResult::RefusedShortcut => vec![format!(
1058 "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
1059 verb.label()
1060 )],
1061 ConditionalFormatResult::RefusedNoVisibleParents => vec![format!(
1062 "Refused: {book} has no parent folder visible to this account, so no folder \
1063 rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
1064 id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
1065 )],
1066 ConditionalFormatResult::RefusedSheetNotFound { title, available } => {
1067 vec![grid_range::no_sheet_refusal(&book, title, available)]
1068 }
1069 ConditionalFormatResult::RefusedInvalidRange { detail } => {
1070 vec![format!("Refused: {detail}")]
1071 }
1072 ConditionalFormatResult::RefusedIndexOutOfBounds {
1073 sheet,
1074 index,
1075 count,
1076 } => {
1077 let max_valid = match verb {
1081 ConditionalFormatVerb::AddConditionalFormat { .. } => *count,
1082 ConditionalFormatVerb::UpdateConditionalFormat { .. }
1083 | ConditionalFormatVerb::DeleteConditionalFormat { .. } => count.saturating_sub(1),
1084 };
1085 vec![format!(
1086 "Refused: sheet '{sheet}' has {count} conditional format rule(s) (valid \
1087 indices 0-{max_valid}); index {index} is out of bounds. Run `drive sheets \
1088 list-conditional-formats` to see the current indices."
1089 )]
1090 }
1091 ConditionalFormatResult::Blocked { decided_by } => vec![match decided_by {
1092 Some(rule) => format!(
1093 "Blocked: {} on {book} refused by rule on {} {}{}",
1094 verb.label(),
1095 rule.kind_label(),
1096 rule.id(),
1097 rule.depth_suffix()
1098 ),
1099 None => format!(
1100 "Blocked: {} on {book} refused by default policy (no matching rule for \
1101 sheets-structure)",
1102 verb.label()
1103 ),
1104 }],
1105 ConditionalFormatResult::RefusedNoLease => LeaseGateRefusal::NoLease
1106 .describe_line(&outcome.spreadsheet_id, &book)
1107 .into_iter()
1108 .collect(),
1109 ConditionalFormatResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1110 .describe_line(&outcome.spreadsheet_id, &book)
1111 .into_iter()
1112 .collect(),
1113 ConditionalFormatResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1114 .describe_line(&outcome.spreadsheet_id, &book)
1115 .into_iter()
1116 .collect(),
1117 ConditionalFormatResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1118 .describe_line(&outcome.spreadsheet_id, &book)
1119 .into_iter()
1120 .collect(),
1121 ConditionalFormatResult::Changed { summary } => {
1122 vec![format!("Applied: {summary} in {book}")]
1123 }
1124 ConditionalFormatResult::Failed { detail } => vec![format!("Failed: {detail}")],
1125 }
1126}
1127
1128#[cfg(test)]
1129#[allow(clippy::unwrap_used, clippy::expect_used)]
1130mod tests {
1131 use super::*;
1132 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1133 use crate::drive::sheets::client::SHEETS_API_URL;
1134 use crate::drive::test_support::seed_lease;
1135 use crate::test_support::env::MapEnv;
1136 use crate::utils::secret::Secret;
1137 use std::collections::HashSet;
1138
1139 #[test]
1142 fn condition_type_str_covers_every_condition() {
1143 let types = [
1144 FormatCondition::NumberBetween(1.0, 2.0).condition_type_str(),
1145 FormatCondition::NumberNotBetween(1.0, 2.0).condition_type_str(),
1146 FormatCondition::NumberGreater(1.0).condition_type_str(),
1147 FormatCondition::NumberGreaterEq(1.0).condition_type_str(),
1148 FormatCondition::NumberLess(1.0).condition_type_str(),
1149 FormatCondition::NumberLessEq(1.0).condition_type_str(),
1150 FormatCondition::NumberEq(1.0).condition_type_str(),
1151 FormatCondition::NumberNotEq(1.0).condition_type_str(),
1152 FormatCondition::TextContains("x".to_string()).condition_type_str(),
1153 FormatCondition::TextNotContains("x".to_string()).condition_type_str(),
1154 FormatCondition::TextStartsWith("x".to_string()).condition_type_str(),
1155 FormatCondition::TextEndsWith("x".to_string()).condition_type_str(),
1156 FormatCondition::TextEq("x".to_string()).condition_type_str(),
1157 FormatCondition::DateAfter(DateValue::Absolute("d".to_string())).condition_type_str(),
1158 FormatCondition::DateBefore(DateValue::Absolute("d".to_string())).condition_type_str(),
1159 FormatCondition::DateOn(DateValue::Absolute("d".to_string())).condition_type_str(),
1160 FormatCondition::DateBetween("a".to_string(), "b".to_string()).condition_type_str(),
1161 FormatCondition::CellEmpty.condition_type_str(),
1162 FormatCondition::CellNotEmpty.condition_type_str(),
1163 FormatCondition::CustomFormula("=TRUE".to_string()).condition_type_str(),
1164 ];
1165 let unique: HashSet<&&str> = types.iter().collect();
1166 assert_eq!(unique.len(), types.len());
1167 }
1168
1169 #[test]
1170 fn cell_empty_and_cell_not_empty_have_no_values() {
1171 let built = FormatCondition::CellEmpty.into_boolean_condition();
1172 assert_eq!(built.condition_type, "BLANK");
1173 assert!(built.values.is_empty());
1174 let built = FormatCondition::CellNotEmpty.into_boolean_condition();
1175 assert_eq!(built.condition_type, "NOT_BLANK");
1176 assert!(built.values.is_empty());
1177 }
1178
1179 fn add_request_json(rule: FormatRule) -> serde_json::Value {
1182 let rule = rule
1183 .into_conditional_format_rule(vec![GridRange::default()])
1184 .unwrap();
1185 serde_json::to_value(BatchUpdateRequestItem::AddConditionalFormatRule(
1186 AddConditionalFormatRuleRequest { rule, index: 0 },
1187 ))
1188 .unwrap()
1189 }
1190
1191 #[test]
1192 fn cell_empty_and_cell_not_empty_send_the_sheets_blank_condition_types() {
1193 for (condition, expected) in [
1196 (FormatCondition::CellEmpty, "BLANK"),
1197 (FormatCondition::CellNotEmpty, "NOT_BLANK"),
1198 ] {
1199 let body = add_request_json(FormatRule::Boolean {
1200 condition,
1201 format: FormatEffect {
1202 background: Some("#CCCCCC".to_string()),
1203 ..FormatEffect::default()
1204 },
1205 });
1206 assert_eq!(
1207 body["addConditionalFormatRule"]["rule"]["booleanRule"]["condition"],
1208 serde_json::json!({"type": expected}),
1209 );
1210 }
1211 }
1212
1213 #[test]
1214 fn a_two_point_gradient_serialises_as_min_and_max_interpolation_points() {
1215 let body = add_request_json(FormatRule::Gradient(GradientSpec {
1218 min_color: "#FFFFFF".to_string(),
1219 max_color: "#00FF00".to_string(),
1220 mid: None,
1221 }));
1222 assert_eq!(
1223 body["addConditionalFormatRule"]["rule"]["gradientRule"],
1224 serde_json::json!({
1225 "minpoint": {
1226 "colorStyle": {"rgbColor": {"red": 1.0, "green": 1.0, "blue": 1.0}},
1227 "type": "MIN",
1228 },
1229 "maxpoint": {
1230 "colorStyle": {"rgbColor": {"red": 0.0, "green": 1.0, "blue": 0.0}},
1231 "type": "MAX",
1232 },
1233 }),
1234 );
1235 }
1236
1237 #[test]
1238 fn a_three_point_gradient_serialises_its_midpoint_with_a_value() {
1239 let body = add_request_json(FormatRule::Gradient(GradientSpec {
1240 min_color: "#FFFFFF".to_string(),
1241 max_color: "#00FF00".to_string(),
1242 mid: Some(GradientMidpoint {
1243 color: "#FF0000".to_string(),
1244 point_type: GradientPointType::Percent,
1245 value: "50".to_string(),
1246 }),
1247 }));
1248 let gradient = &body["addConditionalFormatRule"]["rule"]["gradientRule"];
1249 assert_eq!(
1250 gradient["midpoint"],
1251 serde_json::json!({
1252 "colorStyle": {"rgbColor": {"red": 1.0, "green": 0.0, "blue": 0.0}},
1253 "type": "PERCENT",
1254 "value": "50",
1255 }),
1256 );
1257 assert_eq!(gradient["minpoint"]["type"], "MIN");
1258 assert_eq!(gradient["maxpoint"]["type"], "MAX");
1259 }
1260
1261 #[test]
1262 fn number_greater_builds_a_single_value() {
1263 let built = FormatCondition::NumberGreater(10.0).into_boolean_condition();
1264 assert_eq!(built.condition_type, "NUMBER_GREATER");
1265 assert_eq!(built.values[0].user_entered_value, Some("10".to_string()));
1266 }
1267
1268 #[test]
1269 fn date_condition_builds_a_relative_value() {
1270 let condition = FormatCondition::DateAfter(DateValue::parse("today".to_string()));
1271 let built = condition.into_boolean_condition();
1272 assert_eq!(built.condition_type, "DATE_AFTER");
1273 assert_eq!(built.values[0].user_entered_value, None);
1274 assert_eq!(built.values[0].relative_date, Some("TODAY".to_string()));
1275 }
1276
1277 #[test]
1278 fn into_boolean_condition_covers_every_remaining_variant() {
1279 let cases: Vec<(FormatCondition, &str, usize)> = vec![
1283 (
1284 FormatCondition::NumberBetween(1.0, 2.0),
1285 "NUMBER_BETWEEN",
1286 2,
1287 ),
1288 (
1289 FormatCondition::NumberNotBetween(1.0, 2.0),
1290 "NUMBER_NOT_BETWEEN",
1291 2,
1292 ),
1293 (
1294 FormatCondition::NumberGreaterEq(1.0),
1295 "NUMBER_GREATER_THAN_EQ",
1296 1,
1297 ),
1298 (FormatCondition::NumberLess(1.0), "NUMBER_LESS", 1),
1299 (FormatCondition::NumberLessEq(1.0), "NUMBER_LESS_THAN_EQ", 1),
1300 (FormatCondition::NumberEq(1.0), "NUMBER_EQ", 1),
1301 (FormatCondition::NumberNotEq(1.0), "NUMBER_NOT_EQ", 1),
1302 (
1303 FormatCondition::TextContains("x".to_string()),
1304 "TEXT_CONTAINS",
1305 1,
1306 ),
1307 (
1308 FormatCondition::TextNotContains("x".to_string()),
1309 "TEXT_NOT_CONTAINS",
1310 1,
1311 ),
1312 (
1313 FormatCondition::TextStartsWith("x".to_string()),
1314 "TEXT_STARTS_WITH",
1315 1,
1316 ),
1317 (
1318 FormatCondition::TextEndsWith("x".to_string()),
1319 "TEXT_ENDS_WITH",
1320 1,
1321 ),
1322 (FormatCondition::TextEq("x".to_string()), "TEXT_EQ", 1),
1323 (
1324 FormatCondition::DateBefore(DateValue::Absolute("2024-01-01".to_string())),
1325 "DATE_BEFORE",
1326 1,
1327 ),
1328 (
1329 FormatCondition::DateOn(DateValue::Absolute("2024-01-01".to_string())),
1330 "DATE_EQ",
1331 1,
1332 ),
1333 (
1334 FormatCondition::DateBetween("2024-01-01".to_string(), "2024-01-02".to_string()),
1335 "DATE_BETWEEN",
1336 2,
1337 ),
1338 (
1339 FormatCondition::CustomFormula("=A1>0".to_string()),
1340 "CUSTOM_FORMULA",
1341 1,
1342 ),
1343 ];
1344 for (condition, expected_type, expected_len) in cases {
1345 let built = condition.into_boolean_condition();
1346 assert_eq!(built.condition_type, expected_type, "{expected_type}");
1347 assert_eq!(built.values.len(), expected_len, "{expected_type}");
1348 }
1349 }
1350
1351 #[test]
1354 fn into_gradient_rule_builds_each_midpoint_type() {
1355 for (point_type, expected) in [
1356 (GradientPointType::Number, "NUMBER"),
1357 (GradientPointType::Percent, "PERCENT"),
1358 (GradientPointType::Percentile, "PERCENTILE"),
1359 ] {
1360 let spec = GradientSpec {
1361 min_color: "#FFFFFF".to_string(),
1362 max_color: "#000000".to_string(),
1363 mid: Some(GradientMidpoint {
1364 color: "#FF00FF".to_string(),
1365 point_type,
1366 value: "50".to_string(),
1367 }),
1368 };
1369 let built = spec.into_gradient_rule().unwrap();
1370 let midpoint = built.midpoint.expect("midpoint should be set");
1371 assert_eq!(midpoint.point_type, expected, "{expected}");
1372 assert_eq!(midpoint.value.as_deref(), Some("50"));
1373 assert_eq!(
1374 midpoint.color_style.rgb_color,
1375 Some(parse_hex_color("#FF00FF").unwrap())
1376 );
1377 let min = built.minpoint.expect("minpoint should be set");
1378 assert_eq!(min.point_type, "MIN");
1379 assert_eq!(min.value, None);
1380 assert_eq!(
1381 min.color_style.rgb_color,
1382 Some(parse_hex_color("#FFFFFF").unwrap())
1383 );
1384 let max = built.maxpoint.expect("maxpoint should be set");
1385 assert_eq!(max.point_type, "MAX");
1386 assert_eq!(max.value, None);
1387 assert_eq!(
1388 max.color_style.rgb_color,
1389 Some(parse_hex_color("#000000").unwrap())
1390 );
1391 }
1392 }
1393
1394 #[test]
1395 fn into_gradient_rule_propagates_a_malformed_midpoint_color() {
1396 let spec = GradientSpec {
1402 min_color: "#FFFFFF".to_string(),
1403 max_color: "#000000".to_string(),
1404 mid: Some(GradientMidpoint {
1405 color: "not-a-color".to_string(),
1406 point_type: GradientPointType::Number,
1407 value: "50".to_string(),
1408 }),
1409 };
1410 let err = spec.into_gradient_rule().unwrap_err();
1411 assert!(err.contains("--gradient-mid-color"), "{err}");
1412 }
1413
1414 #[test]
1415 fn into_cell_format_propagates_a_malformed_color() {
1416 let err = FormatEffect {
1421 background: Some("not-a-color".to_string()),
1422 text_color: None,
1423 bold: None,
1424 }
1425 .into_cell_format()
1426 .unwrap_err();
1427 assert!(err.contains("--background"), "{err}");
1428
1429 let err = FormatEffect {
1430 background: None,
1431 text_color: Some("not-a-color".to_string()),
1432 bold: None,
1433 }
1434 .into_cell_format()
1435 .unwrap_err();
1436 assert!(err.contains("--text-color"), "{err}");
1437 }
1438
1439 #[test]
1440 fn into_gradient_rule_propagates_a_malformed_min_or_max_color() {
1441 let err = GradientSpec {
1442 min_color: "not-a-color".to_string(),
1443 max_color: "#000000".to_string(),
1444 mid: None,
1445 }
1446 .into_gradient_rule()
1447 .unwrap_err();
1448 assert!(err.contains("--gradient-min-color"), "{err}");
1449
1450 let err = GradientSpec {
1451 min_color: "#FFFFFF".to_string(),
1452 max_color: "not-a-color".to_string(),
1453 mid: None,
1454 }
1455 .into_gradient_rule()
1456 .unwrap_err();
1457 assert!(err.contains("--gradient-max-color"), "{err}");
1458 }
1459
1460 #[test]
1461 fn into_gradient_rule_with_no_midpoint_leaves_it_none() {
1462 let built = GradientSpec {
1467 min_color: "#FFFFFF".to_string(),
1468 max_color: "#000000".to_string(),
1469 mid: None,
1470 }
1471 .into_gradient_rule()
1472 .unwrap();
1473 assert!(built.midpoint.is_none());
1474 }
1475
1476 #[test]
1477 fn into_conditional_format_rule_covers_both_variants_and_their_errors() {
1478 let rule = FormatRule::Boolean {
1484 condition: FormatCondition::CellEmpty,
1485 format: FormatEffect {
1486 background: Some("#FF0000".to_string()),
1487 text_color: None,
1488 bold: None,
1489 },
1490 }
1491 .into_conditional_format_rule(vec![GridRange::default()])
1492 .unwrap();
1493 assert!(rule.boolean_rule.is_some());
1494 assert!(rule.gradient_rule.is_none());
1495
1496 let err = FormatRule::Boolean {
1497 condition: FormatCondition::CellEmpty,
1498 format: FormatEffect {
1499 background: Some("not-a-color".to_string()),
1500 text_color: None,
1501 bold: None,
1502 },
1503 }
1504 .into_conditional_format_rule(vec![GridRange::default()])
1505 .unwrap_err();
1506 assert!(err.contains("--background"), "{err}");
1507
1508 let rule = FormatRule::Gradient(GradientSpec {
1509 min_color: "#FFFFFF".to_string(),
1510 max_color: "#000000".to_string(),
1511 mid: None,
1512 })
1513 .into_conditional_format_rule(vec![GridRange::default()])
1514 .unwrap();
1515 assert!(rule.gradient_rule.is_some());
1516 assert!(rule.boolean_rule.is_none());
1517
1518 let err = FormatRule::Gradient(GradientSpec {
1519 min_color: "not-a-color".to_string(),
1520 max_color: "#000000".to_string(),
1521 mid: None,
1522 })
1523 .into_conditional_format_rule(vec![GridRange::default()])
1524 .unwrap_err();
1525 assert!(err.contains("--gradient-min-color"), "{err}");
1526 }
1527
1528 #[test]
1531 fn validate_format_rule_short_circuits_on_a_condition_error() {
1532 let err = validate_format_rule(&FormatRule::Boolean {
1537 condition: FormatCondition::NumberBetween(10.0, 1.0),
1538 format: FormatEffect {
1539 background: Some("#FF0000".to_string()),
1540 text_color: None,
1541 bold: None,
1542 },
1543 })
1544 .unwrap_err();
1545 assert!(err.contains("must not exceed"), "{err}");
1546
1547 validate_format_rule(&FormatRule::Boolean {
1548 condition: FormatCondition::CellEmpty,
1549 format: FormatEffect {
1550 background: Some("#FF0000".to_string()),
1551 text_color: None,
1552 bold: None,
1553 },
1554 })
1555 .unwrap();
1556 }
1557
1558 #[test]
1559 fn validate_format_rule_dispatches_gradient_specs_to_validate_gradient() {
1560 validate_format_rule(&FormatRule::Gradient(GradientSpec {
1564 min_color: "#FFFFFF".to_string(),
1565 max_color: "#000000".to_string(),
1566 mid: None,
1567 }))
1568 .unwrap();
1569
1570 let err = validate_format_rule(&FormatRule::Gradient(GradientSpec {
1571 min_color: "not-a-color".to_string(),
1572 max_color: "#000000".to_string(),
1573 mid: None,
1574 }))
1575 .unwrap_err();
1576 assert!(err.contains("--gradient-min-color"), "{err}");
1577 }
1578
1579 #[test]
1582 fn validate_condition_rejects_a_reversed_number_between() {
1583 let err = validate_condition(&FormatCondition::NumberBetween(10.0, 1.0)).unwrap_err();
1584 assert!(err.contains("must not exceed"), "{err}");
1585 }
1586
1587 #[test]
1588 fn validate_condition_accepts_cell_empty() {
1589 validate_condition(&FormatCondition::CellEmpty).unwrap();
1590 validate_condition(&FormatCondition::CellNotEmpty).unwrap();
1591 }
1592
1593 #[test]
1594 fn validate_condition_rejects_a_reversed_number_not_between() {
1595 let err = validate_condition(&FormatCondition::NumberNotBetween(10.0, 1.0)).unwrap_err();
1596 assert!(err.contains("--number-not-between"), "{err}");
1597 assert!(err.contains("must not exceed"), "{err}");
1598 }
1599
1600 #[test]
1601 fn validate_condition_rejects_nan_across_every_single_number_flag() {
1602 let cases = [
1603 (FormatCondition::NumberGreater(f64::NAN), "--number-greater"),
1604 (
1605 FormatCondition::NumberGreaterEq(f64::NAN),
1606 "--number-greater-eq",
1607 ),
1608 (FormatCondition::NumberLess(f64::NAN), "--number-less"),
1609 (FormatCondition::NumberLessEq(f64::NAN), "--number-less-eq"),
1610 (FormatCondition::NumberEq(f64::NAN), "--number-eq"),
1611 (FormatCondition::NumberNotEq(f64::NAN), "--number-not-eq"),
1612 ];
1613 for (condition, flag) in cases {
1614 let err = validate_condition(&condition).unwrap_err();
1615 assert!(err.contains(flag), "{err}");
1616 assert!(err.contains("NaN"), "{err}");
1617 }
1618 validate_condition(&FormatCondition::NumberGreater(1.0)).unwrap();
1620 validate_condition(&FormatCondition::NumberGreaterEq(1.0)).unwrap();
1621 validate_condition(&FormatCondition::NumberLess(1.0)).unwrap();
1622 validate_condition(&FormatCondition::NumberLessEq(1.0)).unwrap();
1623 validate_condition(&FormatCondition::NumberEq(1.0)).unwrap();
1624 validate_condition(&FormatCondition::NumberNotEq(1.0)).unwrap();
1625 }
1626
1627 #[test]
1628 fn validate_condition_rejects_an_empty_string_across_every_text_flag() {
1629 let cases = [
1630 (
1631 FormatCondition::TextContains(String::new()),
1632 "--text-contains",
1633 ),
1634 (
1635 FormatCondition::TextNotContains(String::new()),
1636 "--text-not-contains",
1637 ),
1638 (
1639 FormatCondition::TextStartsWith(String::new()),
1640 "--text-starts-with",
1641 ),
1642 (
1643 FormatCondition::TextEndsWith(String::new()),
1644 "--text-ends-with",
1645 ),
1646 (FormatCondition::TextEq(String::new()), "--text-eq"),
1647 ];
1648 for (condition, flag) in cases {
1649 let err = validate_condition(&condition).unwrap_err();
1650 assert!(err.contains(flag), "{err}");
1651 }
1652 validate_condition(&FormatCondition::TextContains("x".to_string())).unwrap();
1654 validate_condition(&FormatCondition::TextNotContains("x".to_string())).unwrap();
1655 validate_condition(&FormatCondition::TextStartsWith("x".to_string())).unwrap();
1656 validate_condition(&FormatCondition::TextEndsWith("x".to_string())).unwrap();
1657 validate_condition(&FormatCondition::TextEq("x".to_string())).unwrap();
1658 }
1659
1660 #[test]
1661 fn validate_condition_rejects_a_blank_date_across_every_single_date_flag() {
1662 let blank = DateValue::Absolute(String::new());
1663 let cases = [
1664 (FormatCondition::DateAfter(blank.clone()), "--date-after"),
1665 (FormatCondition::DateBefore(blank.clone()), "--date-before"),
1666 (FormatCondition::DateOn(blank), "--date-on"),
1667 ];
1668 for (condition, flag) in cases {
1669 let err = validate_condition(&condition).unwrap_err();
1670 assert!(err.contains(flag), "{err}");
1671 }
1672 let today = DateValue::parse("today".to_string());
1674 validate_condition(&FormatCondition::DateAfter(today.clone())).unwrap();
1675 validate_condition(&FormatCondition::DateBefore(today.clone())).unwrap();
1676 validate_condition(&FormatCondition::DateOn(today)).unwrap();
1677 }
1678
1679 #[test]
1680 fn validate_condition_rejects_an_invalid_or_reversed_date_between() {
1681 let err = validate_condition(&FormatCondition::DateBetween(
1682 "2024-01-01".to_string(),
1683 String::new(),
1684 ))
1685 .unwrap_err();
1686 assert!(err.contains("--date-between"), "{err}");
1687
1688 let err = validate_condition(&FormatCondition::DateBetween(
1689 "2024-06-01".to_string(),
1690 "2024-01-01".to_string(),
1691 ))
1692 .unwrap_err();
1693 assert!(err.contains("--date-between"), "{err}");
1694
1695 validate_condition(&FormatCondition::DateBetween(
1696 "2024-01-01".to_string(),
1697 "2024-06-01".to_string(),
1698 ))
1699 .unwrap();
1700 }
1701
1702 #[test]
1703 fn validate_condition_rejects_a_blank_custom_formula() {
1704 let err =
1705 validate_condition(&FormatCondition::CustomFormula(" ".to_string())).unwrap_err();
1706 assert!(err.contains("--custom-formula"), "{err}");
1707
1708 validate_condition(&FormatCondition::CustomFormula("=A1>0".to_string())).unwrap();
1709 }
1710
1711 #[test]
1712 fn validate_format_effect_rejects_no_fields_set() {
1713 let err = validate_format_effect(&FormatEffect::default()).unwrap_err();
1714 assert!(err.contains("at least one of"), "{err}");
1715 }
1716
1717 #[test]
1718 fn validate_format_effect_accepts_background_only() {
1719 validate_format_effect(&FormatEffect {
1720 background: Some("#FF0000".to_string()),
1721 text_color: None,
1722 bold: None,
1723 })
1724 .unwrap();
1725 }
1726
1727 #[test]
1728 fn validate_format_effect_rejects_a_malformed_color() {
1729 let err = validate_format_effect(&FormatEffect {
1730 background: Some("not-a-color".to_string()),
1731 text_color: None,
1732 bold: None,
1733 })
1734 .unwrap_err();
1735 assert!(err.contains("--background"), "{err}");
1736 }
1737
1738 #[test]
1739 fn validate_gradient_accepts_min_max_only() {
1740 validate_gradient(&GradientSpec {
1741 min_color: "#FFFFFF".to_string(),
1742 max_color: "#00FF00".to_string(),
1743 mid: None,
1744 })
1745 .unwrap();
1746 }
1747
1748 #[test]
1749 fn validate_gradient_rejects_a_malformed_midpoint_color() {
1750 let err = validate_gradient(&GradientSpec {
1751 min_color: "#FFFFFF".to_string(),
1752 max_color: "#00FF00".to_string(),
1753 mid: Some(GradientMidpoint {
1754 color: "bad".to_string(),
1755 point_type: GradientPointType::Percent,
1756 value: "50".to_string(),
1757 }),
1758 })
1759 .unwrap_err();
1760 assert!(err.contains("--gradient-mid-color"), "{err}");
1761 }
1762
1763 #[test]
1764 fn validate_gradient_rejects_a_blank_midpoint_value() {
1765 let err = validate_gradient(&GradientSpec {
1766 min_color: "#FFFFFF".to_string(),
1767 max_color: "#00FF00".to_string(),
1768 mid: Some(GradientMidpoint {
1769 color: "#000000".to_string(),
1770 point_type: GradientPointType::Number,
1771 value: " ".to_string(),
1772 }),
1773 })
1774 .unwrap_err();
1775 assert!(err.contains("--gradient-mid-value"), "{err}");
1776 }
1777
1778 #[test]
1781 fn describe_existing_rule_covers_boolean_and_gradient() {
1782 let boolean = ConditionalFormatRule {
1783 ranges: vec![GridRange::default()],
1784 boolean_rule: Some(BooleanRule {
1785 condition: BooleanCondition {
1786 condition_type: "NUMBER_GREATER".to_string(),
1787 values: Vec::new(),
1788 },
1789 format: CellFormat::default(),
1790 }),
1791 gradient_rule: None,
1792 };
1793 assert!(describe_existing_rule(&boolean).contains("number greater"));
1794
1795 let gradient = ConditionalFormatRule {
1796 ranges: vec![GridRange::default()],
1797 boolean_rule: None,
1798 gradient_rule: Some(GradientRule::default()),
1799 };
1800 assert!(describe_existing_rule(&gradient).contains("gradient rule"));
1801 }
1802
1803 #[test]
1804 fn describe_existing_rule_joins_multiple_boolean_format_parts() {
1805 let rule = ConditionalFormatRule {
1806 ranges: vec![GridRange::default()],
1807 boolean_rule: Some(BooleanRule {
1808 condition: BooleanCondition {
1809 condition_type: "BLANK".to_string(),
1810 values: Vec::new(),
1811 },
1812 format: CellFormat {
1813 background_color_style: Some(ColorStyle::rgb(
1814 parse_hex_color("#FF0000").unwrap(),
1815 )),
1816 text_format: Some(TextFormat {
1817 foreground_color_style: Some(ColorStyle::rgb(
1818 parse_hex_color("#00FF00").unwrap(),
1819 )),
1820 bold: Some(true),
1821 ..Default::default()
1822 }),
1823 ..Default::default()
1824 },
1825 }),
1826 gradient_rule: None,
1827 };
1828 let described = describe_existing_rule(&rule);
1829 assert!(
1830 described.contains("background+text color+bold"),
1831 "{described}"
1832 );
1833 }
1834
1835 #[test]
1836 fn describe_existing_rule_includes_the_gradient_midpoint() {
1837 let rule = ConditionalFormatRule {
1838 ranges: vec![GridRange::default()],
1839 boolean_rule: None,
1840 gradient_rule: Some(GradientRule {
1841 minpoint: Some(point("MIN", None)),
1842 midpoint: Some(point("PERCENT", Some("50"))),
1843 maxpoint: Some(point("MAX", None)),
1844 }),
1845 };
1846 assert_eq!(
1847 describe_existing_rule(&rule),
1848 "gradient rule (min/max colors, mid percent at 50)"
1849 );
1850 }
1851
1852 fn point(point_type: &str, value: Option<&str>) -> InterpolationPoint {
1853 InterpolationPoint {
1854 color_style: ColorStyle::default(),
1855 point_type: point_type.to_string(),
1856 value: value.map(str::to_string),
1857 }
1858 }
1859
1860 #[test]
1861 fn describe_existing_rule_names_a_value_anchored_endpoint() {
1862 let rule = ConditionalFormatRule {
1865 ranges: vec![GridRange::default()],
1866 boolean_rule: None,
1867 gradient_rule: Some(GradientRule {
1868 minpoint: Some(point("NUMBER", Some("10"))),
1869 midpoint: None,
1870 maxpoint: Some(point("PERCENTILE", Some("90"))),
1871 }),
1872 };
1873 assert_eq!(
1874 describe_existing_rule(&rule),
1875 "gradient rule (min/max colors, min number at 10, max percentile at 90)"
1876 );
1877 }
1878
1879 #[test]
1880 fn describe_existing_rule_falls_back_for_an_unrecognized_rule_type() {
1881 let rule = ConditionalFormatRule {
1885 ranges: vec![GridRange::default()],
1886 boolean_rule: None,
1887 gradient_rule: None,
1888 };
1889 assert_eq!(describe_existing_rule(&rule), "rule (unrecognized type)");
1890 }
1891
1892 #[test]
1895 fn log_operation_and_label_cover_every_verb() {
1896 let add = ConditionalFormatVerb::AddConditionalFormat {
1897 sheet: "Q1".to_string(),
1898 ranges: vec!["A1".to_string()],
1899 index: None,
1900 rule: FormatRule::Boolean {
1901 condition: FormatCondition::CellEmpty,
1902 format: FormatEffect::default(),
1903 },
1904 };
1905 let update = ConditionalFormatVerb::UpdateConditionalFormat {
1906 sheet: "Q1".to_string(),
1907 ranges: vec!["A1".to_string()],
1908 index: 0,
1909 rule: FormatRule::Boolean {
1910 condition: FormatCondition::CellEmpty,
1911 format: FormatEffect::default(),
1912 },
1913 };
1914 let delete = ConditionalFormatVerb::DeleteConditionalFormat {
1915 sheet: "Q1".to_string(),
1916 index: 0,
1917 };
1918 assert_eq!(add.log_operation(), "sheets-add-conditional-format");
1919 assert_eq!(update.log_operation(), "sheets-update-conditional-format");
1920 assert_eq!(delete.log_operation(), "sheets-delete-conditional-format");
1921 assert_eq!(add.label(), "add-conditional-format");
1922 assert_eq!(update.label(), "update-conditional-format");
1923 assert_eq!(delete.label(), "delete-conditional-format");
1924 }
1925
1926 #[test]
1929 fn from_lease_refusal_maps_every_variant() {
1930 assert_eq!(
1931 <ConditionalFormatResult as FromLeaseRefusal>::from_no_lease(),
1932 ConditionalFormatResult::RefusedNoLease
1933 );
1934 assert_eq!(
1935 <ConditionalFormatResult as FromLeaseRefusal>::from_lease_expired(),
1936 ConditionalFormatResult::RefusedLeaseExpired
1937 );
1938 assert_eq!(
1939 <ConditionalFormatResult as FromLeaseRefusal>::from_lease_wrong_file(),
1940 ConditionalFormatResult::RefusedLeaseWrongFile
1941 );
1942 assert_eq!(
1943 <ConditionalFormatResult as FromLeaseRefusal>::from_lease_stale(),
1944 ConditionalFormatResult::RefusedLeaseStale
1945 );
1946 assert_eq!(
1947 <ConditionalFormatResult as FromLeaseRefusal>::from_lease_failed("boom".to_string()),
1948 ConditionalFormatResult::Failed {
1949 detail: "boom".to_string()
1950 }
1951 );
1952 }
1953
1954 #[test]
1955 fn log_status_covers_every_result_variant() {
1956 let cases = [
1957 (
1958 ConditionalFormatResult::WouldChange {
1959 summary: String::new(),
1960 },
1961 "would-change",
1962 ),
1963 (
1964 ConditionalFormatResult::RefusedNotASpreadsheet {
1965 mime_type: String::new(),
1966 },
1967 "refused-not-a-spreadsheet",
1968 ),
1969 (ConditionalFormatResult::RefusedShortcut, "refused-shortcut"),
1970 (
1971 ConditionalFormatResult::RefusedNoVisibleParents,
1972 "refused-no-visible-parents",
1973 ),
1974 (
1975 ConditionalFormatResult::RefusedSheetNotFound {
1976 title: String::new(),
1977 available: Vec::new(),
1978 },
1979 "refused-sheet-not-found",
1980 ),
1981 (
1982 ConditionalFormatResult::RefusedInvalidRange {
1983 detail: String::new(),
1984 },
1985 "refused-invalid-range",
1986 ),
1987 (
1988 ConditionalFormatResult::RefusedIndexOutOfBounds {
1989 sheet: String::new(),
1990 index: 0,
1991 count: 0,
1992 },
1993 "refused-index-out-of-bounds",
1994 ),
1995 (
1996 ConditionalFormatResult::Blocked { decided_by: None },
1997 "blocked",
1998 ),
1999 (ConditionalFormatResult::RefusedNoLease, "refused-no-lease"),
2000 (
2001 ConditionalFormatResult::RefusedLeaseExpired,
2002 "refused-lease-expired",
2003 ),
2004 (
2005 ConditionalFormatResult::RefusedLeaseWrongFile,
2006 "refused-lease-wrong-file",
2007 ),
2008 (
2009 ConditionalFormatResult::RefusedLeaseStale,
2010 "refused-lease-stale",
2011 ),
2012 (
2013 ConditionalFormatResult::Changed {
2014 summary: String::new(),
2015 },
2016 "changed",
2017 ),
2018 (
2019 ConditionalFormatResult::Failed {
2020 detail: String::new(),
2021 },
2022 "failed",
2023 ),
2024 ];
2025 for (result, expected) in cases {
2026 assert_eq!(result.log_status(), expected, "{result:?}");
2027 }
2028 }
2029
2030 #[test]
2031 fn write_jsonl_delegates_to_write_scalar_jsonl() {
2032 let outcome = ConditionalFormatOutcome {
2033 spreadsheet_id: "sheet-1".to_string(),
2034 file_name: Some("Budget".to_string()),
2035 resolved_folder_id: None,
2036 verb: add_verb(),
2037 result: ConditionalFormatResult::Changed {
2038 summary: "x".to_string(),
2039 },
2040 };
2041 let mut buf = Vec::new();
2042 outcome.write_jsonl(&mut buf).unwrap();
2043 let text = String::from_utf8(buf).unwrap();
2044 assert!(text.contains("\"status\":\"changed\""), "{text}");
2045 assert!(text.contains("\"spreadsheet_id\":\"sheet-1\""), "{text}");
2046 }
2047
2048 fn describe_outcome(
2051 verb: ConditionalFormatVerb,
2052 result: ConditionalFormatResult,
2053 ) -> ConditionalFormatOutcome {
2054 ConditionalFormatOutcome {
2055 spreadsheet_id: "sheet-1".to_string(),
2056 file_name: Some("Budget".to_string()),
2057 resolved_folder_id: None,
2058 verb,
2059 result,
2060 }
2061 }
2062
2063 #[test]
2064 fn describe_lines_renders_would_change_and_changed() {
2065 let outcome = describe_outcome(
2066 add_verb(),
2067 ConditionalFormatResult::WouldChange {
2068 summary: "add conditional format (boolean rule)".to_string(),
2069 },
2070 );
2071 let lines = describe_lines(&outcome);
2072 assert_eq!(lines.len(), 1);
2073 assert!(
2074 lines[0].contains("Would add conditional format"),
2075 "{lines:?}"
2076 );
2077 assert!(lines[0].contains("'Budget'"), "{lines:?}");
2078
2079 let outcome = describe_outcome(
2080 add_verb(),
2081 ConditionalFormatResult::Changed {
2082 summary: "add conditional format (boolean rule)".to_string(),
2083 },
2084 );
2085 let lines = describe_lines(&outcome);
2086 assert_eq!(lines.len(), 1);
2087 assert!(
2088 lines[0].contains("Applied: add conditional format"),
2089 "{lines:?}"
2090 );
2091 }
2092
2093 #[test]
2094 fn describe_lines_falls_back_to_the_spreadsheet_id_with_no_file_name() {
2095 let outcome = ConditionalFormatOutcome {
2096 spreadsheet_id: "sheet-1".to_string(),
2097 file_name: None,
2098 resolved_folder_id: None,
2099 verb: add_verb(),
2100 result: ConditionalFormatResult::Changed {
2101 summary: "add conditional format".to_string(),
2102 },
2103 };
2104 let lines = describe_lines(&outcome);
2105 assert!(lines[0].contains("'sheet-1'"), "{lines:?}");
2106 }
2107
2108 #[test]
2109 fn describe_lines_renders_refused_not_a_spreadsheet() {
2110 let outcome = describe_outcome(
2111 add_verb(),
2112 ConditionalFormatResult::RefusedNotASpreadsheet {
2113 mime_type: "text/plain".to_string(),
2114 },
2115 );
2116 let lines = describe_lines(&outcome);
2117 assert_eq!(lines.len(), 1);
2118 assert!(lines[0].contains("not a Google Sheet"), "{lines:?}");
2119 assert!(lines[0].contains("text/plain"), "{lines:?}");
2120 assert!(lines[0].contains("add-conditional-format"), "{lines:?}");
2121 }
2122
2123 #[test]
2124 fn describe_lines_renders_refused_shortcut() {
2125 let outcome = describe_outcome(add_verb(), ConditionalFormatResult::RefusedShortcut);
2126 let lines = describe_lines(&outcome);
2127 assert_eq!(lines.len(), 1);
2128 assert!(lines[0].contains("is a shortcut"), "{lines:?}");
2129 }
2130
2131 #[test]
2132 fn describe_lines_renders_refused_no_visible_parents() {
2133 let outcome =
2134 describe_outcome(add_verb(), ConditionalFormatResult::RefusedNoVisibleParents);
2135 let lines = describe_lines(&outcome);
2136 assert_eq!(lines.len(), 1);
2137 assert!(lines[0].contains("no parent folder visible"), "{lines:?}");
2138 assert!(lines[0].contains("sheets-structure"), "{lines:?}");
2139 }
2140
2141 #[test]
2142 fn describe_lines_renders_refused_sheet_not_found() {
2143 let outcome = describe_outcome(
2144 add_verb(),
2145 ConditionalFormatResult::RefusedSheetNotFound {
2146 title: "Missing".to_string(),
2147 available: vec!["Q1".to_string(), "Q2".to_string()],
2148 },
2149 );
2150 let lines = describe_lines(&outcome);
2151 assert_eq!(lines.len(), 1);
2152 assert!(lines[0].contains("no sheet titled 'Missing'"), "{lines:?}");
2153 assert!(lines[0].contains("'Q1', 'Q2'"), "{lines:?}");
2154
2155 let outcome = describe_outcome(
2156 add_verb(),
2157 ConditionalFormatResult::RefusedSheetNotFound {
2158 title: "Missing".to_string(),
2159 available: Vec::new(),
2160 },
2161 );
2162 let lines = describe_lines(&outcome);
2163 assert!(lines[0].contains("Available: none"), "{lines:?}");
2164 }
2165
2166 #[test]
2167 fn describe_lines_renders_refused_invalid_range() {
2168 let outcome = describe_outcome(
2169 add_verb(),
2170 ConditionalFormatResult::RefusedInvalidRange {
2171 detail: "bad range".to_string(),
2172 },
2173 );
2174 let lines = describe_lines(&outcome);
2175 assert_eq!(lines, vec!["Refused: bad range".to_string()]);
2176 }
2177
2178 #[test]
2179 fn describe_lines_renders_index_out_of_bounds_for_add_with_full_range() {
2180 let outcome = describe_outcome(
2183 add_verb(),
2184 ConditionalFormatResult::RefusedIndexOutOfBounds {
2185 sheet: "Q1".to_string(),
2186 index: 5,
2187 count: 3,
2188 },
2189 );
2190 let lines = describe_lines(&outcome);
2191 assert_eq!(lines.len(), 1);
2192 assert!(lines[0].contains("valid indices 0-3"), "{lines:?}");
2193 assert!(lines[0].contains("index 5 is out of bounds"), "{lines:?}");
2194 }
2195
2196 #[test]
2197 fn describe_lines_renders_index_out_of_bounds_for_update_and_delete_with_shrunk_range() {
2198 let update_verb = ConditionalFormatVerb::UpdateConditionalFormat {
2201 sheet: "Q1".to_string(),
2202 ranges: vec!["A1".to_string()],
2203 index: 5,
2204 rule: FormatRule::Boolean {
2205 condition: FormatCondition::CellEmpty,
2206 format: FormatEffect::default(),
2207 },
2208 };
2209 let outcome = describe_outcome(
2210 update_verb,
2211 ConditionalFormatResult::RefusedIndexOutOfBounds {
2212 sheet: "Q1".to_string(),
2213 index: 5,
2214 count: 3,
2215 },
2216 );
2217 let lines = describe_lines(&outcome);
2218 assert!(lines[0].contains("valid indices 0-2"), "{lines:?}");
2219
2220 let delete_verb = ConditionalFormatVerb::DeleteConditionalFormat {
2221 sheet: "Q1".to_string(),
2222 index: 5,
2223 };
2224 let outcome = describe_outcome(
2225 delete_verb,
2226 ConditionalFormatResult::RefusedIndexOutOfBounds {
2227 sheet: "Q1".to_string(),
2228 index: 5,
2229 count: 3,
2230 },
2231 );
2232 let lines = describe_lines(&outcome);
2233 assert!(lines[0].contains("valid indices 0-2"), "{lines:?}");
2234
2235 let delete_verb = ConditionalFormatVerb::DeleteConditionalFormat {
2238 sheet: "Q1".to_string(),
2239 index: 0,
2240 };
2241 let outcome = describe_outcome(
2242 delete_verb,
2243 ConditionalFormatResult::RefusedIndexOutOfBounds {
2244 sheet: "Q1".to_string(),
2245 index: 0,
2246 count: 0,
2247 },
2248 );
2249 let lines = describe_lines(&outcome);
2250 assert!(lines[0].contains("valid indices 0-0"), "{lines:?}");
2251 }
2252
2253 #[test]
2254 fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
2255 let outcome = describe_outcome(
2256 add_verb(),
2257 ConditionalFormatResult::Blocked {
2258 decided_by: Some(DecidingRule::Folder {
2259 folder_id: "folder-1".to_string(),
2260 depth: 2,
2261 }),
2262 },
2263 );
2264 let lines = describe_lines(&outcome);
2265 assert_eq!(lines.len(), 1);
2266 assert!(
2267 lines[0].contains("Blocked: add-conditional-format"),
2268 "{lines:?}"
2269 );
2270 assert!(lines[0].contains("rule on folder folder-1"), "{lines:?}");
2271 assert!(lines[0].contains("(depth 2)"), "{lines:?}");
2272
2273 let outcome = describe_outcome(
2274 add_verb(),
2275 ConditionalFormatResult::Blocked { decided_by: None },
2276 );
2277 let lines = describe_lines(&outcome);
2278 assert_eq!(lines.len(), 1);
2279 assert!(lines[0].contains("refused by default policy"), "{lines:?}");
2280 assert!(lines[0].contains("sheets-structure"), "{lines:?}");
2281 }
2282
2283 #[test]
2284 fn describe_lines_renders_every_lease_refusal() {
2285 for result in [
2286 ConditionalFormatResult::RefusedNoLease,
2287 ConditionalFormatResult::RefusedLeaseExpired,
2288 ConditionalFormatResult::RefusedLeaseWrongFile,
2289 ConditionalFormatResult::RefusedLeaseStale,
2290 ] {
2291 let outcome = describe_outcome(add_verb(), result.clone());
2292 let lines = describe_lines(&outcome);
2293 assert_eq!(lines.len(), 1, "{result:?} -> {lines:?}");
2294 assert!(lines[0].starts_with("Refused:"), "{result:?} -> {lines:?}");
2295 }
2296 }
2297
2298 #[test]
2299 fn describe_lines_renders_failed() {
2300 let outcome = describe_outcome(
2301 add_verb(),
2302 ConditionalFormatResult::Failed {
2303 detail: "boom".to_string(),
2304 },
2305 );
2306 let lines = describe_lines(&outcome);
2307 assert_eq!(lines, vec!["Failed: boom".to_string()]);
2308 }
2309
2310 fn test_credentials() -> DriveCredentials {
2313 DriveCredentials {
2314 client_id: "client-1".to_string(),
2315 client_secret: Secret::new("secret-1"),
2316 refresh_token: Secret::new("refresh-1"),
2317 scope: DriveGrantedScopes::READONLY,
2318 }
2319 }
2320
2321 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
2322 wiremock::Mock::given(wiremock::matchers::method("POST"))
2323 .and(wiremock::matchers::path("/token"))
2324 .respond_with(
2325 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2326 "access_token": "test-token", "expires_in": 3600,
2327 })),
2328 )
2329 .mount(server)
2330 .await;
2331 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
2332 crate::drive::client::test_support::replace_session(
2333 &mut drive,
2334 &test_credentials(),
2335 &format!("{}/token", server.uri()),
2336 );
2337 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
2338 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
2339 (drive, sheets)
2340 }
2341
2342 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
2343 let parents: Vec<&str> = parents.to_vec();
2344 wiremock::Mock::given(wiremock::matchers::method("GET"))
2345 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
2346 .respond_with(
2347 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2348 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
2349 "version": "1",
2350 })),
2351 )
2352 }
2353
2354 fn mount_folder(id: &str) -> wiremock::Mock {
2355 mount_file(id, "application/vnd.google-apps.folder", &[])
2356 }
2357
2358 fn allow_rule(folder: &str) -> FolderPermissionRule {
2359 FolderPermissionRule {
2360 folder_id: Some(folder.to_string()),
2361 file_id: None,
2362 recursive: true,
2363 allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
2364 deny: HashSet::default(),
2365 require_lease: true,
2366 }
2367 }
2368
2369 fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
2370 let ledger_path = tempfile::tempdir()
2371 .unwrap()
2372 .keep()
2373 .join("lease-ledger.jsonl");
2374 let token = seed_lease(&ledger_path, spreadsheet_id, "1");
2375 (Some(token), ledger_path)
2376 }
2377
2378 fn mount_workbook() -> wiremock::Mock {
2381 wiremock::Mock::given(wiremock::matchers::method("GET"))
2382 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2383 .respond_with(
2384 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2385 "spreadsheetId": "sheet-1",
2386 "properties": {"title": "Budget"},
2387 "sheets": [
2388 {
2389 "properties": {"sheetId": 0, "title": "Q1", "index": 0},
2390 "conditionalFormats": [
2391 {
2392 "ranges": [{"sheetId": 0, "startRowIndex": 0, "endRowIndex": 1}],
2393 "booleanRule": {
2394 "condition": {"type": "BLANK", "values": []},
2395 "format": {"backgroundColorStyle": {"rgbColor": {"red": 1.0, "green": 0.0, "blue": 0.0}}},
2396 },
2397 },
2398 {
2399 "ranges": [{"sheetId": 0, "startRowIndex": 1, "endRowIndex": 2}],
2400 "gradientRule": {
2401 "minpoint": {"colorStyle": {"rgbColor": {"red": 1.0, "green": 1.0, "blue": 1.0}}, "type": "MIN"},
2402 "maxpoint": {"colorStyle": {"rgbColor": {"red": 0.0, "green": 1.0, "blue": 0.0}}, "type": "MAX"},
2403 },
2404 },
2405 ],
2406 },
2407 ],
2408 })),
2409 )
2410 }
2411
2412 fn add_verb() -> ConditionalFormatVerb {
2413 ConditionalFormatVerb::AddConditionalFormat {
2414 sheet: "Q1".to_string(),
2415 ranges: vec!["A1:A10".to_string()],
2416 index: None,
2417 rule: FormatRule::Boolean {
2418 condition: FormatCondition::NumberGreater(100.0),
2419 format: FormatEffect {
2420 background: Some("#00FF00".to_string()),
2421 text_color: None,
2422 bold: None,
2423 },
2424 },
2425 }
2426 }
2427
2428 #[tokio::test]
2429 async fn a_denied_gate_blocks_before_any_batch_update_call() {
2430 let server = wiremock::MockServer::start().await;
2431 let (drive, sheets) = clients(&server).await;
2432 mount_file(
2433 "sheet-1",
2434 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2435 &["folder-1"],
2436 )
2437 .mount(&server)
2438 .await;
2439 mount_folder("folder-1").mount(&server).await;
2440 let rules: Vec<FolderPermissionRule> = Vec::new();
2441 let opts = ConditionalFormatOptions {
2442 spreadsheet_id: "sheet-1".to_string(),
2443 verb: add_verb(),
2444 dry_run: false,
2445 lease_token: None,
2446 ledger_path: std::path::PathBuf::new(),
2447 };
2448 let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2449 assert!(matches!(
2450 outcome.result,
2451 ConditionalFormatResult::Blocked { .. }
2452 ));
2453 }
2454
2455 #[tokio::test]
2456 async fn add_sends_a_boolean_rule_and_appends_at_the_current_length() {
2457 let server = wiremock::MockServer::start().await;
2458 let (drive, sheets) = clients(&server).await;
2459 mount_file(
2460 "sheet-1",
2461 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2462 &["folder-1"],
2463 )
2464 .mount(&server)
2465 .await;
2466 mount_folder("folder-1").mount(&server).await;
2467 mount_workbook().mount(&server).await;
2468 wiremock::Mock::given(wiremock::matchers::method("POST"))
2469 .and(wiremock::matchers::path(
2470 "/v4/spreadsheets/sheet-1:batchUpdate",
2471 ))
2472 .respond_with(
2473 wiremock::ResponseTemplate::new(200)
2474 .set_body_json(serde_json::json!({"replies": [{}]})),
2475 )
2476 .mount(&server)
2477 .await;
2478 let rules = vec![allow_rule("folder-1")];
2479 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2480 let opts = ConditionalFormatOptions {
2481 spreadsheet_id: "sheet-1".to_string(),
2482 verb: add_verb(),
2483 dry_run: false,
2484 lease_token,
2485 ledger_path,
2486 };
2487 let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2488 assert!(
2489 matches!(outcome.result, ConditionalFormatResult::Changed { .. }),
2490 "{:?}",
2491 outcome.result
2492 );
2493
2494 let requests = server.received_requests().await.unwrap();
2495 let batch = requests
2496 .iter()
2497 .find(|r| r.url.path().ends_with(":batchUpdate"))
2498 .unwrap();
2499 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
2500 let added = &body["requests"][0]["addConditionalFormatRule"];
2501 assert_eq!(added["index"], 2);
2504 assert_eq!(
2505 added["rule"]["booleanRule"]["condition"]["type"],
2506 "NUMBER_GREATER"
2507 );
2508 }
2509
2510 #[tokio::test]
2511 async fn add_refuses_an_explicit_out_of_bounds_or_negative_index() {
2512 let server = wiremock::MockServer::start().await;
2513 let (drive, sheets) = clients(&server).await;
2514 mount_file(
2515 "sheet-1",
2516 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2517 &["folder-1"],
2518 )
2519 .mount(&server)
2520 .await;
2521 mount_folder("folder-1").mount(&server).await;
2522 mount_workbook().mount(&server).await;
2523 let rules = vec![allow_rule("folder-1")];
2524
2525 for bad_index in [-1, 3] {
2526 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2527 let verb = match add_verb() {
2528 ConditionalFormatVerb::AddConditionalFormat {
2529 sheet,
2530 ranges,
2531 rule,
2532 ..
2533 } => ConditionalFormatVerb::AddConditionalFormat {
2534 sheet,
2535 ranges,
2536 index: Some(bad_index),
2537 rule,
2538 },
2539 other => other,
2540 };
2541 let opts = ConditionalFormatOptions {
2542 spreadsheet_id: "sheet-1".to_string(),
2543 verb,
2544 dry_run: false,
2545 lease_token,
2546 ledger_path,
2547 };
2548 let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2549 match outcome.result {
2550 ConditionalFormatResult::RefusedIndexOutOfBounds {
2551 sheet,
2552 index,
2553 count,
2554 } => {
2555 assert_eq!(sheet, "Q1");
2556 assert_eq!(index, bad_index);
2557 assert_eq!(count, 2);
2560 }
2561 other => panic!("expected RefusedIndexOutOfBounds, got {other:?}"),
2562 }
2563 }
2564
2565 let requests = server.received_requests().await.unwrap();
2567 assert!(!requests
2568 .iter()
2569 .any(|r| r.url.path().ends_with(":batchUpdate")));
2570 }
2571
2572 #[tokio::test]
2573 async fn delete_sends_the_sheet_id_and_index() {
2574 let server = wiremock::MockServer::start().await;
2575 let (drive, sheets) = clients(&server).await;
2576 mount_file(
2577 "sheet-1",
2578 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2579 &["folder-1"],
2580 )
2581 .mount(&server)
2582 .await;
2583 mount_folder("folder-1").mount(&server).await;
2584 mount_workbook().mount(&server).await;
2585 wiremock::Mock::given(wiremock::matchers::method("POST"))
2586 .and(wiremock::matchers::path(
2587 "/v4/spreadsheets/sheet-1:batchUpdate",
2588 ))
2589 .respond_with(
2590 wiremock::ResponseTemplate::new(200)
2591 .set_body_json(serde_json::json!({"replies": [{}]})),
2592 )
2593 .mount(&server)
2594 .await;
2595 let rules = vec![allow_rule("folder-1")];
2596 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2597 let opts = ConditionalFormatOptions {
2598 spreadsheet_id: "sheet-1".to_string(),
2599 verb: ConditionalFormatVerb::DeleteConditionalFormat {
2600 sheet: "Q1".to_string(),
2601 index: 1,
2602 },
2603 dry_run: false,
2604 lease_token,
2605 ledger_path,
2606 };
2607 let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2608 assert!(
2609 matches!(outcome.result, ConditionalFormatResult::Changed { .. }),
2610 "{:?}",
2611 outcome.result
2612 );
2613
2614 let requests = server.received_requests().await.unwrap();
2615 let batch = requests
2616 .iter()
2617 .find(|r| r.url.path().ends_with(":batchUpdate"))
2618 .unwrap();
2619 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
2620 let deleted = &body["requests"][0]["deleteConditionalFormatRule"];
2621 assert_eq!(deleted["sheetId"], 0);
2622 assert_eq!(deleted["index"], 1);
2623 }
2624
2625 #[tokio::test]
2626 async fn delete_refuses_an_out_of_bounds_index() {
2627 let server = wiremock::MockServer::start().await;
2628 let (drive, sheets) = clients(&server).await;
2629 mount_file(
2630 "sheet-1",
2631 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2632 &["folder-1"],
2633 )
2634 .mount(&server)
2635 .await;
2636 mount_folder("folder-1").mount(&server).await;
2637 mount_workbook().mount(&server).await;
2638 let rules = vec![allow_rule("folder-1")];
2639 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2640 let opts = ConditionalFormatOptions {
2641 spreadsheet_id: "sheet-1".to_string(),
2642 verb: ConditionalFormatVerb::DeleteConditionalFormat {
2643 sheet: "Q1".to_string(),
2644 index: 5,
2645 },
2646 dry_run: false,
2647 lease_token,
2648 ledger_path,
2649 };
2650 let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2651 match outcome.result {
2652 ConditionalFormatResult::RefusedIndexOutOfBounds {
2653 sheet,
2654 index,
2655 count,
2656 } => {
2657 assert_eq!(sheet, "Q1");
2658 assert_eq!(index, 5);
2659 assert_eq!(count, 2);
2660 }
2661 other => panic!("expected RefusedIndexOutOfBounds, got {other:?}"),
2662 }
2663
2664 let requests = server.received_requests().await.unwrap();
2667 assert!(!requests
2668 .iter()
2669 .any(|r| r.url.path().ends_with(":batchUpdate")));
2670 }
2671
2672 #[tokio::test]
2673 async fn update_dry_run_echoes_the_rule_currently_at_the_index() {
2674 let server = wiremock::MockServer::start().await;
2675 let (drive, sheets) = clients(&server).await;
2676 mount_file(
2677 "sheet-1",
2678 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2679 &["folder-1"],
2680 )
2681 .mount(&server)
2682 .await;
2683 mount_folder("folder-1").mount(&server).await;
2684 mount_workbook().mount(&server).await;
2685 let rules = vec![allow_rule("folder-1")];
2686 let opts = ConditionalFormatOptions {
2687 spreadsheet_id: "sheet-1".to_string(),
2688 verb: ConditionalFormatVerb::UpdateConditionalFormat {
2689 sheet: "Q1".to_string(),
2690 ranges: vec!["A1:A10".to_string()],
2691 index: 0,
2692 rule: FormatRule::Boolean {
2693 condition: FormatCondition::NumberGreater(100.0),
2694 format: FormatEffect {
2695 background: Some("#00FF00".to_string()),
2696 text_color: None,
2697 bold: None,
2698 },
2699 },
2700 },
2701 dry_run: true,
2702 lease_token: None,
2703 ledger_path: std::path::PathBuf::new(),
2704 };
2705 let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2706 match outcome.result {
2707 ConditionalFormatResult::WouldChange { summary } => {
2708 assert!(
2709 summary.contains("currently: boolean rule (blank"),
2710 "{summary}"
2711 );
2712 assert!(summary.contains("number greater"), "{summary}");
2713 }
2714 other => panic!("expected WouldChange, got {other:?}"),
2715 }
2716
2717 let requests = server.received_requests().await.unwrap();
2719 assert!(!requests
2720 .iter()
2721 .any(|r| r.url.path().ends_with(":batchUpdate")));
2722 }
2723
2724 #[tokio::test]
2725 async fn add_rejects_a_malformed_background_color_before_the_lease_gate() {
2726 let server = wiremock::MockServer::start().await;
2727 let (drive, sheets) = clients(&server).await;
2728 mount_file(
2729 "sheet-1",
2730 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2731 &["folder-1"],
2732 )
2733 .mount(&server)
2734 .await;
2735 mount_folder("folder-1").mount(&server).await;
2736 let rules = vec![allow_rule("folder-1")];
2737 let opts = ConditionalFormatOptions {
2738 spreadsheet_id: "sheet-1".to_string(),
2739 verb: ConditionalFormatVerb::AddConditionalFormat {
2740 sheet: "Q1".to_string(),
2741 ranges: vec!["A1:A10".to_string()],
2742 index: None,
2743 rule: FormatRule::Boolean {
2744 condition: FormatCondition::CellEmpty,
2745 format: FormatEffect {
2746 background: Some("not-a-color".to_string()),
2747 text_color: None,
2748 bold: None,
2749 },
2750 },
2751 },
2752 dry_run: false,
2753 lease_token: None,
2754 ledger_path: std::path::PathBuf::new(),
2755 };
2756 let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2757 match outcome.result {
2758 ConditionalFormatResult::RefusedInvalidRange { detail } => {
2759 assert!(detail.contains("--background"), "{detail}");
2760 }
2761 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2762 }
2763 }
2764
2765 #[tokio::test]
2766 async fn add_refuses_an_empty_ranges_list_before_any_network_call() {
2767 let server = wiremock::MockServer::start().await;
2770 let (drive, sheets) = clients(&server).await;
2771 let rules: Vec<FolderPermissionRule> = Vec::new();
2772 let opts = ConditionalFormatOptions {
2773 spreadsheet_id: "sheet-1".to_string(),
2774 verb: ConditionalFormatVerb::AddConditionalFormat {
2775 sheet: "Q1".to_string(),
2776 ranges: Vec::new(),
2777 index: None,
2778 rule: FormatRule::Boolean {
2779 condition: FormatCondition::CellEmpty,
2780 format: FormatEffect::default(),
2781 },
2782 },
2783 dry_run: false,
2784 lease_token: None,
2785 ledger_path: std::path::PathBuf::new(),
2786 };
2787 let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2788 match outcome.result {
2789 ConditionalFormatResult::RefusedInvalidRange { detail } => {
2790 assert!(detail.contains("needs at least one --range"), "{detail}");
2791 }
2792 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2793 }
2794 let requests = server.received_requests().await.unwrap();
2795 assert!(requests.is_empty(), "{requests:?}");
2796 }
2797}