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