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