1use std::path::PathBuf;
58use std::time::{Duration, Instant};
59
60use serde::Serialize;
61
62use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
63use crate::drive::client::DriveClient;
64use crate::drive::files_api::FilesApi;
65use crate::drive::lease::check::{
66 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
67 LeasedWrite,
68};
69use crate::drive::sheets::a1;
70use crate::drive::sheets::api::{
71 applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
72};
73use crate::drive::sheets::client::SheetsClient;
74use crate::drive::sheets::grid_range;
75use crate::drive::sheets::target_gate;
76use crate::drive::sheets::types::{
77 BatchUpdateRequestItem, CellSnapshot, GridCoordinate, PivotCellData, PivotFilterCriteria,
78 PivotFilterSpec, PivotGroup, PivotRowData, PivotTable, PivotValue, UpdateCellsRequest,
79};
80use crate::drive::types::SheetTargetRefusal;
81use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
82use crate::request_log::{self, DriveMutationOutcome};
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90enum SortOrder {
91 Ascending,
92 Descending,
93}
94
95impl SortOrder {
96 const fn as_sheets_str(self) -> &'static str {
97 match self {
98 Self::Ascending => "ASCENDING",
99 Self::Descending => "DESCENDING",
100 }
101 }
102
103 const fn describe(self) -> &'static str {
104 match self {
105 Self::Ascending => "asc",
106 Self::Descending => "desc",
107 }
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117enum SummarizeFunction {
118 Sum,
119 Counta,
120 Count,
121 CountUnique,
122 Average,
123 Max,
124 Min,
125 Median,
126 Product,
127 Stdev,
128 Stdevp,
129 Var,
130 Varp,
131}
132
133impl SummarizeFunction {
134 const fn as_sheets_str(self) -> &'static str {
135 match self {
136 Self::Sum => "SUM",
137 Self::Counta => "COUNTA",
138 Self::Count => "COUNT",
139 Self::CountUnique => "COUNTUNIQUE",
140 Self::Average => "AVERAGE",
141 Self::Max => "MAX",
142 Self::Min => "MIN",
143 Self::Median => "MEDIAN",
144 Self::Product => "PRODUCT",
145 Self::Stdev => "STDEV",
146 Self::Stdevp => "STDEVP",
147 Self::Var => "VAR",
148 Self::Varp => "VARP",
149 }
150 }
151
152 fn parse(s: &str) -> Option<Self> {
153 Some(match s.trim().to_ascii_lowercase().as_str() {
154 "sum" => Self::Sum,
155 "counta" => Self::Counta,
156 "count" => Self::Count,
157 "countunique" => Self::CountUnique,
158 "average" | "avg" => Self::Average,
159 "max" => Self::Max,
160 "min" => Self::Min,
161 "median" => Self::Median,
162 "product" => Self::Product,
163 "stdev" => Self::Stdev,
164 "stdevp" => Self::Stdevp,
165 "var" => Self::Var,
166 "varp" => Self::Varp,
167 _ => return None,
168 })
169 }
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum ValueLayout {
180 Horizontal,
182 Vertical,
184}
185
186impl ValueLayout {
187 const fn as_sheets_str(self) -> &'static str {
188 match self {
189 Self::Horizontal => "HORIZONTAL",
190 Self::Vertical => "VERTICAL",
191 }
192 }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
197struct PivotGroupSpec {
198 column: i64,
200 sort_order: Option<SortOrder>,
204}
205
206impl PivotGroupSpec {
207 fn effective_sort_order(&self) -> SortOrder {
215 self.sort_order.unwrap_or(SortOrder::Ascending)
216 }
217
218 fn to_wire(&self, show_totals: bool) -> PivotGroup {
219 PivotGroup {
220 source_column_offset: self.column,
221 show_totals,
222 sort_order: Some(self.effective_sort_order().as_sheets_str().to_string()),
223 }
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
229struct PivotValueSpec {
230 column: i64,
232 function: SummarizeFunction,
233}
234
235impl PivotValueSpec {
236 fn to_wire(&self) -> PivotValue {
237 PivotValue {
238 source_column_offset: self.column,
239 summarize_function: self.function.as_sheets_str().to_string(),
240 name: None,
241 }
242 }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
247struct PivotFilterSpecArg {
248 column: i64,
250 visible_values: Vec<String>,
251}
252
253impl PivotFilterSpecArg {
254 fn to_wire(&self) -> PivotFilterSpec {
255 PivotFilterSpec {
256 column_offset_index: self.column,
257 filter_criteria: PivotFilterCriteria {
258 visible_values: self.visible_values.clone(),
259 },
260 }
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
266pub enum PivotVerb {
267 AddPivotTable {
270 sheet: String,
272 anchor: String,
275 source: String,
279 rows: Vec<String>,
282 columns: Vec<String>,
284 values: Vec<String>,
286 filters: Vec<String>,
288 value_layout: Option<ValueLayout>,
290 show_totals: bool,
294 },
295 DeletePivotTable {
297 sheet: String,
299 anchor: String,
302 },
303}
304
305impl PivotVerb {
306 const fn log_operation(&self) -> &'static str {
308 match self {
309 Self::AddPivotTable { .. } => "sheets-add-pivot-table",
310 Self::DeletePivotTable { .. } => "sheets-delete-pivot-table",
311 }
312 }
313
314 const fn label(&self) -> &'static str {
316 match self {
317 Self::AddPivotTable { .. } => "add-pivot-table",
318 Self::DeletePivotTable { .. } => "delete-pivot-table",
319 }
320 }
321
322 fn sheet(&self) -> &str {
323 match self {
324 Self::AddPivotTable { sheet, .. } | Self::DeletePivotTable { sheet, .. } => sheet,
325 }
326 }
327
328 fn anchor(&self) -> &str {
329 match self {
330 Self::AddPivotTable { anchor, .. } | Self::DeletePivotTable { anchor, .. } => anchor,
331 }
332 }
333
334 const fn gate_operations(&self) -> &'static [DriveOperation] {
340 match self {
341 Self::AddPivotTable { .. } => {
342 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure]
343 }
344 Self::DeletePivotTable { .. } => &[DriveOperation::SheetsWrite],
345 }
346 }
347}
348
349#[derive(Debug, Clone)]
351pub struct PivotOptions {
352 pub spreadsheet_id: String,
354 pub verb: PivotVerb,
356 pub dry_run: bool,
358 pub lease_token: Option<String>,
361 pub ledger_path: PathBuf,
363}
364
365#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
368pub struct PivotChange {
369 pub anchor: String,
371 #[serde(skip_serializing_if = "Option::is_none")]
374 pub source: Option<String>,
375 #[serde(skip_serializing_if = "Option::is_none")]
378 pub config: Option<String>,
379 pub anchor_currently: String,
384}
385
386#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
388#[serde(tag = "status", rename_all = "kebab-case")]
389pub enum PivotResult {
390 WouldChange(PivotChange),
392 RefusedNotASpreadsheet {
394 mime_type: String,
396 },
397 RefusedShortcut,
399 RefusedNoVisibleParents,
401 RefusedSheetNotFound {
403 title: String,
405 available: Vec<String>,
407 },
408 RefusedInvalidRange {
411 detail: String,
413 },
414 RefusedInvalidAnchor {
416 detail: String,
418 },
419 RefusedInvalidSource {
423 detail: String,
425 },
426 RefusedAnchorHasPivotTable {
428 anchor: String,
430 existing_summary: String,
432 },
433 RefusedNoPivotTableAtAnchor {
435 anchor: String,
437 },
438 Blocked {
440 operation: DriveOperation,
442 decided_by: Option<DecidingRule>,
444 },
445 RefusedNoLease,
447 RefusedLeaseExpired,
450 RefusedLeaseWrongFile,
452 RefusedLeaseStale,
454 Changed(PivotChange),
456 AppliedReplyUnreadable {
461 summary: String,
463 detail: String,
465 #[serde(flatten)]
469 change: PivotChange,
470 },
471 Failed {
473 detail: String,
475 },
476}
477
478impl FromLeaseRefusal for PivotResult {
479 fn from_no_lease() -> Self {
480 Self::RefusedNoLease
481 }
482 fn from_lease_expired() -> Self {
483 Self::RefusedLeaseExpired
484 }
485 fn from_lease_wrong_file() -> Self {
486 Self::RefusedLeaseWrongFile
487 }
488 fn from_lease_stale() -> Self {
489 Self::RefusedLeaseStale
490 }
491 fn from_lease_failed(detail: String) -> Self {
492 Self::Failed { detail }
493 }
494}
495
496impl PivotResult {
497 fn log_status(&self) -> &'static str {
498 match self {
499 Self::WouldChange(_) => "would-change",
500 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
501 Self::RefusedShortcut => "refused-shortcut",
502 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
503 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
504 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
505 Self::RefusedInvalidAnchor { .. } => "refused-invalid-anchor",
506 Self::RefusedInvalidSource { .. } => "refused-invalid-source",
507 Self::RefusedAnchorHasPivotTable { .. } => "refused-anchor-has-pivot-table",
508 Self::RefusedNoPivotTableAtAnchor { .. } => "refused-no-pivot-table-at-anchor",
509 Self::Blocked { .. } => "blocked",
510 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
511 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
512 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
513 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
514 Self::Changed(_) => "changed",
515 Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
516 Self::Failed { .. } => "failed",
517 }
518 }
519}
520
521#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
523pub struct PivotOutcome {
524 pub spreadsheet_id: String,
526 #[serde(skip_serializing_if = "Option::is_none")]
528 pub file_name: Option<String>,
529 #[serde(skip_serializing_if = "Option::is_none")]
531 pub resolved_folder_id: Option<String>,
532 #[serde(skip)]
534 pub verb: PivotVerb,
535 pub result: PivotResult,
537}
538
539impl JsonlSerialize for PivotOutcome {
540 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
541 write_scalar_jsonl(self, out)
542 }
543}
544
545pub async fn pivot(
548 drive: &DriveClient,
549 sheets: &SheetsClient,
550 opts: &PivotOptions,
551 rules: &[FolderPermissionRule],
552) -> PivotOutcome {
553 let started = Instant::now();
554 let outcome = pivot_inner(drive, sheets, opts, rules).await;
555 if !opts.dry_run {
556 record_attempt(&outcome, opts, started.elapsed());
557 }
558 outcome
559}
560
561async fn pivot_inner(
562 drive: &DriveClient,
563 sheets: &SheetsClient,
564 opts: &PivotOptions,
565 rules: &[FolderPermissionRule],
566) -> PivotOutcome {
567 let bare = |result| PivotOutcome {
568 spreadsheet_id: opts.spreadsheet_id.clone(),
569 file_name: None,
570 resolved_folder_id: None,
571 verb: opts.verb.clone(),
572 result,
573 };
574
575 let anchor_composed = match a1::compose(Some(opts.verb.sheet()), Some(opts.verb.anchor())) {
576 Ok(composed) => composed,
577 Err(err) => {
578 return bare(PivotResult::RefusedInvalidRange {
579 detail: err.to_string(),
580 })
581 }
582 };
583
584 let operations = opts.verb.gate_operations();
585
586 let (target, verdict, denied, resolved_folder_id, requires_lease) =
587 match target_gate::resolve_all(drive, &opts.spreadsheet_id, operations, rules).await {
588 target_gate::TargetGateUnionOutcome::MetadataFetchFailed { detail } => {
589 return bare(PivotResult::Failed { detail })
590 }
591 target_gate::TargetGateUnionOutcome::Refused { target, refusal } => {
592 let result = match refusal {
593 SheetTargetRefusal::Shortcut => PivotResult::RefusedShortcut,
594 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
595 PivotResult::RefusedNotASpreadsheet { mime_type }
596 }
597 SheetTargetRefusal::NoVisibleParents => PivotResult::RefusedNoVisibleParents,
598 };
599 return PivotOutcome {
600 spreadsheet_id: opts.spreadsheet_id.clone(),
601 file_name: Some(target.name),
602 resolved_folder_id: None,
603 verb: opts.verb.clone(),
604 result,
605 };
606 }
607 target_gate::TargetGateUnionOutcome::GateFetchFailed { target, detail } => {
608 return PivotOutcome {
609 spreadsheet_id: opts.spreadsheet_id.clone(),
610 file_name: Some(target.name),
611 resolved_folder_id: None,
612 verb: opts.verb.clone(),
613 result: PivotResult::Failed { detail },
614 };
615 }
616 target_gate::TargetGateUnionOutcome::Gated {
617 target,
618 verdict,
619 denied,
620 resolved_folder_id,
621 requires_lease,
622 } => (target, verdict, denied, resolved_folder_id, requires_lease),
623 };
624
625 let gated = |result| PivotOutcome {
626 spreadsheet_id: opts.spreadsheet_id.clone(),
627 file_name: Some(target.name.clone()),
628 resolved_folder_id: resolved_folder_id.clone(),
629 verb: opts.verb.clone(),
630 result,
631 };
632
633 if verdict == write_gate::Verdict::Deny {
634 let (operation, decided_by) = denied.unwrap_or((operations[0], None));
635 return gated(PivotResult::Blocked {
636 operation,
637 decided_by,
638 });
639 }
640
641 let api = SheetsApi::new(sheets);
642 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
643 Ok(workbook) => workbook,
644 Err(err) => {
645 return gated(PivotResult::Failed {
646 detail: format!("{err:#}"),
647 })
648 }
649 };
650
651 let (_, anchor_grid) = match grid_range::resolve_grid_range(
652 &workbook,
653 &anchor_composed,
654 |detail| PivotResult::RefusedInvalidRange { detail },
655 |title, available| PivotResult::RefusedSheetNotFound { title, available },
656 ) {
657 Ok(resolved) => resolved,
658 Err(result) => return gated(result),
659 };
660
661 if !grid_range::is_single_cell(&anchor_grid) {
662 return gated(PivotResult::RefusedInvalidAnchor {
663 detail: format!(
664 "--anchor '{}' must name a single cell, not a range",
665 opts.verb.anchor()
666 ),
667 });
668 }
669 let start = GridCoordinate {
670 sheet_id: anchor_grid.sheet_id,
671 row_index: anchor_grid.start_row_index.unwrap_or(0),
672 column_index: anchor_grid.start_column_index.unwrap_or(0),
673 };
674
675 let cell_workbook = match api
679 .get_cell_pivot(&opts.spreadsheet_id, &anchor_composed)
680 .await
681 {
682 Ok(workbook) => workbook,
683 Err(err) => {
684 return gated(PivotResult::Failed {
685 detail: format!("{err:#}"),
686 })
687 }
688 };
689 let existing_cell = cell_snapshot_at(&cell_workbook, anchor_grid.sheet_id);
690 let existing_pivot = existing_cell.and_then(|c| c.pivot_table.as_ref());
691
692 let (config, source_a1, request) = match &opts.verb {
693 PivotVerb::AddPivotTable {
694 source,
695 rows,
696 columns,
697 values,
698 filters,
699 value_layout,
700 show_totals,
701 ..
702 } => {
703 if let Some(existing) = existing_pivot {
704 return gated(PivotResult::RefusedAnchorHasPivotTable {
705 anchor: anchor_composed.clone(),
706 existing_summary: describe_pivot_table(existing),
707 });
708 }
709
710 let source_composed = match compose_source(opts.verb.sheet(), source) {
711 Ok(composed) => composed,
712 Err(detail) => return gated(PivotResult::RefusedInvalidRange { detail }),
713 };
714 let (_, source_grid) = match grid_range::resolve_grid_range(
715 &workbook,
716 &source_composed,
717 |detail| PivotResult::RefusedInvalidRange { detail },
718 |title, available| PivotResult::RefusedSheetNotFound { title, available },
719 ) {
720 Ok(resolved) => resolved,
721 Err(result) => return gated(result),
722 };
723 if !grid_range::is_bounded(&source_grid) {
724 return gated(PivotResult::RefusedInvalidSource {
725 detail: format!(
726 "--source '{source}' must be a bounded range, not an open-ended \
727 column/row span"
728 ),
729 });
730 }
731 #[allow(clippy::unwrap_used)] let width =
733 source_grid.end_column_index.unwrap() - source_grid.start_column_index.unwrap();
734
735 let row_specs = match parse_group_specs(rows) {
736 Ok(specs) => specs,
737 Err(detail) => return gated(PivotResult::RefusedInvalidSource { detail }),
738 };
739 let column_specs = match parse_group_specs(columns) {
740 Ok(specs) => specs,
741 Err(detail) => return gated(PivotResult::RefusedInvalidSource { detail }),
742 };
743 let value_specs = match parse_value_specs(values) {
744 Ok(specs) => specs,
745 Err(detail) => return gated(PivotResult::RefusedInvalidSource { detail }),
746 };
747 let filter_specs = match parse_filter_specs(filters) {
748 Ok(specs) => specs,
749 Err(detail) => return gated(PivotResult::RefusedInvalidSource { detail }),
750 };
751
752 if value_specs.is_empty() {
753 return gated(PivotResult::RefusedInvalidSource {
754 detail: "add-pivot-table needs at least one --value".to_string(),
755 });
756 }
757
758 let out_of_bounds = row_specs
759 .iter()
760 .map(|s| s.column)
761 .chain(column_specs.iter().map(|s| s.column))
762 .chain(value_specs.iter().map(|s| s.column))
763 .chain(filter_specs.iter().map(|s| s.column))
764 .find(|&col| col < 0 || col >= width);
765 if let Some(col) = out_of_bounds {
766 return gated(PivotResult::RefusedInvalidSource {
767 detail: format!(
768 "column offset {col} is out of bounds for source '{source_composed}' \
769 ({width} column(s), valid range 0-{})",
770 width - 1
771 ),
772 });
773 }
774
775 let pivot_table = PivotTable {
776 source: source_grid,
777 rows: row_specs.iter().map(|s| s.to_wire(*show_totals)).collect(),
778 columns: column_specs
779 .iter()
780 .map(|s| s.to_wire(*show_totals))
781 .collect(),
782 values: value_specs.iter().map(PivotValueSpec::to_wire).collect(),
783 filter_specs: filter_specs
784 .iter()
785 .map(PivotFilterSpecArg::to_wire)
786 .collect(),
787 value_layout: value_layout.map(|l| l.as_sheets_str().to_string()),
788 };
789
790 let config = describe_pivot_config(
791 &row_specs,
792 &column_specs,
793 &value_specs,
794 &filter_specs,
795 *value_layout,
796 *show_totals,
797 );
798
799 let request = BatchUpdateRequestItem::UpdateCells(UpdateCellsRequest {
800 start,
801 rows: vec![PivotRowData {
802 values: vec![PivotCellData {
803 pivot_table: Some(pivot_table),
804 }],
805 }],
806 fields: "pivotTable".to_string(),
807 });
808
809 (Some(config), Some(source_composed), request)
810 }
811 PivotVerb::DeletePivotTable { .. } => {
812 if existing_pivot.is_none() {
813 return gated(PivotResult::RefusedNoPivotTableAtAnchor {
814 anchor: anchor_composed.clone(),
815 });
816 }
817 let request = BatchUpdateRequestItem::UpdateCells(UpdateCellsRequest {
818 start,
819 rows: vec![PivotRowData {
820 values: vec![PivotCellData { pivot_table: None }],
821 }],
822 fields: "pivotTable".to_string(),
823 });
824 (None, None, request)
825 }
826 };
827
828 let change = PivotChange {
829 anchor: anchor_composed.clone(),
830 source: source_a1,
831 config,
832 anchor_currently: describe_anchor_currently(existing_cell),
833 };
834
835 if opts.dry_run {
836 return gated(PivotResult::WouldChange(change));
837 }
838
839 let files_api = FilesApi::new(drive);
844 let leased = LeasedWrite {
845 log_prefix: "drive sheets pivot",
846 operation: opts.verb.log_operation(),
847 ledger_path: &opts.ledger_path,
848 file_id: &opts.spreadsheet_id,
849 };
850 let lease_grant = match gate_optional_leased_write(
851 leased,
852 &files_api,
853 requires_lease,
854 opts.lease_token.as_deref(),
855 )
856 .await
857 {
858 Ok(grant) => grant,
859 Err(err) => return gated(err.into_result()),
860 };
861
862 let result = match conclude_native_leased_write(
863 leased,
864 &lease_grant,
865 &files_api,
866 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
867 |err| format!("{err:#}"),
868 )
869 .await
870 {
871 Ok(BatchUpdateOutcome::Applied(_)) => PivotResult::Changed(change),
872 Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
875 PivotResult::AppliedReplyUnreadable {
876 summary: pivot_action_summary(&opts.verb, &change),
877 detail,
878 change,
879 }
880 }
881 Err(err) => PivotResult::Failed {
882 detail: format!("{err:#}"),
883 },
884 };
885 drop(lease_grant);
886 gated(result)
887}
888
889fn compose_source(sheet: &str, source: &str) -> Result<String, String> {
893 if a1::split_sheet_prefix(source).is_some() {
894 a1::validate_range(source)
895 .map(|()| source.to_string())
896 .map_err(|err| err.to_string())
897 } else {
898 a1::compose(Some(sheet), Some(source)).map_err(|err| err.to_string())
899 }
900}
901
902fn cell_snapshot_at(
907 workbook: &crate::drive::sheets::types::Spreadsheet,
908 sheet_id: i64,
909) -> Option<&CellSnapshot> {
910 workbook
911 .sheets
912 .iter()
913 .find(|s| s.sheet_id() == Some(sheet_id))
914 .and_then(|s| s.data.first())
915 .and_then(|gd| gd.row_data.first())
916 .and_then(|rd| rd.values.first())
917}
918
919fn parse_group_specs(flags: &[String]) -> Result<Vec<PivotGroupSpec>, String> {
922 flags
923 .iter()
924 .map(|flag| {
925 let (col_str, order_str) = match flag.split_once(':') {
926 Some((col, order)) => (col, Some(order)),
927 None => (flag.as_str(), None),
928 };
929 let column: i64 = col_str
930 .trim()
931 .parse()
932 .map_err(|_| format!("'{col_str}' in '{flag}' is not a column offset"))?;
933 let sort_order = match order_str {
934 None => None,
935 Some(order) => Some(match order.trim().to_ascii_lowercase().as_str() {
936 "asc" | "ascending" => SortOrder::Ascending,
937 "desc" | "descending" => SortOrder::Descending,
938 other => return Err(format!("'{other}' in '{flag}' is not 'asc' or 'desc'")),
939 }),
940 };
941 Ok(PivotGroupSpec { column, sort_order })
942 })
943 .collect()
944}
945
946fn parse_value_specs(flags: &[String]) -> Result<Vec<PivotValueSpec>, String> {
949 flags
950 .iter()
951 .map(|flag| {
952 let (col, func) = flag
953 .split_once(':')
954 .ok_or_else(|| format!("'{flag}' is not COLUMN:FUNC — expected e.g. '3:sum'"))?;
955 let column: i64 = col
956 .trim()
957 .parse()
958 .map_err(|_| format!("'{col}' in '{flag}' is not a column offset"))?;
959 let function = SummarizeFunction::parse(func).ok_or_else(|| {
960 format!(
961 "'{func}' in '{flag}' is not a recognized summarize function (sum, counta, \
962 count, countunique, average, max, min, median, product, stdev, stdevp, \
963 var, varp)"
964 )
965 })?;
966 Ok(PivotValueSpec { column, function })
967 })
968 .collect()
969}
970
971fn parse_filter_specs(flags: &[String]) -> Result<Vec<PivotFilterSpecArg>, String> {
974 flags
975 .iter()
976 .map(|flag| {
977 let (col, values) = flag.split_once(':').ok_or_else(|| {
978 format!("'{flag}' is not COLUMN:VALUE[,VALUE...] — expected e.g. '1:Foo,Bar'")
979 })?;
980 let column: i64 = col
981 .trim()
982 .parse()
983 .map_err(|_| format!("'{col}' in '{flag}' is not a column offset"))?;
984 if values.is_empty() {
985 return Err(format!("'{flag}' names no values to filter"));
986 }
987 let visible_values = values.split(',').map(str::to_string).collect();
988 Ok(PivotFilterSpecArg {
989 column,
990 visible_values,
991 })
992 })
993 .collect()
994}
995
996fn describe_pivot_config(
999 rows: &[PivotGroupSpec],
1000 columns: &[PivotGroupSpec],
1001 values: &[PivotValueSpec],
1002 filters: &[PivotFilterSpecArg],
1003 value_layout: Option<ValueLayout>,
1004 show_totals: bool,
1005) -> String {
1006 let describe_group = |g: &PivotGroupSpec| {
1007 let order = g.effective_sort_order().describe();
1008 if g.sort_order.is_some() {
1009 format!("col {} ({order})", g.column)
1010 } else {
1011 format!("col {} ({order}, default)", g.column)
1012 }
1013 };
1014 let rows_desc = if rows.is_empty() {
1015 "none".to_string()
1016 } else {
1017 rows.iter()
1018 .map(describe_group)
1019 .collect::<Vec<_>>()
1020 .join(", ")
1021 };
1022 let columns_desc = if columns.is_empty() {
1023 "none".to_string()
1024 } else {
1025 columns
1026 .iter()
1027 .map(describe_group)
1028 .collect::<Vec<_>>()
1029 .join(", ")
1030 };
1031 let values_desc = values
1032 .iter()
1033 .map(|v| format!("{} of col {}", v.function.as_sheets_str(), v.column))
1034 .collect::<Vec<_>>()
1035 .join(", ");
1036 let layout_desc = value_layout.map_or("HORIZONTAL (default)", ValueLayout::as_sheets_str);
1037 let totals_desc = if show_totals { "on" } else { "off" };
1038 let mut desc = format!(
1039 "rows: {rows_desc}; columns: {columns_desc}; values: {values_desc}; \
1040 layout: {layout_desc}; totals: {totals_desc}"
1041 );
1042 if !filters.is_empty() {
1043 let filters_desc = filters
1044 .iter()
1045 .map(|f| format!("col {} in [{}]", f.column, f.visible_values.join(", ")))
1046 .collect::<Vec<_>>()
1047 .join(", ");
1048 desc.push_str(&format!("; filters: {filters_desc}"));
1049 }
1050 desc
1051}
1052
1053#[must_use]
1056pub(crate) fn describe_pivot_table(pt: &PivotTable) -> String {
1057 format!(
1058 "{} row group(s), {} column group(s), {} value(s)",
1059 pt.rows.len(),
1060 pt.columns.len(),
1061 pt.values.len()
1062 )
1063}
1064
1065pub(crate) struct PivotListEntry {
1067 pub sheet: String,
1069 pub anchor: String,
1072 pub summary: String,
1074}
1075
1076pub(crate) fn list_entries(
1084 workbook: &crate::drive::sheets::types::Spreadsheet,
1085) -> Vec<PivotListEntry> {
1086 let mut entries = Vec::new();
1087 for sheet in &workbook.sheets {
1088 for chunk in &sheet.data {
1089 let row0 = chunk.start_row.unwrap_or(0);
1090 let col0 = chunk.start_column.unwrap_or(0);
1091 for (row_idx, row) in chunk.row_data.iter().enumerate() {
1092 for (col_idx, cell) in row.values.iter().enumerate() {
1093 let Some(pivot_table) = &cell.pivot_table else {
1094 continue;
1095 };
1096 let anchor = format!(
1097 "{}{}",
1098 grid_range::column_index_to_letters(col0 + col_idx as i64),
1099 row0 + row_idx as i64 + 1
1100 );
1101 entries.push(PivotListEntry {
1102 sheet: sheet.title().to_string(),
1103 anchor,
1104 summary: describe_pivot_table(pivot_table),
1105 });
1106 }
1107 }
1108 }
1109 }
1110 entries
1111}
1112
1113fn describe_anchor_currently(cell: Option<&CellSnapshot>) -> String {
1115 let Some(cell) = cell else {
1116 return "empty".to_string();
1117 };
1118 if let Some(pivot) = &cell.pivot_table {
1119 return format!("a pivot table ({})", describe_pivot_table(pivot));
1120 }
1121 match cell.formatted_value.as_deref() {
1122 Some(value) if !value.is_empty() => format!("value {value:?}"),
1123 _ => "empty".to_string(),
1124 }
1125}
1126
1127fn record_attempt(outcome: &PivotOutcome, opts: &PivotOptions, duration: Duration) {
1128 request_log::record_drive_mutation(mutation_outcome(outcome, opts, duration));
1129}
1130
1131fn mutation_outcome(
1134 outcome: &PivotOutcome,
1135 opts: &PivotOptions,
1136 duration: Duration,
1137) -> DriveMutationOutcome {
1138 let error = match &outcome.result {
1139 PivotResult::Failed { detail } | PivotResult::AppliedReplyUnreadable { detail, .. } => {
1140 Some(detail.clone())
1141 }
1142 _ => None,
1143 };
1144 let fields_changed = match &outcome.result {
1145 PivotResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
1146 _ => None,
1147 };
1148 let decided_by = match &outcome.result {
1149 PivotResult::Blocked { decided_by, .. } => decided_by.as_ref(),
1150 _ => None,
1151 };
1152 let decided_by = write_gate::decided_by_log_fields(decided_by);
1153
1154 DriveMutationOutcome {
1155 operation: opts.verb.log_operation(),
1156 file_id: outcome.spreadsheet_id.clone(),
1157 file_name: outcome.file_name.clone().unwrap_or_default(),
1158 status: outcome.result.log_status().to_string(),
1159 resolved_folder_id: outcome.resolved_folder_id.clone(),
1160 decided_by_folder_id: decided_by.folder_id,
1161 decided_by_depth: decided_by.depth,
1162 decided_by_file_id: decided_by.file_id,
1163 fields_changed,
1164 error,
1165 duration,
1166 ..Default::default()
1167 }
1168}
1169
1170fn pivot_action_summary(verb: &PivotVerb, change: &PivotChange) -> String {
1173 let action = match verb {
1174 PivotVerb::AddPivotTable { .. } => "add a pivot table",
1175 PivotVerb::DeletePivotTable { .. } => "delete the pivot table",
1176 };
1177 format!("{action} anchored at {}", change.anchor)
1178}
1179
1180fn pivot_change_lines(verb: &PivotVerb, change: &PivotChange, book: &str) -> Vec<String> {
1184 let mut lines = vec![format!("{} in {book}", pivot_action_summary(verb, change))];
1185 if let Some(source) = &change.source {
1186 lines.push(format!(" source: {source}"));
1187 }
1188 if let Some(config) = &change.config {
1189 lines.push(format!(" {config}"));
1190 }
1191 lines.push(format!(
1192 " anchor {} currently: {}",
1193 change.anchor, change.anchor_currently
1194 ));
1195 if change.source.is_some() {
1196 lines.push(
1197 " NOTE: the rendered extent is computed by the server from the source data and \
1198 is not known until the request is sent; cells right of and below the anchor may \
1199 be overwritten."
1200 .to_string(),
1201 );
1202 }
1203 lines
1204}
1205
1206#[must_use]
1208pub fn describe(outcome: &PivotOutcome) -> String {
1209 describe_lines(outcome).join("\n")
1210}
1211
1212#[must_use]
1215pub fn describe_lines(outcome: &PivotOutcome) -> Vec<String> {
1216 let verb = &outcome.verb;
1217 let book = outcome.file_name.as_deref().map_or_else(
1218 || format!("'{}'", outcome.spreadsheet_id),
1219 |n| format!("'{n}'"),
1220 );
1221 match &outcome.result {
1222 PivotResult::WouldChange(change) => {
1223 let mut lines = pivot_change_lines(verb, change, &book);
1224 lines[0] = format!("Would {}", lines[0]);
1225 lines
1226 }
1227 PivotResult::Changed(change) => {
1228 let mut lines = pivot_change_lines(verb, change, &book);
1229 lines[0] = format!("Applied: {}", lines[0]);
1230 lines
1231 }
1232 PivotResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1233 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); `drive sheets {}` \
1234 only works on spreadsheets",
1235 verb.label()
1236 )],
1237 PivotResult::RefusedShortcut => vec![format!(
1238 "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
1239 verb.label()
1240 )],
1241 PivotResult::RefusedNoVisibleParents => {
1242 let ops = match verb {
1243 PivotVerb::AddPivotTable { .. } => "\"sheets-write\", \"sheets-structure\"",
1244 PivotVerb::DeletePivotTable { .. } => "\"sheets-write\"",
1245 };
1246 vec![format!(
1247 "Refused: {book} has no parent folder visible to this account, so no folder \
1248 rule can apply to it. Grant it by id instead: add {{\"file_id\": \
1249 \"<spreadsheet id>\", \"allow\": [{ops}]}} to write_permissions.rules."
1250 )]
1251 }
1252 PivotResult::RefusedSheetNotFound { title, available } => {
1253 vec![grid_range::no_sheet_refusal(&book, title, available)]
1254 }
1255 PivotResult::RefusedInvalidRange { detail }
1256 | PivotResult::RefusedInvalidAnchor { detail }
1257 | PivotResult::RefusedInvalidSource { detail } => vec![format!("Refused: {detail}")],
1258 PivotResult::RefusedAnchorHasPivotTable {
1259 anchor,
1260 existing_summary,
1261 } => vec![format!(
1262 "Refused: {book}'s anchor {anchor} already holds a pivot table \
1263 ({existing_summary}); delete it first with `drive sheets delete-pivot-table`, or \
1264 choose a different anchor"
1265 )],
1266 PivotResult::RefusedNoPivotTableAtAnchor { anchor } => vec![format!(
1267 "Refused: {book} has no pivot table anchored at {anchor}. Run `drive sheets \
1268 list-pivot-tables` to see existing ones."
1269 )],
1270 PivotResult::Blocked {
1271 operation,
1272 decided_by,
1273 } => vec![match decided_by {
1274 Some(rule) => format!(
1275 "Blocked: {} on {book} refused by rule on {} {}{}",
1276 verb.label(),
1277 rule.kind_label(),
1278 rule.id(),
1279 rule.depth_suffix()
1280 ),
1281 None => format!(
1282 "Blocked: {} on {book} refused by default policy (no matching rule for \
1283 {operation})",
1284 verb.label()
1285 ),
1286 }],
1287 PivotResult::RefusedNoLease => LeaseGateRefusal::NoLease
1288 .describe_line(&outcome.spreadsheet_id, &book)
1289 .into_iter()
1290 .collect(),
1291 PivotResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1292 .describe_line(&outcome.spreadsheet_id, &book)
1293 .into_iter()
1294 .collect(),
1295 PivotResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1296 .describe_line(&outcome.spreadsheet_id, &book)
1297 .into_iter()
1298 .collect(),
1299 PivotResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1300 .describe_line(&outcome.spreadsheet_id, &book)
1301 .into_iter()
1302 .collect(),
1303 PivotResult::AppliedReplyUnreadable {
1304 summary,
1305 detail,
1306 change,
1307 } => {
1308 let mut lines = pivot_change_lines(verb, change, &book);
1311 lines[0] =
1312 applied_reply_unreadable_line(summary, &book, RetryHint::NotIdempotent, detail);
1313 lines
1314 }
1315 PivotResult::Failed { detail } => vec![format!("Failed: {detail}")],
1316 }
1317}
1318
1319#[cfg(test)]
1320#[allow(clippy::unwrap_used, clippy::expect_used)]
1321mod tests {
1322 use super::*;
1323 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1324 use crate::drive::sheets::client::SHEETS_API_URL;
1325 use crate::drive::sheets::types::GridRange;
1326 use crate::drive::test_support::seed_lease;
1327 use crate::test_support::env::MapEnv;
1328 use crate::utils::secret::Secret;
1329 use std::collections::HashSet;
1330
1331 #[test]
1334 fn parse_group_specs_accepts_a_bare_column_or_with_sort_order() {
1335 let specs = parse_group_specs(&["0".to_string(), "2:desc".to_string()]).unwrap();
1336 assert_eq!(
1337 specs,
1338 vec![
1339 PivotGroupSpec {
1340 column: 0,
1341 sort_order: None
1342 },
1343 PivotGroupSpec {
1344 column: 2,
1345 sort_order: Some(SortOrder::Descending)
1346 },
1347 ]
1348 );
1349 }
1350
1351 #[test]
1352 fn parse_group_specs_rejects_an_unknown_direction() {
1353 let err = parse_group_specs(&["0:sideways".to_string()]).unwrap_err();
1354 assert!(err.contains("'sideways'"), "{err}");
1355 }
1356
1357 #[test]
1358 fn parse_group_specs_rejects_a_non_numeric_column() {
1359 let err = parse_group_specs(&["x:asc".to_string()]).unwrap_err();
1360 assert!(err.contains("column offset"), "{err}");
1361 }
1362
1363 #[test]
1364 fn parse_value_specs_parses_column_and_function() {
1365 let specs = parse_value_specs(&["3:sum".to_string(), "1:AVERAGE".to_string()]).unwrap();
1366 assert_eq!(
1367 specs,
1368 vec![
1369 PivotValueSpec {
1370 column: 3,
1371 function: SummarizeFunction::Sum
1372 },
1373 PivotValueSpec {
1374 column: 1,
1375 function: SummarizeFunction::Average
1376 },
1377 ]
1378 );
1379 }
1380
1381 #[test]
1382 fn parse_value_specs_rejects_a_missing_colon() {
1383 let err = parse_value_specs(&["3sum".to_string()]).unwrap_err();
1384 assert!(err.contains("COLUMN:FUNC"), "{err}");
1385 }
1386
1387 #[test]
1388 fn parse_value_specs_rejects_an_unknown_function() {
1389 let err = parse_value_specs(&["3:bogus".to_string()]).unwrap_err();
1390 assert!(err.contains("'bogus'"), "{err}");
1391 }
1392
1393 #[test]
1394 fn parse_filter_specs_splits_values_on_commas() {
1395 let specs = parse_filter_specs(&["1:Foo,Bar".to_string()]).unwrap();
1396 assert_eq!(
1397 specs,
1398 vec![PivotFilterSpecArg {
1399 column: 1,
1400 visible_values: vec!["Foo".to_string(), "Bar".to_string()],
1401 }]
1402 );
1403 }
1404
1405 #[test]
1406 fn parse_filter_specs_rejects_an_empty_value_list() {
1407 let err = parse_filter_specs(&["1:".to_string()]).unwrap_err();
1408 assert!(err.contains("names no values"), "{err}");
1409 }
1410
1411 #[test]
1412 fn parse_filter_specs_rejects_a_missing_colon() {
1413 let err = parse_filter_specs(&["1".to_string()]).unwrap_err();
1414 assert!(err.contains("COLUMN:VALUE"), "{err}");
1415 }
1416
1417 #[test]
1420 fn pivot_group_spec_to_wire_maps_sort_order_and_totals() {
1421 let asc = PivotGroupSpec {
1422 column: 0,
1423 sort_order: Some(SortOrder::Ascending),
1424 }
1425 .to_wire(true);
1426 assert_eq!(asc.sort_order.as_deref(), Some("ASCENDING"));
1427 assert!(asc.show_totals);
1428
1429 let desc = PivotGroupSpec {
1430 column: 1,
1431 sort_order: Some(SortOrder::Descending),
1432 }
1433 .to_wire(false);
1434 assert_eq!(desc.sort_order.as_deref(), Some("DESCENDING"));
1435 assert!(!desc.show_totals);
1436
1437 let none = PivotGroupSpec {
1440 column: 2,
1441 sort_order: None,
1442 }
1443 .to_wire(true);
1444 assert_eq!(none.sort_order.as_deref(), Some("ASCENDING"));
1445 let json = serde_json::to_value(&none).unwrap();
1446 assert_eq!(json["sortOrder"], "ASCENDING", "{json}");
1447 }
1448
1449 #[test]
1450 fn summarize_function_as_sheets_str_covers_every_variant() {
1451 let cases = [
1452 (SummarizeFunction::Sum, "SUM"),
1453 (SummarizeFunction::Counta, "COUNTA"),
1454 (SummarizeFunction::Count, "COUNT"),
1455 (SummarizeFunction::CountUnique, "COUNTUNIQUE"),
1456 (SummarizeFunction::Average, "AVERAGE"),
1457 (SummarizeFunction::Max, "MAX"),
1458 (SummarizeFunction::Min, "MIN"),
1459 (SummarizeFunction::Median, "MEDIAN"),
1460 (SummarizeFunction::Product, "PRODUCT"),
1461 (SummarizeFunction::Stdev, "STDEV"),
1462 (SummarizeFunction::Stdevp, "STDEVP"),
1463 (SummarizeFunction::Var, "VAR"),
1464 (SummarizeFunction::Varp, "VARP"),
1465 ];
1466 for (func, expected) in cases {
1467 assert_eq!(func.as_sheets_str(), expected);
1468 }
1469 }
1470
1471 #[test]
1472 fn pivot_filter_spec_arg_to_wire_maps_fields() {
1473 let spec = PivotFilterSpecArg {
1474 column: 2,
1475 visible_values: vec!["Open".to_string(), "Closed".to_string()],
1476 };
1477 let wire = spec.to_wire();
1478 assert_eq!(wire.column_offset_index, 2);
1479 assert_eq!(
1480 wire.filter_criteria.visible_values,
1481 vec!["Open".to_string(), "Closed".to_string()]
1482 );
1483 }
1484
1485 #[test]
1488 fn describe_pivot_config_names_every_axis() {
1489 let desc = describe_pivot_config(
1490 &[PivotGroupSpec {
1491 column: 0,
1492 sort_order: Some(SortOrder::Ascending),
1493 }],
1494 &[],
1495 &[PivotValueSpec {
1496 column: 3,
1497 function: SummarizeFunction::Sum,
1498 }],
1499 &[],
1500 None,
1501 true,
1502 );
1503 assert!(desc.contains("rows: col 0 (asc)"), "{desc}");
1504 assert!(desc.contains("columns: none"), "{desc}");
1505 assert!(desc.contains("values: SUM of col 3"), "{desc}");
1506 assert!(desc.contains("layout: HORIZONTAL (default)"), "{desc}");
1507 assert!(desc.contains("totals: on"), "{desc}");
1508 }
1509
1510 #[test]
1511 fn describe_pivot_config_appends_filters_only_when_present() {
1512 let without = describe_pivot_config(&[], &[], &[], &[], None, false);
1513 assert!(!without.contains("filters:"), "{without}");
1514
1515 let with = describe_pivot_config(
1516 &[],
1517 &[],
1518 &[],
1519 &[PivotFilterSpecArg {
1520 column: 1,
1521 visible_values: vec!["Foo".to_string()],
1522 }],
1523 Some(ValueLayout::Vertical),
1524 false,
1525 );
1526 assert!(with.contains("filters: col 1 in [Foo]"), "{with}");
1527 assert!(with.contains("layout: VERTICAL"), "{with}");
1528 assert!(with.contains("totals: off"), "{with}");
1529 }
1530
1531 #[test]
1532 fn describe_pivot_config_covers_every_sort_order_and_an_explicit_horizontal_layout() {
1533 let desc = describe_pivot_config(
1534 &[
1535 PivotGroupSpec {
1536 column: 0,
1537 sort_order: None,
1538 },
1539 PivotGroupSpec {
1540 column: 1,
1541 sort_order: Some(SortOrder::Descending),
1542 },
1543 ],
1544 &[PivotGroupSpec {
1545 column: 2,
1546 sort_order: None,
1547 }],
1548 &[PivotValueSpec {
1549 column: 3,
1550 function: SummarizeFunction::Sum,
1551 }],
1552 &[],
1553 Some(ValueLayout::Horizontal),
1554 true,
1555 );
1556 assert!(
1559 desc.contains("rows: col 0 (asc, default), col 1 (desc)"),
1560 "{desc}"
1561 );
1562 assert!(desc.contains("columns: col 2 (asc, default)"), "{desc}");
1563 assert!(desc.contains("layout: HORIZONTAL"), "{desc}");
1566 assert!(!desc.contains("HORIZONTAL (default)"), "{desc}");
1567 }
1568
1569 #[test]
1570 fn describe_anchor_currently_covers_empty_value_and_pivot() {
1571 assert_eq!(describe_anchor_currently(None), "empty");
1572
1573 let value_cell = CellSnapshot {
1574 pivot_table: None,
1575 formatted_value: Some("Q2 totals".to_string()),
1576 ..Default::default()
1577 };
1578 assert_eq!(
1579 describe_anchor_currently(Some(&value_cell)),
1580 "value \"Q2 totals\""
1581 );
1582
1583 let pivot_cell = CellSnapshot {
1584 pivot_table: Some(PivotTable {
1585 source: GridRange::default(),
1586 values: vec![PivotValue::default()],
1587 ..Default::default()
1588 }),
1589 formatted_value: None,
1590 ..Default::default()
1591 };
1592 let pivot_desc = describe_anchor_currently(Some(&pivot_cell));
1593 assert!(pivot_desc.contains("a pivot table"), "{pivot_desc}");
1594
1595 let blank_cell = CellSnapshot {
1598 pivot_table: None,
1599 formatted_value: None,
1600 ..Default::default()
1601 };
1602 assert_eq!(describe_anchor_currently(Some(&blank_cell)), "empty");
1603 let empty_string_cell = CellSnapshot {
1604 pivot_table: None,
1605 formatted_value: Some(String::new()),
1606 ..Default::default()
1607 };
1608 assert_eq!(describe_anchor_currently(Some(&empty_string_cell)), "empty");
1609 }
1610
1611 #[test]
1614 fn pivot_verb_label_names_each_verb() {
1615 let add = PivotVerb::AddPivotTable {
1616 sheet: String::new(),
1617 anchor: String::new(),
1618 source: String::new(),
1619 rows: vec![],
1620 columns: vec![],
1621 values: vec![],
1622 filters: vec![],
1623 value_layout: None,
1624 show_totals: true,
1625 };
1626 assert_eq!(add.label(), "add-pivot-table");
1627 let delete = PivotVerb::DeletePivotTable {
1628 sheet: String::new(),
1629 anchor: String::new(),
1630 };
1631 assert_eq!(delete.label(), "delete-pivot-table");
1632 }
1633
1634 #[test]
1635 fn list_entries_computes_the_real_anchor_from_chunk_offsets() {
1636 let workbook: crate::drive::sheets::types::Spreadsheet =
1637 serde_json::from_value(serde_json::json!({
1638 "sheets": [{
1639 "properties": {"sheetId": 0, "title": "Report"},
1640 "data": [{
1641 "startRow": 2, "startColumn": 1,
1642 "rowData": [
1643 {"values": [{}, {"pivotTable": existing_pivot_json()}]},
1644 ],
1645 }],
1646 }],
1647 }))
1648 .unwrap();
1649 let entries = list_entries(&workbook);
1650 assert_eq!(entries.len(), 1);
1651 assert_eq!(entries[0].sheet, "Report");
1652 assert_eq!(entries[0].anchor, "C3");
1655 let summary = &entries[0].summary;
1656 assert!(summary.contains("value(s)"), "{summary}");
1657 }
1658
1659 #[test]
1660 fn pivot_outcome_write_jsonl_emits_the_result_status() {
1661 let outcome = outcome_with(add_verb(), Some("Budget"), PivotResult::RefusedShortcut);
1662 let mut buf = Vec::new();
1663 outcome.write_jsonl(&mut buf).unwrap();
1664 let text = String::from_utf8(buf).unwrap();
1665 assert!(text.contains("refused-shortcut"), "{text}");
1666 }
1667
1668 #[test]
1669 fn log_operations_are_distinct_and_kebab_cased() {
1670 let ops: HashSet<&str> = [
1671 PivotVerb::AddPivotTable {
1672 sheet: String::new(),
1673 anchor: String::new(),
1674 source: String::new(),
1675 rows: vec![],
1676 columns: vec![],
1677 values: vec![],
1678 filters: vec![],
1679 value_layout: None,
1680 show_totals: true,
1681 }
1682 .log_operation(),
1683 PivotVerb::DeletePivotTable {
1684 sheet: String::new(),
1685 anchor: String::new(),
1686 }
1687 .log_operation(),
1688 ]
1689 .into_iter()
1690 .collect();
1691 assert_eq!(ops.len(), 2);
1692 for op in ops {
1693 assert!(op.starts_with("sheets-"), "{op}");
1694 assert!(!op.contains('_'), "{op}");
1695 }
1696 }
1697
1698 #[test]
1699 fn gate_operations_put_sheets_write_first_for_add() {
1700 let verb = PivotVerb::AddPivotTable {
1701 sheet: String::new(),
1702 anchor: String::new(),
1703 source: String::new(),
1704 rows: vec![],
1705 columns: vec![],
1706 values: vec![],
1707 filters: vec![],
1708 value_layout: None,
1709 show_totals: true,
1710 };
1711 assert_eq!(
1712 verb.gate_operations(),
1713 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure]
1714 );
1715 let delete = PivotVerb::DeletePivotTable {
1716 sheet: String::new(),
1717 anchor: String::new(),
1718 };
1719 assert_eq!(delete.gate_operations(), &[DriveOperation::SheetsWrite]);
1720 }
1721
1722 fn test_credentials() -> DriveCredentials {
1725 DriveCredentials {
1726 client_id: "client-1".to_string(),
1727 client_secret: Secret::new("secret-1"),
1728 refresh_token: Secret::new("refresh-1"),
1729 scope: DriveGrantedScopes::READONLY,
1730 }
1731 }
1732
1733 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1734 wiremock::Mock::given(wiremock::matchers::method("POST"))
1735 .and(wiremock::matchers::path("/token"))
1736 .respond_with(
1737 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1738 "access_token": "test-token", "expires_in": 3600,
1739 })),
1740 )
1741 .mount(server)
1742 .await;
1743 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1744 crate::drive::client::test_support::replace_session(
1745 &mut drive,
1746 &test_credentials(),
1747 &format!("{}/token", server.uri()),
1748 );
1749 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1750 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1751 (drive, sheets)
1752 }
1753
1754 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1755 let parents: Vec<&str> = parents.to_vec();
1756 wiremock::Mock::given(wiremock::matchers::method("GET"))
1757 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1758 .respond_with(
1759 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1760 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1761 "version": "1",
1762 })),
1763 )
1764 }
1765
1766 fn mount_folder(id: &str) -> wiremock::Mock {
1767 mount_file(id, "application/vnd.google-apps.folder", &[])
1768 }
1769
1770 fn allow_rule(folder: &str, ops: &[DriveOperation]) -> FolderPermissionRule {
1771 FolderPermissionRule {
1772 folder_id: Some(folder.to_string()),
1773 file_id: None,
1774 recursive: true,
1775 allow: ops.iter().copied().collect(),
1776 deny: HashSet::default(),
1777 require_lease: true,
1778 }
1779 }
1780
1781 fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
1782 let ledger_path = tempfile::tempdir()
1783 .unwrap()
1784 .keep()
1785 .join("lease-ledger.jsonl");
1786 let token = seed_lease(&ledger_path, spreadsheet_id, "1");
1787 (Some(token), ledger_path)
1788 }
1789
1790 fn mount_workbook(anchor_pivot: Option<serde_json::Value>) -> wiremock::Mock {
1794 let report_data = anchor_pivot.map(|pivot| {
1795 serde_json::json!([{
1796 "startRow": 0, "startColumn": 0,
1797 "rowData": [{"values": [{"pivotTable": pivot}]}],
1798 }])
1799 });
1800 let mut report_sheet = serde_json::json!({
1801 "properties": {"sheetId": 0, "title": "Report", "index": 0},
1802 });
1803 if let Some(data) = report_data {
1804 report_sheet["data"] = data;
1805 }
1806 wiremock::Mock::given(wiremock::matchers::method("GET"))
1807 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1808 .respond_with(
1809 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1810 "spreadsheetId": "sheet-1",
1811 "properties": {"title": "Budget"},
1812 "sheets": [
1813 report_sheet,
1814 {"properties": {"sheetId": 1, "title": "Data", "index": 1}},
1815 ],
1816 })),
1817 )
1818 }
1819
1820 fn add_verb() -> PivotVerb {
1821 PivotVerb::AddPivotTable {
1822 sheet: "Report".to_string(),
1823 anchor: "A1".to_string(),
1824 source: "Data!A1:D100".to_string(),
1825 rows: vec!["0:asc".to_string()],
1826 columns: vec![],
1827 values: vec!["3:sum".to_string()],
1828 filters: vec![],
1829 value_layout: None,
1830 show_totals: true,
1831 }
1832 }
1833
1834 fn delete_verb() -> PivotVerb {
1835 PivotVerb::DeletePivotTable {
1836 sheet: "Report".to_string(),
1837 anchor: "A1".to_string(),
1838 }
1839 }
1840
1841 fn existing_pivot_json() -> serde_json::Value {
1842 serde_json::json!({
1843 "source": {
1844 "sheetId": 1, "startRowIndex": 0, "endRowIndex": 100,
1845 "startColumnIndex": 0, "endColumnIndex": 4,
1846 },
1847 "values": [{"sourceColumnOffset": 2, "summarizeFunction": "COUNTA"}],
1848 })
1849 }
1850
1851 #[tokio::test]
1852 async fn add_denied_when_only_sheets_structure_is_granted_names_sheets_write() {
1853 let server = wiremock::MockServer::start().await;
1854 let (drive, sheets) = clients(&server).await;
1855 mount_file(
1856 "sheet-1",
1857 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1858 &["folder-1"],
1859 )
1860 .mount(&server)
1861 .await;
1862 mount_folder("folder-1").mount(&server).await;
1863 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsStructure])];
1864 let opts = PivotOptions {
1865 spreadsheet_id: "sheet-1".to_string(),
1866 verb: add_verb(),
1867 dry_run: false,
1868 lease_token: None,
1869 ledger_path: std::path::PathBuf::new(),
1870 };
1871 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
1872 match outcome.result {
1873 PivotResult::Blocked { operation, .. } => {
1874 assert_eq!(operation, DriveOperation::SheetsWrite);
1875 }
1876 other => panic!("expected Blocked naming sheets-write, got {other:?}"),
1877 }
1878 }
1882
1883 #[tokio::test]
1884 async fn delete_allowed_by_sheets_write_alone() {
1885 let server = wiremock::MockServer::start().await;
1886 let (drive, sheets) = clients(&server).await;
1887 mount_file(
1888 "sheet-1",
1889 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1890 &["folder-1"],
1891 )
1892 .mount(&server)
1893 .await;
1894 mount_folder("folder-1").mount(&server).await;
1895 mount_workbook(Some(existing_pivot_json()))
1896 .mount(&server)
1897 .await;
1898 wiremock::Mock::given(wiremock::matchers::method("POST"))
1899 .and(wiremock::matchers::path(
1900 "/v4/spreadsheets/sheet-1:batchUpdate",
1901 ))
1902 .respond_with(
1903 wiremock::ResponseTemplate::new(200)
1904 .set_body_json(serde_json::json!({"replies": [{}]})),
1905 )
1906 .mount(&server)
1907 .await;
1908 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
1911 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1912 let opts = PivotOptions {
1913 spreadsheet_id: "sheet-1".to_string(),
1914 verb: delete_verb(),
1915 dry_run: false,
1916 lease_token,
1917 ledger_path,
1918 };
1919 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
1920 assert!(
1921 matches!(outcome.result, PivotResult::Changed(_)),
1922 "{:?}",
1923 outcome.result
1924 );
1925 }
1926
1927 #[tokio::test]
1928 async fn add_dry_run_reports_the_extent_note_and_calls_no_batch_update() {
1929 let server = wiremock::MockServer::start().await;
1930 let (drive, sheets) = clients(&server).await;
1931 mount_file(
1932 "sheet-1",
1933 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1934 &["folder-1"],
1935 )
1936 .mount(&server)
1937 .await;
1938 mount_folder("folder-1").mount(&server).await;
1939 mount_workbook(None).mount(&server).await;
1940 let rules = vec![allow_rule(
1941 "folder-1",
1942 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
1943 )];
1944 let opts = PivotOptions {
1945 spreadsheet_id: "sheet-1".to_string(),
1946 verb: add_verb(),
1947 dry_run: true,
1948 lease_token: None,
1949 ledger_path: std::path::PathBuf::new(),
1950 };
1951 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
1952 let PivotResult::WouldChange(change) = &outcome.result else {
1953 panic!("expected WouldChange, got {:?}", outcome.result); };
1955 assert_eq!(change.anchor_currently, "empty");
1956 let rendered = describe(&outcome);
1957 assert!(rendered.contains("computed by the server"), "{rendered}");
1958 assert!(rendered.contains("Data!A1:D100"), "{rendered}");
1959
1960 assert!(!server
1961 .received_requests()
1962 .await
1963 .unwrap()
1964 .iter()
1965 .any(|r| r.url.path().ends_with(":batchUpdate")));
1966 }
1967
1968 #[tokio::test]
1969 async fn add_sends_updatecells_with_no_uservalue_and_the_pivot_field_mask() {
1970 let server = wiremock::MockServer::start().await;
1971 let (drive, sheets) = clients(&server).await;
1972 mount_file(
1973 "sheet-1",
1974 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1975 &["folder-1"],
1976 )
1977 .mount(&server)
1978 .await;
1979 mount_folder("folder-1").mount(&server).await;
1980 mount_workbook(None).mount(&server).await;
1981 wiremock::Mock::given(wiremock::matchers::method("POST"))
1982 .and(wiremock::matchers::path(
1983 "/v4/spreadsheets/sheet-1:batchUpdate",
1984 ))
1985 .respond_with(
1986 wiremock::ResponseTemplate::new(200)
1987 .set_body_json(serde_json::json!({"replies": [{}]})),
1988 )
1989 .mount(&server)
1990 .await;
1991 let rules = vec![allow_rule(
1992 "folder-1",
1993 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
1994 )];
1995 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1996 let opts = PivotOptions {
1997 spreadsheet_id: "sheet-1".to_string(),
1998 verb: add_verb(),
1999 dry_run: false,
2000 lease_token,
2001 ledger_path,
2002 };
2003 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2004 assert!(
2005 matches!(outcome.result, PivotResult::Changed(_)),
2006 "{:?}",
2007 outcome.result
2008 );
2009
2010 let requests = server.received_requests().await.unwrap();
2011 let batch = requests
2012 .iter()
2013 .find(|r| r.url.path().ends_with(":batchUpdate"))
2014 .unwrap();
2015 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
2016 let update = &body["requests"][0]["updateCells"];
2017 assert_eq!(update["fields"], "pivotTable");
2018 assert_eq!(update["start"]["sheetId"], 0);
2019 assert_eq!(update["start"]["rowIndex"], 0);
2020 assert_eq!(update["start"]["columnIndex"], 0);
2021 let cell = &update["rows"][0]["values"][0];
2022 assert!(
2023 cell.get("userEnteredValue").is_none(),
2024 "updateCells must never carry a value: {cell}"
2025 );
2026 let pivot = &cell["pivotTable"];
2027 assert_eq!(pivot["source"]["sheetId"], 1);
2028 assert_eq!(pivot["rows"][0]["sourceColumnOffset"], 0);
2029 assert_eq!(pivot["rows"][0]["sortOrder"], "ASCENDING");
2030 assert_eq!(pivot["values"][0]["sourceColumnOffset"], 3);
2031 assert_eq!(pivot["values"][0]["summarizeFunction"], "SUM");
2032 }
2033
2034 #[tokio::test]
2035 async fn delete_sends_an_empty_cell_to_clear_the_pivot() {
2036 let server = wiremock::MockServer::start().await;
2037 let (drive, sheets) = clients(&server).await;
2038 mount_file(
2039 "sheet-1",
2040 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2041 &["folder-1"],
2042 )
2043 .mount(&server)
2044 .await;
2045 mount_folder("folder-1").mount(&server).await;
2046 mount_workbook(Some(existing_pivot_json()))
2047 .mount(&server)
2048 .await;
2049 wiremock::Mock::given(wiremock::matchers::method("POST"))
2050 .and(wiremock::matchers::path(
2051 "/v4/spreadsheets/sheet-1:batchUpdate",
2052 ))
2053 .respond_with(
2054 wiremock::ResponseTemplate::new(200)
2055 .set_body_json(serde_json::json!({"replies": [{}]})),
2056 )
2057 .mount(&server)
2058 .await;
2059 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
2060 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2061 let opts = PivotOptions {
2062 spreadsheet_id: "sheet-1".to_string(),
2063 verb: delete_verb(),
2064 dry_run: false,
2065 lease_token,
2066 ledger_path,
2067 };
2068 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2069 assert!(
2070 matches!(outcome.result, PivotResult::Changed(_)),
2071 "{:?}",
2072 outcome.result
2073 );
2074
2075 let requests = server.received_requests().await.unwrap();
2076 let batch = requests
2077 .iter()
2078 .find(|r| r.url.path().ends_with(":batchUpdate"))
2079 .unwrap();
2080 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
2081 let update = &body["requests"][0]["updateCells"];
2082 assert_eq!(update["fields"], "pivotTable");
2083 assert_eq!(update["rows"][0]["values"][0], serde_json::json!({}));
2084 }
2085
2086 #[tokio::test]
2087 async fn add_refuses_an_anchor_that_already_holds_a_pivot_table() {
2088 let server = wiremock::MockServer::start().await;
2089 let (drive, sheets) = clients(&server).await;
2090 mount_file(
2091 "sheet-1",
2092 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2093 &["folder-1"],
2094 )
2095 .mount(&server)
2096 .await;
2097 mount_folder("folder-1").mount(&server).await;
2098 mount_workbook(Some(existing_pivot_json()))
2099 .mount(&server)
2100 .await;
2101 let rules = vec![allow_rule(
2102 "folder-1",
2103 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2104 )];
2105 let opts = PivotOptions {
2106 spreadsheet_id: "sheet-1".to_string(),
2107 verb: add_verb(),
2108 dry_run: true,
2109 lease_token: None,
2110 ledger_path: std::path::PathBuf::new(),
2111 };
2112 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2113 assert!(
2114 matches!(
2115 outcome.result,
2116 PivotResult::RefusedAnchorHasPivotTable { .. }
2117 ),
2118 "{:?}",
2119 outcome.result
2120 );
2121 }
2122
2123 #[tokio::test]
2124 async fn delete_refuses_an_anchor_with_no_pivot_table() {
2125 let server = wiremock::MockServer::start().await;
2126 let (drive, sheets) = clients(&server).await;
2127 mount_file(
2128 "sheet-1",
2129 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2130 &["folder-1"],
2131 )
2132 .mount(&server)
2133 .await;
2134 mount_folder("folder-1").mount(&server).await;
2135 mount_workbook(None).mount(&server).await;
2136 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
2137 let opts = PivotOptions {
2138 spreadsheet_id: "sheet-1".to_string(),
2139 verb: delete_verb(),
2140 dry_run: true,
2141 lease_token: None,
2142 ledger_path: std::path::PathBuf::new(),
2143 };
2144 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2145 assert!(
2146 matches!(
2147 outcome.result,
2148 PivotResult::RefusedNoPivotTableAtAnchor { .. }
2149 ),
2150 "{:?}",
2151 outcome.result
2152 );
2153 }
2154
2155 #[tokio::test]
2156 async fn add_refuses_a_column_offset_out_of_bounds_for_the_source() {
2157 let server = wiremock::MockServer::start().await;
2158 let (drive, sheets) = clients(&server).await;
2159 mount_file(
2160 "sheet-1",
2161 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2162 &["folder-1"],
2163 )
2164 .mount(&server)
2165 .await;
2166 mount_folder("folder-1").mount(&server).await;
2167 mount_workbook(None).mount(&server).await;
2168 let rules = vec![allow_rule(
2169 "folder-1",
2170 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2171 )];
2172 let verb = PivotVerb::AddPivotTable {
2173 sheet: "Report".to_string(),
2174 anchor: "A1".to_string(),
2175 source: "Data!A1:D100".to_string(),
2176 rows: vec![],
2177 columns: vec![],
2178 values: vec!["9:sum".to_string()],
2180 filters: vec![],
2181 value_layout: None,
2182 show_totals: true,
2183 };
2184 let opts = PivotOptions {
2185 spreadsheet_id: "sheet-1".to_string(),
2186 verb,
2187 dry_run: true,
2188 lease_token: None,
2189 ledger_path: std::path::PathBuf::new(),
2190 };
2191 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2192 match outcome.result {
2193 PivotResult::RefusedInvalidSource { detail } => {
2194 assert!(detail.contains("out of bounds"), "{detail}");
2195 }
2196 other => panic!("expected RefusedInvalidSource, got {other:?}"),
2197 }
2198 }
2199
2200 #[tokio::test]
2201 async fn add_refuses_with_no_values_given() {
2202 let server = wiremock::MockServer::start().await;
2203 let (drive, sheets) = clients(&server).await;
2204 mount_file(
2205 "sheet-1",
2206 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2207 &["folder-1"],
2208 )
2209 .mount(&server)
2210 .await;
2211 mount_folder("folder-1").mount(&server).await;
2212 mount_workbook(None).mount(&server).await;
2213 let rules = vec![allow_rule(
2214 "folder-1",
2215 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2216 )];
2217 let verb = PivotVerb::AddPivotTable {
2218 sheet: "Report".to_string(),
2219 anchor: "A1".to_string(),
2220 source: "Data!A1:D100".to_string(),
2221 rows: vec![],
2222 columns: vec![],
2223 values: vec![],
2224 filters: vec![],
2225 value_layout: None,
2226 show_totals: true,
2227 };
2228 let opts = PivotOptions {
2229 spreadsheet_id: "sheet-1".to_string(),
2230 verb,
2231 dry_run: true,
2232 lease_token: None,
2233 ledger_path: std::path::PathBuf::new(),
2234 };
2235 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2236 match outcome.result {
2237 PivotResult::RefusedInvalidSource { detail } => {
2238 assert!(detail.contains("at least one --value"), "{detail}");
2239 }
2240 other => panic!("expected RefusedInvalidSource, got {other:?}"),
2241 }
2242 }
2243
2244 #[tokio::test]
2245 async fn add_refuses_an_anchor_that_names_more_than_one_cell() {
2246 let server = wiremock::MockServer::start().await;
2247 let (drive, sheets) = clients(&server).await;
2248 mount_file(
2249 "sheet-1",
2250 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2251 &["folder-1"],
2252 )
2253 .mount(&server)
2254 .await;
2255 mount_folder("folder-1").mount(&server).await;
2256 mount_workbook(None).mount(&server).await;
2257 let rules = vec![allow_rule(
2258 "folder-1",
2259 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2260 )];
2261 let mut verb = add_verb();
2262 if let PivotVerb::AddPivotTable { anchor, .. } = &mut verb {
2263 *anchor = "A1:B2".to_string();
2264 }
2265 let opts = PivotOptions {
2266 spreadsheet_id: "sheet-1".to_string(),
2267 verb,
2268 dry_run: true,
2269 lease_token: None,
2270 ledger_path: std::path::PathBuf::new(),
2271 };
2272 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2273 assert!(
2274 matches!(outcome.result, PivotResult::RefusedInvalidAnchor { .. }),
2275 "{:?}",
2276 outcome.result
2277 );
2278 }
2279
2280 #[tokio::test]
2281 async fn add_refuses_when_sheet_and_anchor_conflict_on_the_sheet_name() {
2282 let server = wiremock::MockServer::start().await;
2283 let (drive, sheets) = clients(&server).await;
2284 let mut verb = add_verb();
2287 if let PivotVerb::AddPivotTable { anchor, .. } = &mut verb {
2288 *anchor = "Other!A1".to_string();
2289 }
2290 let opts = PivotOptions {
2291 spreadsheet_id: "sheet-1".to_string(),
2292 verb,
2293 dry_run: true,
2294 lease_token: None,
2295 ledger_path: std::path::PathBuf::new(),
2296 };
2297 let outcome = pivot(&drive, &sheets, &opts, &[]).await;
2298 assert!(
2299 matches!(outcome.result, PivotResult::RefusedInvalidRange { .. }),
2300 "{:?}",
2301 outcome.result
2302 );
2303 assert_eq!(outcome.file_name, None);
2304 }
2305
2306 #[tokio::test]
2307 async fn pivot_reports_failed_on_a_metadata_fetch_failure() {
2308 let server = wiremock::MockServer::start().await;
2309 let (drive, sheets) = clients(&server).await;
2310 wiremock::Mock::given(wiremock::matchers::method("GET"))
2311 .and(wiremock::matchers::path("/drive/v3/files/missing"))
2312 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
2313 .mount(&server)
2314 .await;
2315 let opts = PivotOptions {
2316 spreadsheet_id: "missing".to_string(),
2317 verb: add_verb(),
2318 dry_run: true,
2319 lease_token: None,
2320 ledger_path: std::path::PathBuf::new(),
2321 };
2322 let outcome = pivot(&drive, &sheets, &opts, &[]).await;
2323 assert!(
2324 matches!(outcome.result, PivotResult::Failed { .. }),
2325 "{:?}",
2326 outcome.result
2327 );
2328 }
2329
2330 #[tokio::test]
2331 async fn pivot_refuses_a_shortcut_target() {
2332 let server = wiremock::MockServer::start().await;
2333 let (drive, sheets) = clients(&server).await;
2334 mount_file(
2335 "sheet-1",
2336 "application/vnd.google-apps.shortcut",
2337 &["folder-1"],
2338 )
2339 .mount(&server)
2340 .await;
2341 let rules = vec![allow_rule(
2343 "folder-1",
2344 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2345 )];
2346 let opts = PivotOptions {
2347 spreadsheet_id: "sheet-1".to_string(),
2348 verb: add_verb(),
2349 dry_run: true,
2350 lease_token: None,
2351 ledger_path: std::path::PathBuf::new(),
2352 };
2353 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2354 assert!(
2355 matches!(outcome.result, PivotResult::RefusedShortcut),
2356 "{:?}",
2357 outcome.result
2358 );
2359 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2360 }
2361
2362 #[tokio::test]
2363 async fn pivot_refuses_a_non_spreadsheet_target() {
2364 let server = wiremock::MockServer::start().await;
2365 let (drive, sheets) = clients(&server).await;
2366 mount_file("sheet-1", "text/plain", &["folder-1"])
2367 .mount(&server)
2368 .await;
2369 let rules = vec![allow_rule(
2370 "folder-1",
2371 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2372 )];
2373 let opts = PivotOptions {
2374 spreadsheet_id: "sheet-1".to_string(),
2375 verb: add_verb(),
2376 dry_run: true,
2377 lease_token: None,
2378 ledger_path: std::path::PathBuf::new(),
2379 };
2380 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2381 assert!(
2382 matches!(outcome.result, PivotResult::RefusedNotASpreadsheet { .. }),
2383 "{:?}",
2384 outcome.result
2385 );
2386 }
2387
2388 #[tokio::test]
2389 async fn pivot_refuses_a_target_with_no_visible_parents() {
2390 let server = wiremock::MockServer::start().await;
2391 let (drive, sheets) = clients(&server).await;
2392 mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
2393 .mount(&server)
2394 .await;
2395 let opts = PivotOptions {
2396 spreadsheet_id: "sheet-1".to_string(),
2397 verb: add_verb(),
2398 dry_run: true,
2399 lease_token: None,
2400 ledger_path: std::path::PathBuf::new(),
2401 };
2402 let outcome = pivot(&drive, &sheets, &opts, &[]).await;
2403 assert!(
2404 matches!(outcome.result, PivotResult::RefusedNoVisibleParents),
2405 "{:?}",
2406 outcome.result
2407 );
2408 }
2409
2410 #[tokio::test]
2411 async fn pivot_reports_failed_on_a_gate_ancestor_fetch_failure() {
2412 let server = wiremock::MockServer::start().await;
2413 let (drive, sheets) = clients(&server).await;
2414 mount_file(
2415 "sheet-1",
2416 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2417 &["folder-1"],
2418 )
2419 .mount(&server)
2420 .await;
2421 wiremock::Mock::given(wiremock::matchers::method("GET"))
2422 .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
2423 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2424 .mount(&server)
2425 .await;
2426 let rules = vec![allow_rule(
2427 "folder-1",
2428 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2429 )];
2430 let opts = PivotOptions {
2431 spreadsheet_id: "sheet-1".to_string(),
2432 verb: add_verb(),
2433 dry_run: true,
2434 lease_token: None,
2435 ledger_path: std::path::PathBuf::new(),
2436 };
2437 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2438 assert!(
2439 matches!(outcome.result, PivotResult::Failed { .. }),
2440 "{:?}",
2441 outcome.result
2442 );
2443 }
2444
2445 #[tokio::test]
2446 async fn add_reports_failed_when_the_workbook_fetch_fails() {
2447 let server = wiremock::MockServer::start().await;
2448 let (drive, sheets) = clients(&server).await;
2449 mount_file(
2450 "sheet-1",
2451 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2452 &["folder-1"],
2453 )
2454 .mount(&server)
2455 .await;
2456 mount_folder("folder-1").mount(&server).await;
2457 wiremock::Mock::given(wiremock::matchers::method("GET"))
2458 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2459 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2460 .mount(&server)
2461 .await;
2462 let rules = vec![allow_rule(
2463 "folder-1",
2464 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2465 )];
2466 let opts = PivotOptions {
2467 spreadsheet_id: "sheet-1".to_string(),
2468 verb: add_verb(),
2469 dry_run: true,
2470 lease_token: None,
2471 ledger_path: std::path::PathBuf::new(),
2472 };
2473 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2474 assert!(
2475 matches!(outcome.result, PivotResult::Failed { .. }),
2476 "{:?}",
2477 outcome.result
2478 );
2479 }
2480
2481 #[tokio::test]
2482 async fn pivot_reports_failed_when_the_cell_pivot_fetch_fails() {
2483 let server = wiremock::MockServer::start().await;
2484 let (drive, sheets) = clients(&server).await;
2485 mount_file(
2486 "sheet-1",
2487 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2488 &["folder-1"],
2489 )
2490 .mount(&server)
2491 .await;
2492 mount_folder("folder-1").mount(&server).await;
2493 mount_workbook(None).mount(&server).await;
2494 wiremock::Mock::given(wiremock::matchers::method("GET"))
2502 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2503 .and(wiremock::matchers::query_param("ranges", "'Report'!A1"))
2504 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2505 .with_priority(1)
2506 .mount(&server)
2507 .await;
2508 let rules = vec![allow_rule(
2509 "folder-1",
2510 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2511 )];
2512 let opts = PivotOptions {
2513 spreadsheet_id: "sheet-1".to_string(),
2514 verb: add_verb(),
2515 dry_run: true,
2516 lease_token: None,
2517 ledger_path: std::path::PathBuf::new(),
2518 };
2519 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2520 assert!(
2521 matches!(outcome.result, PivotResult::Failed { .. }),
2522 "{:?}",
2523 outcome.result
2524 );
2525 }
2526
2527 #[tokio::test]
2528 async fn add_refuses_an_anchor_sheet_that_does_not_exist() {
2529 let server = wiremock::MockServer::start().await;
2530 let (drive, sheets) = clients(&server).await;
2531 mount_file(
2532 "sheet-1",
2533 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2534 &["folder-1"],
2535 )
2536 .mount(&server)
2537 .await;
2538 mount_folder("folder-1").mount(&server).await;
2539 mount_workbook(None).mount(&server).await;
2540 let rules = vec![allow_rule(
2541 "folder-1",
2542 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2543 )];
2544 let mut verb = add_verb();
2545 if let PivotVerb::AddPivotTable { sheet, .. } = &mut verb {
2546 *sheet = "Nope".to_string();
2547 }
2548 let opts = PivotOptions {
2549 spreadsheet_id: "sheet-1".to_string(),
2550 verb,
2551 dry_run: true,
2552 lease_token: None,
2553 ledger_path: std::path::PathBuf::new(),
2554 };
2555 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2556 assert!(
2557 matches!(outcome.result, PivotResult::RefusedSheetNotFound { .. }),
2558 "{:?}",
2559 outcome.result
2560 );
2561 }
2562
2563 #[tokio::test]
2564 async fn add_refuses_an_anchor_with_invalid_range_syntax() {
2565 let server = wiremock::MockServer::start().await;
2566 let (drive, sheets) = clients(&server).await;
2567 mount_file(
2568 "sheet-1",
2569 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2570 &["folder-1"],
2571 )
2572 .mount(&server)
2573 .await;
2574 mount_folder("folder-1").mount(&server).await;
2575 mount_workbook(None).mount(&server).await;
2576 let rules = vec![allow_rule(
2577 "folder-1",
2578 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2579 )];
2580 let mut verb = add_verb();
2581 if let PivotVerb::AddPivotTable { anchor, .. } = &mut verb {
2582 *anchor = "A0".to_string();
2585 }
2586 let opts = PivotOptions {
2587 spreadsheet_id: "sheet-1".to_string(),
2588 verb,
2589 dry_run: true,
2590 lease_token: None,
2591 ledger_path: std::path::PathBuf::new(),
2592 };
2593 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2594 assert!(
2595 matches!(outcome.result, PivotResult::RefusedInvalidRange { .. }),
2596 "{:?}",
2597 outcome.result
2598 );
2599 }
2600
2601 #[tokio::test]
2602 async fn add_refuses_an_invalid_source_composed_from_sheet_and_bare_range() {
2603 let server = wiremock::MockServer::start().await;
2604 let (drive, sheets) = clients(&server).await;
2605 mount_file(
2606 "sheet-1",
2607 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2608 &["folder-1"],
2609 )
2610 .mount(&server)
2611 .await;
2612 mount_folder("folder-1").mount(&server).await;
2613 mount_workbook(None).mount(&server).await;
2614 let rules = vec![allow_rule(
2615 "folder-1",
2616 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2617 )];
2618 let mut verb = add_verb();
2619 if let PivotVerb::AddPivotTable { source, .. } = &mut verb {
2620 *source = "A1\n:D100".to_string();
2625 }
2626 let opts = PivotOptions {
2627 spreadsheet_id: "sheet-1".to_string(),
2628 verb,
2629 dry_run: true,
2630 lease_token: None,
2631 ledger_path: std::path::PathBuf::new(),
2632 };
2633 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2634 assert!(
2635 matches!(outcome.result, PivotResult::RefusedInvalidRange { .. }),
2636 "{:?}",
2637 outcome.result
2638 );
2639 }
2640
2641 #[tokio::test]
2642 async fn add_refuses_a_source_sheet_that_does_not_exist() {
2643 let server = wiremock::MockServer::start().await;
2644 let (drive, sheets) = clients(&server).await;
2645 mount_file(
2646 "sheet-1",
2647 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2648 &["folder-1"],
2649 )
2650 .mount(&server)
2651 .await;
2652 mount_folder("folder-1").mount(&server).await;
2653 mount_workbook(None).mount(&server).await;
2654 let rules = vec![allow_rule(
2655 "folder-1",
2656 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2657 )];
2658 let mut verb = add_verb();
2659 if let PivotVerb::AddPivotTable { source, .. } = &mut verb {
2660 *source = "Nope!A1:D10".to_string();
2661 }
2662 let opts = PivotOptions {
2663 spreadsheet_id: "sheet-1".to_string(),
2664 verb,
2665 dry_run: true,
2666 lease_token: None,
2667 ledger_path: std::path::PathBuf::new(),
2668 };
2669 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2670 assert!(
2671 matches!(outcome.result, PivotResult::RefusedSheetNotFound { .. }),
2672 "{:?}",
2673 outcome.result
2674 );
2675 }
2676
2677 #[tokio::test]
2678 async fn add_refuses_a_source_with_invalid_range_syntax() {
2679 let server = wiremock::MockServer::start().await;
2680 let (drive, sheets) = clients(&server).await;
2681 mount_file(
2682 "sheet-1",
2683 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2684 &["folder-1"],
2685 )
2686 .mount(&server)
2687 .await;
2688 mount_folder("folder-1").mount(&server).await;
2689 mount_workbook(None).mount(&server).await;
2690 let rules = vec![allow_rule(
2691 "folder-1",
2692 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2693 )];
2694 let mut verb = add_verb();
2695 if let PivotVerb::AddPivotTable { source, .. } = &mut verb {
2696 *source = "Data!A0:D10".to_string();
2697 }
2698 let opts = PivotOptions {
2699 spreadsheet_id: "sheet-1".to_string(),
2700 verb,
2701 dry_run: true,
2702 lease_token: None,
2703 ledger_path: std::path::PathBuf::new(),
2704 };
2705 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2706 assert!(
2707 matches!(outcome.result, PivotResult::RefusedInvalidRange { .. }),
2708 "{:?}",
2709 outcome.result
2710 );
2711 }
2712
2713 #[tokio::test]
2714 async fn add_refuses_an_unbounded_source_range() {
2715 let server = wiremock::MockServer::start().await;
2716 let (drive, sheets) = clients(&server).await;
2717 mount_file(
2718 "sheet-1",
2719 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2720 &["folder-1"],
2721 )
2722 .mount(&server)
2723 .await;
2724 mount_folder("folder-1").mount(&server).await;
2725 mount_workbook(None).mount(&server).await;
2726 let rules = vec![allow_rule(
2727 "folder-1",
2728 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2729 )];
2730 let mut verb = add_verb();
2731 if let PivotVerb::AddPivotTable { source, .. } = &mut verb {
2732 *source = "Data!A:D".to_string();
2734 }
2735 let opts = PivotOptions {
2736 spreadsheet_id: "sheet-1".to_string(),
2737 verb,
2738 dry_run: true,
2739 lease_token: None,
2740 ledger_path: std::path::PathBuf::new(),
2741 };
2742 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2743 match outcome.result {
2744 PivotResult::RefusedInvalidSource { detail } => {
2745 assert!(detail.contains("bounded range"), "{detail}");
2746 }
2747 other => panic!("expected RefusedInvalidSource, got {other:?}"),
2748 }
2749 }
2750
2751 #[tokio::test]
2752 async fn add_refuses_an_unparseable_row_spec() {
2753 let server = wiremock::MockServer::start().await;
2754 let (drive, sheets) = clients(&server).await;
2755 mount_file(
2756 "sheet-1",
2757 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2758 &["folder-1"],
2759 )
2760 .mount(&server)
2761 .await;
2762 mount_folder("folder-1").mount(&server).await;
2763 mount_workbook(None).mount(&server).await;
2764 let rules = vec![allow_rule(
2765 "folder-1",
2766 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2767 )];
2768 let mut verb = add_verb();
2769 if let PivotVerb::AddPivotTable { rows, .. } = &mut verb {
2770 *rows = vec!["bad".to_string()];
2771 }
2772 let opts = PivotOptions {
2773 spreadsheet_id: "sheet-1".to_string(),
2774 verb,
2775 dry_run: true,
2776 lease_token: None,
2777 ledger_path: std::path::PathBuf::new(),
2778 };
2779 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2780 assert!(
2781 matches!(outcome.result, PivotResult::RefusedInvalidSource { .. }),
2782 "{:?}",
2783 outcome.result
2784 );
2785 }
2786
2787 #[tokio::test]
2788 async fn add_refuses_an_unparseable_column_spec() {
2789 let server = wiremock::MockServer::start().await;
2790 let (drive, sheets) = clients(&server).await;
2791 mount_file(
2792 "sheet-1",
2793 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2794 &["folder-1"],
2795 )
2796 .mount(&server)
2797 .await;
2798 mount_folder("folder-1").mount(&server).await;
2799 mount_workbook(None).mount(&server).await;
2800 let rules = vec![allow_rule(
2801 "folder-1",
2802 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2803 )];
2804 let mut verb = add_verb();
2805 if let PivotVerb::AddPivotTable { columns, .. } = &mut verb {
2806 *columns = vec!["bad".to_string()];
2807 }
2808 let opts = PivotOptions {
2809 spreadsheet_id: "sheet-1".to_string(),
2810 verb,
2811 dry_run: true,
2812 lease_token: None,
2813 ledger_path: std::path::PathBuf::new(),
2814 };
2815 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2816 assert!(
2817 matches!(outcome.result, PivotResult::RefusedInvalidSource { .. }),
2818 "{:?}",
2819 outcome.result
2820 );
2821 }
2822
2823 #[tokio::test]
2824 async fn add_refuses_an_unparseable_value_spec() {
2825 let server = wiremock::MockServer::start().await;
2826 let (drive, sheets) = clients(&server).await;
2827 mount_file(
2828 "sheet-1",
2829 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2830 &["folder-1"],
2831 )
2832 .mount(&server)
2833 .await;
2834 mount_folder("folder-1").mount(&server).await;
2835 mount_workbook(None).mount(&server).await;
2836 let rules = vec![allow_rule(
2837 "folder-1",
2838 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2839 )];
2840 let mut verb = add_verb();
2841 if let PivotVerb::AddPivotTable { values, .. } = &mut verb {
2842 *values = vec!["bad".to_string()];
2843 }
2844 let opts = PivotOptions {
2845 spreadsheet_id: "sheet-1".to_string(),
2846 verb,
2847 dry_run: true,
2848 lease_token: None,
2849 ledger_path: std::path::PathBuf::new(),
2850 };
2851 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2852 assert!(
2853 matches!(outcome.result, PivotResult::RefusedInvalidSource { .. }),
2854 "{:?}",
2855 outcome.result
2856 );
2857 }
2858
2859 #[tokio::test]
2860 async fn add_refuses_an_unparseable_filter_spec() {
2861 let server = wiremock::MockServer::start().await;
2862 let (drive, sheets) = clients(&server).await;
2863 mount_file(
2864 "sheet-1",
2865 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2866 &["folder-1"],
2867 )
2868 .mount(&server)
2869 .await;
2870 mount_folder("folder-1").mount(&server).await;
2871 mount_workbook(None).mount(&server).await;
2872 let rules = vec![allow_rule(
2873 "folder-1",
2874 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2875 )];
2876 let mut verb = add_verb();
2877 if let PivotVerb::AddPivotTable { filters, .. } = &mut verb {
2878 *filters = vec!["bad".to_string()];
2879 }
2880 let opts = PivotOptions {
2881 spreadsheet_id: "sheet-1".to_string(),
2882 verb,
2883 dry_run: true,
2884 lease_token: None,
2885 ledger_path: std::path::PathBuf::new(),
2886 };
2887 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2888 assert!(
2889 matches!(outcome.result, PivotResult::RefusedInvalidSource { .. }),
2890 "{:?}",
2891 outcome.result
2892 );
2893 }
2894
2895 #[tokio::test]
2896 async fn add_reports_failed_when_batch_update_fails() {
2897 let server = wiremock::MockServer::start().await;
2898 let (drive, sheets) = clients(&server).await;
2899 mount_file(
2900 "sheet-1",
2901 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2902 &["folder-1"],
2903 )
2904 .mount(&server)
2905 .await;
2906 mount_folder("folder-1").mount(&server).await;
2907 mount_workbook(None).mount(&server).await;
2908 wiremock::Mock::given(wiremock::matchers::method("POST"))
2909 .and(wiremock::matchers::path(
2910 "/v4/spreadsheets/sheet-1:batchUpdate",
2911 ))
2912 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2913 .mount(&server)
2914 .await;
2915 let rules = vec![allow_rule(
2916 "folder-1",
2917 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2918 )];
2919 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2920 let opts = PivotOptions {
2921 spreadsheet_id: "sheet-1".to_string(),
2922 verb: add_verb(),
2923 dry_run: false,
2924 lease_token,
2925 ledger_path,
2926 };
2927 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2928 assert!(
2929 matches!(outcome.result, PivotResult::Failed { .. }),
2930 "{:?}",
2931 outcome.result
2932 );
2933 }
2934
2935 #[tokio::test]
2936 async fn add_with_an_unreadable_reply_reports_applied_reply_unreadable() {
2937 let server = wiremock::MockServer::start().await;
2938 let (drive, sheets) = clients(&server).await;
2939 mount_file(
2940 "sheet-1",
2941 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2942 &["folder-1"],
2943 )
2944 .mount(&server)
2945 .await;
2946 mount_folder("folder-1").mount(&server).await;
2947 mount_workbook(None).mount(&server).await;
2948 wiremock::Mock::given(wiremock::matchers::method("POST"))
2950 .and(wiremock::matchers::path(
2951 "/v4/spreadsheets/sheet-1:batchUpdate",
2952 ))
2953 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2954 .expect(1)
2955 .mount(&server)
2956 .await;
2957 let rules = vec![allow_rule(
2958 "folder-1",
2959 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2960 )];
2961 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2962 let opts = PivotOptions {
2963 spreadsheet_id: "sheet-1".to_string(),
2964 verb: add_verb(),
2965 dry_run: false,
2966 lease_token,
2967 ledger_path,
2968 };
2969 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2970 assert!(
2971 matches!(outcome.result, PivotResult::AppliedReplyUnreadable { .. }),
2972 "{:?}",
2973 outcome.result
2974 );
2975 assert_eq!(outcome.result.log_status(), "applied-reply-unreadable");
2976 let text = describe(&outcome);
2977 assert!(
2978 text.starts_with("Applied, but the reply could not be read"),
2979 "{text}"
2980 );
2981 }
2982
2983 #[tokio::test]
2986 async fn add_refuses_without_a_lease_when_the_rule_requires_one() {
2987 let server = wiremock::MockServer::start().await;
2988 let (drive, sheets) = clients(&server).await;
2989 mount_file(
2990 "sheet-1",
2991 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2992 &["folder-1"],
2993 )
2994 .mount(&server)
2995 .await;
2996 mount_folder("folder-1").mount(&server).await;
2997 mount_workbook(None).mount(&server).await;
2998 let rules = vec![allow_rule(
2999 "folder-1",
3000 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
3001 )];
3002 let opts = PivotOptions {
3003 spreadsheet_id: "sheet-1".to_string(),
3004 verb: add_verb(),
3005 dry_run: false,
3006 lease_token: None,
3007 ledger_path: std::path::PathBuf::new(),
3008 };
3009 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
3010 assert!(matches!(outcome.result, PivotResult::RefusedNoLease));
3011 }
3012
3013 #[tokio::test]
3014 async fn add_refuses_an_unknown_lease_token() {
3015 let server = wiremock::MockServer::start().await;
3016 let (drive, sheets) = clients(&server).await;
3017 mount_file(
3018 "sheet-1",
3019 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3020 &["folder-1"],
3021 )
3022 .mount(&server)
3023 .await;
3024 mount_folder("folder-1").mount(&server).await;
3025 mount_workbook(None).mount(&server).await;
3026 let rules = vec![allow_rule(
3027 "folder-1",
3028 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
3029 )];
3030 let ledger_path = tempfile::tempdir()
3031 .unwrap()
3032 .keep()
3033 .join("lease-ledger.jsonl");
3034 let opts = PivotOptions {
3035 spreadsheet_id: "sheet-1".to_string(),
3036 verb: add_verb(),
3037 dry_run: false,
3038 lease_token: Some("bogus-token".to_string()),
3039 ledger_path,
3040 };
3041 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
3042 assert!(matches!(outcome.result, PivotResult::RefusedLeaseExpired));
3043 }
3044
3045 #[tokio::test]
3046 async fn add_refuses_a_lease_bound_to_a_different_file() {
3047 let server = wiremock::MockServer::start().await;
3048 let (drive, sheets) = clients(&server).await;
3049 mount_file(
3050 "sheet-1",
3051 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3052 &["folder-1"],
3053 )
3054 .mount(&server)
3055 .await;
3056 mount_folder("folder-1").mount(&server).await;
3057 mount_workbook(None).mount(&server).await;
3058 let rules = vec![allow_rule(
3059 "folder-1",
3060 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
3061 )];
3062 let ledger_path = tempfile::tempdir()
3063 .unwrap()
3064 .keep()
3065 .join("lease-ledger.jsonl");
3066 let token = seed_lease(&ledger_path, "some-other-sheet", "1");
3067 let opts = PivotOptions {
3068 spreadsheet_id: "sheet-1".to_string(),
3069 verb: add_verb(),
3070 dry_run: false,
3071 lease_token: Some(token),
3072 ledger_path,
3073 };
3074 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
3075 assert!(matches!(outcome.result, PivotResult::RefusedLeaseWrongFile));
3076 }
3077
3078 #[tokio::test]
3079 async fn add_refuses_a_stale_lease_when_the_file_has_moved() {
3080 let server = wiremock::MockServer::start().await;
3081 let (drive, sheets) = clients(&server).await;
3082 mount_file(
3083 "sheet-1",
3084 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3085 &["folder-1"],
3086 )
3087 .mount(&server)
3088 .await;
3089 mount_folder("folder-1").mount(&server).await;
3090 mount_workbook(None).mount(&server).await;
3091 let rules = vec![allow_rule(
3092 "folder-1",
3093 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
3094 )];
3095 let ledger_path = tempfile::tempdir()
3096 .unwrap()
3097 .keep()
3098 .join("lease-ledger.jsonl");
3099 let token = seed_lease(&ledger_path, "sheet-1", "0");
3100 let opts = PivotOptions {
3101 spreadsheet_id: "sheet-1".to_string(),
3102 verb: add_verb(),
3103 dry_run: false,
3104 lease_token: Some(token),
3105 ledger_path,
3106 };
3107 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
3108 assert!(matches!(outcome.result, PivotResult::RefusedLeaseStale));
3109 }
3110
3111 #[tokio::test]
3112 async fn add_reports_failed_when_the_lease_checks_live_version_refetch_fails() {
3113 let server = wiremock::MockServer::start().await;
3114 let (drive, sheets) = clients(&server).await;
3115 mount_file(
3120 "sheet-1",
3121 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3122 &["folder-1"],
3123 )
3124 .up_to_n_times(1)
3125 .mount(&server)
3126 .await;
3127 wiremock::Mock::given(wiremock::matchers::method("GET"))
3128 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
3129 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
3130 .mount(&server)
3131 .await;
3132 mount_folder("folder-1").mount(&server).await;
3133 mount_workbook(None).mount(&server).await;
3134 let rules = vec![allow_rule(
3135 "folder-1",
3136 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
3137 )];
3138 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3139 let opts = PivotOptions {
3140 spreadsheet_id: "sheet-1".to_string(),
3141 verb: add_verb(),
3142 dry_run: false,
3143 lease_token,
3144 ledger_path,
3145 };
3146 let outcome = pivot(&drive, &sheets, &opts, &rules).await;
3147 assert!(
3148 matches!(outcome.result, PivotResult::Failed { .. }),
3149 "{:?}",
3150 outcome.result
3151 );
3152 }
3153
3154 fn outcome_with(verb: PivotVerb, file_name: Option<&str>, result: PivotResult) -> PivotOutcome {
3157 PivotOutcome {
3158 spreadsheet_id: "sheet-1".to_string(),
3159 file_name: file_name.map(str::to_string),
3160 resolved_folder_id: None,
3161 verb,
3162 result,
3163 }
3164 }
3165
3166 #[test]
3167 fn describe_lines_renders_book_fallback_when_file_name_is_absent() {
3168 let out = outcome_with(add_verb(), None, PivotResult::RefusedShortcut);
3169 let text = describe(&out);
3170 assert!(text.contains("'sheet-1'"), "{text}");
3171 }
3172
3173 #[test]
3174 fn describe_lines_renders_not_a_spreadsheet_and_shortcut() {
3175 let not_a_sheet = outcome_with(
3176 add_verb(),
3177 Some("Budget"),
3178 PivotResult::RefusedNotASpreadsheet {
3179 mime_type: "text/plain".to_string(),
3180 },
3181 );
3182 let text = describe(¬_a_sheet);
3183 assert!(text.contains("not a Google Sheet"), "{text}");
3184 assert!(text.contains("add-pivot-table"), "{text}");
3185
3186 let shortcut = outcome_with(delete_verb(), Some("Budget"), PivotResult::RefusedShortcut);
3187 let text = describe(&shortcut);
3188 assert!(text.contains("shortcut"), "{text}");
3189 assert!(text.contains("delete-pivot-table"), "{text}");
3190 }
3191
3192 #[test]
3193 fn describe_lines_renders_no_visible_parents_naming_each_verbs_operations() {
3194 let add = outcome_with(
3195 add_verb(),
3196 Some("Budget"),
3197 PivotResult::RefusedNoVisibleParents,
3198 );
3199 let text = describe(&add);
3200 assert!(text.contains("\"sheets-write\""), "{text}");
3201 assert!(text.contains("\"sheets-structure\""), "{text}");
3202
3203 let delete = outcome_with(
3204 delete_verb(),
3205 Some("Budget"),
3206 PivotResult::RefusedNoVisibleParents,
3207 );
3208 let text = describe(&delete);
3209 assert!(text.contains("\"sheets-write\""), "{text}");
3210 assert!(!text.contains("sheets-structure"), "{text}");
3211 }
3212
3213 #[test]
3214 fn describe_lines_renders_sheet_not_found_with_and_without_available_titles() {
3215 let none_available = outcome_with(
3216 add_verb(),
3217 Some("Budget"),
3218 PivotResult::RefusedSheetNotFound {
3219 title: "Nope".to_string(),
3220 available: vec![],
3221 },
3222 );
3223 let text = describe(&none_available);
3224 assert!(text.contains("Available: none"), "{text}");
3225
3226 let some_available = outcome_with(
3227 add_verb(),
3228 Some("Budget"),
3229 PivotResult::RefusedSheetNotFound {
3230 title: "Nope".to_string(),
3231 available: vec!["Report".to_string(), "Data".to_string()],
3232 },
3233 );
3234 let text = describe(&some_available);
3235 assert!(text.contains("'Report', 'Data'"), "{text}");
3236 }
3237
3238 #[test]
3239 fn describe_lines_renders_invalid_range_anchor_and_source() {
3240 for result in [
3241 PivotResult::RefusedInvalidRange {
3242 detail: "bad range".to_string(),
3243 },
3244 PivotResult::RefusedInvalidAnchor {
3245 detail: "bad anchor".to_string(),
3246 },
3247 PivotResult::RefusedInvalidSource {
3248 detail: "bad source".to_string(),
3249 },
3250 ] {
3251 let out = outcome_with(add_verb(), Some("Budget"), result);
3252 let text = describe(&out);
3253 assert!(text.starts_with("Refused: bad "), "{text}");
3254 }
3255 }
3256
3257 #[test]
3258 fn describe_lines_renders_anchor_has_pivot_table_and_no_pivot_at_anchor() {
3259 let occupied = outcome_with(
3260 add_verb(),
3261 Some("Budget"),
3262 PivotResult::RefusedAnchorHasPivotTable {
3263 anchor: "'Report'!A1".to_string(),
3264 existing_summary: "1 row group(s), 0 column group(s), 1 value(s)".to_string(),
3265 },
3266 );
3267 let text = describe(&occupied);
3268 assert!(text.contains("already holds a pivot table"), "{text}");
3269 assert!(text.contains("delete-pivot-table"), "{text}");
3270
3271 let missing = outcome_with(
3272 delete_verb(),
3273 Some("Budget"),
3274 PivotResult::RefusedNoPivotTableAtAnchor {
3275 anchor: "'Report'!A1".to_string(),
3276 },
3277 );
3278 let text = describe(&missing);
3279 assert!(text.contains("no pivot table anchored"), "{text}");
3280 assert!(text.contains("list-pivot-tables"), "{text}");
3281 }
3282
3283 #[test]
3284 fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
3285 let folder_rule = outcome_with(
3286 add_verb(),
3287 Some("Budget"),
3288 PivotResult::Blocked {
3289 operation: DriveOperation::SheetsWrite,
3290 decided_by: Some(DecidingRule::Folder {
3291 folder_id: "folder-1".to_string(),
3292 depth: 2,
3293 }),
3294 },
3295 );
3296 let text = describe(&folder_rule);
3297 assert!(text.contains("add-pivot-table"), "{text}");
3298 assert!(text.contains("folder folder-1 (depth 2)"), "{text}");
3299
3300 let default_policy = outcome_with(
3301 delete_verb(),
3302 Some("Budget"),
3303 PivotResult::Blocked {
3304 operation: DriveOperation::SheetsWrite,
3305 decided_by: None,
3306 },
3307 );
3308 let text = describe(&default_policy);
3309 assert!(text.contains("default policy"), "{text}");
3310 assert!(text.contains("sheets-write"), "{text}");
3311 }
3312
3313 #[test]
3314 fn describe_lines_renders_every_lease_refusal() {
3315 for (result, needle) in [
3316 (PivotResult::RefusedNoLease, "requires a Drive write lease"),
3317 (
3318 PivotResult::RefusedLeaseExpired,
3319 "expired, released, or unknown",
3320 ),
3321 (
3322 PivotResult::RefusedLeaseWrongFile,
3323 "acquired for a different file",
3324 ),
3325 (
3326 PivotResult::RefusedLeaseStale,
3327 "changed since the lease was acquired",
3328 ),
3329 ] {
3330 let out = outcome_with(add_verb(), Some("Budget"), result);
3331 let text = describe(&out);
3332 assert!(text.contains(needle), "{text}");
3333 assert!(text.contains("drive lease acquire sheet-1"), "{text}");
3334 }
3335 }
3336
3337 #[test]
3338 fn describe_lines_renders_changed_for_delete_and_failed() {
3339 let change = PivotChange {
3340 anchor: "'Report'!A1".to_string(),
3341 source: None,
3342 config: None,
3343 anchor_currently: "a pivot table (1 row group(s), 0 column group(s), 1 value(s))"
3344 .to_string(),
3345 };
3346 let changed = outcome_with(delete_verb(), Some("Budget"), PivotResult::Changed(change));
3347 let text = describe(&changed);
3348 assert!(text.contains("Applied: delete the pivot table"), "{text}");
3349
3350 let failed = outcome_with(
3351 add_verb(),
3352 Some("Budget"),
3353 PivotResult::Failed {
3354 detail: "boom".to_string(),
3355 },
3356 );
3357 assert_eq!(describe(&failed), "Failed: boom");
3358 }
3359
3360 fn unreadable_add_change() -> PivotChange {
3361 PivotChange {
3362 anchor: "'Report'!A1".to_string(),
3363 source: Some("'Data'!A1:D20".to_string()),
3364 config: Some("rows: col 0; values: SUM col 3".to_string()),
3365 anchor_currently: "empty".to_string(),
3366 }
3367 }
3368
3369 #[test]
3370 fn describe_lines_renders_applied_reply_unreadable_with_the_plan_time_lines() {
3371 let change = unreadable_add_change();
3372 let out = outcome_with(
3373 add_verb(),
3374 Some("Budget"),
3375 PivotResult::AppliedReplyUnreadable {
3376 summary: "add a pivot table anchored at 'Report'!A1".to_string(),
3377 detail: "bad reply".to_string(),
3378 change: change.clone(),
3379 },
3380 );
3381 let lines = describe_lines(&out);
3382 assert_eq!(
3383 lines[0],
3384 "Applied, but the reply could not be read: add a pivot table anchored at \
3385 'Report'!A1 in 'Budget' — do not retry; check the spreadsheet first (bad reply)"
3386 );
3387 let changed = outcome_with(add_verb(), Some("Budget"), PivotResult::Changed(change));
3389 assert_eq!(lines[1..], describe_lines(&changed)[1..]);
3390 assert_eq!(lines.len(), 5, "{lines:?}");
3391 assert_eq!(lines[1], " source: 'Data'!A1:D20");
3392 }
3393
3394 #[test]
3395 fn the_unreadable_reply_is_logged_with_its_summary_and_detail() {
3396 let out = outcome_with(
3397 delete_verb(),
3398 Some("Budget"),
3399 PivotResult::AppliedReplyUnreadable {
3400 summary: "delete the pivot table anchored at 'Report'!A1".to_string(),
3401 detail: "bad reply".to_string(),
3402 change: unreadable_add_change(),
3403 },
3404 );
3405 let opts = PivotOptions {
3406 spreadsheet_id: "sheet-1".to_string(),
3407 verb: delete_verb(),
3408 dry_run: false,
3409 lease_token: None,
3410 ledger_path: std::path::PathBuf::new(),
3411 };
3412 let logged = mutation_outcome(&out, &opts, Duration::ZERO);
3413 assert_eq!(logged.status, "applied-reply-unreadable");
3414 assert_eq!(
3415 logged.fields_changed.as_deref(),
3416 Some("delete the pivot table anchored at 'Report'!A1")
3417 );
3418 assert_eq!(logged.error.as_deref(), Some("bad reply"));
3419 }
3420
3421 #[test]
3422 fn log_status_covers_every_variant() {
3423 let decided_by = None;
3424 let change = PivotChange {
3425 anchor: "'Report'!A1".to_string(),
3426 source: None,
3427 config: None,
3428 anchor_currently: "empty".to_string(),
3429 };
3430 let results = [
3431 PivotResult::WouldChange(change.clone()),
3432 PivotResult::RefusedNotASpreadsheet {
3433 mime_type: "text/plain".to_string(),
3434 },
3435 PivotResult::RefusedShortcut,
3436 PivotResult::RefusedNoVisibleParents,
3437 PivotResult::RefusedSheetNotFound {
3438 title: "X".to_string(),
3439 available: vec![],
3440 },
3441 PivotResult::RefusedInvalidRange {
3442 detail: String::new(),
3443 },
3444 PivotResult::RefusedInvalidAnchor {
3445 detail: String::new(),
3446 },
3447 PivotResult::RefusedInvalidSource {
3448 detail: String::new(),
3449 },
3450 PivotResult::RefusedAnchorHasPivotTable {
3451 anchor: String::new(),
3452 existing_summary: String::new(),
3453 },
3454 PivotResult::RefusedNoPivotTableAtAnchor {
3455 anchor: String::new(),
3456 },
3457 PivotResult::Blocked {
3458 operation: DriveOperation::SheetsWrite,
3459 decided_by,
3460 },
3461 PivotResult::RefusedNoLease,
3462 PivotResult::RefusedLeaseExpired,
3463 PivotResult::RefusedLeaseWrongFile,
3464 PivotResult::RefusedLeaseStale,
3465 PivotResult::Changed(change.clone()),
3466 PivotResult::AppliedReplyUnreadable {
3467 summary: String::new(),
3468 detail: String::new(),
3469 change,
3470 },
3471 PivotResult::Failed {
3472 detail: String::new(),
3473 },
3474 ];
3475 let statuses: HashSet<&str> = results.iter().map(PivotResult::log_status).collect();
3476 assert_eq!(
3477 statuses.len(),
3478 results.len(),
3479 "log_status must be unique per variant"
3480 );
3481 for status in statuses {
3482 assert!(!status.contains('_'), "{status}");
3483 }
3484 }
3485}