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