1use std::path::PathBuf;
37use std::time::{Duration, Instant};
38
39use serde::Serialize;
40
41use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
42use crate::drive::client::DriveClient;
43use crate::drive::files_api::FilesApi;
44use crate::drive::lease::check::{
45 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
46 LeasedWrite,
47};
48use crate::drive::sheets::a1;
49use crate::drive::sheets::api::{
50 applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
51};
52use crate::drive::sheets::client::SheetsClient;
53use crate::drive::sheets::format::parse_hex_color;
54use crate::drive::sheets::grid_range;
55use crate::drive::sheets::target_gate;
56use crate::drive::sheets::types::{
57 AddBandingRequest, BandedRange, BandingProperties, BatchUpdateRequestItem, BatchUpdateResponse,
58 ColorStyle, DeleteBandingRequest, GridRange, Spreadsheet, UpdateBandingRequest,
59};
60use crate::drive::types::SheetTargetRefusal;
61use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
62use crate::request_log::{self, DriveMutationOutcome};
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum BandingAxis {
74 Rows,
76 Columns,
78}
79
80impl BandingAxis {
81 const fn label(self) -> &'static str {
82 match self {
83 Self::Rows => "row",
84 Self::Columns => "column",
85 }
86 }
87
88 const fn field_name(self) -> &'static str {
89 match self {
90 Self::Rows => "rowProperties",
91 Self::Columns => "columnProperties",
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum BandingVerb {
99 AddBanding {
101 sheet: String,
103 range: String,
105 axis: BandingAxis,
107 header_color: Option<String>,
110 first_band_color: String,
112 second_band_color: String,
114 footer_color: Option<String>,
117 },
118 UpdateBanding {
120 banded_range_id: i64,
122 sheet: Option<String>,
125 range: Option<String>,
127 axis: BandingAxis,
129 header_color: Option<String>,
131 first_band_color: Option<String>,
133 second_band_color: Option<String>,
135 footer_color: Option<String>,
137 },
138 DeleteBanding {
140 banded_range_id: i64,
142 },
143}
144
145impl BandingVerb {
146 const fn log_operation(&self) -> &'static str {
147 match self {
148 Self::AddBanding { .. } => "sheets-add-banding",
149 Self::UpdateBanding { .. } => "sheets-update-banding",
150 Self::DeleteBanding { .. } => "sheets-delete-banding",
151 }
152 }
153
154 const fn label(&self) -> &'static str {
155 match self {
156 Self::AddBanding { .. } => "add-banding",
157 Self::UpdateBanding { .. } => "update-banding",
158 Self::DeleteBanding { .. } => "delete-banding",
159 }
160 }
161}
162
163#[derive(Debug, Clone)]
165pub struct BandingOptions {
166 pub spreadsheet_id: String,
168 pub verb: BandingVerb,
170 pub dry_run: bool,
172 pub lease_token: Option<String>,
177 pub ledger_path: PathBuf,
181}
182
183#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
185#[serde(tag = "status", rename_all = "kebab-case")]
186pub enum BandingResult {
187 WouldChange {
189 summary: String,
191 },
192 RefusedNotASpreadsheet {
194 mime_type: String,
196 },
197 RefusedShortcut,
199 RefusedNoVisibleParents,
201 RefusedSheetNotFound {
203 title: String,
205 available: Vec<String>,
207 },
208 RefusedInvalidRange {
212 detail: String,
214 },
215 RefusedBandedRangeNotFound {
218 banded_range_id: i64,
220 },
221 Blocked {
223 decided_by: Option<DecidingRule>,
225 },
226 RefusedNoLease,
229 RefusedLeaseExpired,
232 RefusedLeaseWrongFile,
234 RefusedLeaseStale,
237 Changed {
239 summary: String,
241 banded_range_id: Option<i64>,
244 },
245 AppliedReplyUnreadable {
250 summary: String,
252 banded_range_id: Option<i64>,
256 detail: String,
258 },
259 Failed {
261 detail: String,
263 },
264}
265
266impl FromLeaseRefusal for BandingResult {
267 fn from_no_lease() -> Self {
268 Self::RefusedNoLease
269 }
270 fn from_lease_expired() -> Self {
271 Self::RefusedLeaseExpired
272 }
273 fn from_lease_wrong_file() -> Self {
274 Self::RefusedLeaseWrongFile
275 }
276 fn from_lease_stale() -> Self {
277 Self::RefusedLeaseStale
278 }
279 fn from_lease_failed(detail: String) -> Self {
280 Self::Failed { detail }
281 }
282}
283
284impl BandingResult {
285 fn log_status(&self) -> &'static str {
286 match self {
287 Self::WouldChange { .. } => "would-change",
288 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
289 Self::RefusedShortcut => "refused-shortcut",
290 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
291 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
292 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
293 Self::RefusedBandedRangeNotFound { .. } => "refused-banded-range-not-found",
294 Self::Blocked { .. } => "blocked",
295 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
296 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
297 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
298 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
299 Self::Changed { .. } => "changed",
300 Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
301 Self::Failed { .. } => "failed",
302 }
303 }
304}
305
306#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
308pub struct BandingOutcome {
309 pub spreadsheet_id: String,
311 #[serde(skip_serializing_if = "Option::is_none")]
313 pub file_name: Option<String>,
314 #[serde(skip_serializing_if = "Option::is_none")]
316 pub resolved_folder_id: Option<String>,
317 #[serde(skip_serializing_if = "Option::is_none")]
319 pub sheet_id: Option<i64>,
320 #[serde(skip)]
322 pub verb: BandingVerb,
323 pub result: BandingResult,
325}
326
327impl JsonlSerialize for BandingOutcome {
328 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
329 write_scalar_jsonl(self, out)
330 }
331}
332
333pub async fn banding(
335 drive: &DriveClient,
336 sheets: &SheetsClient,
337 opts: &BandingOptions,
338 rules: &[FolderPermissionRule],
339) -> BandingOutcome {
340 let started = Instant::now();
341 let outcome = banding_inner(drive, sheets, opts, rules).await;
342 if !opts.dry_run {
343 record_attempt(&outcome, opts, started.elapsed());
344 }
345 outcome
346}
347
348async fn banding_inner(
349 drive: &DriveClient,
350 sheets: &SheetsClient,
351 opts: &BandingOptions,
352 rules: &[FolderPermissionRule],
353) -> BandingOutcome {
354 let bare = |result| BandingOutcome {
355 spreadsheet_id: opts.spreadsheet_id.clone(),
356 file_name: None,
357 resolved_folder_id: None,
358 sheet_id: None,
359 verb: opts.verb.clone(),
360 result,
361 };
362
363 if let Err(detail) = validate_verb(&opts.verb) {
364 return bare(BandingResult::RefusedInvalidRange { detail });
365 }
366 let composed_range = match compose_target(&opts.verb) {
367 Ok(composed) => composed,
368 Err(detail) => return bare(BandingResult::RefusedInvalidRange { detail }),
369 };
370
371 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
372 drive,
373 &opts.spreadsheet_id,
374 DriveOperation::SheetsStructure,
375 rules,
376 )
377 .await
378 {
379 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
380 return bare(BandingResult::Failed { detail })
381 }
382 target_gate::TargetGateOutcome::Refused { target, refusal } => {
383 let result = match refusal {
384 SheetTargetRefusal::Shortcut => BandingResult::RefusedShortcut,
385 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
386 BandingResult::RefusedNotASpreadsheet { mime_type }
387 }
388 SheetTargetRefusal::NoVisibleParents => BandingResult::RefusedNoVisibleParents,
389 };
390 return BandingOutcome {
391 spreadsheet_id: opts.spreadsheet_id.clone(),
392 file_name: Some(target.name),
393 resolved_folder_id: None,
394 sheet_id: None,
395 verb: opts.verb.clone(),
396 result,
397 };
398 }
399 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
400 return BandingOutcome {
401 spreadsheet_id: opts.spreadsheet_id.clone(),
402 file_name: Some(target.name),
403 resolved_folder_id: None,
404 sheet_id: None,
405 verb: opts.verb.clone(),
406 result: BandingResult::Failed { detail },
407 };
408 }
409 target_gate::TargetGateOutcome::Gated {
410 target,
411 decision,
412 resolved_folder_id,
413 requires_lease,
414 } => (target, decision, resolved_folder_id, requires_lease),
415 };
416
417 let pre_gated = |result| BandingOutcome {
418 spreadsheet_id: opts.spreadsheet_id.clone(),
419 file_name: Some(target.name.clone()),
420 resolved_folder_id: resolved_folder_id.clone(),
421 sheet_id: None,
422 verb: opts.verb.clone(),
423 result,
424 };
425
426 if decision.verdict == write_gate::Verdict::Deny {
427 return pre_gated(BandingResult::Blocked {
428 decided_by: decision.decided_by,
429 });
430 }
431
432 let api = SheetsApi::new(sheets);
433 let workbook = match api.get_spreadsheet_with_banding(&opts.spreadsheet_id).await {
434 Ok(workbook) => workbook,
435 Err(err) => {
436 return pre_gated(BandingResult::Failed {
437 detail: format!("{err:#}"),
438 })
439 }
440 };
441
442 let composed_range = match (&opts.verb, composed_range) {
445 (
446 BandingVerb::UpdateBanding {
447 banded_range_id,
448 sheet: None,
449 ..
450 },
451 Some(composed),
452 ) => {
453 let current_sheet = match find_existing_banded_range(&workbook, *banded_range_id) {
456 Ok(banded) => banded.range.as_ref().map(|range| range.sheet_id),
457 Err(result) => return pre_gated(result),
458 };
459 Some(grid_range::default_sheet_prefix(
460 &workbook,
461 composed,
462 current_sheet,
463 ))
464 }
465 (_, composed) => composed,
466 };
467 let resolved_target =
468 match resolve_sheet_target(&workbook, &opts.verb, composed_range.as_deref()) {
469 Ok(resolved) => resolved,
470 Err(result) => return pre_gated(result),
471 };
472
473 let existing = match &opts.verb {
474 BandingVerb::UpdateBanding {
475 banded_range_id, ..
476 }
477 | BandingVerb::DeleteBanding { banded_range_id } => {
478 match find_existing_banded_range(&workbook, *banded_range_id) {
479 Ok(existing) => Some(existing),
480 Err(result) => return pre_gated(result),
481 }
482 }
483 BandingVerb::AddBanding { .. } => None,
484 };
485
486 let sheet_id = resolved_target.map(|grid| grid.sheet_id).or_else(|| {
487 existing
488 .and_then(|existing| existing.range.as_ref())
489 .map(|range| range.sheet_id)
490 });
491
492 let gated = |result| BandingOutcome {
493 spreadsheet_id: opts.spreadsheet_id.clone(),
494 file_name: Some(target.name.clone()),
495 resolved_folder_id: resolved_folder_id.clone(),
496 sheet_id,
497 verb: opts.verb.clone(),
498 result,
499 };
500
501 if let Err(detail) = validate_new_axis_colors(&opts.verb, existing) {
506 return gated(BandingResult::RefusedInvalidRange { detail });
507 }
508
509 let summary = describe_effect(&opts.verb);
510
511 if opts.dry_run {
512 return gated(BandingResult::WouldChange { summary });
513 }
514
515 let (request, existing_id) = match build_request(&opts.verb, resolved_target, existing) {
521 Ok(built) => built,
522 Err(detail) => return gated(BandingResult::RefusedInvalidRange { detail }),
523 };
524
525 let files_api = FilesApi::new(drive);
526 let leased = LeasedWrite {
527 log_prefix: "drive sheets banding",
528 operation: opts.verb.log_operation(),
529 ledger_path: &opts.ledger_path,
530 file_id: &opts.spreadsheet_id,
531 };
532 let lease_grant = match gate_optional_leased_write(
533 leased,
534 &files_api,
535 requires_lease,
536 opts.lease_token.as_deref(),
537 )
538 .await
539 {
540 Ok(grant) => grant,
541 Err(err) => return gated(err.into_result()),
542 };
543
544 let result = match conclude_native_leased_write(
545 leased,
546 &lease_grant,
547 &files_api,
548 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
549 |err| format!("{err:#}"),
550 )
551 .await
552 {
553 Ok(BatchUpdateOutcome::Applied(response)) => {
554 let banded_range_id = added_banded_range_id(&response).or(existing_id);
555 BandingResult::Changed {
556 summary,
557 banded_range_id,
558 }
559 }
560 Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
562 BandingResult::AppliedReplyUnreadable {
563 summary,
564 banded_range_id: existing_id,
565 detail,
566 }
567 }
568 Err(err) => BandingResult::Failed {
569 detail: format!("{err:#}"),
570 },
571 };
572 drop(lease_grant);
573 gated(result)
574}
575
576fn compose_target(verb: &BandingVerb) -> Result<Option<String>, String> {
580 match verb {
581 BandingVerb::AddBanding { sheet, range, .. } => a1::compose(Some(sheet), Some(range))
582 .map(Some)
583 .map_err(|err| err.to_string()),
584 BandingVerb::UpdateBanding { sheet, range, .. } => {
585 match (sheet.as_deref(), range.as_deref()) {
586 (None, None) => Ok(None),
587 (sheet, range) => a1::compose(sheet, range)
588 .map(Some)
589 .map_err(|err| err.to_string()),
590 }
591 }
592 BandingVerb::DeleteBanding { .. } => Ok(None),
593 }
594}
595
596fn validate_verb(verb: &BandingVerb) -> Result<(), String> {
599 if let BandingVerb::UpdateBanding {
600 sheet,
601 range,
602 header_color,
603 first_band_color,
604 second_band_color,
605 footer_color,
606 ..
607 } = verb
608 {
609 let nothing_to_change = sheet.is_none()
610 && range.is_none()
611 && header_color.is_none()
612 && first_band_color.is_none()
613 && second_band_color.is_none()
614 && footer_color.is_none();
615 if nothing_to_change {
616 return Err(
617 "nothing to change: pass --sheet/--range, --header-color, --first-band-color, \
618 --second-band-color, or --footer-color"
619 .to_string(),
620 );
621 }
622 }
623 Ok(())
624}
625
626fn resolve_sheet_target(
632 workbook: &Spreadsheet,
633 verb: &BandingVerb,
634 composed: Option<&str>,
635) -> Result<Option<GridRange>, BandingResult> {
636 match verb {
637 BandingVerb::AddBanding { .. } => {
638 let composed = composed.unwrap_or_default();
639 let (_, grid) = grid_range::resolve_grid_range(
640 workbook,
641 composed,
642 |detail| BandingResult::RefusedInvalidRange { detail },
643 |title, available| BandingResult::RefusedSheetNotFound { title, available },
644 )?;
645 Ok(Some(grid))
646 }
647 BandingVerb::UpdateBanding { .. } => match composed {
648 Some(composed) => {
649 let (_, grid) = grid_range::resolve_grid_range(
650 workbook,
651 composed,
652 |detail| BandingResult::RefusedInvalidRange { detail },
653 |title, available| BandingResult::RefusedSheetNotFound { title, available },
654 )?;
655 Ok(Some(grid))
656 }
657 None => Ok(None),
658 },
659 BandingVerb::DeleteBanding { .. } => Ok(None),
660 }
661}
662
663fn find_existing_banded_range(
668 workbook: &Spreadsheet,
669 banded_range_id: i64,
670) -> Result<&BandedRange, BandingResult> {
671 workbook
672 .sheets
673 .iter()
674 .flat_map(|sheet| sheet.banded_ranges.iter())
675 .find(|banded| banded.banded_range_id == Some(banded_range_id))
676 .ok_or(BandingResult::RefusedBandedRangeNotFound { banded_range_id })
677}
678
679fn validate_new_axis_colors(
690 verb: &BandingVerb,
691 existing: Option<&BandedRange>,
692) -> Result<(), String> {
693 let BandingVerb::UpdateBanding {
694 axis,
695 header_color,
696 first_band_color,
697 second_band_color,
698 footer_color,
699 ..
700 } = verb
701 else {
702 return Ok(());
703 };
704 let Some(existing) = existing else {
705 return Ok(());
706 };
707 let axis_exists = match axis {
708 BandingAxis::Rows => existing.row_properties.is_some(),
709 BandingAxis::Columns => existing.column_properties.is_some(),
710 };
711 let colors_changed = header_color.is_some()
712 || first_band_color.is_some()
713 || second_band_color.is_some()
714 || footer_color.is_some();
715 if axis_exists || !colors_changed {
716 return Ok(());
717 }
718 let missing: Vec<&str> = [
719 ("--first-band-color", first_band_color.is_none()),
720 ("--second-band-color", second_band_color.is_none()),
721 ]
722 .into_iter()
723 .filter_map(|(flag, is_missing)| is_missing.then_some(flag))
724 .collect();
725 if missing.is_empty() {
726 return Ok(());
727 }
728 let label = axis.label();
729 Err(format!(
730 "banded range {} has no {label} banding, so setting {label} colors adds it from \
731 scratch, which requires both --first-band-color and --second-band-color; missing: {}",
732 existing
733 .banded_range_id
734 .map_or_else(|| "?".to_string(), |id| id.to_string()),
735 missing.join(", ")
736 ))
737}
738
739fn parse_color_style(hex: &str) -> Result<ColorStyle, String> {
742 Ok(ColorStyle::rgb(parse_hex_color(hex)?))
743}
744
745fn build_request(
749 verb: &BandingVerb,
750 resolved: Option<GridRange>,
751 existing: Option<&BandedRange>,
752) -> Result<(BatchUpdateRequestItem, Option<i64>), String> {
753 match verb {
754 BandingVerb::AddBanding {
755 axis,
756 header_color,
757 first_band_color,
758 second_band_color,
759 footer_color,
760 ..
761 } => {
762 let Some(range) = resolved else {
763 unreachable!("resolved is resolved for AddBanding above") };
765 let properties = BandingProperties {
766 header_color_style: header_color.as_deref().map(parse_color_style).transpose()?,
767 first_band_color_style: Some(parse_color_style(first_band_color)?),
768 second_band_color_style: Some(parse_color_style(second_band_color)?),
769 footer_color_style: footer_color.as_deref().map(parse_color_style).transpose()?,
770 };
771 let mut banded_range = BandedRange {
772 banded_range_id: None,
773 range: Some(range),
774 row_properties: None,
775 column_properties: None,
776 };
777 match axis {
778 BandingAxis::Rows => banded_range.row_properties = Some(properties),
779 BandingAxis::Columns => banded_range.column_properties = Some(properties),
780 }
781 Ok((
782 BatchUpdateRequestItem::AddBanding(AddBandingRequest { banded_range }),
783 None,
784 ))
785 }
786 BandingVerb::UpdateBanding {
787 banded_range_id,
788 axis,
789 header_color,
790 first_band_color,
791 second_band_color,
792 footer_color,
793 ..
794 } => {
795 let Some(existing) = existing else {
796 unreachable!("existing is resolved for UpdateBanding above") };
798 let update = build_update(
799 existing,
800 *banded_range_id,
801 *axis,
802 resolved,
803 header_color.as_deref(),
804 first_band_color.as_deref(),
805 second_band_color.as_deref(),
806 footer_color.as_deref(),
807 )?;
808 Ok((
809 BatchUpdateRequestItem::UpdateBanding(update),
810 Some(*banded_range_id),
811 ))
812 }
813 BandingVerb::DeleteBanding { banded_range_id } => Ok((
814 BatchUpdateRequestItem::DeleteBanding(DeleteBandingRequest {
815 banded_range_id: *banded_range_id,
816 }),
817 Some(*banded_range_id),
818 )),
819 }
820}
821
822#[allow(clippy::too_many_arguments)]
830fn build_update(
831 existing: &BandedRange,
832 banded_range_id: i64,
833 axis: BandingAxis,
834 range: Option<GridRange>,
835 header_color: Option<&str>,
836 first_band_color: Option<&str>,
837 second_band_color: Option<&str>,
838 footer_color: Option<&str>,
839) -> Result<UpdateBandingRequest, String> {
840 let mut fields = Vec::new();
841 let mut banded_range = BandedRange {
842 banded_range_id: Some(banded_range_id),
843 range: None,
844 row_properties: None,
845 column_properties: None,
846 };
847 if let Some(range) = range {
848 banded_range.range = Some(range);
849 fields.push("range");
850 }
851
852 let properties_changed = header_color.is_some()
853 || first_band_color.is_some()
854 || second_band_color.is_some()
855 || footer_color.is_some();
856 let mut properties = match axis {
857 BandingAxis::Rows => existing.row_properties.clone().unwrap_or_default(),
858 BandingAxis::Columns => existing.column_properties.clone().unwrap_or_default(),
859 };
860 if let Some(hex) = header_color {
861 properties.header_color_style = Some(parse_color_style(hex)?);
862 }
863 if let Some(hex) = first_band_color {
864 properties.first_band_color_style = Some(parse_color_style(hex)?);
865 }
866 if let Some(hex) = second_band_color {
867 properties.second_band_color_style = Some(parse_color_style(hex)?);
868 }
869 if let Some(hex) = footer_color {
870 properties.footer_color_style = Some(parse_color_style(hex)?);
871 }
872 if properties_changed {
873 match axis {
874 BandingAxis::Rows => banded_range.row_properties = Some(properties),
875 BandingAxis::Columns => banded_range.column_properties = Some(properties),
876 }
877 fields.push(axis.field_name());
878 }
879
880 Ok(UpdateBandingRequest {
881 banded_range,
882 fields: fields.join(","),
883 })
884}
885
886fn added_banded_range_id(response: &BatchUpdateResponse) -> Option<i64> {
887 response
888 .replies
889 .iter()
890 .find_map(|reply| reply.add_banding.as_ref())
891 .and_then(|added| added.banded_range.as_ref())
892 .and_then(|banded| banded.banded_range_id)
893}
894
895fn describe_effect(verb: &BandingVerb) -> String {
896 match verb {
897 BandingVerb::AddBanding { axis, .. } => format!("add {} banding", axis.label()),
898 BandingVerb::UpdateBanding {
899 banded_range_id,
900 axis,
901 ..
902 } => format!("update {} banding id {banded_range_id}", axis.label()),
903 BandingVerb::DeleteBanding { banded_range_id } => {
904 format!("delete banding id {banded_range_id}")
905 }
906 }
907}
908
909fn record_attempt(outcome: &BandingOutcome, opts: &BandingOptions, duration: Duration) {
910 request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
911}
912
913fn mutation_record(
916 outcome: &BandingOutcome,
917 opts: &BandingOptions,
918 duration: Duration,
919) -> DriveMutationOutcome {
920 let error = match &outcome.result {
921 BandingResult::Failed { detail } | BandingResult::AppliedReplyUnreadable { detail, .. } => {
922 Some(detail.clone())
923 }
924 _ => None,
925 };
926 let decided_by = match &outcome.result {
927 BandingResult::Blocked { decided_by } => decided_by.as_ref(),
928 _ => None,
929 };
930 let decided_by = write_gate::decided_by_log_fields(decided_by);
931 let banded_range_id = match &outcome.result {
932 BandingResult::Changed {
933 banded_range_id, ..
934 }
935 | BandingResult::AppliedReplyUnreadable {
936 banded_range_id, ..
937 } => *banded_range_id,
938 _ => None,
939 };
940 let fields_changed = match &outcome.result {
941 BandingResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
942 _ => None,
943 };
944
945 DriveMutationOutcome {
946 operation: opts.verb.log_operation(),
947 file_id: outcome.spreadsheet_id.clone(),
948 file_name: outcome.file_name.clone().unwrap_or_default(),
949 status: outcome.result.log_status().to_string(),
950 resolved_folder_id: outcome.resolved_folder_id.clone(),
951 decided_by_folder_id: decided_by.folder_id,
952 decided_by_depth: decided_by.depth,
953 decided_by_file_id: decided_by.file_id,
954 sheet_id: outcome.sheet_id,
955 banded_range_id,
956 fields_changed,
957 error,
958 duration,
959 ..Default::default()
960 }
961}
962
963#[must_use]
965pub fn describe(outcome: &BandingOutcome) -> String {
966 describe_lines(outcome).join("\n")
967}
968
969#[must_use]
972pub fn describe_lines(outcome: &BandingOutcome) -> Vec<String> {
973 let verb = &outcome.verb;
974 let book = outcome.file_name.as_deref().map_or_else(
975 || format!("'{}'", outcome.spreadsheet_id),
976 |n| format!("'{n}'"),
977 );
978 match &outcome.result {
979 BandingResult::WouldChange { summary } => vec![format!("Would {summary} in {book}")],
980 BandingResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
981 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
982 `drive sheets {}` only works on spreadsheets",
983 verb.label()
984 )],
985 BandingResult::RefusedShortcut => vec![format!(
986 "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
987 verb.label()
988 )],
989 BandingResult::RefusedNoVisibleParents => vec![format!(
990 "Refused: {book} has no parent folder visible to this account, so no folder \
991 rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
992 id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
993 )],
994 BandingResult::RefusedSheetNotFound { title, available } => {
995 vec![grid_range::no_sheet_refusal(&book, title, available)]
996 }
997 BandingResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
998 BandingResult::RefusedBandedRangeNotFound { banded_range_id } => vec![format!(
999 "Refused: {book} has no banded range with id {banded_range_id}; run \
1000 `drive sheets list-bandings` to see what exists"
1001 )],
1002 BandingResult::Blocked { decided_by } => vec![match decided_by {
1003 Some(rule) => format!(
1004 "Blocked: {} on {book} refused by rule on {} {}{}",
1005 verb.label(),
1006 rule.kind_label(),
1007 rule.id(),
1008 rule.depth_suffix()
1009 ),
1010 None => format!(
1011 "Blocked: {} on {book} refused by default policy (no matching rule for \
1012 sheets-structure)",
1013 verb.label()
1014 ),
1015 }],
1016 BandingResult::RefusedNoLease => LeaseGateRefusal::NoLease
1017 .describe_line(&outcome.spreadsheet_id, &book)
1018 .into_iter()
1019 .collect(),
1020 BandingResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1021 .describe_line(&outcome.spreadsheet_id, &book)
1022 .into_iter()
1023 .collect(),
1024 BandingResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1025 .describe_line(&outcome.spreadsheet_id, &book)
1026 .into_iter()
1027 .collect(),
1028 BandingResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1029 .describe_line(&outcome.spreadsheet_id, &book)
1030 .into_iter()
1031 .collect(),
1032 BandingResult::Changed {
1033 summary,
1034 banded_range_id,
1035 } => {
1036 let id = banded_range_id
1039 .filter(|_| matches!(outcome.verb, BandingVerb::AddBanding { .. }))
1040 .map_or_else(String::new, |id| format!(" (id {id})"));
1041 vec![format!("Applied: {summary}{id} in {book}")]
1042 }
1043 BandingResult::AppliedReplyUnreadable {
1044 summary, detail, ..
1045 } => {
1046 let mut lines = vec![applied_reply_unreadable_line(
1047 summary,
1048 &book,
1049 RetryHint::NotIdempotent,
1050 detail,
1051 )];
1052 if matches!(verb, BandingVerb::AddBanding { .. }) {
1053 lines.push(
1054 " the new banded range's id was in the unreadable reply; run \
1055 `list-bandings` to find it"
1056 .to_string(),
1057 );
1058 }
1059 lines
1060 }
1061 BandingResult::Failed { detail } => vec![format!("Failed: {detail}")],
1062 }
1063}
1064
1065#[cfg(test)]
1066#[allow(clippy::unwrap_used, clippy::expect_used)]
1067mod tests {
1068 use super::*;
1069 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1070 use crate::drive::sheets::client::SHEETS_API_URL;
1071 use crate::drive::sheets::types::Sheet;
1072 use crate::drive::test_support::seed_lease;
1073 use crate::test_support::env::MapEnv;
1074 use crate::utils::secret::Secret;
1075 use std::collections::HashSet;
1076
1077 #[test]
1080 fn log_operation_and_label_are_distinct_for_every_verb() {
1081 let verbs = [
1082 BandingVerb::AddBanding {
1083 sheet: "Q1".to_string(),
1084 range: "A1:D10".to_string(),
1085 axis: BandingAxis::Rows,
1086 header_color: None,
1087 first_band_color: "#FFFFFF".to_string(),
1088 second_band_color: "#EEEEEE".to_string(),
1089 footer_color: None,
1090 },
1091 BandingVerb::UpdateBanding {
1092 banded_range_id: 1,
1093 sheet: None,
1094 range: None,
1095 axis: BandingAxis::Rows,
1096 header_color: Some("#000000".to_string()),
1097 first_band_color: None,
1098 second_band_color: None,
1099 footer_color: None,
1100 },
1101 BandingVerb::DeleteBanding { banded_range_id: 1 },
1102 ];
1103 let mut operations: Vec<&str> = verbs.iter().map(BandingVerb::log_operation).collect();
1104 let mut labels: Vec<&str> = verbs.iter().map(BandingVerb::label).collect();
1105 operations.sort_unstable();
1106 operations.dedup();
1107 labels.sort_unstable();
1108 labels.dedup();
1109 assert_eq!(operations.len(), verbs.len());
1110 assert_eq!(labels.len(), verbs.len());
1111 }
1112
1113 #[test]
1114 fn validate_verb_rejects_update_banding_with_nothing_to_change() {
1115 let verb = BandingVerb::UpdateBanding {
1116 banded_range_id: 1,
1117 sheet: None,
1118 range: None,
1119 axis: BandingAxis::Rows,
1120 header_color: None,
1121 first_band_color: None,
1122 second_band_color: None,
1123 footer_color: None,
1124 };
1125 let err = validate_verb(&verb).unwrap_err();
1126 assert!(err.contains("nothing to change"), "{err}");
1127 }
1128
1129 #[test]
1130 fn validate_verb_accepts_update_banding_with_only_a_color_change() {
1131 let verb = BandingVerb::UpdateBanding {
1132 banded_range_id: 1,
1133 sheet: None,
1134 range: None,
1135 axis: BandingAxis::Rows,
1136 header_color: Some("#000000".to_string()),
1137 first_band_color: None,
1138 second_band_color: None,
1139 footer_color: None,
1140 };
1141 validate_verb(&verb).unwrap();
1142 }
1143
1144 #[test]
1145 fn banding_axis_label_and_field_name_cover_both_axes() {
1146 assert_eq!(BandingAxis::Rows.label(), "row");
1147 assert_eq!(BandingAxis::Columns.label(), "column");
1148 assert_eq!(BandingAxis::Rows.field_name(), "rowProperties");
1149 assert_eq!(BandingAxis::Columns.field_name(), "columnProperties");
1150 }
1151
1152 fn sheet_with_banding(sheet_id: i64, banded_range_id: i64) -> Sheet {
1153 Sheet {
1154 banded_ranges: vec![BandedRange {
1155 banded_range_id: Some(banded_range_id),
1156 range: Some(GridRange {
1157 sheet_id,
1158 start_row_index: Some(0),
1159 end_row_index: Some(10),
1160 start_column_index: Some(0),
1161 end_column_index: Some(4),
1162 }),
1163 row_properties: Some(BandingProperties {
1164 header_color_style: None,
1165 first_band_color_style: Some(ColorStyle::rgb(
1166 crate::drive::sheets::types::Color {
1167 red: 1.0,
1168 green: 1.0,
1169 blue: 1.0,
1170 },
1171 )),
1172 second_band_color_style: Some(ColorStyle::rgb(
1173 crate::drive::sheets::types::Color {
1174 red: 0.0,
1175 green: 0.0,
1176 blue: 0.0,
1177 },
1178 )),
1179 footer_color_style: None,
1180 }),
1181 column_properties: None,
1182 }],
1183 ..Default::default()
1184 }
1185 }
1186
1187 #[test]
1188 fn find_existing_banded_range_returns_not_found_for_unknown_id() {
1189 let workbook = Spreadsheet {
1190 sheets: vec![sheet_with_banding(0, 7)],
1191 ..Default::default()
1192 };
1193 let err = find_existing_banded_range(&workbook, 99).unwrap_err();
1194 assert_eq!(
1195 err,
1196 BandingResult::RefusedBandedRangeNotFound {
1197 banded_range_id: 99
1198 }
1199 );
1200 }
1201
1202 #[test]
1203 fn find_existing_banded_range_finds_the_matching_id() {
1204 let workbook = Spreadsheet {
1205 sheets: vec![sheet_with_banding(0, 7)],
1206 ..Default::default()
1207 };
1208 let found = find_existing_banded_range(&workbook, 7).unwrap();
1209 assert_eq!(found.banded_range_id, Some(7));
1210 }
1211
1212 #[test]
1213 fn build_request_add_banding_sets_row_properties_for_the_rows_axis() {
1214 let range = GridRange {
1215 sheet_id: 0,
1216 start_row_index: Some(0),
1217 end_row_index: Some(10),
1218 start_column_index: Some(0),
1219 end_column_index: Some(4),
1220 };
1221 let verb = BandingVerb::AddBanding {
1222 sheet: "Q1".to_string(),
1223 range: "A1:D10".to_string(),
1224 axis: BandingAxis::Rows,
1225 header_color: Some("#111111".to_string()),
1226 first_band_color: "#FFFFFF".to_string(),
1227 second_band_color: "#EEEEEE".to_string(),
1228 footer_color: None,
1229 };
1230 let (request, existing_id) = build_request(&verb, Some(range), None).unwrap();
1231 assert_eq!(existing_id, None);
1232 let BatchUpdateRequestItem::AddBanding(add) = request else {
1233 panic!("expected AddBanding"); };
1235 assert_eq!(add.banded_range.banded_range_id, None);
1236 assert_eq!(add.banded_range.range, Some(range));
1237 assert!(add.banded_range.row_properties.is_some());
1238 assert!(add.banded_range.column_properties.is_none());
1239 let properties = add.banded_range.row_properties.unwrap();
1240 assert!(properties.header_color_style.is_some());
1241 assert!(properties.footer_color_style.is_none());
1242 }
1243
1244 #[test]
1245 fn build_request_add_banding_sets_column_properties_for_the_columns_axis() {
1246 let range = GridRange {
1247 sheet_id: 0,
1248 ..Default::default()
1249 };
1250 let verb = BandingVerb::AddBanding {
1251 sheet: "Q1".to_string(),
1252 range: "A:D".to_string(),
1253 axis: BandingAxis::Columns,
1254 header_color: None,
1255 first_band_color: "#FFFFFF".to_string(),
1256 second_band_color: "#EEEEEE".to_string(),
1257 footer_color: None,
1258 };
1259 let (request, _) = build_request(&verb, Some(range), None).unwrap();
1260 let BatchUpdateRequestItem::AddBanding(add) = request else {
1261 panic!("expected AddBanding"); };
1263 assert!(add.banded_range.row_properties.is_none());
1264 assert!(add.banded_range.column_properties.is_some());
1265 }
1266
1267 #[test]
1268 fn build_request_add_banding_rejects_a_malformed_color() {
1269 let range = GridRange {
1270 sheet_id: 0,
1271 ..Default::default()
1272 };
1273 let verb = BandingVerb::AddBanding {
1274 sheet: "Q1".to_string(),
1275 range: "A1:D10".to_string(),
1276 axis: BandingAxis::Rows,
1277 header_color: None,
1278 first_band_color: "not-a-color".to_string(),
1279 second_band_color: "#EEEEEE".to_string(),
1280 footer_color: None,
1281 };
1282 let err = build_request(&verb, Some(range), None).unwrap_err();
1283 assert!(err.contains("not a color"), "{err}");
1284 }
1285
1286 #[test]
1287 fn build_request_update_banding_merges_onto_the_existing_axis_without_touching_unset_colors() {
1288 let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1289 let verb = BandingVerb::UpdateBanding {
1290 banded_range_id: 7,
1291 sheet: None,
1292 range: None,
1293 axis: BandingAxis::Rows,
1294 header_color: Some("#123456".to_string()),
1295 first_band_color: None,
1296 second_band_color: None,
1297 footer_color: None,
1298 };
1299 let (request, existing_id) = build_request(&verb, None, Some(&existing)).unwrap();
1300 assert_eq!(existing_id, Some(7));
1301 let BatchUpdateRequestItem::UpdateBanding(update) = request else {
1302 panic!("expected UpdateBanding"); };
1304 assert_eq!(update.fields, "rowProperties");
1305 assert!(update.banded_range.range.is_none());
1306 let properties = update.banded_range.row_properties.unwrap();
1307 assert!(properties.header_color_style.is_some());
1308 assert_eq!(
1311 properties.first_band_color_style,
1312 existing.row_properties.unwrap().first_band_color_style
1313 );
1314 }
1315
1316 #[test]
1317 fn build_request_update_banding_preserves_an_existing_theme_colored_band() {
1318 let mut sheet = sheet_with_banding(0, 7);
1324 sheet.banded_ranges[0]
1325 .row_properties
1326 .as_mut()
1327 .unwrap()
1328 .first_band_color_style = Some(ColorStyle::theme("ACCENT1"));
1329 let existing = sheet.banded_ranges.remove(0);
1330 let verb = BandingVerb::UpdateBanding {
1331 banded_range_id: 7,
1332 sheet: None,
1333 range: None,
1334 axis: BandingAxis::Rows,
1335 header_color: Some("#123456".to_string()),
1336 first_band_color: None,
1337 second_band_color: None,
1338 footer_color: None,
1339 };
1340 let (request, _) = build_request(&verb, None, Some(&existing)).unwrap();
1341 let BatchUpdateRequestItem::UpdateBanding(update) = request else {
1342 panic!("expected UpdateBanding"); };
1344 let properties = update.banded_range.row_properties.unwrap();
1345 let serialized = serde_json::to_value(&properties).unwrap();
1346 let first_band = properties.first_band_color_style.unwrap();
1347 assert_eq!(first_band.theme_color, Some("ACCENT1".to_string()));
1348 assert_eq!(first_band.rgb_color, None);
1349
1350 let first_band_json = &serialized["firstBandColorStyle"];
1351 assert_eq!(first_band_json["themeColor"], "ACCENT1");
1352 assert!(
1353 first_band_json.get("rgbColor").is_none(),
1354 "a preserved theme-colored band must not gain a rgbColor field: {serialized}"
1355 );
1356 }
1357
1358 #[test]
1359 fn build_request_update_banding_changes_every_color_field() {
1360 let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1361 let verb = BandingVerb::UpdateBanding {
1362 banded_range_id: 7,
1363 sheet: None,
1364 range: None,
1365 axis: BandingAxis::Rows,
1366 header_color: Some("#123456".to_string()),
1367 first_band_color: Some("#111111".to_string()),
1368 second_band_color: Some("#222222".to_string()),
1369 footer_color: Some("#333333".to_string()),
1370 };
1371 let (request, _) = build_request(&verb, None, Some(&existing)).unwrap();
1372 let BatchUpdateRequestItem::UpdateBanding(update) = request else {
1373 panic!("expected UpdateBanding"); };
1375 assert_eq!(update.fields, "rowProperties");
1376 let properties = update.banded_range.row_properties.unwrap();
1377 assert!(properties.header_color_style.is_some());
1378 assert!(properties.first_band_color_style.is_some());
1379 assert!(properties.second_band_color_style.is_some());
1380 assert!(properties.footer_color_style.is_some());
1381 }
1382
1383 #[test]
1384 fn build_request_update_banding_sets_column_properties_for_the_columns_axis() {
1385 let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1386 let verb = BandingVerb::UpdateBanding {
1387 banded_range_id: 7,
1388 sheet: None,
1389 range: None,
1390 axis: BandingAxis::Columns,
1391 header_color: Some("#123456".to_string()),
1392 first_band_color: Some("#111111".to_string()),
1393 second_band_color: Some("#222222".to_string()),
1394 footer_color: None,
1395 };
1396 let (request, _) = build_request(&verb, None, Some(&existing)).unwrap();
1397 let BatchUpdateRequestItem::UpdateBanding(update) = request else {
1398 panic!("expected UpdateBanding"); };
1400 assert_eq!(update.fields, "columnProperties");
1401 assert!(update.banded_range.row_properties.is_none());
1402 let properties = update.banded_range.column_properties.unwrap();
1403 assert!(properties.first_band_color_style.is_some());
1404 assert!(properties.second_band_color_style.is_some());
1405 }
1406
1407 fn band_color_update_verb(
1408 axis: BandingAxis,
1409 first_band_color: Option<&str>,
1410 second_band_color: Option<&str>,
1411 ) -> BandingVerb {
1412 BandingVerb::UpdateBanding {
1413 banded_range_id: 7,
1414 sheet: None,
1415 range: None,
1416 axis,
1417 header_color: None,
1418 first_band_color: first_band_color.map(str::to_string),
1419 second_band_color: second_band_color.map(str::to_string),
1420 footer_color: None,
1421 }
1422 }
1423
1424 #[test]
1425 fn validate_new_axis_colors_refuses_a_new_axis_with_only_one_band_color() {
1426 let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1427 let verb = band_color_update_verb(BandingAxis::Columns, Some("#ABCDEF"), None);
1428 let detail = validate_new_axis_colors(&verb, Some(&existing)).unwrap_err();
1429 assert!(detail.contains("no column banding"), "{detail}");
1430 assert!(detail.ends_with("missing: --second-band-color"), "{detail}");
1431 }
1432
1433 #[test]
1434 fn validate_new_axis_colors_names_both_missing_flags_for_a_header_only_change() {
1435 let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1436 let verb = BandingVerb::UpdateBanding {
1437 banded_range_id: 7,
1438 sheet: None,
1439 range: None,
1440 axis: BandingAxis::Columns,
1441 header_color: Some("#123456".to_string()),
1442 first_band_color: None,
1443 second_band_color: None,
1444 footer_color: None,
1445 };
1446 let detail = validate_new_axis_colors(&verb, Some(&existing)).unwrap_err();
1447 assert!(
1448 detail.ends_with("missing: --first-band-color, --second-band-color"),
1449 "{detail}"
1450 );
1451 }
1452
1453 #[test]
1454 fn validate_new_axis_colors_accepts_a_new_axis_with_both_band_colors() {
1455 let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1456 let verb = band_color_update_verb(BandingAxis::Columns, Some("#ABCDEF"), Some("#FEDCBA"));
1457 assert!(validate_new_axis_colors(&verb, Some(&existing)).is_ok());
1458 }
1459
1460 #[test]
1461 fn validate_new_axis_colors_accepts_one_color_on_an_existing_axis() {
1462 let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1463 let verb = band_color_update_verb(BandingAxis::Rows, Some("#ABCDEF"), None);
1464 assert!(validate_new_axis_colors(&verb, Some(&existing)).is_ok());
1465 }
1466
1467 #[test]
1468 fn validate_new_axis_colors_ignores_a_range_only_change_on_a_new_axis() {
1469 let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1470 let verb = BandingVerb::UpdateBanding {
1471 banded_range_id: 7,
1472 sheet: Some("Q1".to_string()),
1473 range: Some("A1:D20".to_string()),
1474 axis: BandingAxis::Columns,
1475 header_color: None,
1476 first_band_color: None,
1477 second_band_color: None,
1478 footer_color: None,
1479 };
1480 assert!(validate_new_axis_colors(&verb, Some(&existing)).is_ok());
1481 }
1482
1483 #[test]
1484 fn validate_new_axis_colors_ignores_other_verbs() {
1485 let verb = BandingVerb::DeleteBanding { banded_range_id: 7 };
1486 assert!(validate_new_axis_colors(&verb, None).is_ok());
1487 }
1488
1489 #[test]
1490 fn build_request_update_banding_with_only_a_range_change_carries_no_properties() {
1491 let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1492 let range = GridRange {
1493 sheet_id: 0,
1494 start_row_index: Some(5),
1495 end_row_index: Some(15),
1496 start_column_index: Some(0),
1497 end_column_index: Some(4),
1498 };
1499 let verb = BandingVerb::UpdateBanding {
1500 banded_range_id: 7,
1501 sheet: Some("Q1".to_string()),
1502 range: Some("A6:D15".to_string()),
1503 axis: BandingAxis::Rows,
1504 header_color: None,
1505 first_band_color: None,
1506 second_band_color: None,
1507 footer_color: None,
1508 };
1509 let (request, _) = build_request(&verb, Some(range), Some(&existing)).unwrap();
1510 let BatchUpdateRequestItem::UpdateBanding(update) = request else {
1511 panic!("expected UpdateBanding"); };
1513 assert_eq!(update.fields, "range");
1514 assert_eq!(update.banded_range.range, Some(range));
1515 assert!(update.banded_range.row_properties.is_none());
1516 }
1517
1518 #[test]
1519 fn build_request_delete_banding_carries_the_id() {
1520 let verb = BandingVerb::DeleteBanding { banded_range_id: 7 };
1521 let (request, existing_id) = build_request(&verb, None, None).unwrap();
1522 assert_eq!(existing_id, Some(7));
1523 let BatchUpdateRequestItem::DeleteBanding(delete) = request else {
1524 panic!("expected DeleteBanding"); };
1526 assert_eq!(delete.banded_range_id, 7);
1527 }
1528
1529 fn test_credentials() -> DriveCredentials {
1532 DriveCredentials {
1533 client_id: "client-1".to_string(),
1534 client_secret: Secret::new("secret-1"),
1535 refresh_token: Secret::new("refresh-1"),
1536 scope: DriveGrantedScopes::READONLY,
1537 }
1538 }
1539
1540 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1541 wiremock::Mock::given(wiremock::matchers::method("POST"))
1542 .and(wiremock::matchers::path("/token"))
1543 .respond_with(
1544 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1545 "access_token": "test-token", "expires_in": 3600,
1546 })),
1547 )
1548 .mount(server)
1549 .await;
1550 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1551 crate::drive::client::test_support::replace_session(
1552 &mut drive,
1553 &test_credentials(),
1554 &format!("{}/token", server.uri()),
1555 );
1556 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1557 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1558 (drive, sheets)
1559 }
1560
1561 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1562 let parents: Vec<&str> = parents.to_vec();
1563 wiremock::Mock::given(wiremock::matchers::method("GET"))
1564 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1565 .respond_with(
1566 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1567 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1568 "version": "1",
1569 })),
1570 )
1571 }
1572
1573 fn mount_folder(id: &str) -> wiremock::Mock {
1574 mount_file(id, "application/vnd.google-apps.folder", &[])
1575 }
1576
1577 fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
1578 let ledger_path = tempfile::tempdir()
1579 .unwrap()
1580 .keep()
1581 .join("lease-ledger.jsonl");
1582 let token = seed_lease(&ledger_path, spreadsheet_id, "1");
1583 (Some(token), ledger_path)
1584 }
1585
1586 fn allow_rule(folder: &str) -> FolderPermissionRule {
1587 FolderPermissionRule {
1588 folder_id: Some(folder.to_string()),
1589 file_id: None,
1590 recursive: true,
1591 allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
1592 deny: HashSet::default(),
1593 require_lease: true,
1594 }
1595 }
1596
1597 fn mount_workbook(sheets: serde_json::Value) -> wiremock::Mock {
1598 wiremock::Mock::given(wiremock::matchers::method("GET"))
1599 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1600 .respond_with(
1601 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1602 "spreadsheetId": "sheet-1",
1603 "properties": {"title": "Budget"},
1604 "sheets": sheets,
1605 })),
1606 )
1607 }
1608
1609 #[tokio::test]
1610 async fn a_denied_gate_blocks_before_any_read_or_batch_update_call() {
1611 let server = wiremock::MockServer::start().await;
1612 let (drive, sheets) = clients(&server).await;
1613 mount_file(
1614 "sheet-1",
1615 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1616 &["folder-1"],
1617 )
1618 .mount(&server)
1619 .await;
1620 mount_folder("folder-1").mount(&server).await;
1621 let rules: Vec<FolderPermissionRule> = Vec::new();
1622 let opts = BandingOptions {
1623 spreadsheet_id: "sheet-1".to_string(),
1624 verb: BandingVerb::AddBanding {
1625 sheet: "Q1".to_string(),
1626 range: "A1:D10".to_string(),
1627 axis: BandingAxis::Rows,
1628 header_color: None,
1629 first_band_color: "#FFFFFF".to_string(),
1630 second_band_color: "#EEEEEE".to_string(),
1631 footer_color: None,
1632 },
1633 dry_run: false,
1634 lease_token: None,
1635 ledger_path: std::path::PathBuf::new(),
1636 };
1637 let outcome = banding(&drive, &sheets, &opts, &rules).await;
1638 assert!(matches!(outcome.result, BandingResult::Blocked { .. }));
1639 }
1640
1641 #[tokio::test]
1642 async fn add_banding_dry_run_reports_would_change_and_makes_no_batch_update_call() {
1643 let server = wiremock::MockServer::start().await;
1644 let (drive, sheets) = clients(&server).await;
1645 mount_file(
1646 "sheet-1",
1647 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1648 &["folder-1"],
1649 )
1650 .mount(&server)
1651 .await;
1652 mount_folder("folder-1").mount(&server).await;
1653 mount_workbook(serde_json::json!([
1654 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1655 ]))
1656 .mount(&server)
1657 .await;
1658 let rules = vec![allow_rule("folder-1")];
1659 let opts = BandingOptions {
1660 spreadsheet_id: "sheet-1".to_string(),
1661 verb: BandingVerb::AddBanding {
1662 sheet: "Q1".to_string(),
1663 range: "A1:D10".to_string(),
1664 axis: BandingAxis::Rows,
1665 header_color: None,
1666 first_band_color: "#FFFFFF".to_string(),
1667 second_band_color: "#EEEEEE".to_string(),
1668 footer_color: None,
1669 },
1670 dry_run: true,
1671 lease_token: None,
1672 ledger_path: std::path::PathBuf::new(),
1673 };
1674 let outcome = banding(&drive, &sheets, &opts, &rules).await;
1675 assert!(matches!(outcome.result, BandingResult::WouldChange { .. }));
1676 }
1677
1678 #[tokio::test]
1679 async fn add_banding_sends_an_add_banding_request_and_reports_the_assigned_id() {
1680 let server = wiremock::MockServer::start().await;
1681 let (drive, sheets) = clients(&server).await;
1682 mount_file(
1683 "sheet-1",
1684 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1685 &["folder-1"],
1686 )
1687 .mount(&server)
1688 .await;
1689 mount_folder("folder-1").mount(&server).await;
1690 mount_workbook(serde_json::json!([
1691 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1692 ]))
1693 .mount(&server)
1694 .await;
1695 wiremock::Mock::given(wiremock::matchers::method("POST"))
1696 .and(wiremock::matchers::path(
1697 "/v4/spreadsheets/sheet-1:batchUpdate",
1698 ))
1699 .respond_with(
1700 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1701 "replies": [{"addBanding": {"bandedRange": {"bandedRangeId": 42}}}]
1702 })),
1703 )
1704 .mount(&server)
1705 .await;
1706 let rules = vec![allow_rule("folder-1")];
1707 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1708 let opts = BandingOptions {
1709 spreadsheet_id: "sheet-1".to_string(),
1710 verb: BandingVerb::AddBanding {
1711 sheet: "Q1".to_string(),
1712 range: "A1:D10".to_string(),
1713 axis: BandingAxis::Rows,
1714 header_color: None,
1715 first_band_color: "#FFFFFF".to_string(),
1716 second_band_color: "#EEEEEE".to_string(),
1717 footer_color: None,
1718 },
1719 dry_run: false,
1720 lease_token,
1721 ledger_path,
1722 };
1723 let outcome = banding(&drive, &sheets, &opts, &rules).await;
1724 assert_eq!(
1725 outcome.result,
1726 BandingResult::Changed {
1727 summary: "add row banding".to_string(),
1728 banded_range_id: Some(42),
1729 }
1730 );
1731 assert_eq!(outcome.sheet_id, Some(0));
1732 }
1733
1734 #[tokio::test]
1735 async fn add_banding_with_an_unreadable_reply_reports_applied_reply_unreadable() {
1736 let server = wiremock::MockServer::start().await;
1737 let (drive, sheets) = clients(&server).await;
1738 mount_file(
1739 "sheet-1",
1740 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1741 &["folder-1"],
1742 )
1743 .mount(&server)
1744 .await;
1745 mount_folder("folder-1").mount(&server).await;
1746 mount_workbook(serde_json::json!([
1747 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1748 ]))
1749 .mount(&server)
1750 .await;
1751 wiremock::Mock::given(wiremock::matchers::method("POST"))
1753 .and(wiremock::matchers::path(
1754 "/v4/spreadsheets/sheet-1:batchUpdate",
1755 ))
1756 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
1757 .expect(1)
1758 .mount(&server)
1759 .await;
1760 let rules = vec![allow_rule("folder-1")];
1761 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1762 let opts = BandingOptions {
1763 spreadsheet_id: "sheet-1".to_string(),
1764 verb: add_verb(),
1765 dry_run: false,
1766 lease_token,
1767 ledger_path,
1768 };
1769 let outcome = banding(&drive, &sheets, &opts, &rules).await;
1770 assert!(
1771 matches!(outcome.result, BandingResult::AppliedReplyUnreadable { .. }),
1772 "{:?}",
1773 outcome.result
1774 );
1775 assert_eq!(outcome.result.log_status(), "applied-reply-unreadable");
1776 assert!(
1777 describe(&outcome).starts_with("Applied, but the reply could not be read"),
1778 "{}",
1779 describe(&outcome)
1780 );
1781 }
1782
1783 #[tokio::test]
1784 async fn update_banding_refuses_an_unknown_id() {
1785 let server = wiremock::MockServer::start().await;
1786 let (drive, sheets) = clients(&server).await;
1787 mount_file(
1788 "sheet-1",
1789 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1790 &["folder-1"],
1791 )
1792 .mount(&server)
1793 .await;
1794 mount_folder("folder-1").mount(&server).await;
1795 mount_workbook(serde_json::json!([
1796 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}, "bandedRanges": []},
1797 ]))
1798 .mount(&server)
1799 .await;
1800 let rules = vec![allow_rule("folder-1")];
1801 let opts = BandingOptions {
1802 spreadsheet_id: "sheet-1".to_string(),
1803 verb: BandingVerb::UpdateBanding {
1804 banded_range_id: 99,
1805 sheet: None,
1806 range: None,
1807 axis: BandingAxis::Rows,
1808 header_color: Some("#000000".to_string()),
1809 first_band_color: None,
1810 second_band_color: None,
1811 footer_color: None,
1812 },
1813 dry_run: false,
1814 lease_token: None,
1815 ledger_path: std::path::PathBuf::new(),
1816 };
1817 let outcome = banding(&drive, &sheets, &opts, &rules).await;
1818 assert_eq!(
1819 outcome.result,
1820 BandingResult::RefusedBandedRangeNotFound {
1821 banded_range_id: 99
1822 }
1823 );
1824 }
1825
1826 #[tokio::test]
1827 async fn update_banding_sends_an_update_banding_request_and_reports_the_id() {
1828 let server = wiremock::MockServer::start().await;
1829 let (drive, sheets) = clients(&server).await;
1830 mount_file(
1831 "sheet-1",
1832 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1833 &["folder-1"],
1834 )
1835 .mount(&server)
1836 .await;
1837 mount_folder("folder-1").mount(&server).await;
1838 mount_workbook(serde_json::json!([
1839 {
1840 "properties": {"sheetId": 0, "title": "Q1", "index": 0},
1841 "bandedRanges": [{
1842 "bandedRangeId": 7,
1843 "range": {"sheetId": 0},
1844 "rowProperties": {
1845 "firstBandColorStyle": {"rgbColor": {"red": 1, "green": 1, "blue": 1}},
1846 "secondBandColorStyle": {"rgbColor": {"red": 0, "green": 0, "blue": 0}},
1847 },
1848 }],
1849 },
1850 ]))
1851 .mount(&server)
1852 .await;
1853 wiremock::Mock::given(wiremock::matchers::method("POST"))
1854 .and(wiremock::matchers::path(
1855 "/v4/spreadsheets/sheet-1:batchUpdate",
1856 ))
1857 .respond_with(
1858 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1859 "replies": [{}]
1860 })),
1861 )
1862 .mount(&server)
1863 .await;
1864 let rules = vec![allow_rule("folder-1")];
1865 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1866 let opts = BandingOptions {
1867 spreadsheet_id: "sheet-1".to_string(),
1868 verb: BandingVerb::UpdateBanding {
1869 banded_range_id: 7,
1870 sheet: None,
1871 range: None,
1872 axis: BandingAxis::Rows,
1873 header_color: Some("#000000".to_string()),
1874 first_band_color: None,
1875 second_band_color: None,
1876 footer_color: None,
1877 },
1878 dry_run: false,
1879 lease_token,
1880 ledger_path,
1881 };
1882 let outcome = banding(&drive, &sheets, &opts, &rules).await;
1883 assert_eq!(
1884 outcome.result,
1885 BandingResult::Changed {
1886 summary: "update row banding id 7".to_string(),
1887 banded_range_id: Some(7),
1888 }
1889 );
1890 assert_eq!(outcome.sheet_id, Some(0));
1891 }
1892
1893 async fn mount_row_only_banding(server: &wiremock::MockServer, batch_updates: u64) {
1897 mount_file(
1898 "sheet-1",
1899 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1900 &["folder-1"],
1901 )
1902 .mount(server)
1903 .await;
1904 mount_folder("folder-1").mount(server).await;
1905 mount_workbook(serde_json::json!([
1906 {
1907 "properties": {"sheetId": 0, "title": "Q1", "index": 0},
1908 "bandedRanges": [{
1909 "bandedRangeId": 7,
1910 "range": {"sheetId": 0},
1911 "rowProperties": {
1912 "firstBandColorStyle": {"rgbColor": {"red": 1, "green": 1, "blue": 1}},
1913 "secondBandColorStyle": {"rgbColor": {"red": 0, "green": 0, "blue": 0}},
1914 },
1915 }],
1916 },
1917 ]))
1918 .mount(server)
1919 .await;
1920 wiremock::Mock::given(wiremock::matchers::method("POST"))
1921 .and(wiremock::matchers::path(
1922 "/v4/spreadsheets/sheet-1:batchUpdate",
1923 ))
1924 .respond_with(
1925 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1926 "replies": [{}]
1927 })),
1928 )
1929 .expect(batch_updates)
1930 .mount(server)
1931 .await;
1932 }
1933
1934 async fn run_update_on_row_only_banding(
1935 axis: BandingAxis,
1936 first_band_color: Option<&str>,
1937 second_band_color: Option<&str>,
1938 dry_run: bool,
1939 batch_updates: u64,
1940 ) -> (BandingOutcome, wiremock::MockServer) {
1941 let server = wiremock::MockServer::start().await;
1942 let (drive, sheets) = clients(&server).await;
1943 mount_row_only_banding(&server, batch_updates).await;
1944 let rules = vec![allow_rule("folder-1")];
1945 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1946 let opts = BandingOptions {
1947 spreadsheet_id: "sheet-1".to_string(),
1948 verb: band_color_update_verb(axis, first_band_color, second_band_color),
1949 dry_run,
1950 lease_token,
1951 ledger_path,
1952 };
1953 (banding(&drive, &sheets, &opts, &rules).await, server)
1954 }
1955
1956 #[tokio::test]
1959 async fn update_banding_range_without_sheet_defaults_to_the_banded_ranges_sheet() {
1960 let server = wiremock::MockServer::start().await;
1961 let (drive, sheets) = clients(&server).await;
1962 mount_row_only_banding(&server, 0).await;
1963 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1964 let opts = BandingOptions {
1965 spreadsheet_id: "sheet-1".to_string(),
1966 verb: BandingVerb::UpdateBanding {
1967 banded_range_id: 7,
1968 sheet: None,
1969 range: Some("A1:D20".to_string()),
1970 axis: BandingAxis::Rows,
1971 header_color: None,
1972 first_band_color: None,
1973 second_band_color: None,
1974 footer_color: None,
1975 },
1976 dry_run: true,
1977 lease_token,
1978 ledger_path,
1979 };
1980 let outcome = banding(&drive, &sheets, &opts, &[allow_rule("folder-1")]).await;
1981 assert!(
1982 matches!(outcome.result, BandingResult::WouldChange { .. }),
1983 "{:?}",
1984 outcome.result
1985 );
1986 assert_eq!(outcome.sheet_id, Some(0));
1987 }
1988
1989 #[tokio::test]
1992 async fn update_banding_range_without_sheet_reports_an_unknown_id_as_not_found() {
1993 let server = wiremock::MockServer::start().await;
1994 let (drive, sheets) = clients(&server).await;
1995 mount_row_only_banding(&server, 0).await;
1996 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1997 let opts = BandingOptions {
1998 spreadsheet_id: "sheet-1".to_string(),
1999 verb: BandingVerb::UpdateBanding {
2000 banded_range_id: 99,
2001 sheet: None,
2002 range: Some("A1:D20".to_string()),
2003 axis: BandingAxis::Rows,
2004 header_color: None,
2005 first_band_color: None,
2006 second_band_color: None,
2007 footer_color: None,
2008 },
2009 dry_run: true,
2010 lease_token,
2011 ledger_path,
2012 };
2013 let outcome = banding(&drive, &sheets, &opts, &[allow_rule("folder-1")]).await;
2014 assert!(
2015 matches!(
2016 outcome.result,
2017 BandingResult::RefusedBandedRangeNotFound { .. }
2018 ),
2019 "{:?}",
2020 outcome.result
2021 );
2022 }
2023
2024 fn assert_refused_missing_second_band_color(outcome: &BandingOutcome) {
2025 let BandingResult::RefusedInvalidRange { detail } = &outcome.result else {
2026 panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
2028 assert!(detail.contains("no column banding"), "{detail}");
2029 assert!(detail.ends_with("missing: --second-band-color"), "{detail}");
2030 }
2031
2032 #[tokio::test]
2033 async fn update_banding_dry_run_refuses_a_new_axis_with_only_one_band_color() {
2034 let (outcome, server) =
2035 run_update_on_row_only_banding(BandingAxis::Columns, Some("#ABCDEF"), None, true, 0)
2036 .await;
2037 assert_refused_missing_second_band_color(&outcome);
2038 assert_eq!(outcome.sheet_id, Some(0));
2039 server.verify().await;
2040 }
2041
2042 #[tokio::test]
2043 async fn update_banding_refuses_a_new_axis_with_only_one_band_color_without_calling_the_api() {
2044 let (outcome, server) =
2045 run_update_on_row_only_banding(BandingAxis::Columns, Some("#ABCDEF"), None, false, 0)
2046 .await;
2047 assert_refused_missing_second_band_color(&outcome);
2048 server.verify().await;
2049 }
2050
2051 #[tokio::test]
2052 async fn update_banding_adds_a_new_axis_when_both_band_colors_are_given() {
2053 let (outcome, server) = run_update_on_row_only_banding(
2054 BandingAxis::Columns,
2055 Some("#ABCDEF"),
2056 Some("#FEDCBA"),
2057 false,
2058 1,
2059 )
2060 .await;
2061 assert_eq!(
2062 outcome.result,
2063 BandingResult::Changed {
2064 summary: "update column banding id 7".to_string(),
2065 banded_range_id: Some(7),
2066 }
2067 );
2068 let requests = server.received_requests().await.unwrap();
2069 let body: serde_json::Value = requests
2070 .iter()
2071 .find(|r| r.url.path().ends_with(":batchUpdate"))
2072 .unwrap()
2073 .body_json()
2074 .unwrap();
2075 let update = &body["requests"][0]["updateBanding"];
2076 assert_eq!(update["fields"], "columnProperties");
2077 let columns = &update["bandedRange"]["columnProperties"];
2078 assert!(columns["firstBandColorStyle"].is_object(), "{update}");
2079 assert!(columns["secondBandColorStyle"].is_object(), "{update}");
2080 server.verify().await;
2081 }
2082
2083 #[tokio::test]
2084 async fn update_banding_changes_one_color_on_an_existing_axis() {
2085 let (outcome, server) =
2086 run_update_on_row_only_banding(BandingAxis::Rows, Some("#ABCDEF"), None, false, 1)
2087 .await;
2088 assert_eq!(
2089 outcome.result,
2090 BandingResult::Changed {
2091 summary: "update row banding id 7".to_string(),
2092 banded_range_id: Some(7),
2093 }
2094 );
2095 server.verify().await;
2096 }
2097
2098 #[tokio::test]
2099 async fn update_banding_dry_run_accepts_one_color_on_an_existing_axis() {
2100 let (outcome, server) =
2101 run_update_on_row_only_banding(BandingAxis::Rows, Some("#ABCDEF"), None, true, 0).await;
2102 assert_eq!(
2103 outcome.result,
2104 BandingResult::WouldChange {
2105 summary: "update row banding id 7".to_string(),
2106 }
2107 );
2108 server.verify().await;
2109 }
2110
2111 #[tokio::test]
2112 async fn delete_banding_sends_a_delete_banding_request() {
2113 let server = wiremock::MockServer::start().await;
2114 let (drive, sheets) = clients(&server).await;
2115 mount_file(
2116 "sheet-1",
2117 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2118 &["folder-1"],
2119 )
2120 .mount(&server)
2121 .await;
2122 mount_folder("folder-1").mount(&server).await;
2123 mount_workbook(serde_json::json!([
2124 {
2125 "properties": {"sheetId": 0, "title": "Q1", "index": 0},
2126 "bandedRanges": [{
2127 "bandedRangeId": 7,
2128 "range": {"sheetId": 0},
2129 "rowProperties": {
2130 "firstBandColorStyle": {"rgbColor": {"red": 1, "green": 1, "blue": 1}},
2131 "secondBandColorStyle": {"rgbColor": {"red": 0, "green": 0, "blue": 0}},
2132 },
2133 }],
2134 },
2135 ]))
2136 .mount(&server)
2137 .await;
2138 wiremock::Mock::given(wiremock::matchers::method("POST"))
2139 .and(wiremock::matchers::path(
2140 "/v4/spreadsheets/sheet-1:batchUpdate",
2141 ))
2142 .respond_with(
2143 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2144 "replies": [{}]
2145 })),
2146 )
2147 .mount(&server)
2148 .await;
2149 let rules = vec![allow_rule("folder-1")];
2150 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2151 let opts = BandingOptions {
2152 spreadsheet_id: "sheet-1".to_string(),
2153 verb: BandingVerb::DeleteBanding { banded_range_id: 7 },
2154 dry_run: false,
2155 lease_token,
2156 ledger_path,
2157 };
2158 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2159 assert_eq!(
2160 outcome.result,
2161 BandingResult::Changed {
2162 summary: "delete banding id 7".to_string(),
2163 banded_range_id: Some(7),
2164 }
2165 );
2166 assert_eq!(outcome.sheet_id, Some(0));
2167 }
2168
2169 #[tokio::test]
2173 async fn update_banding_end_to_end_refuses_nothing_to_change() {
2174 let server = wiremock::MockServer::start().await;
2175 let (drive, sheets) = clients(&server).await;
2176 let rules: Vec<FolderPermissionRule> = Vec::new();
2177 let opts = BandingOptions {
2178 spreadsheet_id: "sheet-1".to_string(),
2179 verb: BandingVerb::UpdateBanding {
2180 banded_range_id: 1,
2181 sheet: None,
2182 range: None,
2183 axis: BandingAxis::Rows,
2184 header_color: None,
2185 first_band_color: None,
2186 second_band_color: None,
2187 footer_color: None,
2188 },
2189 dry_run: false,
2190 lease_token: None,
2191 ledger_path: std::path::PathBuf::new(),
2192 };
2193 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2194 assert!(matches!(
2195 outcome.result,
2196 BandingResult::RefusedInvalidRange { .. }
2197 ));
2198 }
2199
2200 #[tokio::test]
2201 async fn add_banding_end_to_end_rejects_conflicting_sheet_and_range() {
2202 let server = wiremock::MockServer::start().await;
2203 let (drive, sheets) = clients(&server).await;
2204 let rules: Vec<FolderPermissionRule> = Vec::new();
2205 let opts = BandingOptions {
2206 spreadsheet_id: "sheet-1".to_string(),
2207 verb: BandingVerb::AddBanding {
2208 sheet: "Other".to_string(),
2209 range: "Sheet1!A1:B2".to_string(),
2210 axis: BandingAxis::Rows,
2211 header_color: None,
2212 first_band_color: "#FFFFFF".to_string(),
2213 second_band_color: "#EEEEEE".to_string(),
2214 footer_color: None,
2215 },
2216 dry_run: false,
2217 lease_token: None,
2218 ledger_path: std::path::PathBuf::new(),
2219 };
2220 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2221 assert!(matches!(
2222 outcome.result,
2223 BandingResult::RefusedInvalidRange { .. }
2224 ));
2225 }
2226
2227 #[tokio::test]
2230 async fn a_metadata_fetch_failure_surfaces_as_failed() {
2231 let server = wiremock::MockServer::start().await;
2232 let (drive, sheets) = clients(&server).await;
2233 wiremock::Mock::given(wiremock::matchers::method("GET"))
2234 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2235 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
2236 .mount(&server)
2237 .await;
2238 let rules: Vec<FolderPermissionRule> = Vec::new();
2239 let opts = BandingOptions {
2240 spreadsheet_id: "sheet-1".to_string(),
2241 verb: BandingVerb::AddBanding {
2242 sheet: "Q1".to_string(),
2243 range: "A1:D10".to_string(),
2244 axis: BandingAxis::Rows,
2245 header_color: None,
2246 first_band_color: "#FFFFFF".to_string(),
2247 second_band_color: "#EEEEEE".to_string(),
2248 footer_color: None,
2249 },
2250 dry_run: false,
2251 lease_token: None,
2252 ledger_path: std::path::PathBuf::new(),
2253 };
2254 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2255 assert!(matches!(outcome.result, BandingResult::Failed { .. }));
2256 }
2257
2258 #[tokio::test]
2259 async fn a_shortcut_target_is_refused() {
2260 let server = wiremock::MockServer::start().await;
2261 let (drive, sheets) = clients(&server).await;
2262 mount_file(
2263 "sheet-1",
2264 "application/vnd.google-apps.shortcut",
2265 &["folder-1"],
2266 )
2267 .mount(&server)
2268 .await;
2269 let rules = vec![allow_rule("folder-1")];
2270 let opts = BandingOptions {
2271 spreadsheet_id: "sheet-1".to_string(),
2272 verb: BandingVerb::AddBanding {
2273 sheet: "Q1".to_string(),
2274 range: "A1:D10".to_string(),
2275 axis: BandingAxis::Rows,
2276 header_color: None,
2277 first_band_color: "#FFFFFF".to_string(),
2278 second_band_color: "#EEEEEE".to_string(),
2279 footer_color: None,
2280 },
2281 dry_run: false,
2282 lease_token: None,
2283 ledger_path: std::path::PathBuf::new(),
2284 };
2285 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2286 assert!(matches!(outcome.result, BandingResult::RefusedShortcut));
2287 }
2288
2289 #[tokio::test]
2290 async fn a_non_spreadsheet_target_is_refused() {
2291 let server = wiremock::MockServer::start().await;
2292 let (drive, sheets) = clients(&server).await;
2293 mount_file(
2294 "sheet-1",
2295 "application/vnd.google-apps.document",
2296 &["folder-1"],
2297 )
2298 .mount(&server)
2299 .await;
2300 let rules = vec![allow_rule("folder-1")];
2301 let opts = BandingOptions {
2302 spreadsheet_id: "sheet-1".to_string(),
2303 verb: BandingVerb::AddBanding {
2304 sheet: "Q1".to_string(),
2305 range: "A1:D10".to_string(),
2306 axis: BandingAxis::Rows,
2307 header_color: None,
2308 first_band_color: "#FFFFFF".to_string(),
2309 second_band_color: "#EEEEEE".to_string(),
2310 footer_color: None,
2311 },
2312 dry_run: false,
2313 lease_token: None,
2314 ledger_path: std::path::PathBuf::new(),
2315 };
2316 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2317 assert!(matches!(
2318 outcome.result,
2319 BandingResult::RefusedNotASpreadsheet { .. }
2320 ));
2321 }
2322
2323 #[tokio::test]
2324 async fn a_target_with_no_visible_parents_is_refused() {
2325 let server = wiremock::MockServer::start().await;
2326 let (drive, sheets) = clients(&server).await;
2327 mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
2328 .mount(&server)
2329 .await;
2330 let rules: Vec<FolderPermissionRule> = Vec::new();
2331 let opts = BandingOptions {
2332 spreadsheet_id: "sheet-1".to_string(),
2333 verb: BandingVerb::AddBanding {
2334 sheet: "Q1".to_string(),
2335 range: "A1:D10".to_string(),
2336 axis: BandingAxis::Rows,
2337 header_color: None,
2338 first_band_color: "#FFFFFF".to_string(),
2339 second_band_color: "#EEEEEE".to_string(),
2340 footer_color: None,
2341 },
2342 dry_run: false,
2343 lease_token: None,
2344 ledger_path: std::path::PathBuf::new(),
2345 };
2346 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2347 assert!(matches!(
2348 outcome.result,
2349 BandingResult::RefusedNoVisibleParents
2350 ));
2351 }
2352
2353 #[tokio::test]
2354 async fn a_gate_ancestor_fetch_failure_surfaces_as_failed() {
2355 let server = wiremock::MockServer::start().await;
2356 let (drive, sheets) = clients(&server).await;
2357 mount_file(
2358 "sheet-1",
2359 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2360 &["folder-1"],
2361 )
2362 .mount(&server)
2363 .await;
2364 wiremock::Mock::given(wiremock::matchers::method("GET"))
2365 .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
2366 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2367 .mount(&server)
2368 .await;
2369 let rules = vec![allow_rule("folder-1")];
2370 let opts = BandingOptions {
2371 spreadsheet_id: "sheet-1".to_string(),
2372 verb: BandingVerb::AddBanding {
2373 sheet: "Q1".to_string(),
2374 range: "A1:D10".to_string(),
2375 axis: BandingAxis::Rows,
2376 header_color: None,
2377 first_band_color: "#FFFFFF".to_string(),
2378 second_band_color: "#EEEEEE".to_string(),
2379 footer_color: None,
2380 },
2381 dry_run: false,
2382 lease_token: None,
2383 ledger_path: std::path::PathBuf::new(),
2384 };
2385 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2386 assert!(matches!(outcome.result, BandingResult::Failed { .. }));
2387 }
2388
2389 #[tokio::test]
2390 async fn a_workbook_fetch_failure_after_a_granted_gate_surfaces_as_failed() {
2391 let server = wiremock::MockServer::start().await;
2392 let (drive, sheets) = clients(&server).await;
2393 mount_file(
2394 "sheet-1",
2395 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2396 &["folder-1"],
2397 )
2398 .mount(&server)
2399 .await;
2400 mount_folder("folder-1").mount(&server).await;
2401 wiremock::Mock::given(wiremock::matchers::method("GET"))
2402 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2403 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2404 .mount(&server)
2405 .await;
2406 let rules = vec![allow_rule("folder-1")];
2407 let opts = BandingOptions {
2408 spreadsheet_id: "sheet-1".to_string(),
2409 verb: BandingVerb::AddBanding {
2410 sheet: "Q1".to_string(),
2411 range: "A1:D10".to_string(),
2412 axis: BandingAxis::Rows,
2413 header_color: None,
2414 first_band_color: "#FFFFFF".to_string(),
2415 second_band_color: "#EEEEEE".to_string(),
2416 footer_color: None,
2417 },
2418 dry_run: false,
2419 lease_token: None,
2420 ledger_path: std::path::PathBuf::new(),
2421 };
2422 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2423 assert!(matches!(outcome.result, BandingResult::Failed { .. }));
2424 }
2425
2426 #[tokio::test]
2430 async fn add_banding_reports_sheet_not_found_for_an_unknown_sheet() {
2431 let server = wiremock::MockServer::start().await;
2432 let (drive, sheets) = clients(&server).await;
2433 mount_file(
2434 "sheet-1",
2435 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2436 &["folder-1"],
2437 )
2438 .mount(&server)
2439 .await;
2440 mount_folder("folder-1").mount(&server).await;
2441 mount_workbook(serde_json::json!([
2442 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2443 ]))
2444 .mount(&server)
2445 .await;
2446 let rules = vec![allow_rule("folder-1")];
2447 let opts = BandingOptions {
2448 spreadsheet_id: "sheet-1".to_string(),
2449 verb: BandingVerb::AddBanding {
2450 sheet: "Missing".to_string(),
2451 range: "A1:D10".to_string(),
2452 axis: BandingAxis::Rows,
2453 header_color: None,
2454 first_band_color: "#FFFFFF".to_string(),
2455 second_band_color: "#EEEEEE".to_string(),
2456 footer_color: None,
2457 },
2458 dry_run: false,
2459 lease_token: None,
2460 ledger_path: std::path::PathBuf::new(),
2461 };
2462 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2463 assert!(matches!(
2464 outcome.result,
2465 BandingResult::RefusedSheetNotFound { .. }
2466 ));
2467 }
2468
2469 #[tokio::test]
2470 async fn add_banding_reports_invalid_range_for_a_malformed_range() {
2471 let server = wiremock::MockServer::start().await;
2472 let (drive, sheets) = clients(&server).await;
2473 mount_file(
2474 "sheet-1",
2475 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2476 &["folder-1"],
2477 )
2478 .mount(&server)
2479 .await;
2480 mount_folder("folder-1").mount(&server).await;
2481 mount_workbook(serde_json::json!([
2482 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2483 ]))
2484 .mount(&server)
2485 .await;
2486 let rules = vec![allow_rule("folder-1")];
2487 let opts = BandingOptions {
2488 spreadsheet_id: "sheet-1".to_string(),
2489 verb: BandingVerb::AddBanding {
2490 sheet: "Q1".to_string(),
2491 range: "not-a-range".to_string(),
2492 axis: BandingAxis::Rows,
2493 header_color: None,
2494 first_band_color: "#FFFFFF".to_string(),
2495 second_band_color: "#EEEEEE".to_string(),
2496 footer_color: None,
2497 },
2498 dry_run: false,
2499 lease_token: None,
2500 ledger_path: std::path::PathBuf::new(),
2501 };
2502 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2503 assert!(matches!(
2504 outcome.result,
2505 BandingResult::RefusedInvalidRange { .. }
2506 ));
2507 }
2508
2509 #[tokio::test]
2510 async fn update_banding_reports_sheet_not_found_when_re_pointing() {
2511 let server = wiremock::MockServer::start().await;
2512 let (drive, sheets) = clients(&server).await;
2513 mount_file(
2514 "sheet-1",
2515 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2516 &["folder-1"],
2517 )
2518 .mount(&server)
2519 .await;
2520 mount_folder("folder-1").mount(&server).await;
2521 mount_workbook(serde_json::json!([
2522 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2523 ]))
2524 .mount(&server)
2525 .await;
2526 let rules = vec![allow_rule("folder-1")];
2527 let opts = BandingOptions {
2528 spreadsheet_id: "sheet-1".to_string(),
2529 verb: BandingVerb::UpdateBanding {
2530 banded_range_id: 7,
2531 sheet: Some("Missing".to_string()),
2532 range: Some("A1:B2".to_string()),
2533 axis: BandingAxis::Rows,
2534 header_color: None,
2535 first_band_color: None,
2536 second_band_color: None,
2537 footer_color: None,
2538 },
2539 dry_run: false,
2540 lease_token: None,
2541 ledger_path: std::path::PathBuf::new(),
2542 };
2543 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2544 assert!(matches!(
2545 outcome.result,
2546 BandingResult::RefusedSheetNotFound { .. }
2547 ));
2548 }
2549
2550 #[tokio::test]
2551 async fn update_banding_reports_invalid_range_for_a_malformed_range_when_re_pointing() {
2552 let server = wiremock::MockServer::start().await;
2553 let (drive, sheets) = clients(&server).await;
2554 mount_file(
2555 "sheet-1",
2556 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2557 &["folder-1"],
2558 )
2559 .mount(&server)
2560 .await;
2561 mount_folder("folder-1").mount(&server).await;
2562 mount_workbook(serde_json::json!([
2563 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2564 ]))
2565 .mount(&server)
2566 .await;
2567 let rules = vec![allow_rule("folder-1")];
2568 let opts = BandingOptions {
2569 spreadsheet_id: "sheet-1".to_string(),
2570 verb: BandingVerb::UpdateBanding {
2571 banded_range_id: 7,
2572 sheet: Some("Q1".to_string()),
2573 range: Some("not-a-range".to_string()),
2574 axis: BandingAxis::Rows,
2575 header_color: None,
2576 first_band_color: None,
2577 second_band_color: None,
2578 footer_color: None,
2579 },
2580 dry_run: false,
2581 lease_token: None,
2582 ledger_path: std::path::PathBuf::new(),
2583 };
2584 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2585 assert!(matches!(
2586 outcome.result,
2587 BandingResult::RefusedInvalidRange { .. }
2588 ));
2589 }
2590
2591 #[tokio::test]
2592 async fn update_banding_with_a_new_range_succeeds() {
2593 let server = wiremock::MockServer::start().await;
2594 let (drive, sheets) = clients(&server).await;
2595 mount_file(
2596 "sheet-1",
2597 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2598 &["folder-1"],
2599 )
2600 .mount(&server)
2601 .await;
2602 mount_folder("folder-1").mount(&server).await;
2603 mount_workbook(serde_json::json!([
2604 {
2605 "properties": {"sheetId": 0, "title": "Q1", "index": 0},
2606 "bandedRanges": [{
2607 "bandedRangeId": 7,
2608 "range": {"sheetId": 0},
2609 "rowProperties": {
2610 "firstBandColorStyle": {"rgbColor": {"red": 1, "green": 1, "blue": 1}},
2611 "secondBandColorStyle": {"rgbColor": {"red": 0, "green": 0, "blue": 0}},
2612 },
2613 }],
2614 },
2615 ]))
2616 .mount(&server)
2617 .await;
2618 let rules = vec![allow_rule("folder-1")];
2619 let opts = BandingOptions {
2620 spreadsheet_id: "sheet-1".to_string(),
2621 verb: BandingVerb::UpdateBanding {
2622 banded_range_id: 7,
2623 sheet: Some("Q1".to_string()),
2624 range: Some("A1:B2".to_string()),
2625 axis: BandingAxis::Rows,
2626 header_color: None,
2627 first_band_color: None,
2628 second_band_color: None,
2629 footer_color: None,
2630 },
2631 dry_run: true,
2632 lease_token: None,
2633 ledger_path: std::path::PathBuf::new(),
2634 };
2635 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2636 assert!(matches!(outcome.result, BandingResult::WouldChange { .. }));
2637 }
2638
2639 #[tokio::test]
2643 async fn update_banding_end_to_end_rejects_a_malformed_color() {
2644 let server = wiremock::MockServer::start().await;
2645 let (drive, sheets) = clients(&server).await;
2646 mount_file(
2647 "sheet-1",
2648 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2649 &["folder-1"],
2650 )
2651 .mount(&server)
2652 .await;
2653 mount_folder("folder-1").mount(&server).await;
2654 mount_workbook(serde_json::json!([
2655 {
2656 "properties": {"sheetId": 0, "title": "Q1", "index": 0},
2657 "bandedRanges": [{
2658 "bandedRangeId": 7,
2659 "range": {"sheetId": 0},
2660 "rowProperties": {
2661 "firstBandColorStyle": {"rgbColor": {"red": 1, "green": 1, "blue": 1}},
2662 "secondBandColorStyle": {"rgbColor": {"red": 0, "green": 0, "blue": 0}},
2663 },
2664 }],
2665 },
2666 ]))
2667 .mount(&server)
2668 .await;
2669 let rules = vec![allow_rule("folder-1")];
2670 let opts = BandingOptions {
2671 spreadsheet_id: "sheet-1".to_string(),
2672 verb: BandingVerb::UpdateBanding {
2673 banded_range_id: 7,
2674 sheet: None,
2675 range: None,
2676 axis: BandingAxis::Rows,
2677 header_color: None,
2678 first_band_color: Some("not-a-color".to_string()),
2679 second_band_color: None,
2680 footer_color: None,
2681 },
2682 dry_run: false,
2683 lease_token: None,
2684 ledger_path: std::path::PathBuf::new(),
2685 };
2686 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2687 assert!(matches!(
2688 outcome.result,
2689 BandingResult::RefusedInvalidRange { .. }
2690 ));
2691 }
2692
2693 #[tokio::test]
2696 async fn a_batch_update_failure_surfaces_as_failed() {
2697 let server = wiremock::MockServer::start().await;
2698 let (drive, sheets) = clients(&server).await;
2699 mount_file(
2700 "sheet-1",
2701 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2702 &["folder-1"],
2703 )
2704 .mount(&server)
2705 .await;
2706 mount_folder("folder-1").mount(&server).await;
2707 mount_workbook(serde_json::json!([
2708 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2709 ]))
2710 .mount(&server)
2711 .await;
2712 wiremock::Mock::given(wiremock::matchers::method("POST"))
2713 .and(wiremock::matchers::path(
2714 "/v4/spreadsheets/sheet-1:batchUpdate",
2715 ))
2716 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("bad request"))
2717 .mount(&server)
2718 .await;
2719 let rules = vec![allow_rule("folder-1")];
2720 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2721 let opts = BandingOptions {
2722 spreadsheet_id: "sheet-1".to_string(),
2723 verb: BandingVerb::AddBanding {
2724 sheet: "Q1".to_string(),
2725 range: "A1:D10".to_string(),
2726 axis: BandingAxis::Rows,
2727 header_color: None,
2728 first_band_color: "#FFFFFF".to_string(),
2729 second_band_color: "#EEEEEE".to_string(),
2730 footer_color: None,
2731 },
2732 dry_run: false,
2733 lease_token,
2734 ledger_path,
2735 };
2736 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2737 assert!(matches!(outcome.result, BandingResult::Failed { .. }));
2738 }
2739
2740 #[tokio::test]
2743 async fn refuses_without_a_lease_when_the_rule_requires_one() {
2744 let server = wiremock::MockServer::start().await;
2745 let (drive, sheets) = clients(&server).await;
2746 mount_file(
2747 "sheet-1",
2748 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2749 &["folder-1"],
2750 )
2751 .mount(&server)
2752 .await;
2753 mount_folder("folder-1").mount(&server).await;
2754 mount_workbook(serde_json::json!([
2755 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2756 ]))
2757 .mount(&server)
2758 .await;
2759 let rules = vec![allow_rule("folder-1")];
2760 let opts = BandingOptions {
2761 spreadsheet_id: "sheet-1".to_string(),
2762 verb: BandingVerb::AddBanding {
2763 sheet: "Q1".to_string(),
2764 range: "A1:D10".to_string(),
2765 axis: BandingAxis::Rows,
2766 header_color: None,
2767 first_band_color: "#FFFFFF".to_string(),
2768 second_band_color: "#EEEEEE".to_string(),
2769 footer_color: None,
2770 },
2771 dry_run: false,
2772 lease_token: None,
2773 ledger_path: std::path::PathBuf::new(),
2774 };
2775 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2776 assert!(matches!(outcome.result, BandingResult::RefusedNoLease));
2777 }
2778
2779 #[tokio::test]
2780 async fn refuses_an_unknown_lease_token() {
2781 let server = wiremock::MockServer::start().await;
2782 let (drive, sheets) = clients(&server).await;
2783 mount_file(
2784 "sheet-1",
2785 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2786 &["folder-1"],
2787 )
2788 .mount(&server)
2789 .await;
2790 mount_folder("folder-1").mount(&server).await;
2791 mount_workbook(serde_json::json!([
2792 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2793 ]))
2794 .mount(&server)
2795 .await;
2796 let rules = vec![allow_rule("folder-1")];
2797 let ledger_path = tempfile::tempdir()
2798 .unwrap()
2799 .keep()
2800 .join("lease-ledger.jsonl");
2801 let opts = BandingOptions {
2802 spreadsheet_id: "sheet-1".to_string(),
2803 verb: BandingVerb::AddBanding {
2804 sheet: "Q1".to_string(),
2805 range: "A1:D10".to_string(),
2806 axis: BandingAxis::Rows,
2807 header_color: None,
2808 first_band_color: "#FFFFFF".to_string(),
2809 second_band_color: "#EEEEEE".to_string(),
2810 footer_color: None,
2811 },
2812 dry_run: false,
2813 lease_token: Some("bogus-token".to_string()),
2814 ledger_path,
2815 };
2816 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2817 assert!(matches!(outcome.result, BandingResult::RefusedLeaseExpired));
2818 }
2819
2820 #[tokio::test]
2821 async fn refuses_a_lease_bound_to_a_different_file() {
2822 let server = wiremock::MockServer::start().await;
2823 let (drive, sheets) = clients(&server).await;
2824 mount_file(
2825 "sheet-1",
2826 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2827 &["folder-1"],
2828 )
2829 .mount(&server)
2830 .await;
2831 mount_folder("folder-1").mount(&server).await;
2832 mount_workbook(serde_json::json!([
2833 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2834 ]))
2835 .mount(&server)
2836 .await;
2837 let rules = vec![allow_rule("folder-1")];
2838 let ledger_path = tempfile::tempdir()
2839 .unwrap()
2840 .keep()
2841 .join("lease-ledger.jsonl");
2842 let token = seed_lease(&ledger_path, "some-other-sheet", "1");
2843 let opts = BandingOptions {
2844 spreadsheet_id: "sheet-1".to_string(),
2845 verb: BandingVerb::AddBanding {
2846 sheet: "Q1".to_string(),
2847 range: "A1:D10".to_string(),
2848 axis: BandingAxis::Rows,
2849 header_color: None,
2850 first_band_color: "#FFFFFF".to_string(),
2851 second_band_color: "#EEEEEE".to_string(),
2852 footer_color: None,
2853 },
2854 dry_run: false,
2855 lease_token: Some(token),
2856 ledger_path,
2857 };
2858 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2859 assert!(matches!(
2860 outcome.result,
2861 BandingResult::RefusedLeaseWrongFile
2862 ));
2863 }
2864
2865 #[tokio::test]
2866 async fn refuses_a_stale_lease_when_the_file_has_moved() {
2867 let server = wiremock::MockServer::start().await;
2868 let (drive, sheets) = clients(&server).await;
2869 mount_file(
2870 "sheet-1",
2871 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2872 &["folder-1"],
2873 )
2874 .mount(&server)
2875 .await;
2876 mount_folder("folder-1").mount(&server).await;
2877 mount_workbook(serde_json::json!([
2878 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2879 ]))
2880 .mount(&server)
2881 .await;
2882 let rules = vec![allow_rule("folder-1")];
2883 let ledger_path = tempfile::tempdir()
2884 .unwrap()
2885 .keep()
2886 .join("lease-ledger.jsonl");
2887 let token = seed_lease(&ledger_path, "sheet-1", "0");
2888 let opts = BandingOptions {
2889 spreadsheet_id: "sheet-1".to_string(),
2890 verb: BandingVerb::AddBanding {
2891 sheet: "Q1".to_string(),
2892 range: "A1:D10".to_string(),
2893 axis: BandingAxis::Rows,
2894 header_color: None,
2895 first_band_color: "#FFFFFF".to_string(),
2896 second_band_color: "#EEEEEE".to_string(),
2897 footer_color: None,
2898 },
2899 dry_run: false,
2900 lease_token: Some(token),
2901 ledger_path,
2902 };
2903 let outcome = banding(&drive, &sheets, &opts, &rules).await;
2904 assert!(matches!(outcome.result, BandingResult::RefusedLeaseStale));
2905 }
2906
2907 fn outcome_with(
2910 verb: BandingVerb,
2911 file_name: Option<&str>,
2912 result: BandingResult,
2913 ) -> BandingOutcome {
2914 BandingOutcome {
2915 spreadsheet_id: "sheet-1".to_string(),
2916 file_name: file_name.map(str::to_string),
2917 resolved_folder_id: None,
2918 sheet_id: None,
2919 verb,
2920 result,
2921 }
2922 }
2923
2924 fn add_verb() -> BandingVerb {
2925 BandingVerb::AddBanding {
2926 sheet: "Q1".to_string(),
2927 range: "A1:D10".to_string(),
2928 axis: BandingAxis::Rows,
2929 header_color: None,
2930 first_band_color: "#FFFFFF".to_string(),
2931 second_band_color: "#EEEEEE".to_string(),
2932 footer_color: None,
2933 }
2934 }
2935
2936 fn update_verb() -> BandingVerb {
2937 BandingVerb::UpdateBanding {
2938 banded_range_id: 7,
2939 sheet: None,
2940 range: None,
2941 axis: BandingAxis::Rows,
2942 header_color: Some("#000000".to_string()),
2943 first_band_color: None,
2944 second_band_color: None,
2945 footer_color: None,
2946 }
2947 }
2948
2949 fn delete_verb() -> BandingVerb {
2950 BandingVerb::DeleteBanding { banded_range_id: 7 }
2951 }
2952
2953 #[test]
2954 fn describe_lines_renders_would_change() {
2955 let out = outcome_with(
2956 add_verb(),
2957 Some("Budget"),
2958 BandingResult::WouldChange {
2959 summary: "add row banding".to_string(),
2960 },
2961 );
2962 assert_eq!(describe(&out), "Would add row banding in 'Budget'");
2963 }
2964
2965 #[test]
2966 fn describe_lines_renders_not_a_spreadsheet_with_no_file_name() {
2967 let out = outcome_with(
2968 add_verb(),
2969 None,
2970 BandingResult::RefusedNotASpreadsheet {
2971 mime_type: "text/plain".to_string(),
2972 },
2973 );
2974 let text = describe(&out);
2975 assert!(text.contains("'sheet-1'"), "{text}");
2976 assert!(text.contains("add-banding"), "{text}");
2977 assert!(text.contains("text/plain"), "{text}");
2978 }
2979
2980 #[test]
2981 fn describe_lines_renders_shortcut() {
2982 let out = outcome_with(
2983 delete_verb(),
2984 Some("Budget"),
2985 BandingResult::RefusedShortcut,
2986 );
2987 let text = describe(&out);
2988 assert!(text.contains("shortcut"), "{text}");
2989 assert!(text.contains("delete-banding"), "{text}");
2990 }
2991
2992 #[test]
2993 fn describe_lines_renders_no_visible_parents() {
2994 let out = outcome_with(
2995 add_verb(),
2996 Some("Budget"),
2997 BandingResult::RefusedNoVisibleParents,
2998 );
2999 assert!(describe(&out).contains("sheets-structure"));
3000 }
3001
3002 #[test]
3003 fn describe_lines_renders_sheet_not_found_with_and_without_available_titles() {
3004 let none = outcome_with(
3005 add_verb(),
3006 Some("Budget"),
3007 BandingResult::RefusedSheetNotFound {
3008 title: "Q2".to_string(),
3009 available: Vec::new(),
3010 },
3011 );
3012 assert!(describe(&none).contains("Available: none"));
3013
3014 let some = outcome_with(
3015 add_verb(),
3016 Some("Budget"),
3017 BandingResult::RefusedSheetNotFound {
3018 title: "Q2".to_string(),
3019 available: vec!["Q1".to_string(), "Q3".to_string()],
3020 },
3021 );
3022 assert!(describe(&some).contains("'Q1', 'Q3'"));
3023 }
3024
3025 #[test]
3026 fn describe_lines_renders_invalid_range_and_banded_range_not_found() {
3027 let invalid = outcome_with(
3028 add_verb(),
3029 Some("Budget"),
3030 BandingResult::RefusedInvalidRange {
3031 detail: "bad range".to_string(),
3032 },
3033 );
3034 assert_eq!(describe(&invalid), "Refused: bad range");
3035
3036 let not_found = outcome_with(
3037 update_verb(),
3038 Some("Budget"),
3039 BandingResult::RefusedBandedRangeNotFound {
3040 banded_range_id: 99,
3041 },
3042 );
3043 let text = describe(¬_found);
3044 assert!(text.contains("no banded range with id 99"), "{text}");
3045 assert!(text.contains("list-bandings"), "{text}");
3046 }
3047
3048 #[test]
3049 fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
3050 let folder_rule = outcome_with(
3051 update_verb(),
3052 Some("Budget"),
3053 BandingResult::Blocked {
3054 decided_by: Some(DecidingRule::Folder {
3055 folder_id: "folder-1".to_string(),
3056 depth: 2,
3057 }),
3058 },
3059 );
3060 let text = describe(&folder_rule);
3061 assert!(text.contains("update-banding"), "{text}");
3062 assert!(text.contains("folder folder-1 (depth 2)"), "{text}");
3063
3064 let default_policy = outcome_with(
3065 delete_verb(),
3066 Some("Budget"),
3067 BandingResult::Blocked { decided_by: None },
3068 );
3069 assert!(describe(&default_policy).contains("default policy"));
3070 }
3071
3072 #[test]
3073 fn describe_lines_renders_every_lease_refusal() {
3074 for (result, needle) in [
3075 (
3076 BandingResult::RefusedNoLease,
3077 "requires a Drive write lease",
3078 ),
3079 (
3080 BandingResult::RefusedLeaseExpired,
3081 "expired, released, or unknown",
3082 ),
3083 (
3084 BandingResult::RefusedLeaseWrongFile,
3085 "acquired for a different file",
3086 ),
3087 (
3088 BandingResult::RefusedLeaseStale,
3089 "changed since the lease was acquired",
3090 ),
3091 ] {
3092 let out = outcome_with(add_verb(), Some("Budget"), result);
3093 let text = describe(&out);
3094 assert!(text.contains(needle), "{text}");
3095 assert!(text.contains("drive lease acquire sheet-1"), "{text}");
3096 }
3097 }
3098
3099 #[test]
3100 fn describe_lines_renders_changed_with_and_without_an_id() {
3101 let with_id = outcome_with(
3102 add_verb(),
3103 Some("Budget"),
3104 BandingResult::Changed {
3105 summary: "add row banding".to_string(),
3106 banded_range_id: Some(42),
3107 },
3108 );
3109 assert!(describe(&with_id).contains("(id 42)"));
3110
3111 let without_id = outcome_with(
3112 update_verb(),
3113 Some("Budget"),
3114 BandingResult::Changed {
3115 summary: "update row banding id 7".to_string(),
3116 banded_range_id: None,
3117 },
3118 );
3119 let text = describe(&without_id);
3120 assert!(!text.contains("(id"), "{text}");
3121 }
3122
3123 #[test]
3126 fn describe_lines_names_an_update_or_delete_id_once() {
3127 for (verb, summary) in [
3128 (update_verb(), "update row banding id 7"),
3129 (delete_verb(), "delete banding id 7"),
3130 ] {
3131 let out = outcome_with(
3132 verb,
3133 Some("Budget"),
3134 BandingResult::Changed {
3135 summary: summary.to_string(),
3136 banded_range_id: Some(7),
3137 },
3138 );
3139 assert_eq!(describe(&out), format!("Applied: {summary} in 'Budget'"));
3140 }
3141 }
3142
3143 #[test]
3144 fn describe_lines_renders_applied_reply_unreadable_with_an_id_hint_only_for_adds() {
3145 let added = outcome_with(
3146 add_verb(),
3147 Some("Budget"),
3148 BandingResult::AppliedReplyUnreadable {
3149 summary: "add row banding".to_string(),
3150 banded_range_id: None,
3151 detail: "bad reply".to_string(),
3152 },
3153 );
3154 assert_eq!(
3155 describe_lines(&added),
3156 vec![
3157 "Applied, but the reply could not be read: add row banding in 'Budget' — \
3158 do not retry; check the spreadsheet first (bad reply)"
3159 .to_string(),
3160 " the new banded range's id was in the unreadable reply; run \
3161 `list-bandings` to find it"
3162 .to_string(),
3163 ]
3164 );
3165
3166 let deleted = outcome_with(
3167 delete_verb(),
3168 Some("Budget"),
3169 BandingResult::AppliedReplyUnreadable {
3170 summary: "delete banding id 7".to_string(),
3171 banded_range_id: Some(7),
3172 detail: "bad reply".to_string(),
3173 },
3174 );
3175 assert_eq!(
3177 describe_lines(&deleted),
3178 vec![
3179 "Applied, but the reply could not be read: delete banding id 7 in 'Budget' — \
3180 do not retry; check the spreadsheet first (bad reply)"
3181 .to_string()
3182 ]
3183 );
3184 }
3185
3186 #[test]
3187 fn mutation_record_logs_the_same_fields_for_an_unreadable_reply_as_for_changed() {
3188 let opts = BandingOptions {
3189 spreadsheet_id: "sheet-1".to_string(),
3190 verb: delete_verb(),
3191 dry_run: false,
3192 lease_token: None,
3193 ledger_path: std::path::PathBuf::new(),
3194 };
3195 let record = |result| {
3196 let mut out = outcome_with(delete_verb(), Some("Budget"), result);
3197 out.sheet_id = Some(3);
3198 mutation_record(&out, &opts, Duration::ZERO)
3199 };
3200 let changed = record(BandingResult::Changed {
3201 summary: "delete banding id 7".to_string(),
3202 banded_range_id: Some(7),
3203 });
3204 let unreadable = record(BandingResult::AppliedReplyUnreadable {
3205 summary: "delete banding id 7".to_string(),
3206 banded_range_id: Some(7),
3207 detail: "bad reply".to_string(),
3208 });
3209 assert_eq!(unreadable.sheet_id, Some(3));
3210 assert_eq!(unreadable.banded_range_id, changed.banded_range_id);
3211 assert_eq!(unreadable.status, "applied-reply-unreadable");
3212 assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
3213 assert_eq!(
3214 unreadable.fields_changed.as_deref(),
3215 Some("delete banding id 7")
3216 );
3217 }
3218
3219 #[test]
3220 fn describe_lines_renders_failed() {
3221 let out = outcome_with(
3222 add_verb(),
3223 Some("Budget"),
3224 BandingResult::Failed {
3225 detail: "boom".to_string(),
3226 },
3227 );
3228 assert_eq!(describe(&out), "Failed: boom");
3229 }
3230
3231 #[test]
3234 fn write_jsonl_emits_one_line_of_json() {
3235 let outcome = outcome_with(
3236 add_verb(),
3237 Some("Budget"),
3238 BandingResult::Changed {
3239 summary: "add row banding".to_string(),
3240 banded_range_id: Some(1),
3241 },
3242 );
3243 let mut buf = Vec::new();
3244 outcome.write_jsonl(&mut buf).unwrap();
3245 let text = String::from_utf8(buf).unwrap();
3246 assert_eq!(text.matches('\n').count(), 1);
3247 let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
3248 assert_eq!(parsed["result"]["status"], "changed");
3249 }
3250
3251 #[test]
3252 fn banding_result_log_status_names_every_variant() {
3253 assert_eq!(
3254 BandingResult::WouldChange {
3255 summary: String::new(),
3256 }
3257 .log_status(),
3258 "would-change"
3259 );
3260 assert_eq!(
3261 BandingResult::RefusedNotASpreadsheet {
3262 mime_type: String::new(),
3263 }
3264 .log_status(),
3265 "refused-not-a-spreadsheet"
3266 );
3267 assert_eq!(
3268 BandingResult::RefusedShortcut.log_status(),
3269 "refused-shortcut"
3270 );
3271 assert_eq!(
3272 BandingResult::RefusedNoVisibleParents.log_status(),
3273 "refused-no-visible-parents"
3274 );
3275 assert_eq!(
3276 BandingResult::RefusedSheetNotFound {
3277 title: String::new(),
3278 available: Vec::new(),
3279 }
3280 .log_status(),
3281 "refused-sheet-not-found"
3282 );
3283 assert_eq!(
3284 BandingResult::RefusedInvalidRange {
3285 detail: String::new(),
3286 }
3287 .log_status(),
3288 "refused-invalid-range"
3289 );
3290 assert_eq!(
3291 BandingResult::RefusedBandedRangeNotFound { banded_range_id: 1 }.log_status(),
3292 "refused-banded-range-not-found"
3293 );
3294 assert_eq!(
3295 BandingResult::Blocked { decided_by: None }.log_status(),
3296 "blocked"
3297 );
3298 assert_eq!(
3299 BandingResult::RefusedNoLease.log_status(),
3300 LeaseGateRefusal::NoLease.log_status()
3301 );
3302 assert_eq!(
3303 BandingResult::RefusedLeaseExpired.log_status(),
3304 LeaseGateRefusal::Expired.log_status()
3305 );
3306 assert_eq!(
3307 BandingResult::RefusedLeaseWrongFile.log_status(),
3308 LeaseGateRefusal::WrongFile.log_status()
3309 );
3310 assert_eq!(
3311 BandingResult::RefusedLeaseStale.log_status(),
3312 LeaseGateRefusal::Stale.log_status()
3313 );
3314 assert_eq!(
3315 BandingResult::Changed {
3316 summary: String::new(),
3317 banded_range_id: None,
3318 }
3319 .log_status(),
3320 "changed"
3321 );
3322 assert_eq!(
3323 BandingResult::AppliedReplyUnreadable {
3324 summary: String::new(),
3325 banded_range_id: None,
3326 detail: String::new(),
3327 }
3328 .log_status(),
3329 "applied-reply-unreadable"
3330 );
3331 assert_eq!(
3332 BandingResult::Failed {
3333 detail: String::new(),
3334 }
3335 .log_status(),
3336 "failed"
3337 );
3338 }
3339}