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