1use std::path::PathBuf;
36use std::time::{Duration, Instant};
37
38use serde::Serialize;
39
40use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
41use crate::drive::client::DriveClient;
42use crate::drive::files_api::FilesApi;
43use crate::drive::lease::check::{
44 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
45 LeasedWrite,
46};
47use crate::drive::sheets::a1;
48use crate::drive::sheets::api::{
49 applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi, ValueRenderOption,
50};
51use crate::drive::sheets::client::SheetsClient;
52use crate::drive::sheets::grid_range;
53use crate::drive::sheets::target_gate;
54use crate::drive::sheets::types::{
55 AutoResizeDimensionsRequest, BatchUpdateRequestItem, Border, CellFormat, ColorStyle, Dimension,
56 DimensionProperties, DimensionRange, GridRange, MergeCellsRequest, NumberFormat, Padding,
57 RepeatCellData, RepeatCellRequest, Spreadsheet, TextFormat, TextRotation, UnmergeCellsRequest,
58 UpdateBordersRequest, UpdateDimensionPropertiesRequest, ValueRange,
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)]
72pub enum TextRotationFlag {
73 Angle(i64),
76 Vertical,
78}
79
80#[derive(Debug, Clone, Default, PartialEq, Eq)]
86pub struct CellFormatFlags {
87 pub bold: Option<bool>,
89 pub italic: Option<bool>,
91 pub strikethrough: Option<bool>,
93 pub underline: Option<bool>,
95 pub font_size: Option<i64>,
97 pub text_color: Option<String>,
99 pub background: Option<String>,
101 pub horizontal_align: Option<String>,
105 pub vertical_align: Option<String>,
107 pub number_format_pattern: Option<String>,
110 pub number_format_type: Option<String>,
113 pub wrap: Option<String>,
115 pub font_family: Option<String>,
117 pub text_rotation: Option<TextRotationFlag>,
119 pub hyperlink_display_type: Option<String>,
121 pub padding_top: Option<i64>,
123 pub padding_right: Option<i64>,
125 pub padding_bottom: Option<i64>,
127 pub padding_left: Option<i64>,
129 pub text_direction: Option<String>,
131}
132
133#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
139pub struct BorderSides {
140 pub top: bool,
142 pub bottom: bool,
144 pub left: bool,
146 pub right: bool,
148 pub inner_horizontal: bool,
150 pub inner_vertical: bool,
152}
153
154impl BorderSides {
155 fn any(self) -> bool {
156 self.top
157 || self.bottom
158 || self.left
159 || self.right
160 || self.inner_horizontal
161 || self.inner_vertical
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum FormatVerb {
168 FormatCells {
170 sheet: Option<String>,
172 range: Option<String>,
174 format: Box<CellFormatFlags>,
178 },
179 UpdateBorders {
181 sheet: Option<String>,
183 range: Option<String>,
185 sides: BorderSides,
187 style: String,
189 color: Option<String>,
192 },
193 MergeCells {
195 sheet: Option<String>,
197 range: Option<String>,
199 merge_type: String,
202 },
203 UnmergeCells {
205 sheet: Option<String>,
207 range: Option<String>,
209 },
210 AutoResizeDimension {
212 sheet: String,
214 dimension: Dimension,
216 start: i64,
218 end: i64,
220 },
221 UpdateDimensionProperties {
223 sheet: String,
225 dimension: Dimension,
227 start: i64,
229 end: i64,
231 pixel_size: i64,
233 },
234}
235
236impl FormatVerb {
237 const fn log_operation(&self) -> &'static str {
239 match self {
240 Self::FormatCells { .. } => "sheets-format-cells",
241 Self::UpdateBorders { .. } => "sheets-update-borders",
242 Self::MergeCells { .. } => "sheets-merge-cells",
243 Self::UnmergeCells { .. } => "sheets-unmerge-cells",
244 Self::AutoResizeDimension { .. } => "sheets-auto-resize-dimension",
245 Self::UpdateDimensionProperties { .. } => "sheets-update-dimension-properties",
246 }
247 }
248
249 const fn label(&self) -> &'static str {
251 match self {
252 Self::FormatCells { .. } => "format-cells",
253 Self::UpdateBorders { .. } => "update-borders",
254 Self::MergeCells { .. } => "merge-cells",
255 Self::UnmergeCells { .. } => "unmerge-cells",
256 Self::AutoResizeDimension { .. } => "auto-resize-dimension",
257 Self::UpdateDimensionProperties { .. } => "update-dimension-properties",
258 }
259 }
260
261 fn sheet_and_range(&self) -> Option<(Option<&str>, Option<&str>)> {
264 match self {
265 Self::FormatCells { sheet, range, .. }
266 | Self::UpdateBorders { sheet, range, .. }
267 | Self::MergeCells { sheet, range, .. }
268 | Self::UnmergeCells { sheet, range, .. } => Some((sheet.as_deref(), range.as_deref())),
269 Self::AutoResizeDimension { .. } | Self::UpdateDimensionProperties { .. } => None,
270 }
271 }
272}
273
274#[derive(Debug, Clone)]
276pub struct FormatOptions {
277 pub spreadsheet_id: String,
279 pub verb: FormatVerb,
281 pub dry_run: bool,
283 pub lease_token: Option<String>,
288 pub ledger_path: PathBuf,
292}
293
294#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
296#[serde(tag = "status", rename_all = "kebab-case")]
297pub enum FormatResult {
298 WouldChange {
300 summary: String,
303 #[serde(skip_serializing_if = "Vec::is_empty")]
306 discarded_cells: Vec<String>,
307 },
308 RefusedNotASpreadsheet {
310 mime_type: String,
312 },
313 RefusedShortcut,
315 RefusedNoVisibleParents,
317 RefusedSheetNotFound {
319 title: String,
321 available: Vec<String>,
323 },
324 RefusedInvalidRange {
327 detail: String,
329 },
330 Blocked {
332 decided_by: Option<DecidingRule>,
334 },
335 RefusedNoLease,
338 RefusedLeaseExpired,
341 RefusedLeaseWrongFile,
343 RefusedLeaseStale,
346 Changed {
348 summary: String,
351 #[serde(skip_serializing_if = "Vec::is_empty")]
353 discarded_cells: Vec<String>,
354 },
355 AppliedReplyUnreadable {
359 summary: String,
361 #[serde(skip_serializing_if = "Vec::is_empty")]
364 discarded_cells: Vec<String>,
365 detail: String,
367 },
368 Failed {
370 detail: String,
372 },
373}
374
375impl FromLeaseRefusal for FormatResult {
376 fn from_no_lease() -> Self {
377 Self::RefusedNoLease
378 }
379 fn from_lease_expired() -> Self {
380 Self::RefusedLeaseExpired
381 }
382 fn from_lease_wrong_file() -> Self {
383 Self::RefusedLeaseWrongFile
384 }
385 fn from_lease_stale() -> Self {
386 Self::RefusedLeaseStale
387 }
388 fn from_lease_failed(detail: String) -> Self {
389 Self::Failed { detail }
390 }
391}
392
393impl FormatResult {
394 fn log_status(&self) -> &'static str {
395 match self {
396 Self::WouldChange { .. } => "would-change",
397 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
398 Self::RefusedShortcut => "refused-shortcut",
399 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
400 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
401 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
402 Self::Blocked { .. } => "blocked",
403 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
404 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
405 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
406 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
407 Self::Changed { .. } => "changed",
408 Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
409 Self::Failed { .. } => "failed",
410 }
411 }
412}
413
414#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
416pub struct FormatOutcome {
417 pub spreadsheet_id: String,
419 #[serde(skip_serializing_if = "Option::is_none")]
421 pub file_name: Option<String>,
422 #[serde(skip_serializing_if = "Option::is_none")]
424 pub resolved_folder_id: Option<String>,
425 #[serde(skip)]
429 pub verb: FormatVerb,
430 pub result: FormatResult,
432}
433
434impl JsonlSerialize for FormatOutcome {
435 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
436 write_scalar_jsonl(self, out)
437 }
438}
439
440pub(super) fn parse_hex_color(input: &str) -> Result<crate::drive::sheets::types::Color, String> {
448 let hex = input.strip_prefix('#').unwrap_or(input);
449 if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
450 return Err(format!(
451 "'{input}' is not a color; expected 6 hex digits, optionally prefixed with '#' \
452 (e.g. #FF8800)"
453 ));
454 }
455 let byte =
456 |slice: &str| -> f32 { f32::from(u8::from_str_radix(slice, 16).unwrap_or(0)) / 255.0 };
457 Ok(crate::drive::sheets::types::Color {
458 red: byte(&hex[0..2]),
459 green: byte(&hex[2..4]),
460 blue: byte(&hex[4..6]),
461 })
462}
463
464pub(super) fn normalize_hex_for_display(input: &str) -> String {
470 format!("#{}", input.strip_prefix('#').unwrap_or(input))
471}
472
473pub(super) fn format_hex_color(color: crate::drive::sheets::types::Color) -> String {
478 let channel = |v: f32| -> u8 { (v.clamp(0.0, 1.0) * 255.0).round() as u8 };
479 format!(
480 "#{:02X}{:02X}{:02X}",
481 channel(color.red),
482 channel(color.green),
483 channel(color.blue)
484 )
485}
486
487fn to_padding_i32(value: i64, flag: &str) -> Result<i32, String> {
490 match i32::try_from(value) {
491 Ok(n) if n >= 0 => Ok(n),
492 _ => Err(format!(
493 "{flag} must be a non-negative number that fits in 32 bits, got {value}"
494 )),
495 }
496}
497
498fn build_cell_format(flags: &CellFormatFlags) -> Result<(CellFormat, String), String> {
503 let mut format = CellFormat::default();
504 let mut fields = Vec::new();
505 let mut text_format = TextFormat::default();
506 let mut text_format_used = false;
507
508 if let Some(bold) = flags.bold {
509 text_format.bold = Some(bold);
510 text_format_used = true;
511 fields.push("userEnteredFormat.textFormat.bold");
512 }
513 if let Some(italic) = flags.italic {
514 text_format.italic = Some(italic);
515 text_format_used = true;
516 fields.push("userEnteredFormat.textFormat.italic");
517 }
518 if let Some(strikethrough) = flags.strikethrough {
519 text_format.strikethrough = Some(strikethrough);
520 text_format_used = true;
521 fields.push("userEnteredFormat.textFormat.strikethrough");
522 }
523 if let Some(underline) = flags.underline {
524 text_format.underline = Some(underline);
525 text_format_used = true;
526 fields.push("userEnteredFormat.textFormat.underline");
527 }
528 if let Some(font_size) = flags.font_size {
529 text_format.font_size = Some(font_size);
530 text_format_used = true;
531 fields.push("userEnteredFormat.textFormat.fontSize");
532 }
533 if let Some(hex) = &flags.text_color {
534 let color = parse_hex_color(hex)?;
535 text_format.foreground_color_style = Some(ColorStyle::rgb(color));
536 text_format_used = true;
537 fields.push("userEnteredFormat.textFormat.foregroundColorStyle");
538 }
539 if let Some(font_family) = &flags.font_family {
540 text_format.font_family = Some(font_family.clone());
541 text_format_used = true;
542 fields.push("userEnteredFormat.textFormat.fontFamily");
543 }
544 if text_format_used {
545 format.text_format = Some(text_format);
546 }
547
548 if let Some(hex) = &flags.background {
549 let color = parse_hex_color(hex)?;
550 format.background_color_style = Some(ColorStyle::rgb(color));
551 fields.push("userEnteredFormat.backgroundColorStyle");
552 }
553 if let Some(align) = &flags.horizontal_align {
554 format.horizontal_alignment = Some(align.clone());
555 fields.push("userEnteredFormat.horizontalAlignment");
556 }
557 if let Some(align) = &flags.vertical_align {
558 format.vertical_alignment = Some(align.clone());
559 fields.push("userEnteredFormat.verticalAlignment");
560 }
561 match (&flags.number_format_pattern, &flags.number_format_type) {
562 (Some(pattern), Some(format_type)) => {
563 format.number_format = Some(NumberFormat {
564 format_type: format_type.clone(),
565 pattern: pattern.clone(),
566 });
567 fields.push("userEnteredFormat.numberFormat");
568 }
569 (None, None) => {}
570 _ => {
571 return Err(
572 "--number-format and --number-format-type must be given together".to_string(),
573 )
574 }
575 }
576 if let Some(wrap) = &flags.wrap {
577 format.wrap_strategy = Some(wrap.clone());
578 fields.push("userEnteredFormat.wrapStrategy");
579 }
580 if let Some(rotation) = flags.text_rotation {
581 format.text_rotation = Some(match rotation {
582 TextRotationFlag::Angle(angle) => {
583 if !(-90..=90).contains(&angle) {
584 return Err(format!(
585 "--text-rotation-angle must be between -90 and 90, got {angle}"
586 ));
587 }
588 TextRotation {
589 angle: Some(angle as i32),
590 vertical: None,
591 }
592 }
593 TextRotationFlag::Vertical => TextRotation {
594 angle: None,
595 vertical: Some(true),
596 },
597 });
598 fields.push("userEnteredFormat.textRotation");
599 }
600 if let Some(hyperlink_display_type) = &flags.hyperlink_display_type {
601 format.hyperlink_display_type = Some(hyperlink_display_type.clone());
602 fields.push("userEnteredFormat.hyperlinkDisplayType");
603 }
604 if flags.padding_top.is_some()
605 || flags.padding_right.is_some()
606 || flags.padding_bottom.is_some()
607 || flags.padding_left.is_some()
608 {
609 format.padding = Some(Padding {
610 top: flags
611 .padding_top
612 .map(|n| to_padding_i32(n, "--padding-top"))
613 .transpose()?,
614 right: flags
615 .padding_right
616 .map(|n| to_padding_i32(n, "--padding-right"))
617 .transpose()?,
618 bottom: flags
619 .padding_bottom
620 .map(|n| to_padding_i32(n, "--padding-bottom"))
621 .transpose()?,
622 left: flags
623 .padding_left
624 .map(|n| to_padding_i32(n, "--padding-left"))
625 .transpose()?,
626 });
627 fields.push("userEnteredFormat.padding");
628 }
629 if let Some(text_direction) = &flags.text_direction {
630 format.text_direction = Some(text_direction.clone());
631 fields.push("userEnteredFormat.textDirection");
632 }
633
634 if fields.is_empty() {
635 return Err(
636 "format-cells needs at least one property to set (--bold, --background, …)".to_string(),
637 );
638 }
639 Ok((format, fields.join(",")))
640}
641
642pub async fn format(
645 drive: &DriveClient,
646 sheets: &SheetsClient,
647 opts: &FormatOptions,
648 rules: &[FolderPermissionRule],
649) -> FormatOutcome {
650 let started = Instant::now();
651 let outcome = format_inner(drive, sheets, opts, rules).await;
652 if !opts.dry_run {
653 record_attempt(&outcome, opts, started.elapsed());
654 }
655 outcome
656}
657
658async fn format_inner(
659 drive: &DriveClient,
660 sheets: &SheetsClient,
661 opts: &FormatOptions,
662 rules: &[FolderPermissionRule],
663) -> FormatOutcome {
664 let bare = |result| FormatOutcome {
665 spreadsheet_id: opts.spreadsheet_id.clone(),
666 file_name: None,
667 resolved_folder_id: None,
668 verb: opts.verb.clone(),
669 result,
670 };
671
672 let composed_range = if let Some((sheet, range)) = opts.verb.sheet_and_range() {
675 match a1::compose(sheet, range) {
676 Ok(composed) => Some(composed),
677 Err(err) => {
678 return bare(FormatResult::RefusedInvalidRange {
679 detail: err.to_string(),
680 })
681 }
682 }
683 } else {
684 None
685 };
686
687 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
688 drive,
689 &opts.spreadsheet_id,
690 DriveOperation::SheetsStructure,
691 rules,
692 )
693 .await
694 {
695 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
696 return bare(FormatResult::Failed { detail })
697 }
698 target_gate::TargetGateOutcome::Refused { target, refusal } => {
699 let result = match refusal {
700 SheetTargetRefusal::Shortcut => FormatResult::RefusedShortcut,
701 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
702 FormatResult::RefusedNotASpreadsheet { mime_type }
703 }
704 SheetTargetRefusal::NoVisibleParents => FormatResult::RefusedNoVisibleParents,
705 };
706 return FormatOutcome {
707 spreadsheet_id: opts.spreadsheet_id.clone(),
708 file_name: Some(target.name),
709 resolved_folder_id: None,
710 verb: opts.verb.clone(),
711 result,
712 };
713 }
714 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
715 return FormatOutcome {
716 spreadsheet_id: opts.spreadsheet_id.clone(),
717 file_name: Some(target.name),
718 resolved_folder_id: None,
719 verb: opts.verb.clone(),
720 result: FormatResult::Failed { detail },
721 };
722 }
723 target_gate::TargetGateOutcome::Gated {
724 target,
725 decision,
726 resolved_folder_id,
727 requires_lease,
728 } => (target, decision, resolved_folder_id, requires_lease),
729 };
730
731 let gated = |result| FormatOutcome {
732 spreadsheet_id: opts.spreadsheet_id.clone(),
733 file_name: Some(target.name.clone()),
734 resolved_folder_id: resolved_folder_id.clone(),
735 verb: opts.verb.clone(),
736 result,
737 };
738
739 if decision.verdict == write_gate::Verdict::Deny {
740 return gated(FormatResult::Blocked {
741 decided_by: decision.decided_by,
742 });
743 }
744
745 let api = SheetsApi::new(sheets);
746 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
747 Ok(workbook) => workbook,
748 Err(err) => {
749 return gated(FormatResult::Failed {
750 detail: format!("{err:#}"),
751 })
752 }
753 };
754
755 let resolved = match resolve_target(&workbook, &opts.verb, composed_range.as_deref()) {
757 Ok(resolved) => resolved,
758 Err(result) => return gated(result),
759 };
760
761 let discarded_cells = if matches!(opts.verb, FormatVerb::MergeCells { .. }) {
766 match read_discarded_cells(&api, &opts.spreadsheet_id, &resolved).await {
767 Ok(cells) => cells,
768 Err(detail) => return gated(FormatResult::Failed { detail }),
769 }
770 } else {
771 Vec::new()
772 };
773
774 let summary = match describe_effect(&opts.verb, &discarded_cells) {
775 Ok(summary) => summary,
776 Err(detail) => return gated(FormatResult::RefusedInvalidRange { detail }),
777 };
778
779 if opts.dry_run {
780 return gated(FormatResult::WouldChange {
781 summary,
782 discarded_cells,
783 });
784 }
785
786 let request = match build_request(&opts.verb, &resolved) {
793 Ok(request) => request,
794 Err(detail) => return gated(FormatResult::RefusedInvalidRange { detail }),
795 };
796
797 let files_api = FilesApi::new(drive);
805 let leased = LeasedWrite {
806 log_prefix: "drive sheets format",
807 operation: opts.verb.log_operation(),
808 ledger_path: &opts.ledger_path,
809 file_id: &opts.spreadsheet_id,
810 };
811 let lease_grant = match gate_optional_leased_write(
812 leased,
813 &files_api,
814 requires_lease,
815 opts.lease_token.as_deref(),
816 )
817 .await
818 {
819 Ok(grant) => grant,
820 Err(err) => return gated(err.into_result()),
821 };
822
823 let result = match conclude_native_leased_write(
824 leased,
825 &lease_grant,
826 &files_api,
827 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
828 |err| format!("{err:#}"),
829 )
830 .await
831 {
832 Ok(BatchUpdateOutcome::Applied(_response)) => FormatResult::Changed {
833 summary,
834 discarded_cells,
835 },
836 Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
839 FormatResult::AppliedReplyUnreadable {
840 summary,
841 discarded_cells,
842 detail,
843 }
844 }
845 Err(err) => FormatResult::Failed {
846 detail: format!("{err:#}"),
847 },
848 };
849 drop(lease_grant);
850 gated(result)
851}
852
853enum ResolvedTarget {
856 Range {
858 sheet_title: String,
859 grid: GridRange,
860 },
861 Dimension { range: DimensionRange },
863}
864
865fn resolve_target(
866 workbook: &Spreadsheet,
867 verb: &FormatVerb,
868 composed_range: Option<&str>,
869) -> Result<ResolvedTarget, FormatResult> {
870 match verb {
871 FormatVerb::AutoResizeDimension {
872 sheet,
873 dimension,
874 start,
875 end,
876 }
877 | FormatVerb::UpdateDimensionProperties {
878 sheet,
879 dimension,
880 start,
881 end,
882 ..
883 } => {
884 let sheet_id = find_sheet_id(workbook, sheet)?;
885 if *start < 1 || *end < *start {
886 return Err(FormatResult::RefusedInvalidRange {
887 detail: format!(
888 "--start must be at least 1 and --end must be >= --start (got \
889 --start {start} --end {end})"
890 ),
891 });
892 }
893 let range = DimensionRange {
894 sheet_id,
895 dimension: *dimension,
896 start_index: start - 1,
897 end_index: *end,
898 };
899 Ok(ResolvedTarget::Dimension { range })
900 }
901 FormatVerb::FormatCells { .. }
902 | FormatVerb::UpdateBorders { .. }
903 | FormatVerb::MergeCells { .. }
904 | FormatVerb::UnmergeCells { .. } => {
905 let composed = composed_range.unwrap_or_default();
906 let (title, grid) = grid_range::resolve_grid_range(
907 workbook,
908 composed,
909 |detail| FormatResult::RefusedInvalidRange { detail },
910 |title, available| FormatResult::RefusedSheetNotFound { title, available },
911 )?;
912 if matches!(verb, FormatVerb::MergeCells { .. }) {
917 let bare_range =
918 a1::split_sheet_prefix(composed).map_or(composed, |(_, bare_range)| bare_range);
919 let Some(cells) = grid_range::cell_count(&grid) else {
920 return Err(FormatResult::RefusedInvalidRange {
921 detail: format!(
922 "'{bare_range}' is open-ended; merge-cells needs a fully bounded \
923 range (e.g. A1:D20) so its preview can name exactly what would be \
924 discarded"
925 ),
926 });
927 };
928 if cells > MAX_CELLS_PER_MERGE {
933 return Err(FormatResult::RefusedInvalidRange {
934 detail: format!(
935 "'{bare_range}' covers {cells} cells, over the \
936 {MAX_CELLS_PER_MERGE}-cell budget for merge-cells; narrow --range"
937 ),
938 });
939 }
940 }
941 if let FormatVerb::UpdateBorders { sides, .. } = verb {
948 if sides.inner_horizontal
949 && matches!(
950 (grid.start_row_index, grid.end_row_index),
951 (Some(start), Some(end)) if end - start <= 1
952 )
953 {
954 return Err(FormatResult::RefusedInvalidRange {
955 detail: "--inner-horizontal needs a range spanning more than one row; \
956 a single row has no interior horizontal grid line"
957 .to_string(),
958 });
959 }
960 if sides.inner_vertical
961 && matches!(
962 (grid.start_column_index, grid.end_column_index),
963 (Some(start), Some(end)) if end - start <= 1
964 )
965 {
966 return Err(FormatResult::RefusedInvalidRange {
967 detail: "--inner-vertical needs a range spanning more than one column; \
968 a single column has no interior vertical grid line"
969 .to_string(),
970 });
971 }
972 }
973 Ok(ResolvedTarget::Range {
974 sheet_title: title,
975 grid,
976 })
977 }
978 }
979}
980
981fn find_sheet_id(workbook: &Spreadsheet, title: &str) -> Result<i64, FormatResult> {
982 grid_range::find_sheet_id(workbook, title, |title, available| {
983 FormatResult::RefusedSheetNotFound { title, available }
984 })
985}
986
987async fn read_discarded_cells(
994 api: &SheetsApi<'_>,
995 spreadsheet_id: &str,
996 resolved: &ResolvedTarget,
997) -> Result<Vec<String>, String> {
998 let ResolvedTarget::Range { sheet_title, grid } = resolved else {
999 return Ok(Vec::new());
1000 };
1001 let is_single_cell = matches!(
1004 (
1005 grid.start_row_index,
1006 grid.end_row_index,
1007 grid.start_column_index,
1008 grid.end_column_index,
1009 ),
1010 (Some(r0), Some(r1), Some(c0), Some(c1)) if r1 - r0 <= 1 && c1 - c0 <= 1
1011 );
1012 if is_single_cell {
1013 return Ok(Vec::new());
1014 }
1015 let Some(a1_range) = grid_range::bounded_range_to_a1(sheet_title, grid) else {
1019 return Ok(Vec::new());
1020 };
1021 let values = api
1022 .values_get(spreadsheet_id, &a1_range, ValueRenderOption::Formatted)
1023 .await
1024 .map_err(|err| format!("{err:#}"))?;
1025 Ok(discarded_from_values(
1029 &values,
1030 grid.start_row_index.unwrap_or(0),
1031 grid.start_column_index.unwrap_or(0),
1032 ))
1033}
1034
1035fn discarded_from_values(values: &ValueRange, row_offset: i64, col_offset: i64) -> Vec<String> {
1039 let mut discarded = Vec::new();
1040 for (row_idx, row) in values.values.iter().enumerate() {
1041 for (col_idx, cell) in row.iter().enumerate() {
1042 if row_idx == 0 && col_idx == 0 {
1043 continue;
1044 }
1045 let is_blank = cell.is_null() || cell.as_str().is_some_and(str::is_empty);
1046 if is_blank {
1047 continue;
1048 }
1049 let address = format!(
1050 "{}{}",
1051 grid_range::column_index_to_letters(col_idx as i64 + col_offset),
1052 row_idx as i64 + row_offset + 1
1053 );
1054 let display = cell
1055 .as_str()
1056 .map_or_else(|| cell.to_string(), str::to_string);
1057 discarded.push(format!("{address}: {display}"));
1058 }
1059 }
1060 discarded
1061}
1062
1063const MERGE_DISCARD_RENDER_LIMIT: usize = 200;
1071
1072const MAX_CELLS_PER_MERGE: i64 = 50_000;
1081
1082fn describe_effect(verb: &FormatVerb, discarded_cells: &[String]) -> Result<String, String> {
1083 match verb {
1084 FormatVerb::FormatCells { format, .. } => {
1085 let (_, fields) = build_cell_format(format)?;
1086 Ok(format!("set {fields}"))
1087 }
1088 FormatVerb::UpdateBorders { sides, style, .. } => {
1089 if !sides.any() {
1090 return Err(
1091 "update-borders needs at least one side (--top, --bottom, --left, --right, \
1092 --all, --inner-horizontal, --inner-vertical)"
1093 .to_string(),
1094 );
1095 }
1096 let mut names = Vec::new();
1097 if sides.top {
1098 names.push("top");
1099 }
1100 if sides.bottom {
1101 names.push("bottom");
1102 }
1103 if sides.left {
1104 names.push("left");
1105 }
1106 if sides.right {
1107 names.push("right");
1108 }
1109 if sides.inner_horizontal {
1110 names.push("inner-horizontal");
1111 }
1112 if sides.inner_vertical {
1113 names.push("inner-vertical");
1114 }
1115 Ok(format!("set {} border(s), style {style}", names.join(", ")))
1116 }
1117 FormatVerb::MergeCells { merge_type, .. } => {
1118 if discarded_cells.is_empty() {
1119 Ok(format!("merge ({merge_type})"))
1120 } else {
1121 let elided = discarded_cells.len() > MERGE_DISCARD_RENDER_LIMIT;
1126 Ok(format!(
1127 "merge ({merge_type}), discarding {} cell(s): {}{}",
1128 discarded_cells.len(),
1129 grid_range::truncate_joined(discarded_cells, MERGE_DISCARD_RENDER_LIMIT, "; "),
1130 if elided {
1131 " (full list in -o json / the drivemutation log's discarded_cells)"
1132 } else {
1133 ""
1134 }
1135 ))
1136 }
1137 }
1138 FormatVerb::UnmergeCells { .. } => Ok("unmerge".to_string()),
1139 FormatVerb::AutoResizeDimension {
1140 sheet,
1141 dimension,
1142 start,
1143 end,
1144 } => Ok(format!(
1145 "auto-resize {}",
1146 dimension_span(sheet, *dimension, *start, *end)
1147 )),
1148 FormatVerb::UpdateDimensionProperties {
1149 sheet,
1150 dimension,
1151 start,
1152 end,
1153 pixel_size,
1154 } => Ok(format!(
1155 "set the pixelSize of {} to {pixel_size}",
1156 dimension_span(sheet, *dimension, *start, *end)
1157 )),
1158 }
1159}
1160
1161fn dimension_span(sheet: &str, dimension: Dimension, start: i64, end: i64) -> String {
1165 let (first, last) = match dimension {
1166 Dimension::Columns => (
1167 grid_range::column_index_to_letters(start - 1),
1168 grid_range::column_index_to_letters(end - 1),
1169 ),
1170 Dimension::Rows => (start.to_string(), end.to_string()),
1171 };
1172 format!(
1173 "{}(s) {first}:{last} of {}",
1174 dimension.noun(),
1175 a1::quote_sheet_title(sheet)
1176 )
1177}
1178
1179fn past_tense_summary(verb: &FormatVerb, summary: &str) -> String {
1185 let (imperative, past) = match verb {
1186 FormatVerb::FormatCells { .. }
1187 | FormatVerb::UpdateBorders { .. }
1188 | FormatVerb::UpdateDimensionProperties { .. } => ("set", "Set"),
1189 FormatVerb::MergeCells { .. } => ("merge", "Merged cells"),
1190 FormatVerb::UnmergeCells { .. } => ("unmerge", "Unmerged cells"),
1191 FormatVerb::AutoResizeDimension { .. } => ("auto-resize", "Auto-resized"),
1192 };
1193 summary.strip_prefix(imperative).map_or_else(
1194 || format!("Applied: {summary}"),
1195 |rest| format!("{past}{rest}"),
1196 )
1197}
1198
1199fn build_request(
1200 verb: &FormatVerb,
1201 resolved: &ResolvedTarget,
1202) -> Result<BatchUpdateRequestItem, String> {
1203 match verb {
1210 FormatVerb::FormatCells { format, .. } => {
1211 let ResolvedTarget::Range { grid, .. } = resolved else {
1212 unreachable!("resolve_target always resolves FormatCells to a Range")
1213 };
1214 let (cell_format, fields) = build_cell_format(format)?;
1215 Ok(BatchUpdateRequestItem::RepeatCell(RepeatCellRequest {
1216 range: *grid,
1217 cell: RepeatCellData {
1218 user_entered_format: cell_format,
1219 },
1220 fields,
1221 }))
1222 }
1223 FormatVerb::UpdateBorders {
1224 sides,
1225 style,
1226 color,
1227 ..
1228 } => {
1229 let ResolvedTarget::Range { grid, .. } = resolved else {
1230 unreachable!("resolve_target always resolves UpdateBorders to a Range")
1231 };
1232 let color = match color {
1233 Some(hex) => parse_hex_color(hex)?,
1234 None => crate::drive::sheets::types::Color::default(),
1235 };
1236 let border = Border {
1237 style: style.clone(),
1238 color_style: ColorStyle::rgb(color),
1239 };
1240 let mut request = UpdateBordersRequest {
1241 range: *grid,
1242 ..Default::default()
1243 };
1244 if sides.top {
1245 request.top = Some(border.clone());
1246 }
1247 if sides.bottom {
1248 request.bottom = Some(border.clone());
1249 }
1250 if sides.left {
1251 request.left = Some(border.clone());
1252 }
1253 if sides.right {
1254 request.right = Some(border.clone());
1255 }
1256 if sides.inner_horizontal {
1257 request.inner_horizontal = Some(border.clone());
1258 }
1259 if sides.inner_vertical {
1260 request.inner_vertical = Some(border);
1261 }
1262 Ok(BatchUpdateRequestItem::UpdateBorders(request))
1263 }
1264 FormatVerb::MergeCells { merge_type, .. } => {
1265 let ResolvedTarget::Range { grid, .. } = resolved else {
1266 unreachable!("resolve_target always resolves MergeCells to a Range")
1267 };
1268 Ok(BatchUpdateRequestItem::MergeCells(MergeCellsRequest {
1269 range: *grid,
1270 merge_type: merge_type.clone(),
1271 }))
1272 }
1273 FormatVerb::UnmergeCells { .. } => {
1274 let ResolvedTarget::Range { grid, .. } = resolved else {
1275 unreachable!("resolve_target always resolves UnmergeCells to a Range")
1276 };
1277 Ok(BatchUpdateRequestItem::UnmergeCells(UnmergeCellsRequest {
1278 range: *grid,
1279 }))
1280 }
1281 FormatVerb::AutoResizeDimension { .. } => {
1282 let ResolvedTarget::Dimension { range } = resolved else {
1283 unreachable!("resolve_target always resolves AutoResizeDimension to a Dimension")
1284 };
1285 Ok(BatchUpdateRequestItem::AutoResizeDimensions(
1286 AutoResizeDimensionsRequest {
1287 dimensions: range.clone(),
1288 },
1289 ))
1290 }
1291 FormatVerb::UpdateDimensionProperties { pixel_size, .. } => {
1292 let ResolvedTarget::Dimension { range } = resolved else {
1293 unreachable!(
1294 "resolve_target always resolves UpdateDimensionProperties to a Dimension"
1295 )
1296 };
1297 Ok(BatchUpdateRequestItem::UpdateDimensionProperties(
1298 UpdateDimensionPropertiesRequest {
1299 range: range.clone(),
1300 properties: DimensionProperties {
1301 pixel_size: *pixel_size,
1302 },
1303 fields: "pixelSize".to_string(),
1304 },
1305 ))
1306 }
1307 }
1308}
1309
1310fn record_attempt(outcome: &FormatOutcome, opts: &FormatOptions, duration: Duration) {
1311 request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
1312}
1313
1314fn mutation_record(
1317 outcome: &FormatOutcome,
1318 opts: &FormatOptions,
1319 duration: Duration,
1320) -> DriveMutationOutcome {
1321 let error = match &outcome.result {
1322 FormatResult::Failed { detail } | FormatResult::AppliedReplyUnreadable { detail, .. } => {
1323 Some(detail.clone())
1324 }
1325 _ => None,
1326 };
1327 let decided_by = match &outcome.result {
1328 FormatResult::Blocked { decided_by } => decided_by.as_ref(),
1329 _ => None,
1330 };
1331 let decided_by = write_gate::decided_by_log_fields(decided_by);
1332 let (fields_changed, discarded_cells) = match &outcome.result {
1333 FormatResult::Changed {
1334 summary,
1335 discarded_cells,
1336 }
1337 | FormatResult::AppliedReplyUnreadable {
1338 summary,
1339 discarded_cells,
1340 ..
1341 }
1342 | FormatResult::WouldChange {
1343 summary,
1344 discarded_cells,
1345 } => (Some(summary.clone()), discarded_cells.clone()),
1346 _ => (None, Vec::new()),
1347 };
1348
1349 DriveMutationOutcome {
1350 operation: opts.verb.log_operation(),
1351 file_id: outcome.spreadsheet_id.clone(),
1352 file_name: outcome.file_name.clone().unwrap_or_default(),
1353 status: outcome.result.log_status().to_string(),
1354 resolved_folder_id: outcome.resolved_folder_id.clone(),
1355 decided_by_folder_id: decided_by.folder_id,
1356 decided_by_depth: decided_by.depth,
1357 decided_by_file_id: decided_by.file_id,
1358 fields_changed,
1359 discarded_cells,
1360 error,
1361 duration,
1362 ..Default::default()
1363 }
1364}
1365
1366#[must_use]
1368pub fn describe(outcome: &FormatOutcome) -> String {
1369 describe_lines(outcome).join("\n")
1370}
1371
1372#[must_use]
1375pub fn describe_lines(outcome: &FormatOutcome) -> Vec<String> {
1376 let verb = &outcome.verb;
1377 let book = outcome.file_name.as_deref().map_or_else(
1378 || format!("'{}'", outcome.spreadsheet_id),
1379 |n| format!("'{n}'"),
1380 );
1381 match &outcome.result {
1382 FormatResult::WouldChange { summary, .. } => {
1383 vec![format!("Would {summary} in {book}")]
1384 }
1385 FormatResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1386 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
1387 `drive sheets {}` only works on spreadsheets",
1388 verb.label()
1389 )],
1390 FormatResult::RefusedShortcut => vec![format!(
1391 "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
1392 verb.label()
1393 )],
1394 FormatResult::RefusedNoVisibleParents => vec![format!(
1395 "Refused: {book} has no parent folder visible to this account, so no folder \
1396 rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
1397 id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
1398 )],
1399 FormatResult::RefusedSheetNotFound { title, available } => {
1400 vec![grid_range::no_sheet_refusal(&book, title, available)]
1401 }
1402 FormatResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
1403 FormatResult::Blocked { decided_by } => vec![match decided_by {
1404 Some(rule) => format!(
1405 "Blocked: {} on {book} refused by rule on {} {}{}",
1406 verb.label(),
1407 rule.kind_label(),
1408 rule.id(),
1409 rule.depth_suffix()
1410 ),
1411 None => format!(
1412 "Blocked: {} on {book} refused by default policy (no matching rule for \
1413 sheets-structure)",
1414 verb.label()
1415 ),
1416 }],
1417 FormatResult::RefusedNoLease => LeaseGateRefusal::NoLease
1418 .describe_line(&outcome.spreadsheet_id, &book)
1419 .into_iter()
1420 .collect(),
1421 FormatResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1422 .describe_line(&outcome.spreadsheet_id, &book)
1423 .into_iter()
1424 .collect(),
1425 FormatResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1426 .describe_line(&outcome.spreadsheet_id, &book)
1427 .into_iter()
1428 .collect(),
1429 FormatResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1430 .describe_line(&outcome.spreadsheet_id, &book)
1431 .into_iter()
1432 .collect(),
1433 FormatResult::Changed { summary, .. } => {
1434 vec![format!("{} in {book}", past_tense_summary(verb, summary))]
1435 }
1436 FormatResult::AppliedReplyUnreadable {
1438 summary, detail, ..
1439 } => {
1440 vec![applied_reply_unreadable_line(
1441 summary,
1442 &book,
1443 RetryHint::Idempotent,
1444 detail,
1445 )]
1446 }
1447 FormatResult::Failed { detail } => vec![format!("Failed: {detail}")],
1448 }
1449}
1450
1451#[cfg(test)]
1452#[allow(clippy::unwrap_used, clippy::expect_used)]
1453mod tests {
1454 use super::*;
1455 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1456 use crate::drive::sheets::client::SHEETS_API_URL;
1457 use crate::drive::test_support::seed_lease;
1458 use crate::test_support::env::MapEnv;
1459 use crate::utils::secret::Secret;
1460 use std::collections::HashSet;
1461
1462 fn test_credentials() -> DriveCredentials {
1463 DriveCredentials {
1464 client_id: "client-1".to_string(),
1465 client_secret: Secret::new("secret-1"),
1466 refresh_token: Secret::new("refresh-1"),
1467 scope: DriveGrantedScopes::READONLY,
1468 }
1469 }
1470
1471 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1472 wiremock::Mock::given(wiremock::matchers::method("POST"))
1473 .and(wiremock::matchers::path("/token"))
1474 .respond_with(
1475 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1476 "access_token": "test-token", "expires_in": 3600,
1477 })),
1478 )
1479 .mount(server)
1480 .await;
1481 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1482 crate::drive::client::test_support::replace_session(
1483 &mut drive,
1484 &test_credentials(),
1485 &format!("{}/token", server.uri()),
1486 );
1487 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1488 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1489 (drive, sheets)
1490 }
1491
1492 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1496 let parents: Vec<&str> = parents.to_vec();
1497 wiremock::Mock::given(wiremock::matchers::method("GET"))
1498 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1499 .respond_with(
1500 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1501 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1502 "version": "1",
1503 })),
1504 )
1505 }
1506
1507 fn mount_folder(id: &str) -> wiremock::Mock {
1508 mount_file(id, "application/vnd.google-apps.folder", &[])
1509 }
1510
1511 fn allow_rule(folder: &str) -> FolderPermissionRule {
1512 FolderPermissionRule {
1513 folder_id: Some(folder.to_string()),
1514 file_id: None,
1515 recursive: true,
1516 allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
1517 deny: HashSet::default(),
1518 require_lease: true,
1519 }
1520 }
1521
1522 fn mount_workbook() -> wiremock::Mock {
1523 wiremock::Mock::given(wiremock::matchers::method("GET"))
1524 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1525 .respond_with(
1526 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1527 "spreadsheetId": "sheet-1",
1528 "properties": {"title": "Budget"},
1529 "sheets": [
1530 {"properties": {
1531 "sheetId": 0, "title": "Q1", "index": 0,
1532 "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
1533 ],
1534 })),
1535 )
1536 }
1537
1538 fn mount_batch_update(body: serde_json::Value) -> wiremock::Mock {
1539 wiremock::Mock::given(wiremock::matchers::method("POST"))
1540 .and(wiremock::matchers::path(
1541 "/v4/spreadsheets/sheet-1:batchUpdate",
1542 ))
1543 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
1544 }
1545
1546 fn format_cells_opts(dry_run: bool) -> FormatOptions {
1555 let ledger_path = tempfile::tempdir()
1556 .unwrap()
1557 .keep()
1558 .join("lease-ledger.jsonl");
1559 let token = seed_lease(&ledger_path, "sheet-1", "1");
1560 FormatOptions {
1561 spreadsheet_id: "sheet-1".to_string(),
1562 verb: FormatVerb::FormatCells {
1563 sheet: Some("Q1".to_string()),
1564 range: Some("A1:B2".to_string()),
1565 format: Box::new(CellFormatFlags {
1566 bold: Some(true),
1567 ..Default::default()
1568 }),
1569 },
1570 dry_run,
1571 lease_token: Some(token),
1572 ledger_path,
1573 }
1574 }
1575
1576 #[tokio::test]
1577 async fn dry_run_reports_would_change_and_makes_no_batch_update_call() {
1578 let server = wiremock::MockServer::start().await;
1579 let (drive, sheets) = clients(&server).await;
1580 mount_file(
1581 "sheet-1",
1582 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1583 &["folder-1"],
1584 )
1585 .mount(&server)
1586 .await;
1587 mount_folder("folder-1").mount(&server).await;
1588 mount_workbook().mount(&server).await;
1589 let rules = vec![allow_rule("folder-1")];
1590
1591 let outcome = format(&drive, &sheets, &format_cells_opts(true), &rules).await;
1592 match outcome.result {
1593 FormatResult::WouldChange { summary, .. } => {
1594 assert!(summary.contains("bold"), "{summary}");
1595 }
1596 other => panic!("expected WouldChange, got {other:?}"),
1597 }
1598 }
1602
1603 #[tokio::test]
1604 async fn a_denied_gate_blocks_before_any_batch_update_call() {
1605 let server = wiremock::MockServer::start().await;
1606 let (drive, sheets) = clients(&server).await;
1607 mount_file(
1608 "sheet-1",
1609 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1610 &["folder-1"],
1611 )
1612 .mount(&server)
1613 .await;
1614 mount_folder("folder-1").mount(&server).await;
1615 let rules: Vec<FolderPermissionRule> = Vec::new();
1618
1619 let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
1620 match outcome.result {
1621 FormatResult::Blocked { decided_by } => assert!(decided_by.is_none()),
1622 other => panic!("expected Blocked, got {other:?}"),
1623 }
1624 }
1625
1626 #[tokio::test]
1627 async fn format_cells_sends_a_repeat_cell_request_with_no_value_field() {
1628 let server = wiremock::MockServer::start().await;
1629 let (drive, sheets) = clients(&server).await;
1630 mount_file(
1631 "sheet-1",
1632 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1633 &["folder-1"],
1634 )
1635 .mount(&server)
1636 .await;
1637 mount_folder("folder-1").mount(&server).await;
1638 mount_workbook().mount(&server).await;
1639 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
1640 .mount(&server)
1641 .await;
1642 let rules = vec![allow_rule("folder-1")];
1643
1644 let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
1645 assert!(matches!(outcome.result, FormatResult::Changed { .. }));
1646
1647 let requests = server.received_requests().await.unwrap();
1648 let batch = requests
1649 .iter()
1650 .find(|r| r.url.path().ends_with(":batchUpdate"))
1651 .expect("no batchUpdate request was sent");
1652 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
1653 let repeat_cell = &body["requests"][0]["repeatCell"];
1654 assert_eq!(
1655 repeat_cell["cell"]["userEnteredFormat"]["textFormat"]["bold"],
1656 true
1657 );
1658 assert!(
1659 repeat_cell["cell"].get("userEnteredValue").is_none(),
1660 "repeatCell must never carry a value: {repeat_cell}"
1661 );
1662 }
1663
1664 #[tokio::test]
1665 async fn merge_cells_dry_run_lists_discarded_cells() {
1666 let server = wiremock::MockServer::start().await;
1667 let (drive, sheets) = clients(&server).await;
1668 mount_file(
1669 "sheet-1",
1670 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1671 &["folder-1"],
1672 )
1673 .mount(&server)
1674 .await;
1675 mount_folder("folder-1").mount(&server).await;
1676 mount_workbook().mount(&server).await;
1677 wiremock::Mock::given(wiremock::matchers::method("GET"))
1678 .and(wiremock::matchers::path(
1679 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
1680 ))
1681 .respond_with(
1682 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1683 "values": [["keep", "gone"]]
1684 })),
1685 )
1686 .mount(&server)
1687 .await;
1688 let rules = vec![allow_rule("folder-1")];
1689
1690 let opts = FormatOptions {
1691 spreadsheet_id: "sheet-1".to_string(),
1692 verb: FormatVerb::MergeCells {
1693 sheet: Some("Q1".to_string()),
1694 range: Some("A1:B2".to_string()),
1695 merge_type: "MERGE_ALL".to_string(),
1696 },
1697 dry_run: true,
1698 lease_token: None,
1699 ledger_path: std::path::PathBuf::new(),
1700 };
1701 let outcome = format(&drive, &sheets, &opts, &rules).await;
1702 match outcome.result {
1703 FormatResult::WouldChange {
1704 discarded_cells, ..
1705 } => {
1706 assert_eq!(discarded_cells, vec!["B1: gone"]);
1707 }
1708 other => panic!("expected WouldChange, got {other:?}"),
1709 }
1710 }
1711
1712 #[tokio::test]
1713 async fn merge_cells_dry_run_keeps_the_full_discard_list_past_the_render_limit() {
1714 let server = wiremock::MockServer::start().await;
1715 let (drive, sheets) = clients(&server).await;
1716 mount_file(
1717 "sheet-1",
1718 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1719 &["folder-1"],
1720 )
1721 .mount(&server)
1722 .await;
1723 mount_folder("folder-1").mount(&server).await;
1724 mount_workbook().mount(&server).await;
1725 let values: Vec<Vec<String>> = (1..=202).map(|row| vec![format!("v{row}")]).collect();
1728 wiremock::Mock::given(wiremock::matchers::method("GET"))
1729 .and(wiremock::matchers::path(
1730 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:A202",
1731 ))
1732 .respond_with(
1733 wiremock::ResponseTemplate::new(200)
1734 .set_body_json(serde_json::json!({ "values": values })),
1735 )
1736 .mount(&server)
1737 .await;
1738 let rules = vec![allow_rule("folder-1")];
1739
1740 let opts = FormatOptions {
1741 spreadsheet_id: "sheet-1".to_string(),
1742 verb: FormatVerb::MergeCells {
1743 sheet: Some("Q1".to_string()),
1744 range: Some("A1:A202".to_string()),
1745 merge_type: "MERGE_ALL".to_string(),
1746 },
1747 dry_run: true,
1748 lease_token: None,
1749 ledger_path: std::path::PathBuf::new(),
1750 };
1751 let outcome = format(&drive, &sheets, &opts, &rules).await;
1752 let summary = match outcome.result {
1753 FormatResult::WouldChange {
1754 summary,
1755 discarded_cells,
1756 } => {
1757 assert_eq!(discarded_cells.len(), 201);
1758 assert_eq!(discarded_cells[0], "A2: v2");
1759 assert_eq!(discarded_cells[200], "A202: v202");
1760 summary
1761 }
1762 other => panic!("expected WouldChange, got {other:?}"),
1763 };
1764 assert!(summary.contains("discarding 201 cell(s)"), "{summary}");
1765 assert!(summary.contains("A201: v201"), "{summary}");
1766 assert!(!summary.contains("A202: v202"), "{summary}");
1767 assert!(
1768 summary.ends_with(
1769 "… and 1 more (full list in -o json / the drivemutation log's discarded_cells)"
1770 ),
1771 "{summary}"
1772 );
1773 }
1774
1775 #[test]
1776 fn parse_hex_color_accepts_with_and_without_hash() {
1777 let a = parse_hex_color("#FF8800").unwrap();
1778 let b = parse_hex_color("ff8800").unwrap();
1779 assert!((a.red - b.red).abs() < f32::EPSILON);
1780 assert!((a.red - 1.0).abs() < 0.01);
1781 assert!((a.green - 0x88 as f32 / 255.0).abs() < 0.01);
1782 assert!((a.blue - 0.0).abs() < 0.01);
1783 }
1784
1785 #[test]
1786 fn parse_hex_color_rejects_wrong_length_and_non_hex() {
1787 assert!(parse_hex_color("#FFF").is_err());
1788 assert!(parse_hex_color("#GGGGGG").is_err());
1789 assert!(parse_hex_color("").is_err());
1790 }
1791
1792 #[test]
1793 fn normalize_hex_for_display_always_carries_exactly_one_hash() {
1794 assert_eq!(normalize_hex_for_display("#FF8800"), "#FF8800");
1798 assert_eq!(normalize_hex_for_display("FF8800"), "#FF8800");
1799 assert_eq!(normalize_hex_for_display("##FF8800"), "##FF8800");
1802 assert_eq!(normalize_hex_for_display(""), "#");
1803 }
1804
1805 #[test]
1806 fn format_hex_color_round_trips_parse_hex_color() {
1807 for hex in ["#FF8800", "#000000", "#FFFFFF", "#4A86E8"] {
1808 let color = parse_hex_color(hex).unwrap();
1809 assert_eq!(format_hex_color(color), hex);
1810 }
1811 }
1812
1813 #[test]
1814 fn build_cell_format_refuses_an_empty_flag_set() {
1815 let err = build_cell_format(&CellFormatFlags::default()).unwrap_err();
1816 assert!(err.contains("at least one property"), "{err}");
1817 }
1818
1819 #[test]
1820 fn build_cell_format_masks_exactly_the_flags_given() {
1821 let flags = CellFormatFlags {
1822 bold: Some(true),
1823 background: Some("#FF0000".to_string()),
1824 ..Default::default()
1825 };
1826 let (format, fields) = build_cell_format(&flags).unwrap();
1827 assert_eq!(format.text_format.unwrap().bold, Some(true));
1828 assert!(format.background_color_style.is_some());
1829 assert_eq!(
1830 fields,
1831 "userEnteredFormat.textFormat.bold,userEnteredFormat.backgroundColorStyle"
1832 );
1833 }
1834
1835 #[test]
1836 fn build_cell_format_requires_number_format_pattern_and_type_together() {
1837 let flags = CellFormatFlags {
1838 number_format_pattern: Some("0.00".to_string()),
1839 ..Default::default()
1840 };
1841 let err = build_cell_format(&flags).unwrap_err();
1842 assert!(err.contains("together"), "{err}");
1843 }
1844
1845 #[test]
1846 fn discarded_from_values_skips_the_top_left_and_blanks() {
1847 let values: ValueRange = serde_json::from_value(serde_json::json!({
1848 "values": [["keep", "b1", ""], ["a2", null, "c2"]]
1849 }))
1850 .unwrap();
1851 let discarded = discarded_from_values(&values, 0, 0);
1852 assert_eq!(discarded, vec!["B1: b1", "A2: a2", "C2: c2"]);
1853 }
1854
1855 #[test]
1856 fn discarded_from_values_offsets_addresses_by_the_ranges_own_start() {
1857 let values: ValueRange = serde_json::from_value(serde_json::json!({
1860 "values": [["keep", "d5"]]
1861 }))
1862 .unwrap();
1863 let discarded = discarded_from_values(&values, 4, 2);
1864 assert_eq!(discarded, vec!["D5: d5"]);
1865 }
1866
1867 #[test]
1868 fn merge_cells_summary_lists_every_discard_up_to_the_render_limit() {
1869 let discarded: Vec<String> = (1..=200).map(|row| format!("A{row}: v{row}")).collect();
1870 let summary = describe_effect(&merge_cells_verb("A1:A201"), &discarded).unwrap();
1871 assert!(!summary.contains('…'), "{summary}");
1872 assert!(summary.contains("discarding 200 cell(s)"), "{summary}");
1873 assert!(summary.ends_with(&discarded.join("; ")), "{summary}");
1874 }
1875
1876 #[test]
1877 fn merge_cells_summary_elides_past_the_render_limit_but_keeps_the_exact_count() {
1878 let discarded: Vec<String> = (1..=201).map(|row| format!("A{row}: v{row}")).collect();
1879 let summary = describe_effect(&merge_cells_verb("A1:A202"), &discarded).unwrap();
1880 assert!(summary.contains("discarding 201 cell(s)"), "{summary}");
1881 assert!(summary.contains("A200: v200"), "{summary}");
1882 assert!(!summary.contains("A201: v201"), "{summary}");
1883 assert!(
1884 summary.ends_with(
1885 "… and 1 more (full list in -o json / the drivemutation log's discarded_cells)"
1886 ),
1887 "{summary}"
1888 );
1889 }
1890
1891 #[tokio::test]
1892 async fn merge_cells_refuses_an_open_ended_range() {
1893 let server = wiremock::MockServer::start().await;
1894 let (drive, sheets) = clients(&server).await;
1895 mount_file(
1896 "sheet-1",
1897 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1898 &["folder-1"],
1899 )
1900 .mount(&server)
1901 .await;
1902 mount_folder("folder-1").mount(&server).await;
1903 mount_workbook().mount(&server).await;
1904 let rules = vec![allow_rule("folder-1")];
1905
1906 let opts = FormatOptions {
1907 spreadsheet_id: "sheet-1".to_string(),
1908 verb: FormatVerb::MergeCells {
1909 sheet: Some("Q1".to_string()),
1910 range: Some("A:A".to_string()),
1911 merge_type: "MERGE_ALL".to_string(),
1912 },
1913 dry_run: true,
1914 lease_token: None,
1915 ledger_path: std::path::PathBuf::new(),
1916 };
1917 let outcome = format(&drive, &sheets, &opts, &rules).await;
1918 match outcome.result {
1919 FormatResult::RefusedInvalidRange { detail } => {
1920 assert!(detail.contains("open-ended"), "{detail}");
1921 }
1922 other => panic!("expected RefusedInvalidRange, got {other:?}"),
1923 }
1924 }
1929
1930 #[tokio::test]
1931 async fn merge_cells_refuses_a_range_over_the_cell_budget_before_reading_it() {
1932 for dry_run in [true, false] {
1933 let server = wiremock::MockServer::start().await;
1934 let (drive, sheets) = clients(&server).await;
1935 mount_file(
1936 "sheet-1",
1937 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1938 &["folder-1"],
1939 )
1940 .mount(&server)
1941 .await;
1942 mount_folder("folder-1").mount(&server).await;
1943 mount_workbook().mount(&server).await;
1944 wiremock::Mock::given(wiremock::matchers::method("GET"))
1945 .and(wiremock::matchers::path_regex(
1946 r"^/v4/spreadsheets/sheet-1/values/",
1947 ))
1948 .respond_with(wiremock::ResponseTemplate::new(200))
1949 .expect(0)
1950 .mount(&server)
1951 .await;
1952 mount_batch_update(serde_json::json!({}))
1953 .expect(0)
1954 .mount(&server)
1955 .await;
1956 let dir = tempfile::tempdir().unwrap();
1957 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1958 let rules = vec![allow_rule("folder-1")];
1959
1960 let opts = opts(merge_cells_verb("A1:ZZ1000"), dry_run);
1963 let outcome = format(&drive, &sheets, &opts, &rules).await;
1964 match outcome.result {
1965 FormatResult::RefusedInvalidRange { detail } => {
1966 assert_eq!(
1967 detail,
1968 "'A1:ZZ1000' covers 702000 cells, over the 50000-cell budget for \
1969 merge-cells; narrow --range",
1970 "dry_run={dry_run}"
1971 );
1972 }
1973 other => panic!("dry_run={dry_run}: expected RefusedInvalidRange, got {other:?}"),
1974 }
1975 assert!(audit.records().is_empty(), "{:?}", audit.records());
1977 server.verify().await;
1978 }
1979 }
1980
1981 #[tokio::test]
1982 async fn merge_cells_accepts_a_range_exactly_at_the_cell_budget() {
1983 let server = wiremock::MockServer::start().await;
1984 let (drive, sheets) = clients(&server).await;
1985 mount_file(
1986 "sheet-1",
1987 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1988 &["folder-1"],
1989 )
1990 .mount(&server)
1991 .await;
1992 mount_folder("folder-1").mount(&server).await;
1993 mount_workbook().mount(&server).await;
1994 wiremock::Mock::given(wiremock::matchers::method("GET"))
1996 .and(wiremock::matchers::path(
1997 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:J5000",
1998 ))
1999 .respond_with(
2000 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2001 "values": [["keep", "gone"]]
2002 })),
2003 )
2004 .expect(1)
2005 .mount(&server)
2006 .await;
2007 let rules = vec![allow_rule("folder-1")];
2008
2009 let opts = opts(merge_cells_verb("A1:J5000"), true);
2010 let outcome = format(&drive, &sheets, &opts, &rules).await;
2011 match outcome.result {
2012 FormatResult::WouldChange {
2013 discarded_cells, ..
2014 } => assert_eq!(discarded_cells, vec!["B1: gone"]),
2015 other => panic!("expected WouldChange, got {other:?}"),
2016 }
2017 }
2018
2019 fn opts(verb: FormatVerb, dry_run: bool) -> FormatOptions {
2022 let ledger_path = tempfile::tempdir()
2023 .unwrap()
2024 .keep()
2025 .join("lease-ledger.jsonl");
2026 let token = seed_lease(&ledger_path, "sheet-1", "1");
2027 FormatOptions {
2028 spreadsheet_id: "sheet-1".to_string(),
2029 verb,
2030 dry_run,
2031 lease_token: Some(token),
2032 ledger_path,
2033 }
2034 }
2035
2036 fn update_borders_verb(sides: BorderSides, style: &str, color: Option<&str>) -> FormatVerb {
2037 FormatVerb::UpdateBorders {
2038 sheet: Some("Q1".to_string()),
2039 range: Some("A1:B2".to_string()),
2040 sides,
2041 style: style.to_string(),
2042 color: color.map(str::to_string),
2043 }
2044 }
2045
2046 fn merge_cells_verb(range: &str) -> FormatVerb {
2047 FormatVerb::MergeCells {
2048 sheet: Some("Q1".to_string()),
2049 range: Some(range.to_string()),
2050 merge_type: "MERGE_ALL".to_string(),
2051 }
2052 }
2053
2054 fn unmerge_cells_verb() -> FormatVerb {
2055 FormatVerb::UnmergeCells {
2056 sheet: Some("Q1".to_string()),
2057 range: Some("A1:B2".to_string()),
2058 }
2059 }
2060
2061 fn auto_resize_verb(sheet: &str, start: i64, end: i64) -> FormatVerb {
2062 FormatVerb::AutoResizeDimension {
2063 sheet: sheet.to_string(),
2064 dimension: Dimension::Rows,
2065 start,
2066 end,
2067 }
2068 }
2069
2070 fn update_dimension_verb(sheet: &str, start: i64, end: i64, pixel_size: i64) -> FormatVerb {
2071 FormatVerb::UpdateDimensionProperties {
2072 sheet: sheet.to_string(),
2073 dimension: Dimension::Columns,
2074 start,
2075 end,
2076 pixel_size,
2077 }
2078 }
2079
2080 fn find_batch_update(requests: &[wiremock::Request]) -> serde_json::Value {
2081 let batch = requests
2082 .iter()
2083 .find(|r| r.url.path().ends_with(":batchUpdate"))
2084 .expect("no batchUpdate request was sent");
2085 serde_json::from_slice(&batch.body).unwrap()
2086 }
2087
2088 #[test]
2091 fn border_sides_any_is_true_iff_at_least_one_side_is_set() {
2092 assert!(!BorderSides::default().any());
2093 assert!(BorderSides {
2094 top: true,
2095 ..Default::default()
2096 }
2097 .any());
2098 assert!(BorderSides {
2099 bottom: true,
2100 ..Default::default()
2101 }
2102 .any());
2103 assert!(BorderSides {
2104 left: true,
2105 ..Default::default()
2106 }
2107 .any());
2108 assert!(BorderSides {
2109 right: true,
2110 ..Default::default()
2111 }
2112 .any());
2113 assert!(BorderSides {
2114 inner_horizontal: true,
2115 ..Default::default()
2116 }
2117 .any());
2118 assert!(BorderSides {
2119 inner_vertical: true,
2120 ..Default::default()
2121 }
2122 .any());
2123 }
2124
2125 #[test]
2126 fn log_operation_and_label_are_distinct_for_every_verb() {
2127 let verbs = [
2128 format_cells_opts(false).verb,
2129 update_borders_verb(BorderSides::default(), "SOLID", None),
2130 merge_cells_verb("A1:B2"),
2131 unmerge_cells_verb(),
2132 auto_resize_verb("Q1", 1, 2),
2133 update_dimension_verb("Q1", 1, 2, 10),
2134 ];
2135 let ops: HashSet<&str> = verbs.iter().map(FormatVerb::log_operation).collect();
2136 assert_eq!(ops.len(), verbs.len());
2137 let labels: HashSet<&str> = verbs.iter().map(FormatVerb::label).collect();
2138 assert_eq!(labels.len(), verbs.len());
2139 }
2140
2141 #[test]
2142 fn sheet_and_range_is_none_only_for_the_dimension_span_verbs() {
2143 assert_eq!(
2144 merge_cells_verb("A1:B2").sheet_and_range(),
2145 Some((Some("Q1"), Some("A1:B2")))
2146 );
2147 assert!(auto_resize_verb("Q1", 1, 2).sheet_and_range().is_none());
2148 assert!(update_dimension_verb("Q1", 1, 2, 10)
2149 .sheet_and_range()
2150 .is_none());
2151 }
2152
2153 #[test]
2154 fn log_status_is_distinct_for_every_variant() {
2155 let statuses = [
2156 FormatResult::WouldChange {
2157 summary: "x".to_string(),
2158 discarded_cells: Vec::new(),
2159 }
2160 .log_status(),
2161 FormatResult::RefusedNotASpreadsheet {
2162 mime_type: "application/pdf".to_string(),
2163 }
2164 .log_status(),
2165 FormatResult::RefusedShortcut.log_status(),
2166 FormatResult::RefusedNoVisibleParents.log_status(),
2167 FormatResult::RefusedSheetNotFound {
2168 title: "x".to_string(),
2169 available: Vec::new(),
2170 }
2171 .log_status(),
2172 FormatResult::RefusedInvalidRange {
2173 detail: "x".to_string(),
2174 }
2175 .log_status(),
2176 FormatResult::Blocked { decided_by: None }.log_status(),
2177 FormatResult::RefusedNoLease.log_status(),
2178 FormatResult::RefusedLeaseExpired.log_status(),
2179 FormatResult::RefusedLeaseWrongFile.log_status(),
2180 FormatResult::RefusedLeaseStale.log_status(),
2181 FormatResult::Changed {
2182 summary: "x".to_string(),
2183 discarded_cells: Vec::new(),
2184 }
2185 .log_status(),
2186 FormatResult::AppliedReplyUnreadable {
2187 summary: "x".to_string(),
2188 discarded_cells: Vec::new(),
2189 detail: "x".to_string(),
2190 }
2191 .log_status(),
2192 FormatResult::Failed {
2193 detail: "x".to_string(),
2194 }
2195 .log_status(),
2196 ];
2197 let unique: HashSet<&str> = statuses.iter().copied().collect();
2198 assert_eq!(unique.len(), statuses.len());
2199 assert!(statuses.iter().all(|s| !s.is_empty()));
2200 }
2201
2202 #[test]
2203 fn record_attempt_handles_a_would_change_outcome_without_panicking() {
2204 let opts = format_cells_opts(false);
2209 let outcome = FormatOutcome {
2210 spreadsheet_id: "sheet-1".to_string(),
2211 file_name: Some("Budget".to_string()),
2212 resolved_folder_id: None,
2213 verb: opts.verb.clone(),
2214 result: FormatResult::WouldChange {
2215 summary: "set bold".to_string(),
2216 discarded_cells: vec!["B1: gone".to_string()],
2217 },
2218 };
2219 record_attempt(&outcome, &opts, Duration::from_millis(1));
2220 }
2221
2222 #[test]
2223 fn write_jsonl_serializes_the_outcome_as_one_json_line() {
2224 let outcome = FormatOutcome {
2225 spreadsheet_id: "sheet-1".to_string(),
2226 file_name: Some("Budget".to_string()),
2227 resolved_folder_id: None,
2228 verb: format_cells_opts(false).verb,
2229 result: FormatResult::Changed {
2230 summary: "set bold".to_string(),
2231 discarded_cells: Vec::new(),
2232 },
2233 };
2234 let mut buf = Vec::new();
2235 outcome.write_jsonl(&mut buf).unwrap();
2236 let text = String::from_utf8(buf).unwrap();
2237 assert_eq!(text.matches('\n').count(), 1);
2238 let value: serde_json::Value = serde_json::from_str(text.trim_end()).unwrap();
2239 assert_eq!(value["spreadsheet_id"], "sheet-1");
2240 assert_eq!(value["result"]["status"], "changed");
2241 }
2242
2243 #[test]
2246 fn build_cell_format_covers_every_remaining_flag() {
2247 let flags = CellFormatFlags {
2248 italic: Some(true),
2249 strikethrough: Some(true),
2250 underline: Some(true),
2251 font_size: Some(14),
2252 text_color: Some("#112233".to_string()),
2253 horizontal_align: Some("CENTER".to_string()),
2254 vertical_align: Some("MIDDLE".to_string()),
2255 number_format_pattern: Some("0.00".to_string()),
2256 number_format_type: Some("NUMBER".to_string()),
2257 wrap: Some("WRAP".to_string()),
2258 font_family: Some("Arial".to_string()),
2259 text_rotation: Some(TextRotationFlag::Angle(45)),
2260 hyperlink_display_type: Some("PLAIN_TEXT".to_string()),
2261 padding_top: Some(1),
2262 padding_right: Some(2),
2263 padding_bottom: Some(3),
2264 padding_left: Some(4),
2265 text_direction: Some("RIGHT_TO_LEFT".to_string()),
2266 ..Default::default()
2267 };
2268 let (format, fields) = build_cell_format(&flags).unwrap();
2269 let text_format = format.text_format.unwrap();
2270 assert_eq!(text_format.italic, Some(true));
2271 assert_eq!(text_format.strikethrough, Some(true));
2272 assert_eq!(text_format.underline, Some(true));
2273 assert_eq!(text_format.font_size, Some(14));
2274 assert!(text_format.foreground_color_style.is_some());
2275 assert_eq!(text_format.font_family.as_deref(), Some("Arial"));
2276 assert_eq!(format.horizontal_alignment.as_deref(), Some("CENTER"));
2277 assert_eq!(format.vertical_alignment.as_deref(), Some("MIDDLE"));
2278 assert!(format.number_format.is_some());
2279 assert_eq!(format.wrap_strategy.as_deref(), Some("WRAP"));
2280 let text_rotation = format.text_rotation.unwrap();
2281 assert_eq!(text_rotation.angle, Some(45));
2282 assert_eq!(text_rotation.vertical, None);
2283 assert_eq!(format.hyperlink_display_type.as_deref(), Some("PLAIN_TEXT"));
2284 let padding = format.padding.unwrap();
2285 assert_eq!(padding.top, Some(1));
2286 assert_eq!(padding.right, Some(2));
2287 assert_eq!(padding.bottom, Some(3));
2288 assert_eq!(padding.left, Some(4));
2289 assert_eq!(format.text_direction.as_deref(), Some("RIGHT_TO_LEFT"));
2290 for expected in [
2291 "userEnteredFormat.textFormat.italic",
2292 "userEnteredFormat.textFormat.strikethrough",
2293 "userEnteredFormat.textFormat.underline",
2294 "userEnteredFormat.textFormat.fontSize",
2295 "userEnteredFormat.textFormat.foregroundColorStyle",
2296 "userEnteredFormat.textFormat.fontFamily",
2297 "userEnteredFormat.horizontalAlignment",
2298 "userEnteredFormat.verticalAlignment",
2299 "userEnteredFormat.numberFormat",
2300 "userEnteredFormat.wrapStrategy",
2301 "userEnteredFormat.textRotation",
2302 "userEnteredFormat.hyperlinkDisplayType",
2303 "userEnteredFormat.padding",
2304 "userEnteredFormat.textDirection",
2305 ] {
2306 assert!(fields.contains(expected), "{fields} missing {expected}");
2307 }
2308 }
2309
2310 #[test]
2311 fn build_cell_format_sets_text_rotation_vertical() {
2312 let flags = CellFormatFlags {
2313 text_rotation: Some(TextRotationFlag::Vertical),
2314 ..Default::default()
2315 };
2316 let (format, fields) = build_cell_format(&flags).unwrap();
2317 let text_rotation = format.text_rotation.unwrap();
2318 assert_eq!(text_rotation.angle, None);
2319 assert_eq!(text_rotation.vertical, Some(true));
2320 assert_eq!(fields, "userEnteredFormat.textRotation");
2321 }
2322
2323 #[test]
2324 fn build_cell_format_accepts_negative_text_rotation_angle() {
2325 let flags = CellFormatFlags {
2326 text_rotation: Some(TextRotationFlag::Angle(-45)),
2327 ..Default::default()
2328 };
2329 let (format, _fields) = build_cell_format(&flags).unwrap();
2330 let text_rotation = format.text_rotation.unwrap();
2331 assert_eq!(text_rotation.angle, Some(-45));
2332 }
2333
2334 #[test]
2335 fn build_cell_format_rejects_out_of_range_text_rotation_angle() {
2336 for angle in [91, -91, i64::MAX, i64::MIN] {
2337 let flags = CellFormatFlags {
2338 text_rotation: Some(TextRotationFlag::Angle(angle)),
2339 ..Default::default()
2340 };
2341 let err = build_cell_format(&flags).unwrap_err();
2342 assert!(err.contains("-90 and 90"), "{angle}: {err}");
2343 }
2344 }
2345
2346 #[test]
2347 fn build_cell_format_rejects_out_of_range_padding() {
2348 for flags in [
2349 CellFormatFlags {
2350 padding_top: Some(-1),
2351 ..Default::default()
2352 },
2353 CellFormatFlags {
2354 padding_top: Some(i64::from(i32::MAX) + 1),
2355 ..Default::default()
2356 },
2357 ] {
2358 let err = build_cell_format(&flags).unwrap_err();
2359 assert!(err.contains("--padding-top"), "{err}");
2360 }
2361 }
2362
2363 #[test]
2366 #[should_panic(expected = "FormatCells to a Range")]
2367 fn build_request_panics_if_format_cells_resolves_to_a_dimension() {
2368 let verb = format_cells_opts(false).verb;
2369 let resolved = ResolvedTarget::Dimension {
2370 range: DimensionRange {
2371 sheet_id: 0,
2372 dimension: Dimension::Rows,
2373 start_index: 0,
2374 end_index: 1,
2375 },
2376 };
2377 let _ = build_request(&verb, &resolved);
2378 }
2379
2380 #[test]
2381 #[should_panic(expected = "UpdateBorders to a Range")]
2382 fn build_request_panics_if_update_borders_resolves_to_a_dimension() {
2383 let verb = update_borders_verb(
2384 BorderSides {
2385 top: true,
2386 ..Default::default()
2387 },
2388 "SOLID",
2389 None,
2390 );
2391 let resolved = ResolvedTarget::Dimension {
2392 range: DimensionRange {
2393 sheet_id: 0,
2394 dimension: Dimension::Rows,
2395 start_index: 0,
2396 end_index: 1,
2397 },
2398 };
2399 let _ = build_request(&verb, &resolved);
2400 }
2401
2402 #[test]
2403 #[should_panic(expected = "MergeCells to a Range")]
2404 fn build_request_panics_if_merge_cells_resolves_to_a_dimension() {
2405 let resolved = ResolvedTarget::Dimension {
2406 range: DimensionRange {
2407 sheet_id: 0,
2408 dimension: Dimension::Rows,
2409 start_index: 0,
2410 end_index: 1,
2411 },
2412 };
2413 let _ = build_request(&merge_cells_verb("A1:B2"), &resolved);
2414 }
2415
2416 #[test]
2417 #[should_panic(expected = "UnmergeCells to a Range")]
2418 fn build_request_panics_if_unmerge_cells_resolves_to_a_dimension() {
2419 let resolved = ResolvedTarget::Dimension {
2420 range: DimensionRange {
2421 sheet_id: 0,
2422 dimension: Dimension::Rows,
2423 start_index: 0,
2424 end_index: 1,
2425 },
2426 };
2427 let _ = build_request(&unmerge_cells_verb(), &resolved);
2428 }
2429
2430 #[test]
2431 #[should_panic(expected = "AutoResizeDimension to a Dimension")]
2432 fn build_request_panics_if_auto_resize_resolves_to_a_range() {
2433 let resolved = ResolvedTarget::Range {
2434 sheet_title: "Q1".to_string(),
2435 grid: GridRange::default(),
2436 };
2437 let _ = build_request(&auto_resize_verb("Q1", 1, 2), &resolved);
2438 }
2439
2440 #[test]
2441 #[should_panic(expected = "UpdateDimensionProperties to a Dimension")]
2442 fn build_request_panics_if_update_dimension_properties_resolves_to_a_range() {
2443 let resolved = ResolvedTarget::Range {
2444 sheet_title: "Q1".to_string(),
2445 grid: GridRange::default(),
2446 };
2447 let _ = build_request(&update_dimension_verb("Q1", 1, 2, 10), &resolved);
2448 }
2449
2450 #[tokio::test]
2453 async fn read_discarded_cells_returns_empty_for_a_non_range_target() {
2454 let server = wiremock::MockServer::start().await;
2455 let (_drive, sheets) = clients(&server).await;
2456 let api = SheetsApi::new(&sheets);
2457 let resolved = ResolvedTarget::Dimension {
2458 range: DimensionRange {
2459 sheet_id: 0,
2460 dimension: Dimension::Rows,
2461 start_index: 0,
2462 end_index: 1,
2463 },
2464 };
2465 let cells = read_discarded_cells(&api, "sheet-1", &resolved)
2466 .await
2467 .unwrap();
2468 assert!(cells.is_empty());
2469 }
2470
2471 #[tokio::test]
2472 async fn read_discarded_cells_skips_the_read_for_a_single_cell_range() {
2473 let server = wiremock::MockServer::start().await;
2474 let (_drive, sheets) = clients(&server).await;
2475 let api = SheetsApi::new(&sheets);
2476 let resolved = ResolvedTarget::Range {
2478 sheet_title: "Q1".to_string(),
2479 grid: GridRange {
2480 sheet_id: 0,
2481 start_row_index: Some(0),
2482 end_row_index: Some(1),
2483 start_column_index: Some(0),
2484 end_column_index: Some(1),
2485 },
2486 };
2487 let cells = read_discarded_cells(&api, "sheet-1", &resolved)
2488 .await
2489 .unwrap();
2490 assert!(cells.is_empty());
2491 }
2492
2493 #[tokio::test]
2496 async fn a_conflicting_sheet_and_range_is_refused_before_any_drive_call() {
2497 let server = wiremock::MockServer::start().await;
2498 let (drive, sheets) = clients(&server).await;
2499 let rules: Vec<FolderPermissionRule> = Vec::new();
2501 let verb = FormatVerb::FormatCells {
2502 sheet: Some("Other".to_string()),
2503 range: Some("Sheet1!A1:B2".to_string()),
2504 format: Box::new(CellFormatFlags {
2505 bold: Some(true),
2506 ..Default::default()
2507 }),
2508 };
2509
2510 let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
2511 match outcome.result {
2512 FormatResult::RefusedInvalidRange { detail } => {
2513 assert!(detail.contains("already names a sheet"), "{detail}");
2514 }
2515 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2516 }
2517 assert!(outcome.file_name.is_none());
2518 }
2519
2520 #[tokio::test]
2521 async fn a_metadata_fetch_failure_is_reported_as_failed() {
2522 let server = wiremock::MockServer::start().await;
2523 let (drive, sheets) = clients(&server).await;
2524 wiremock::Mock::given(wiremock::matchers::method("GET"))
2525 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2526 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
2527 .mount(&server)
2528 .await;
2529 let rules: Vec<FolderPermissionRule> = Vec::new();
2530
2531 let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
2532 assert!(matches!(outcome.result, FormatResult::Failed { .. }));
2533 assert!(outcome.file_name.is_none());
2534 }
2535
2536 #[tokio::test]
2537 async fn a_non_spreadsheet_target_is_refused_by_mime_type() {
2538 let server = wiremock::MockServer::start().await;
2539 let (drive, sheets) = clients(&server).await;
2540 mount_file("sheet-1", "application/pdf", &["folder-1"])
2541 .mount(&server)
2542 .await;
2543 let rules = vec![allow_rule("folder-1")];
2544
2545 let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
2546 match outcome.result {
2547 FormatResult::RefusedNotASpreadsheet { mime_type } => {
2548 assert_eq!(mime_type, "application/pdf");
2549 }
2550 other => panic!("expected RefusedNotASpreadsheet, got {other:?}"),
2551 }
2552 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2553 }
2554
2555 #[tokio::test]
2556 async fn a_shortcut_target_is_refused() {
2557 let server = wiremock::MockServer::start().await;
2558 let (drive, sheets) = clients(&server).await;
2559 mount_file(
2560 "sheet-1",
2561 "application/vnd.google-apps.shortcut",
2562 &["folder-1"],
2563 )
2564 .mount(&server)
2565 .await;
2566 let rules = vec![allow_rule("folder-1")];
2567
2568 let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
2569 assert!(matches!(outcome.result, FormatResult::RefusedShortcut));
2570 }
2571
2572 #[tokio::test]
2573 async fn a_target_with_no_visible_parents_is_refused() {
2574 let server = wiremock::MockServer::start().await;
2575 let (drive, sheets) = clients(&server).await;
2576 mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
2577 .mount(&server)
2578 .await;
2579 let rules: Vec<FolderPermissionRule> = Vec::new();
2580
2581 let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
2582 assert!(matches!(
2583 outcome.result,
2584 FormatResult::RefusedNoVisibleParents
2585 ));
2586 }
2587
2588 #[tokio::test]
2589 async fn a_gate_ancestor_fetch_failure_is_reported_as_failed() {
2590 let server = wiremock::MockServer::start().await;
2591 let (drive, sheets) = clients(&server).await;
2592 mount_file(
2593 "sheet-1",
2594 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2595 &["folder-1"],
2596 )
2597 .mount(&server)
2598 .await;
2599 wiremock::Mock::given(wiremock::matchers::method("GET"))
2600 .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
2601 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2602 .mount(&server)
2603 .await;
2604 let rules = vec![allow_rule("folder-1")];
2605
2606 let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
2607 assert!(matches!(outcome.result, FormatResult::Failed { .. }));
2608 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2609 }
2610
2611 #[tokio::test]
2612 async fn a_workbook_fetch_failure_is_reported_as_failed() {
2613 let server = wiremock::MockServer::start().await;
2614 let (drive, sheets) = clients(&server).await;
2615 mount_file(
2616 "sheet-1",
2617 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2618 &["folder-1"],
2619 )
2620 .mount(&server)
2621 .await;
2622 mount_folder("folder-1").mount(&server).await;
2623 wiremock::Mock::given(wiremock::matchers::method("GET"))
2624 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2625 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2626 .mount(&server)
2627 .await;
2628 let rules = vec![allow_rule("folder-1")];
2629
2630 let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
2631 assert!(matches!(outcome.result, FormatResult::Failed { .. }));
2632 }
2633
2634 #[tokio::test]
2637 async fn format_cells_reports_sheet_not_found_for_a_range_target() {
2638 let server = wiremock::MockServer::start().await;
2639 let (drive, sheets) = clients(&server).await;
2640 mount_file(
2641 "sheet-1",
2642 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2643 &["folder-1"],
2644 )
2645 .mount(&server)
2646 .await;
2647 mount_folder("folder-1").mount(&server).await;
2648 mount_workbook().mount(&server).await;
2649 let rules = vec![allow_rule("folder-1")];
2650 let verb = FormatVerb::FormatCells {
2651 sheet: Some("Nope".to_string()),
2652 range: Some("A1:B2".to_string()),
2653 format: Box::new(CellFormatFlags {
2654 bold: Some(true),
2655 ..Default::default()
2656 }),
2657 };
2658
2659 let outcome = format(&drive, &sheets, &opts(verb, true), &rules).await;
2660 match outcome.result {
2661 FormatResult::RefusedSheetNotFound { title, available } => {
2662 assert_eq!(title, "Nope");
2663 assert_eq!(available, vec!["Q1".to_string()]);
2664 }
2665 other => panic!("expected RefusedSheetNotFound, got {other:?}"),
2666 }
2667 }
2668
2669 #[tokio::test]
2670 async fn format_cells_reports_an_invalid_a1_token() {
2671 let server = wiremock::MockServer::start().await;
2672 let (drive, sheets) = clients(&server).await;
2673 mount_file(
2674 "sheet-1",
2675 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2676 &["folder-1"],
2677 )
2678 .mount(&server)
2679 .await;
2680 mount_folder("folder-1").mount(&server).await;
2681 mount_workbook().mount(&server).await;
2682 let rules = vec![allow_rule("folder-1")];
2683 let verb = FormatVerb::FormatCells {
2684 sheet: Some("Q1".to_string()),
2685 range: Some("definitely bogus".to_string()),
2686 format: Box::new(CellFormatFlags {
2687 bold: Some(true),
2688 ..Default::default()
2689 }),
2690 };
2691
2692 let outcome = format(&drive, &sheets, &opts(verb, true), &rules).await;
2693 match outcome.result {
2694 FormatResult::RefusedInvalidRange { detail } => {
2695 assert!(detail.contains("not a recognised A1 range"), "{detail}");
2696 }
2697 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2698 }
2699 }
2700
2701 #[tokio::test]
2702 async fn dimension_verb_reports_sheet_not_found() {
2703 let server = wiremock::MockServer::start().await;
2704 let (drive, sheets) = clients(&server).await;
2705 mount_file(
2706 "sheet-1",
2707 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2708 &["folder-1"],
2709 )
2710 .mount(&server)
2711 .await;
2712 mount_folder("folder-1").mount(&server).await;
2713 mount_workbook().mount(&server).await;
2714 let rules = vec![allow_rule("folder-1")];
2715
2716 let outcome = format(
2717 &drive,
2718 &sheets,
2719 &opts(auto_resize_verb("Nope", 1, 2), true),
2720 &rules,
2721 )
2722 .await;
2723 match outcome.result {
2724 FormatResult::RefusedSheetNotFound { title, available } => {
2725 assert_eq!(title, "Nope");
2726 assert_eq!(available, vec!["Q1".to_string()]);
2727 }
2728 other => panic!("expected RefusedSheetNotFound, got {other:?}"),
2729 }
2730 }
2731
2732 #[tokio::test]
2733 async fn dimension_verbs_reject_an_invalid_start_end_span() {
2734 let server = wiremock::MockServer::start().await;
2735 let (drive, sheets) = clients(&server).await;
2736 mount_file(
2737 "sheet-1",
2738 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2739 &["folder-1"],
2740 )
2741 .mount(&server)
2742 .await;
2743 mount_folder("folder-1").mount(&server).await;
2744 mount_workbook().mount(&server).await;
2745 let rules = vec![allow_rule("folder-1")];
2746
2747 for verb in [
2748 auto_resize_verb("Q1", 0, 5),
2749 update_dimension_verb("Q1", 5, 3, 10),
2750 ] {
2751 let outcome = format(&drive, &sheets, &opts(verb, true), &rules).await;
2752 match outcome.result {
2753 FormatResult::RefusedInvalidRange { detail } => {
2754 assert!(detail.contains("--start"), "{detail}");
2755 }
2756 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2757 }
2758 }
2759 }
2760
2761 #[tokio::test]
2764 async fn merge_cells_reports_a_values_read_failure_as_failed() {
2765 let server = wiremock::MockServer::start().await;
2766 let (drive, sheets) = clients(&server).await;
2767 mount_file(
2768 "sheet-1",
2769 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2770 &["folder-1"],
2771 )
2772 .mount(&server)
2773 .await;
2774 mount_folder("folder-1").mount(&server).await;
2775 mount_workbook().mount(&server).await;
2776 wiremock::Mock::given(wiremock::matchers::method("GET"))
2777 .and(wiremock::matchers::path(
2778 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
2779 ))
2780 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2781 .mount(&server)
2782 .await;
2783 let rules = vec![allow_rule("folder-1")];
2784
2785 let outcome = format(
2786 &drive,
2787 &sheets,
2788 &opts(merge_cells_verb("A1:B2"), true),
2789 &rules,
2790 )
2791 .await;
2792 assert!(matches!(outcome.result, FormatResult::Failed { .. }));
2793 }
2794
2795 #[tokio::test]
2796 async fn merge_cells_single_cell_range_has_no_discards_and_skips_the_values_read() {
2797 let server = wiremock::MockServer::start().await;
2798 let (drive, sheets) = clients(&server).await;
2799 mount_file(
2800 "sheet-1",
2801 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2802 &["folder-1"],
2803 )
2804 .mount(&server)
2805 .await;
2806 mount_folder("folder-1").mount(&server).await;
2807 mount_workbook().mount(&server).await;
2808 let rules = vec![allow_rule("folder-1")];
2810
2811 let outcome = format(
2812 &drive,
2813 &sheets,
2814 &opts(merge_cells_verb("A1:A1"), true),
2815 &rules,
2816 )
2817 .await;
2818 match outcome.result {
2819 FormatResult::WouldChange {
2820 summary,
2821 discarded_cells,
2822 } => {
2823 assert!(discarded_cells.is_empty());
2824 assert_eq!(summary, "merge (MERGE_ALL)");
2825 }
2826 other => panic!("expected WouldChange, got {other:?}"),
2827 }
2828 }
2829
2830 #[tokio::test]
2833 async fn update_borders_refuses_when_no_side_is_selected() {
2834 let server = wiremock::MockServer::start().await;
2835 let (drive, sheets) = clients(&server).await;
2836 mount_file(
2837 "sheet-1",
2838 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2839 &["folder-1"],
2840 )
2841 .mount(&server)
2842 .await;
2843 mount_folder("folder-1").mount(&server).await;
2844 mount_workbook().mount(&server).await;
2845 let rules = vec![allow_rule("folder-1")];
2846 let verb = update_borders_verb(BorderSides::default(), "SOLID", None);
2847
2848 let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
2849 match outcome.result {
2850 FormatResult::RefusedInvalidRange { detail } => {
2851 assert!(detail.contains("at least one side"), "{detail}");
2852 }
2853 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2854 }
2855 }
2856
2857 #[tokio::test]
2858 async fn update_borders_refuses_inner_horizontal_on_a_single_row() {
2859 let server = wiremock::MockServer::start().await;
2860 let (drive, sheets) = clients(&server).await;
2861 mount_file(
2862 "sheet-1",
2863 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2864 &["folder-1"],
2865 )
2866 .mount(&server)
2867 .await;
2868 mount_folder("folder-1").mount(&server).await;
2869 mount_workbook().mount(&server).await;
2870 let rules = vec![allow_rule("folder-1")];
2871 let verb = FormatVerb::UpdateBorders {
2872 sheet: Some("Q1".to_string()),
2873 range: Some("A1:B1".to_string()),
2874 sides: BorderSides {
2875 inner_horizontal: true,
2876 ..Default::default()
2877 },
2878 style: "SOLID".to_string(),
2879 color: None,
2880 };
2881
2882 let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
2883 match outcome.result {
2884 FormatResult::RefusedInvalidRange { detail } => {
2885 assert!(detail.contains("--inner-horizontal"), "{detail}");
2886 assert!(detail.contains("more than one row"), "{detail}");
2887 }
2888 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2889 }
2890 }
2891
2892 #[tokio::test]
2893 async fn update_borders_refuses_inner_vertical_on_a_single_column() {
2894 let server = wiremock::MockServer::start().await;
2895 let (drive, sheets) = clients(&server).await;
2896 mount_file(
2897 "sheet-1",
2898 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2899 &["folder-1"],
2900 )
2901 .mount(&server)
2902 .await;
2903 mount_folder("folder-1").mount(&server).await;
2904 mount_workbook().mount(&server).await;
2905 let rules = vec![allow_rule("folder-1")];
2906 let verb = FormatVerb::UpdateBorders {
2907 sheet: Some("Q1".to_string()),
2908 range: Some("A1:A2".to_string()),
2909 sides: BorderSides {
2910 inner_vertical: true,
2911 ..Default::default()
2912 },
2913 style: "SOLID".to_string(),
2914 color: None,
2915 };
2916
2917 let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
2918 match outcome.result {
2919 FormatResult::RefusedInvalidRange { detail } => {
2920 assert!(detail.contains("--inner-vertical"), "{detail}");
2921 assert!(detail.contains("more than one column"), "{detail}");
2922 }
2923 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2924 }
2925 }
2926
2927 #[tokio::test]
2928 async fn update_borders_rejects_a_malformed_color_at_request_build_time() {
2929 let server = wiremock::MockServer::start().await;
2930 let (drive, sheets) = clients(&server).await;
2931 mount_file(
2932 "sheet-1",
2933 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2934 &["folder-1"],
2935 )
2936 .mount(&server)
2937 .await;
2938 mount_folder("folder-1").mount(&server).await;
2939 mount_workbook().mount(&server).await;
2940 let dir = tempfile::tempdir().unwrap();
2944 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
2945 let rules = vec![allow_rule("folder-1")];
2946 let sides = BorderSides {
2947 top: true,
2948 ..Default::default()
2949 };
2950 let verb = update_borders_verb(sides, "SOLID", Some("ZZZZZZ"));
2951
2952 let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
2955 match outcome.result {
2956 FormatResult::RefusedInvalidRange { detail } => {
2957 assert!(detail.contains("not a color"), "{detail}");
2958 }
2959 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2960 }
2961
2962 assert!(audit.records().is_empty(), "{:?}", audit.records());
2966 }
2967
2968 #[tokio::test]
2971 async fn update_borders_sets_every_selected_side() {
2972 let server = wiremock::MockServer::start().await;
2973 let (drive, sheets) = clients(&server).await;
2974 mount_file(
2975 "sheet-1",
2976 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2977 &["folder-1"],
2978 )
2979 .mount(&server)
2980 .await;
2981 mount_folder("folder-1").mount(&server).await;
2982 mount_workbook().mount(&server).await;
2983 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
2984 .mount(&server)
2985 .await;
2986 let rules = vec![allow_rule("folder-1")];
2987 let sides = BorderSides {
2988 top: true,
2989 bottom: true,
2990 left: true,
2991 right: true,
2992 ..Default::default()
2993 };
2994 let verb = update_borders_verb(sides, "DASHED", Some("00FF00"));
2995
2996 let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
2997 match &outcome.result {
2998 FormatResult::Changed { summary, .. } => {
2999 assert!(summary.contains("top, bottom, left, right"), "{summary}");
3000 }
3001 other => panic!("expected Changed, got {other:?}"),
3002 }
3003
3004 let requests = server.received_requests().await.unwrap();
3005 let body = find_batch_update(&requests);
3006 let update_borders = &body["requests"][0]["updateBorders"];
3007 for side in ["top", "bottom", "left", "right"] {
3008 assert_eq!(update_borders[side]["style"], "DASHED", "{update_borders}");
3009 }
3010 }
3011
3012 #[tokio::test]
3013 async fn update_borders_sets_inner_horizontal_and_inner_vertical() {
3014 let server = wiremock::MockServer::start().await;
3015 let (drive, sheets) = clients(&server).await;
3016 mount_file(
3017 "sheet-1",
3018 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3019 &["folder-1"],
3020 )
3021 .mount(&server)
3022 .await;
3023 mount_folder("folder-1").mount(&server).await;
3024 mount_workbook().mount(&server).await;
3025 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3026 .mount(&server)
3027 .await;
3028 let rules = vec![allow_rule("folder-1")];
3029 let sides = BorderSides {
3030 inner_horizontal: true,
3031 inner_vertical: true,
3032 ..Default::default()
3033 };
3034 let verb = update_borders_verb(sides, "DOTTED", Some("00FF00"));
3035
3036 let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
3037 match &outcome.result {
3038 FormatResult::Changed { summary, .. } => {
3039 assert!(
3040 summary.contains("inner-horizontal, inner-vertical"),
3041 "{summary}"
3042 );
3043 }
3044 other => panic!("expected Changed, got {other:?}"),
3045 }
3046
3047 let requests = server.received_requests().await.unwrap();
3048 let body = find_batch_update(&requests);
3049 let update_borders = &body["requests"][0]["updateBorders"];
3050 for side in ["innerHorizontal", "innerVertical"] {
3051 assert_eq!(update_borders[side]["style"], "DOTTED", "{update_borders}");
3052 }
3053 assert!(update_borders.get("top").is_none());
3054 }
3055
3056 #[tokio::test]
3057 async fn update_borders_defaults_to_black_when_no_color_is_given() {
3058 let server = wiremock::MockServer::start().await;
3059 let (drive, sheets) = clients(&server).await;
3060 mount_file(
3061 "sheet-1",
3062 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3063 &["folder-1"],
3064 )
3065 .mount(&server)
3066 .await;
3067 mount_folder("folder-1").mount(&server).await;
3068 mount_workbook().mount(&server).await;
3069 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3070 .mount(&server)
3071 .await;
3072 let rules = vec![allow_rule("folder-1")];
3073 let sides = BorderSides {
3074 top: true,
3075 ..Default::default()
3076 };
3077 let verb = update_borders_verb(sides, "SOLID", None);
3078
3079 let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
3080 assert!(matches!(outcome.result, FormatResult::Changed { .. }));
3081
3082 let requests = server.received_requests().await.unwrap();
3083 let body = find_batch_update(&requests);
3084 let color = &body["requests"][0]["updateBorders"]["top"]["colorStyle"]["rgbColor"];
3085 assert_eq!(color["red"], 0.0);
3086 assert_eq!(color["green"], 0.0);
3087 assert_eq!(color["blue"], 0.0);
3088 }
3089
3090 #[tokio::test]
3091 async fn merge_cells_real_run_sends_merge_type_and_discards_are_still_reported() {
3092 let server = wiremock::MockServer::start().await;
3093 let (drive, sheets) = clients(&server).await;
3094 mount_file(
3095 "sheet-1",
3096 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3097 &["folder-1"],
3098 )
3099 .mount(&server)
3100 .await;
3101 mount_folder("folder-1").mount(&server).await;
3102 mount_workbook().mount(&server).await;
3103 wiremock::Mock::given(wiremock::matchers::method("GET"))
3104 .and(wiremock::matchers::path(
3105 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
3106 ))
3107 .respond_with(
3108 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3109 "values": [["keep", "gone"]]
3110 })),
3111 )
3112 .mount(&server)
3113 .await;
3114 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3115 .mount(&server)
3116 .await;
3117 let rules = vec![allow_rule("folder-1")];
3118
3119 let outcome = format(
3120 &drive,
3121 &sheets,
3122 &opts(merge_cells_verb("A1:B2"), false),
3123 &rules,
3124 )
3125 .await;
3126 match &outcome.result {
3127 FormatResult::Changed {
3128 summary,
3129 discarded_cells,
3130 } => {
3131 assert_eq!(discarded_cells, &vec!["B1: gone".to_string()]);
3132 assert!(summary.contains("discarding 1 cell"), "{summary}");
3133 }
3134 other => panic!("expected Changed, got {other:?}"),
3135 }
3136
3137 let requests = server.received_requests().await.unwrap();
3138 let body = find_batch_update(&requests);
3139 assert_eq!(body["requests"][0]["mergeCells"]["mergeType"], "MERGE_ALL");
3140 }
3141
3142 #[tokio::test]
3143 async fn unmerge_cells_sends_the_composed_range() {
3144 let server = wiremock::MockServer::start().await;
3145 let (drive, sheets) = clients(&server).await;
3146 mount_file(
3147 "sheet-1",
3148 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3149 &["folder-1"],
3150 )
3151 .mount(&server)
3152 .await;
3153 mount_folder("folder-1").mount(&server).await;
3154 mount_workbook().mount(&server).await;
3155 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3156 .mount(&server)
3157 .await;
3158 let rules = vec![allow_rule("folder-1")];
3159
3160 let outcome = format(&drive, &sheets, &opts(unmerge_cells_verb(), false), &rules).await;
3161 match &outcome.result {
3162 FormatResult::Changed { summary, .. } => assert_eq!(summary, "unmerge"),
3163 other => panic!("expected Changed, got {other:?}"),
3164 }
3165
3166 let requests = server.received_requests().await.unwrap();
3167 let body = find_batch_update(&requests);
3168 assert!(body["requests"][0].get("unmergeCells").is_some());
3169 }
3170
3171 #[tokio::test]
3172 async fn auto_resize_dimension_sends_the_resolved_sheet_id_and_span() {
3173 let server = wiremock::MockServer::start().await;
3174 let (drive, sheets) = clients(&server).await;
3175 mount_file(
3176 "sheet-1",
3177 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3178 &["folder-1"],
3179 )
3180 .mount(&server)
3181 .await;
3182 mount_folder("folder-1").mount(&server).await;
3183 mount_workbook().mount(&server).await;
3184 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3185 .mount(&server)
3186 .await;
3187 let rules = vec![allow_rule("folder-1")];
3188
3189 let outcome = format(
3190 &drive,
3191 &sheets,
3192 &opts(auto_resize_verb("Q1", 2, 5), false),
3193 &rules,
3194 )
3195 .await;
3196 match &outcome.result {
3197 FormatResult::Changed { summary, .. } => {
3198 assert!(summary.contains("row(s)"), "{summary}");
3199 }
3200 other => panic!("expected Changed, got {other:?}"),
3201 }
3202
3203 let requests = server.received_requests().await.unwrap();
3204 let body = find_batch_update(&requests);
3205 let dims = &body["requests"][0]["autoResizeDimensions"]["dimensions"];
3206 assert_eq!(dims["sheetId"], 0);
3207 assert_eq!(dims["startIndex"], 1);
3208 assert_eq!(dims["endIndex"], 5);
3209 }
3210
3211 #[tokio::test]
3212 async fn update_dimension_properties_sends_the_pixel_size() {
3213 let server = wiremock::MockServer::start().await;
3214 let (drive, sheets) = clients(&server).await;
3215 mount_file(
3216 "sheet-1",
3217 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3218 &["folder-1"],
3219 )
3220 .mount(&server)
3221 .await;
3222 mount_folder("folder-1").mount(&server).await;
3223 mount_workbook().mount(&server).await;
3224 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3225 .mount(&server)
3226 .await;
3227 let rules = vec![allow_rule("folder-1")];
3228
3229 let outcome = format(
3230 &drive,
3231 &sheets,
3232 &opts(update_dimension_verb("Q1", 1, 3, 120), false),
3233 &rules,
3234 )
3235 .await;
3236 match &outcome.result {
3237 FormatResult::Changed { summary, .. } => assert!(summary.contains("120"), "{summary}"),
3238 other => panic!("expected Changed, got {other:?}"),
3239 }
3240
3241 let requests = server.received_requests().await.unwrap();
3242 let body = find_batch_update(&requests);
3243 let request = &body["requests"][0]["updateDimensionProperties"];
3244 assert_eq!(request["properties"]["pixelSize"], 120);
3245 assert_eq!(request["fields"], "pixelSize");
3246 }
3247
3248 #[tokio::test]
3249 async fn format_cells_with_an_unreadable_reply_reports_applied_reply_unreadable() {
3250 let server = wiremock::MockServer::start().await;
3251 let (drive, sheets) = clients(&server).await;
3252 mount_file(
3253 "sheet-1",
3254 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3255 &["folder-1"],
3256 )
3257 .mount(&server)
3258 .await;
3259 mount_folder("folder-1").mount(&server).await;
3260 mount_workbook().mount(&server).await;
3261 wiremock::Mock::given(wiremock::matchers::method("POST"))
3263 .and(wiremock::matchers::path(
3264 "/v4/spreadsheets/sheet-1:batchUpdate",
3265 ))
3266 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
3267 .expect(1)
3268 .mount(&server)
3269 .await;
3270 let rules = vec![allow_rule("folder-1")];
3271
3272 let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
3273 assert!(
3274 matches!(outcome.result, FormatResult::AppliedReplyUnreadable { .. }),
3275 "{:?}",
3276 outcome.result
3277 );
3278 assert_eq!(outcome.result.log_status(), "applied-reply-unreadable");
3279 assert!(
3280 describe(&outcome).starts_with("Applied, but the reply could not be read"),
3281 "{}",
3282 describe(&outcome)
3283 );
3284 }
3285
3286 #[test]
3287 fn describe_lines_renders_applied_reply_unreadable_as_an_idempotent_single_line() {
3288 let outcome = FormatOutcome {
3289 spreadsheet_id: "sheet-1".to_string(),
3290 file_name: Some("Budget".to_string()),
3291 resolved_folder_id: None,
3292 verb: format_cells_opts(false).verb,
3293 result: FormatResult::AppliedReplyUnreadable {
3294 summary: "set bold on 'Q1'!A1:B2".to_string(),
3295 discarded_cells: Vec::new(),
3296 detail: "bad reply".to_string(),
3297 },
3298 };
3299 assert_eq!(
3300 describe_lines(&outcome),
3301 vec![
3302 "Applied, but the reply could not be read: set bold on 'Q1'!A1:B2 in 'Budget' — \
3303 check the spreadsheet to confirm (bad reply)"
3304 .to_string()
3305 ]
3306 );
3307 }
3308
3309 #[test]
3313 fn an_unreadable_reply_logs_the_summary_and_discarded_cells_like_changed() {
3314 let opts = format_cells_opts(false);
3315 let record = |result| {
3316 mutation_record(
3317 &FormatOutcome {
3318 spreadsheet_id: "sheet-1".to_string(),
3319 file_name: Some("Budget".to_string()),
3320 resolved_folder_id: None,
3321 verb: opts.verb.clone(),
3322 result,
3323 },
3324 &opts,
3325 Duration::from_millis(1),
3326 )
3327 };
3328 let changed = record(FormatResult::Changed {
3329 summary: "merge cells".to_string(),
3330 discarded_cells: vec!["B1: gone".to_string()],
3331 });
3332 let unreadable = record(FormatResult::AppliedReplyUnreadable {
3333 summary: "merge cells".to_string(),
3334 discarded_cells: vec!["B1: gone".to_string()],
3335 detail: "bad reply".to_string(),
3336 });
3337 assert_eq!(unreadable.fields_changed.as_deref(), Some("merge cells"));
3338 assert_eq!(unreadable.fields_changed, changed.fields_changed);
3339 assert_eq!(unreadable.discarded_cells, vec!["B1: gone".to_string()]);
3340 assert_eq!(unreadable.discarded_cells, changed.discarded_cells);
3341 assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
3342 assert_eq!(unreadable.status, "applied-reply-unreadable");
3343 }
3344
3345 #[tokio::test]
3346 async fn a_batch_update_failure_is_reported_as_failed() {
3347 let server = wiremock::MockServer::start().await;
3348 let (drive, sheets) = clients(&server).await;
3349 mount_file(
3350 "sheet-1",
3351 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3352 &["folder-1"],
3353 )
3354 .mount(&server)
3355 .await;
3356 mount_folder("folder-1").mount(&server).await;
3357 mount_workbook().mount(&server).await;
3358 wiremock::Mock::given(wiremock::matchers::method("POST"))
3359 .and(wiremock::matchers::path(
3360 "/v4/spreadsheets/sheet-1:batchUpdate",
3361 ))
3362 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
3363 .mount(&server)
3364 .await;
3365 let rules = vec![allow_rule("folder-1")];
3366
3367 let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
3368 assert!(matches!(outcome.result, FormatResult::Failed { .. }));
3369 }
3370
3371 fn every_format_result() -> Vec<FormatResult> {
3374 vec![
3375 FormatResult::WouldChange {
3376 summary: "set bold".to_string(),
3377 discarded_cells: vec!["B1: gone".to_string()],
3378 },
3379 FormatResult::RefusedNotASpreadsheet {
3380 mime_type: "application/pdf".to_string(),
3381 },
3382 FormatResult::RefusedShortcut,
3383 FormatResult::RefusedNoVisibleParents,
3384 FormatResult::RefusedSheetNotFound {
3385 title: "Q9".to_string(),
3386 available: vec!["Q1".to_string()],
3387 },
3388 FormatResult::RefusedSheetNotFound {
3389 title: "Q9".to_string(),
3390 available: Vec::new(),
3391 },
3392 FormatResult::RefusedInvalidRange {
3393 detail: "bad range".to_string(),
3394 },
3395 FormatResult::Blocked {
3396 decided_by: Some(DecidingRule::Folder {
3397 folder_id: "folder-1".to_string(),
3398 depth: 0,
3399 }),
3400 },
3401 FormatResult::Blocked { decided_by: None },
3402 FormatResult::RefusedNoLease,
3403 FormatResult::RefusedLeaseExpired,
3404 FormatResult::RefusedLeaseWrongFile,
3405 FormatResult::RefusedLeaseStale,
3406 FormatResult::Changed {
3407 summary: "set bold".to_string(),
3408 discarded_cells: Vec::new(),
3409 },
3410 FormatResult::AppliedReplyUnreadable {
3411 summary: "set bold".to_string(),
3412 discarded_cells: vec!["B1: gone".to_string()],
3413 detail: "bad reply".to_string(),
3414 },
3415 FormatResult::Failed {
3416 detail: "boom".to_string(),
3417 },
3418 ]
3419 }
3420
3421 #[test]
3422 fn describe_lines_covers_every_result_with_no_control_characters() {
3423 for file_name in [Some("Budget".to_string()), None] {
3424 for result in every_format_result() {
3425 match &result {
3427 FormatResult::WouldChange { .. }
3428 | FormatResult::RefusedNotASpreadsheet { .. }
3429 | FormatResult::RefusedShortcut
3430 | FormatResult::RefusedNoVisibleParents
3431 | FormatResult::RefusedSheetNotFound { .. }
3432 | FormatResult::RefusedInvalidRange { .. }
3433 | FormatResult::Blocked { .. }
3434 | FormatResult::RefusedNoLease
3435 | FormatResult::RefusedLeaseExpired
3436 | FormatResult::RefusedLeaseWrongFile
3437 | FormatResult::RefusedLeaseStale
3438 | FormatResult::Changed { .. }
3439 | FormatResult::AppliedReplyUnreadable { .. }
3440 | FormatResult::Failed { .. } => {}
3441 }
3442 let outcome = FormatOutcome {
3443 spreadsheet_id: "sheet-1".to_string(),
3444 file_name: file_name.clone(),
3445 resolved_folder_id: None,
3446 verb: format_cells_opts(false).verb,
3447 result,
3448 };
3449 let lines = describe_lines(&outcome);
3450 assert_eq!(lines.len(), 1, "{:?}", outcome.result);
3451 for line in &lines {
3452 assert!(
3453 !line.chars().any(char::is_control),
3454 "{line:?} for {:?}",
3455 outcome.result
3456 );
3457 }
3458 assert_eq!(describe(&outcome), lines.join("\n"));
3459 }
3460 }
3461 }
3462
3463 #[tokio::test]
3466 async fn refuses_without_a_lease_when_the_rule_requires_one() {
3467 let server = wiremock::MockServer::start().await;
3468 let (drive, sheets) = clients(&server).await;
3469 mount_file(
3470 "sheet-1",
3471 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3472 &["folder-1"],
3473 )
3474 .mount(&server)
3475 .await;
3476 mount_folder("folder-1").mount(&server).await;
3477 mount_workbook().mount(&server).await;
3478 let rules = vec![allow_rule("folder-1")];
3481
3482 let opts = FormatOptions {
3483 lease_token: None,
3484 ..format_cells_opts(false)
3485 };
3486 let outcome = format(&drive, &sheets, &opts, &rules).await;
3487 assert!(matches!(outcome.result, FormatResult::RefusedNoLease));
3488 assert_eq!(outcome.result.log_status(), "refused-no-lease");
3489 }
3490
3491 #[tokio::test]
3492 async fn reports_a_lock_acquisition_failure_as_failed() {
3493 let server = wiremock::MockServer::start().await;
3497 let (drive, sheets) = clients(&server).await;
3498 mount_file(
3499 "sheet-1",
3500 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3501 &["folder-1"],
3502 )
3503 .mount(&server)
3504 .await;
3505 mount_folder("folder-1").mount(&server).await;
3506 mount_workbook().mount(&server).await;
3507 let rules = vec![allow_rule("folder-1")];
3508
3509 let opts = format_cells_opts(false);
3510 let mut lock_path = opts.ledger_path.clone().into_os_string();
3516 lock_path.push(".lock");
3517 std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
3518
3519 let outcome = format(&drive, &sheets, &opts, &rules).await;
3520 assert!(matches!(outcome.result, FormatResult::Failed { .. }));
3521 }
3522
3523 #[tokio::test]
3524 async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
3525 let server = wiremock::MockServer::start().await;
3530 let (drive, sheets) = clients(&server).await;
3531 mount_file(
3532 "sheet-1",
3533 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3534 &["folder-1"],
3535 )
3536 .up_to_n_times(1)
3537 .with_priority(1)
3538 .mount(&server)
3539 .await;
3540 wiremock::Mock::given(wiremock::matchers::method("GET"))
3541 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
3542 .respond_with(wiremock::ResponseTemplate::new(500))
3543 .with_priority(2)
3544 .mount(&server)
3545 .await;
3546 mount_folder("folder-1").mount(&server).await;
3547 mount_workbook().mount(&server).await;
3548 wiremock::Mock::given(wiremock::matchers::method("POST"))
3549 .and(wiremock::matchers::path(
3550 "/v4/spreadsheets/sheet-1:batchUpdate",
3551 ))
3552 .respond_with(wiremock::ResponseTemplate::new(200))
3553 .expect(0)
3554 .mount(&server)
3555 .await;
3556 let rules = vec![allow_rule("folder-1")];
3557
3558 let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
3559 assert!(matches!(outcome.result, FormatResult::Failed { .. }));
3560 }
3561
3562 #[tokio::test]
3563 async fn refuses_an_unknown_lease_token() {
3564 let server = wiremock::MockServer::start().await;
3565 let (drive, sheets) = clients(&server).await;
3566 mount_file(
3567 "sheet-1",
3568 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3569 &["folder-1"],
3570 )
3571 .mount(&server)
3572 .await;
3573 mount_folder("folder-1").mount(&server).await;
3574 mount_workbook().mount(&server).await;
3575 let rules = vec![allow_rule("folder-1")];
3576 let ledger_path = tempfile::tempdir()
3577 .unwrap()
3578 .keep()
3579 .join("lease-ledger.jsonl");
3580 let opts = FormatOptions {
3583 lease_token: Some("bogus-token".to_string()),
3584 ledger_path,
3585 ..format_cells_opts(false)
3586 };
3587 let outcome = format(&drive, &sheets, &opts, &rules).await;
3588 assert!(matches!(outcome.result, FormatResult::RefusedLeaseExpired));
3589 }
3590
3591 #[tokio::test]
3592 async fn refuses_an_expired_lease() {
3593 let server = wiremock::MockServer::start().await;
3594 let (drive, sheets) = clients(&server).await;
3595 mount_file(
3596 "sheet-1",
3597 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3598 &["folder-1"],
3599 )
3600 .mount(&server)
3601 .await;
3602 mount_folder("folder-1").mount(&server).await;
3603 mount_workbook().mount(&server).await;
3604 let rules = vec![allow_rule("folder-1")];
3605 let ledger_path = tempfile::tempdir()
3606 .unwrap()
3607 .keep()
3608 .join("lease-ledger.jsonl");
3609 let token = "expired-token".to_string();
3610 let mut ledger = crate::drive::lease::ledger::LeaseLedger::default();
3611 ledger.insert(crate::drive::lease::ledger::LeaseRecord {
3612 token: token.clone(),
3613 file_id: "sheet-1".to_string(),
3614 version: "1".to_string(),
3615 modified_time: None,
3616 backup: crate::drive::lease::ledger::LeaseBackup::Bytes {
3617 path: std::path::PathBuf::from("/tmp/test-backup"),
3618 sha256: "deadbeef".to_string(),
3619 size: 0,
3620 },
3621 acquired_at: chrono::Utc::now() - chrono::Duration::hours(2),
3622 expires_at: chrono::Utc::now() - chrono::Duration::hours(1),
3623 released_at: None,
3624 superseded_by: None,
3625 restored_at: None,
3626 restored_sheet_id: None,
3627 });
3628 ledger.save(&ledger_path).unwrap();
3629
3630 let opts = FormatOptions {
3631 lease_token: Some(token),
3632 ledger_path,
3633 ..format_cells_opts(false)
3634 };
3635 let outcome = format(&drive, &sheets, &opts, &rules).await;
3636 assert!(matches!(outcome.result, FormatResult::RefusedLeaseExpired));
3637 }
3638
3639 #[tokio::test]
3640 async fn refuses_a_lease_bound_to_a_different_file() {
3641 let server = wiremock::MockServer::start().await;
3642 let (drive, sheets) = clients(&server).await;
3643 mount_file(
3644 "sheet-1",
3645 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3646 &["folder-1"],
3647 )
3648 .mount(&server)
3649 .await;
3650 mount_folder("folder-1").mount(&server).await;
3651 mount_workbook().mount(&server).await;
3652 let rules = vec![allow_rule("folder-1")];
3653 let ledger_path = tempfile::tempdir()
3654 .unwrap()
3655 .keep()
3656 .join("lease-ledger.jsonl");
3657 let token = seed_lease(&ledger_path, "some-other-sheet", "1");
3659
3660 let opts = FormatOptions {
3661 lease_token: Some(token),
3662 ledger_path,
3663 ..format_cells_opts(false)
3664 };
3665 let outcome = format(&drive, &sheets, &opts, &rules).await;
3666 assert!(matches!(
3667 outcome.result,
3668 FormatResult::RefusedLeaseWrongFile
3669 ));
3670 }
3671
3672 #[tokio::test]
3673 async fn refuses_a_stale_lease_when_the_file_has_moved() {
3674 let server = wiremock::MockServer::start().await;
3675 let (drive, sheets) = clients(&server).await;
3676 mount_file(
3679 "sheet-1",
3680 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3681 &["folder-1"],
3682 )
3683 .mount(&server)
3684 .await;
3685 mount_folder("folder-1").mount(&server).await;
3686 mount_workbook().mount(&server).await;
3687 let rules = vec![allow_rule("folder-1")];
3688 let ledger_path = tempfile::tempdir()
3689 .unwrap()
3690 .keep()
3691 .join("lease-ledger.jsonl");
3692 let token = seed_lease(&ledger_path, "sheet-1", "0");
3693
3694 let opts = FormatOptions {
3695 lease_token: Some(token),
3696 ledger_path,
3697 ..format_cells_opts(false)
3698 };
3699 let outcome = format(&drive, &sheets, &opts, &rules).await;
3700 assert!(matches!(outcome.result, FormatResult::RefusedLeaseStale));
3701 }
3702
3703 #[tokio::test]
3704 async fn require_lease_false_writes_without_a_lease() {
3705 let server = wiremock::MockServer::start().await;
3706 let (drive, sheets) = clients(&server).await;
3707 mount_file(
3708 "sheet-1",
3709 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3710 &["folder-1"],
3711 )
3712 .mount(&server)
3713 .await;
3714 mount_folder("folder-1").mount(&server).await;
3715 mount_workbook().mount(&server).await;
3716 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3717 .mount(&server)
3718 .await;
3719 let rule = FolderPermissionRule {
3720 folder_id: Some("folder-1".to_string()),
3721 file_id: None,
3722 recursive: true,
3723 allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
3724 deny: HashSet::default(),
3725 require_lease: false,
3726 };
3727
3728 let opts = FormatOptions {
3730 lease_token: None,
3731 ledger_path: std::path::PathBuf::from("/nonexistent/lease-ledger.jsonl"),
3732 ..format_cells_opts(false)
3733 };
3734 let outcome = format(&drive, &sheets, &opts, &[rule]).await;
3735 assert!(matches!(outcome.result, FormatResult::Changed { .. }));
3736 }
3737
3738 #[tokio::test]
3739 async fn require_lease_false_still_refuses_a_stale_lease_if_one_is_presented() {
3740 let server = wiremock::MockServer::start().await;
3744 let (drive, sheets) = clients(&server).await;
3745 mount_file(
3748 "sheet-1",
3749 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3750 &["folder-1"],
3751 )
3752 .mount(&server)
3753 .await;
3754 mount_folder("folder-1").mount(&server).await;
3755 mount_workbook().mount(&server).await;
3756 let rule = FolderPermissionRule {
3759 folder_id: Some("folder-1".to_string()),
3760 file_id: None,
3761 recursive: true,
3762 allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
3763 deny: HashSet::default(),
3764 require_lease: false,
3765 };
3766 let ledger_path = tempfile::tempdir()
3767 .unwrap()
3768 .keep()
3769 .join("lease-ledger.jsonl");
3770 let token = seed_lease(&ledger_path, "sheet-1", "0");
3771
3772 let opts = FormatOptions {
3773 lease_token: Some(token),
3774 ledger_path,
3775 ..format_cells_opts(false)
3776 };
3777 let outcome = format(&drive, &sheets, &opts, &[rule]).await;
3778 assert!(matches!(outcome.result, FormatResult::RefusedLeaseStale));
3779 }
3780
3781 #[tokio::test]
3784 async fn a_leased_format_concludes_its_audit_pair_with_allowed() {
3785 let server = wiremock::MockServer::start().await;
3786 let (drive, sheets) = clients(&server).await;
3787 mount_file(
3788 "sheet-1",
3789 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3790 &["folder-1"],
3791 )
3792 .mount(&server)
3793 .await;
3794 mount_folder("folder-1").mount(&server).await;
3795 mount_workbook().mount(&server).await;
3796 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3797 .mount(&server)
3798 .await;
3799 let dir = tempfile::tempdir().unwrap();
3800 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
3801 let rules = vec![allow_rule("folder-1")];
3802
3803 let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
3804 assert!(matches!(outcome.result, FormatResult::Changed { .. }));
3805
3806 let records = audit.records();
3807 assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
3808 assert_eq!(records[0].command, ["drive", "sheets-format-cells"]);
3812 }
3813
3814 fn borders_verb() -> FormatVerb {
3815 FormatVerb::UpdateBorders {
3816 sheet: Some("Q1".to_string()),
3817 range: Some("A1:B2".to_string()),
3818 sides: BorderSides {
3819 top: true,
3820 left: true,
3821 ..BorderSides::default()
3822 },
3823 style: "SOLID_MEDIUM".to_string(),
3824 color: None,
3825 }
3826 }
3827
3828 fn lines_for(verb: FormatVerb, result: FormatResult) -> Vec<String> {
3829 describe_lines(&FormatOutcome {
3830 spreadsheet_id: "sheet-1".to_string(),
3831 file_name: Some("Budget".to_string()),
3832 resolved_folder_id: None,
3833 verb,
3834 result,
3835 })
3836 }
3837
3838 #[test]
3839 fn update_borders_summary_starts_with_a_verb() {
3840 let summary = describe_effect(&borders_verb(), &[]).unwrap();
3841 assert_eq!(summary, "set top, left border(s), style SOLID_MEDIUM");
3842 let lines = lines_for(
3843 borders_verb(),
3844 FormatResult::WouldChange {
3845 summary,
3846 discarded_cells: Vec::new(),
3847 },
3848 );
3849 assert_eq!(
3850 lines,
3851 ["Would set top, left border(s), style SOLID_MEDIUM in 'Budget'"]
3852 );
3853 }
3854
3855 #[test]
3856 fn dimension_summaries_name_the_sheet_and_the_span() {
3857 let auto_resize = FormatVerb::AutoResizeDimension {
3858 sheet: "Q1".to_string(),
3859 dimension: Dimension::Columns,
3860 start: 2,
3861 end: 4,
3862 };
3863 assert_eq!(
3864 describe_effect(&auto_resize, &[]).unwrap(),
3865 "auto-resize column(s) B:D of 'Q1'"
3866 );
3867 let apostrophe = FormatVerb::AutoResizeDimension {
3868 sheet: "Bob's".to_string(),
3869 dimension: Dimension::Rows,
3870 start: 1,
3871 end: 1,
3872 };
3873 assert_eq!(
3874 describe_effect(&apostrophe, &[]).unwrap(),
3875 "auto-resize row(s) 1:1 of 'Bob''s'"
3876 );
3877 let pixel_size = FormatVerb::UpdateDimensionProperties {
3878 sheet: "Q1".to_string(),
3879 dimension: Dimension::Rows,
3880 start: 2,
3881 end: 5,
3882 pixel_size: 40,
3883 };
3884 assert_eq!(
3885 describe_effect(&pixel_size, &[]).unwrap(),
3886 "set the pixelSize of row(s) 2:5 of 'Q1' to 40"
3887 );
3888 }
3889
3890 #[test]
3891 fn a_real_run_reads_as_past_tense_prose_not_the_hyphenated_verb() {
3892 let auto_resize = FormatVerb::AutoResizeDimension {
3893 sheet: "Q1".to_string(),
3894 dimension: Dimension::Rows,
3895 start: 1,
3896 end: 3,
3897 };
3898 let cases = [
3899 (
3900 borders_verb(),
3901 "set top, left border(s), style SOLID_MEDIUM",
3902 "Set top, left border(s), style SOLID_MEDIUM in 'Budget'",
3903 ),
3904 (
3905 merge_cells_verb("A1:B2"),
3906 "merge (MERGE_ALL)",
3907 "Merged cells (MERGE_ALL) in 'Budget'",
3908 ),
3909 (
3910 unmerge_cells_verb(),
3911 "unmerge",
3912 "Unmerged cells in 'Budget'",
3913 ),
3914 (
3915 auto_resize,
3916 "auto-resize row(s) 1:3 of 'Q1'",
3917 "Auto-resized row(s) 1:3 of 'Q1' in 'Budget'",
3918 ),
3919 ];
3920 for verb in [
3924 format_cells_opts(false).verb,
3925 FormatVerb::UpdateDimensionProperties {
3926 sheet: "Q1".to_string(),
3927 dimension: Dimension::Columns,
3928 start: 1,
3929 end: 1,
3930 pixel_size: 80,
3931 },
3932 ] {
3933 let summary = describe_effect(&verb, &[]).unwrap();
3934 let lines = lines_for(
3935 verb,
3936 FormatResult::Changed {
3937 summary,
3938 discarded_cells: Vec::new(),
3939 },
3940 );
3941 assert!(lines[0].starts_with("Set "), "{lines:?}");
3942 }
3943 for (verb, summary, expected) in cases {
3944 let lines = lines_for(
3945 verb,
3946 FormatResult::Changed {
3947 summary: summary.to_string(),
3948 discarded_cells: Vec::new(),
3949 },
3950 );
3951 assert_eq!(lines, [expected]);
3952 }
3953 }
3954
3955 #[test]
3956 fn a_summary_without_its_verbs_imperative_falls_back_to_applied() {
3957 let lines = lines_for(
3958 borders_verb(),
3959 FormatResult::Changed {
3960 summary: "x".to_string(),
3961 discarded_cells: Vec::new(),
3962 },
3963 );
3964 assert_eq!(lines, ["Applied: x in 'Budget'"]);
3965 }
3966}