1use std::collections::BTreeMap;
54use std::path::PathBuf;
55use std::time::{Duration, Instant};
56
57use serde::Serialize;
58
59use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
60use crate::drive::client::DriveClient;
61use crate::drive::files_api::FilesApi;
62use crate::drive::lease::check::{
63 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
64 LeasedWrite,
65};
66use crate::drive::sheets::a1;
67use crate::drive::sheets::api::{
68 applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
69};
70use crate::drive::sheets::client::SheetsClient;
71use crate::drive::sheets::grid_range;
72use crate::drive::sheets::target_gate;
73use crate::drive::sheets::types::{
74 AddFilterViewRequest, BasicFilter, BatchUpdateRequestItem, ClearBasicFilterRequest,
75 DeleteFilterViewRequest, FilterCriteria, FilterView, FilterViewUpdate, GridRange,
76 SetBasicFilterRequest, SortOrder, SortSpec, Spreadsheet, UpdateFilterViewRequest,
77};
78use crate::drive::types::SheetTargetRefusal;
79use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
80use crate::request_log::{self, DriveMutationOutcome};
81
82const REPLACE_NOTE: &str = " — re-created under the same id, since Sheets cannot remove a \
85 sort column in place";
86
87const GATE_STRUCTURE_ONLY: &[DriveOperation] = &[DriveOperation::SheetsStructure];
89const GATE_WRITE_AND_STRUCTURE: &[DriveOperation] =
95 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure];
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum FilterVerb {
100 SetBasicFilter {
102 sheet: String,
104 range: String,
107 sort_by: Vec<String>,
109 hide_values: Vec<String>,
111 },
112 ClearBasicFilter {
114 sheet: String,
116 },
117 AddFilterView {
119 sheet: String,
121 range: String,
124 title: Option<String>,
126 sort_by: Vec<String>,
128 hide_values: Vec<String>,
130 },
131 UpdateFilterView {
134 filter_view_id: i64,
136 sheet: Option<String>,
139 range: Option<String>,
141 title: Option<String>,
143 sort_by: Vec<String>,
146 hide_values: Vec<String>,
149 clear_sort: bool,
151 clear_criteria: bool,
153 },
154 DeleteFilterView {
156 filter_view_id: i64,
158 },
159}
160
161impl FilterVerb {
162 const fn log_operation(&self) -> &'static str {
163 match self {
164 Self::SetBasicFilter { .. } => "sheets-set-basic-filter",
165 Self::ClearBasicFilter { .. } => "sheets-clear-basic-filter",
166 Self::AddFilterView { .. } => "sheets-add-filter-view",
167 Self::UpdateFilterView { .. } => "sheets-update-filter-view",
168 Self::DeleteFilterView { .. } => "sheets-delete-filter-view",
169 }
170 }
171
172 const fn label(&self) -> &'static str {
173 match self {
174 Self::SetBasicFilter { .. } => "set-basic-filter",
175 Self::ClearBasicFilter { .. } => "clear-basic-filter",
176 Self::AddFilterView { .. } => "add-filter-view",
177 Self::UpdateFilterView { .. } => "update-filter-view",
178 Self::DeleteFilterView { .. } => "delete-filter-view",
179 }
180 }
181
182 fn reorders_rows(&self) -> bool {
186 matches!(self, Self::SetBasicFilter { sort_by, .. } if !sort_by.is_empty())
187 }
188
189 fn gate_operations(&self) -> &'static [DriveOperation] {
193 if self.reorders_rows() {
194 GATE_WRITE_AND_STRUCTURE
195 } else {
196 GATE_STRUCTURE_ONLY
197 }
198 }
199}
200
201#[derive(Debug, Clone)]
203pub struct FilterOptions {
204 pub spreadsheet_id: String,
206 pub verb: FilterVerb,
208 pub dry_run: bool,
210 pub lease_token: Option<String>,
215 pub ledger_path: PathBuf,
219}
220
221#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
223#[serde(tag = "status", rename_all = "kebab-case")]
224pub enum FilterResult {
225 WouldChange {
227 summary: String,
229 #[serde(skip_serializing_if = "Option::is_none")]
233 width_warning: Option<String>,
234 },
235 RefusedNotASpreadsheet {
237 mime_type: String,
239 },
240 RefusedShortcut,
242 RefusedNoVisibleParents,
244 RefusedSheetNotFound {
246 title: String,
248 available: Vec<String>,
250 },
251 RefusedInvalidRange {
255 detail: String,
257 },
258 RefusedFilterViewNotFound {
261 filter_view_id: i64,
263 },
264 Blocked {
266 operation: DriveOperation,
271 decided_by: Option<DecidingRule>,
273 },
274 RefusedNoLease,
277 RefusedLeaseExpired,
280 RefusedLeaseWrongFile,
282 RefusedLeaseStale,
285 Unchanged {
289 detail: String,
291 },
292 Changed {
294 summary: String,
296 filter_view_id: Option<i64>,
300 #[serde(skip_serializing_if = "Option::is_none")]
302 width_warning: Option<String>,
303 },
304 AppliedReplyUnreadable {
309 summary: String,
311 filter_view_id: Option<i64>,
315 #[serde(skip_serializing_if = "Option::is_none")]
317 width_warning: Option<String>,
318 detail: String,
320 },
321 Failed {
323 detail: String,
325 },
326}
327
328impl FromLeaseRefusal for FilterResult {
329 fn from_no_lease() -> Self {
330 Self::RefusedNoLease
331 }
332 fn from_lease_expired() -> Self {
333 Self::RefusedLeaseExpired
334 }
335 fn from_lease_wrong_file() -> Self {
336 Self::RefusedLeaseWrongFile
337 }
338 fn from_lease_stale() -> Self {
339 Self::RefusedLeaseStale
340 }
341 fn from_lease_failed(detail: String) -> Self {
342 Self::Failed { detail }
343 }
344}
345
346impl FilterResult {
347 fn log_status(&self) -> &'static str {
348 match self {
349 Self::WouldChange { .. } => "would-change",
350 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
351 Self::RefusedShortcut => "refused-shortcut",
352 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
353 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
354 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
355 Self::RefusedFilterViewNotFound { .. } => "refused-filter-view-not-found",
356 Self::Blocked { .. } => "blocked",
357 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
358 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
359 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
360 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
361 Self::Unchanged { .. } => "unchanged",
362 Self::Changed { .. } => "changed",
363 Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
364 Self::Failed { .. } => "failed",
365 }
366 }
367}
368
369#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
371pub struct FilterOutcome {
372 pub spreadsheet_id: String,
374 #[serde(skip_serializing_if = "Option::is_none")]
376 pub file_name: Option<String>,
377 #[serde(skip_serializing_if = "Option::is_none")]
379 pub resolved_folder_id: Option<String>,
380 #[serde(skip_serializing_if = "Option::is_none")]
387 pub sheet_id: Option<i64>,
388 #[serde(skip)]
390 pub verb: FilterVerb,
391 pub result: FilterResult,
393}
394
395impl JsonlSerialize for FilterOutcome {
396 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
397 write_scalar_jsonl(self, out)
398 }
399}
400
401pub async fn filter(
403 drive: &DriveClient,
404 sheets: &SheetsClient,
405 opts: &FilterOptions,
406 rules: &[FolderPermissionRule],
407) -> FilterOutcome {
408 let started = Instant::now();
409 let outcome = filter_inner(drive, sheets, opts, rules).await;
410 if !opts.dry_run {
411 record_attempt(&outcome, opts, started.elapsed());
412 }
413 outcome
414}
415
416async fn filter_inner(
417 drive: &DriveClient,
418 sheets: &SheetsClient,
419 opts: &FilterOptions,
420 rules: &[FolderPermissionRule],
421) -> FilterOutcome {
422 let bare = |result| FilterOutcome {
423 spreadsheet_id: opts.spreadsheet_id.clone(),
424 file_name: None,
425 resolved_folder_id: None,
426 sheet_id: None,
427 verb: opts.verb.clone(),
428 result,
429 };
430
431 let sort_specs = match parse_sort_specs(sort_by_flags(&opts.verb)) {
432 Ok(specs) => specs,
433 Err(detail) => return bare(FilterResult::RefusedInvalidRange { detail }),
434 };
435 let criteria = match parse_hidden_values(hide_values_flags(&opts.verb)) {
436 Ok(criteria) => criteria,
437 Err(detail) => return bare(FilterResult::RefusedInvalidRange { detail }),
438 };
439 let composed_range = match compose_target(&opts.verb) {
440 Ok(composed) => composed,
441 Err(detail) => return bare(FilterResult::RefusedInvalidRange { detail }),
442 };
443 if let Err(detail) = validate_verb(&opts.verb) {
444 return bare(FilterResult::RefusedInvalidRange { detail });
445 }
446
447 let operations = opts.verb.gate_operations();
448
449 let (target, verdict, denied, resolved_folder_id, requires_lease) =
450 match target_gate::resolve_all(drive, &opts.spreadsheet_id, operations, rules).await {
451 target_gate::TargetGateUnionOutcome::MetadataFetchFailed { detail } => {
452 return bare(FilterResult::Failed { detail })
453 }
454 target_gate::TargetGateUnionOutcome::Refused { target, refusal } => {
455 let result = match refusal {
456 SheetTargetRefusal::Shortcut => FilterResult::RefusedShortcut,
457 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
458 FilterResult::RefusedNotASpreadsheet { mime_type }
459 }
460 SheetTargetRefusal::NoVisibleParents => FilterResult::RefusedNoVisibleParents,
461 };
462 return FilterOutcome {
463 spreadsheet_id: opts.spreadsheet_id.clone(),
464 file_name: Some(target.name),
465 resolved_folder_id: None,
466 sheet_id: None,
467 verb: opts.verb.clone(),
468 result,
469 };
470 }
471 target_gate::TargetGateUnionOutcome::GateFetchFailed { target, detail } => {
472 return FilterOutcome {
473 spreadsheet_id: opts.spreadsheet_id.clone(),
474 file_name: Some(target.name),
475 resolved_folder_id: None,
476 sheet_id: None,
477 verb: opts.verb.clone(),
478 result: FilterResult::Failed { detail },
479 };
480 }
481 target_gate::TargetGateUnionOutcome::Gated {
482 target,
483 verdict,
484 denied,
485 resolved_folder_id,
486 requires_lease,
487 } => (target, verdict, denied, resolved_folder_id, requires_lease),
488 };
489
490 let pre_gated = |result| FilterOutcome {
497 spreadsheet_id: opts.spreadsheet_id.clone(),
498 file_name: Some(target.name.clone()),
499 resolved_folder_id: resolved_folder_id.clone(),
500 sheet_id: None,
501 verb: opts.verb.clone(),
502 result,
503 };
504
505 if verdict == write_gate::Verdict::Deny {
506 let (operation, decided_by) = denied.unwrap_or((operations[0], None));
509 return pre_gated(FilterResult::Blocked {
510 operation,
511 decided_by,
512 });
513 }
514
515 let api = SheetsApi::new(sheets);
516 let workbook = match api
517 .get_spreadsheet_with_filter_views(&opts.spreadsheet_id)
518 .await
519 {
520 Ok(workbook) => workbook,
521 Err(err) => {
522 return pre_gated(FilterResult::Failed {
523 detail: format!("{err:#}"),
524 })
525 }
526 };
527
528 let composed_range = match (&opts.verb, composed_range) {
531 (
532 FilterVerb::UpdateFilterView {
533 filter_view_id,
534 sheet: None,
535 ..
536 },
537 Some(composed),
538 ) => {
539 let current_sheet = match find_existing_filter_view(&workbook, *filter_view_id) {
542 Ok(view) => view.range.as_ref().map(|range| range.sheet_id),
543 Err(result) => return pre_gated(result),
544 };
545 Some(grid_range::default_sheet_prefix(
546 &workbook,
547 composed,
548 current_sheet,
549 ))
550 }
551 (_, composed) => composed,
552 };
553 let resolved_target =
554 match resolve_sheet_target(&workbook, &opts.verb, composed_range.as_deref()) {
555 Ok(resolved) => resolved,
556 Err(result) => return pre_gated(result),
557 };
558 let sheet_id = resolved_target.map(|grid| grid.sheet_id);
559
560 let gated = |result| FilterOutcome {
565 spreadsheet_id: opts.spreadsheet_id.clone(),
566 file_name: Some(target.name.clone()),
567 resolved_folder_id: resolved_folder_id.clone(),
568 sheet_id: match &opts.verb {
569 FilterVerb::SetBasicFilter { .. } | FilterVerb::ClearBasicFilter { .. } => sheet_id,
570 _ => None,
571 },
572 verb: opts.verb.clone(),
573 result,
574 };
575
576 let existing = match &opts.verb {
577 FilterVerb::UpdateFilterView { filter_view_id, .. }
578 | FilterVerb::DeleteFilterView { filter_view_id } => {
579 match find_existing_filter_view(&workbook, *filter_view_id) {
580 Ok(existing) => Some(existing),
581 Err(result) => return gated(result),
582 }
583 }
584 FilterVerb::SetBasicFilter { .. }
585 | FilterVerb::ClearBasicFilter { .. }
586 | FilterVerb::AddFilterView { .. } => None,
587 };
588
589 if let FilterVerb::ClearBasicFilter { sheet } = &opts.verb {
593 let has_filter = sheet_id
594 .and_then(|id| grid_range::find_sheet_by_id(&workbook, id))
595 .is_some_and(|found| found.basic_filter.is_some());
596 if !has_filter {
597 return gated(FilterResult::Unchanged {
598 detail: format!("sheet '{sheet}' has no basic filter to clear"),
599 });
600 }
601 }
602
603 let effective_grid = resolved_target.or_else(|| existing.and_then(|view| view.range));
612 if let Some(grid) = effective_grid {
613 if let (Some(start), Some(end)) = (grid.start_column_index, grid.end_column_index) {
614 if let Some(spec) = sort_specs
615 .iter()
616 .find(|spec| spec.dimension_index < start || spec.dimension_index >= end)
617 {
618 return gated(FilterResult::RefusedInvalidRange {
619 detail: format!(
620 "sort column {} is outside the selected range's columns {start}..{end} \
621 (zero-based, end exclusive)",
622 spec.dimension_index
623 ),
624 });
625 }
626 }
627 }
628
629 let update_write = match &opts.verb {
635 FilterVerb::UpdateFilterView {
636 filter_view_id,
637 title,
638 clear_sort,
639 clear_criteria,
640 ..
641 } => {
642 let Some(existing) = existing else {
643 unreachable!("existing is resolved for UpdateFilterView above")
645 };
646 match build_update(
647 existing,
648 *filter_view_id,
649 title,
650 resolved_target,
651 sort_specs.clone(),
652 criteria.clone(),
653 *clear_sort,
654 *clear_criteria,
655 ) {
656 Ok(write) => Some(write),
657 Err(detail) => return gated(FilterResult::RefusedInvalidRange { detail }),
658 }
659 }
660 _ => None,
661 };
662
663 let mut summary = describe_effect(&opts.verb);
664 if matches!(update_write, Some(FilterViewWrite::Replace(_))) {
665 summary.push_str(REPLACE_NOTE);
666 }
667 if let (FilterVerb::DeleteFilterView { .. }, Some(view)) = (&opts.verb, existing) {
670 summary.push_str(&describe_existing_view(&workbook, view));
671 }
672 let width_warning = if opts.verb.reorders_rows() {
673 resolved_target
674 .as_ref()
675 .and_then(|grid| reorder_width_warning(&workbook, grid))
676 } else {
677 None
678 };
679
680 if opts.dry_run {
681 return gated(FilterResult::WouldChange {
682 summary,
683 width_warning,
684 });
685 }
686
687 let (requests, existing_id) = match &opts.verb {
691 FilterVerb::SetBasicFilter { .. } => {
692 let Some(grid) = resolved_target else {
693 unreachable!("resolved_target is resolved for SetBasicFilter above")
695 };
696 (
697 vec![BatchUpdateRequestItem::SetBasicFilter(
698 SetBasicFilterRequest {
699 filter: BasicFilter {
700 range: Some(grid),
701 sort_specs,
702 criteria,
703 },
704 },
705 )],
706 None,
707 )
708 }
709 FilterVerb::ClearBasicFilter { .. } => {
710 let Some(sheet_id) = sheet_id else {
711 unreachable!("sheet_id is resolved for ClearBasicFilter above")
713 };
714 (
715 vec![BatchUpdateRequestItem::ClearBasicFilter(
716 ClearBasicFilterRequest { sheet_id },
717 )],
718 None,
719 )
720 }
721 FilterVerb::AddFilterView { title, .. } => {
722 let Some(grid) = resolved_target else {
723 unreachable!("resolved_target is resolved for AddFilterView above")
725 };
726 (
727 vec![BatchUpdateRequestItem::AddFilterView(
728 AddFilterViewRequest {
729 filter: FilterView {
730 filter_view_id: None,
731 title: title.clone(),
732 range: Some(grid),
733 named_range_id: None,
734 table_id: None,
735 sort_specs,
736 criteria,
737 },
738 },
739 )],
740 None,
741 )
742 }
743 FilterVerb::UpdateFilterView { filter_view_id, .. } => {
744 let Some(write) = update_write else {
745 unreachable!("update_write is built for UpdateFilterView above")
747 };
748 (write.into_requests(*filter_view_id), Some(*filter_view_id))
749 }
750 FilterVerb::DeleteFilterView { filter_view_id } => (
751 vec![BatchUpdateRequestItem::DeleteFilterView(
752 DeleteFilterViewRequest {
753 filter_id: *filter_view_id,
754 },
755 )],
756 Some(*filter_view_id),
757 ),
758 };
759
760 let files_api = FilesApi::new(drive);
765 let leased = LeasedWrite {
766 log_prefix: "drive sheets filter",
767 operation: opts.verb.log_operation(),
768 ledger_path: &opts.ledger_path,
769 file_id: &opts.spreadsheet_id,
770 };
771 let lease_grant = match gate_optional_leased_write(
772 leased,
773 &files_api,
774 requires_lease,
775 opts.lease_token.as_deref(),
776 )
777 .await
778 {
779 Ok(grant) => grant,
780 Err(err) => return gated(err.into_result()),
781 };
782
783 let result = match conclude_native_leased_write(
784 leased,
785 &lease_grant,
786 &files_api,
787 api.batch_update(&opts.spreadsheet_id, requests).await,
788 |err| format!("{err:#}"),
789 )
790 .await
791 {
792 Ok(BatchUpdateOutcome::Applied(response)) => {
793 let filter_view_id = added_filter_view_id(&response).or(existing_id);
794 FilterResult::Changed {
795 summary,
796 filter_view_id,
797 width_warning,
798 }
799 }
800 Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
803 FilterResult::AppliedReplyUnreadable {
804 summary,
805 filter_view_id: existing_id,
806 width_warning,
807 detail,
808 }
809 }
810 Err(err) => FilterResult::Failed {
811 detail: format!("{err:#}"),
812 },
813 };
814 drop(lease_grant);
815 gated(result)
816}
817
818fn reorder_width_warning(workbook: &Spreadsheet, grid: &GridRange) -> Option<String> {
823 let allocated_columns = grid_range::find_sheet_by_id(workbook, grid.sheet_id)
824 .and_then(|sheet| sheet.properties.as_ref())
825 .and_then(|props| props.grid_properties.as_ref())
826 .and_then(|props| props.column_count);
827 let start_column = grid.start_column_index.unwrap_or(0);
828 match grid.end_column_index.or(allocated_columns) {
829 Some(end_column) => {
830 grid_range::width_warning("sorting", start_column, end_column, allocated_columns)
831 }
832 None if start_column == 0 => None,
835 None => grid_range::width_warning("sorting", start_column, start_column, None),
836 }
837}
838
839fn sort_by_flags(verb: &FilterVerb) -> &[String] {
841 match verb {
842 FilterVerb::SetBasicFilter { sort_by, .. }
843 | FilterVerb::AddFilterView { sort_by, .. }
844 | FilterVerb::UpdateFilterView { sort_by, .. } => sort_by,
845 FilterVerb::ClearBasicFilter { .. } | FilterVerb::DeleteFilterView { .. } => &[],
846 }
847}
848
849fn hide_values_flags(verb: &FilterVerb) -> &[String] {
851 match verb {
852 FilterVerb::SetBasicFilter { hide_values, .. }
853 | FilterVerb::AddFilterView { hide_values, .. }
854 | FilterVerb::UpdateFilterView { hide_values, .. } => hide_values,
855 FilterVerb::ClearBasicFilter { .. } | FilterVerb::DeleteFilterView { .. } => &[],
856 }
857}
858
859fn parse_sort_specs(flags: &[String]) -> Result<Vec<SortSpec>, String> {
861 flags
862 .iter()
863 .map(|flag| {
864 let (col, order) = flag.split_once(':').ok_or_else(|| {
865 format!("'{flag}' is not COLUMN:asc|desc — expected e.g. '0:asc'")
866 })?;
867 let dimension_index: i64 = col
868 .trim()
869 .parse()
870 .map_err(|_| format!("'{col}' in '{flag}' is not a column index"))?;
871 let sort_order = match order.trim().to_ascii_lowercase().as_str() {
872 "asc" | "ascending" => SortOrder::Ascending,
873 "desc" | "descending" => SortOrder::Descending,
874 other => return Err(format!("'{other}' in '{flag}' is not 'asc' or 'desc'")),
875 };
876 Ok(SortSpec {
877 dimension_index,
878 sort_order,
879 })
880 })
881 .collect()
882}
883
884fn parse_hidden_values(flags: &[String]) -> Result<BTreeMap<String, FilterCriteria>, String> {
886 let mut criteria = BTreeMap::new();
887 for flag in flags {
888 let (col, values) = flag.split_once(':').ok_or_else(|| {
889 format!("'{flag}' is not COLUMN:VALUE[,VALUE...] — expected e.g. '1:Foo,Bar'")
890 })?;
891 let dimension_index: i64 = col
892 .trim()
893 .parse()
894 .map_err(|_| format!("'{col}' in '{flag}' is not a column index"))?;
895 if values.is_empty() {
896 return Err(format!("'{flag}' names no values to hide"));
897 }
898 let hidden_values: Vec<String> = values.split(',').map(str::to_string).collect();
899 let key = dimension_index.to_string();
900 if criteria.contains_key(&key) {
905 return Err(format!(
906 "--hide-values for column {dimension_index} is given more than once"
907 ));
908 }
909 criteria.insert(key, FilterCriteria::hiding(hidden_values));
910 }
911 Ok(criteria)
912}
913
914fn compose_target(verb: &FilterVerb) -> Result<Option<String>, String> {
918 match verb {
919 FilterVerb::SetBasicFilter { sheet, range, .. }
920 | FilterVerb::AddFilterView { sheet, range, .. } => a1::compose(Some(sheet), Some(range))
921 .map(Some)
922 .map_err(|err| err.to_string()),
923 FilterVerb::UpdateFilterView { sheet, range, .. } => {
924 match (sheet.as_deref(), range.as_deref()) {
925 (None, None) => Ok(None),
926 (sheet, range) => a1::compose(sheet, range)
927 .map(Some)
928 .map_err(|err| err.to_string()),
929 }
930 }
931 FilterVerb::ClearBasicFilter { .. } | FilterVerb::DeleteFilterView { .. } => Ok(None),
932 }
933}
934
935fn validate_verb(verb: &FilterVerb) -> Result<(), String> {
938 if let FilterVerb::UpdateFilterView {
939 sheet,
940 range,
941 title,
942 sort_by,
943 hide_values,
944 clear_sort,
945 clear_criteria,
946 ..
947 } = verb
948 {
949 let nothing_to_change = sheet.is_none()
950 && range.is_none()
951 && title.is_none()
952 && sort_by.is_empty()
953 && hide_values.is_empty()
954 && !clear_sort
955 && !clear_criteria;
956 if nothing_to_change {
957 return Err(
958 "nothing to change: pass --title, --sheet/--range, --sort-by, --hide-values, \
959 --clear-sort, or --clear-criteria"
960 .to_string(),
961 );
962 }
963 }
964 Ok(())
965}
966
967fn resolve_sheet_target(
978 workbook: &Spreadsheet,
979 verb: &FilterVerb,
980 composed: Option<&str>,
981) -> Result<Option<GridRange>, FilterResult> {
982 match verb {
983 FilterVerb::SetBasicFilter { .. } | FilterVerb::AddFilterView { .. } => {
984 let composed = composed.unwrap_or_default();
985 let (_, grid) = grid_range::resolve_grid_range(
986 workbook,
987 composed,
988 |detail| FilterResult::RefusedInvalidRange { detail },
989 |title, available| FilterResult::RefusedSheetNotFound { title, available },
990 )?;
991 Ok(Some(grid))
992 }
993 FilterVerb::ClearBasicFilter { sheet } => {
994 let sheet_id = find_sheet_id(workbook, sheet)?;
995 Ok(Some(GridRange {
996 sheet_id,
997 ..Default::default()
998 }))
999 }
1000 FilterVerb::UpdateFilterView { .. } => match composed {
1001 Some(composed) => {
1002 let (_, grid) = grid_range::resolve_grid_range(
1003 workbook,
1004 composed,
1005 |detail| FilterResult::RefusedInvalidRange { detail },
1006 |title, available| FilterResult::RefusedSheetNotFound { title, available },
1007 )?;
1008 Ok(Some(grid))
1009 }
1010 None => Ok(None),
1011 },
1012 FilterVerb::DeleteFilterView { .. } => Ok(None),
1013 }
1014}
1015
1016fn find_sheet_id(workbook: &Spreadsheet, title: &str) -> Result<i64, FilterResult> {
1017 grid_range::find_sheet_id(workbook, title, |title, available| {
1018 FilterResult::RefusedSheetNotFound { title, available }
1019 })
1020}
1021
1022fn find_existing_filter_view(
1027 workbook: &Spreadsheet,
1028 filter_view_id: i64,
1029) -> Result<&FilterView, FilterResult> {
1030 workbook
1031 .sheets
1032 .iter()
1033 .flat_map(|sheet| sheet.filter_views.iter())
1034 .find(|view| view.filter_view_id == Some(filter_view_id))
1035 .ok_or(FilterResult::RefusedFilterViewNotFound { filter_view_id })
1036}
1037
1038#[derive(Debug, Clone, PartialEq, Eq)]
1047enum FilterViewWrite {
1048 Update(UpdateFilterViewRequest),
1050 Replace(FilterView),
1052}
1053
1054impl FilterViewWrite {
1055 fn into_requests(self, filter_view_id: i64) -> Vec<BatchUpdateRequestItem> {
1056 match self {
1057 Self::Update(update) => vec![BatchUpdateRequestItem::UpdateFilterView(update)],
1058 Self::Replace(filter) => vec![
1059 BatchUpdateRequestItem::DeleteFilterView(DeleteFilterViewRequest {
1060 filter_id: filter_view_id,
1061 }),
1062 BatchUpdateRequestItem::AddFilterView(AddFilterViewRequest { filter }),
1063 ],
1064 }
1065 }
1066}
1067
1068#[allow(clippy::too_many_arguments)]
1091fn build_update(
1092 existing: &FilterView,
1093 filter_view_id: i64,
1094 title: &Option<String>,
1095 range: Option<GridRange>,
1096 sort_by: Vec<SortSpec>,
1097 hide_values: BTreeMap<String, FilterCriteria>,
1098 clear_sort: bool,
1099 clear_criteria: bool,
1100) -> Result<FilterViewWrite, String> {
1101 let sort_specs = (clear_sort || !sort_by.is_empty()).then(|| {
1102 let mut resulting = if clear_sort {
1103 Vec::new()
1104 } else {
1105 existing.sort_specs.clone()
1106 };
1107 for spec in sort_by {
1108 if let Some(slot) = resulting
1109 .iter_mut()
1110 .find(|s| s.dimension_index == spec.dimension_index)
1111 {
1112 *slot = spec;
1113 } else {
1114 resulting.push(spec);
1115 }
1116 }
1117 resulting
1118 });
1119 let removes_sort_column = sort_specs.as_ref().is_some_and(|resulting| {
1120 existing.sort_specs.iter().any(|old| {
1121 !resulting
1122 .iter()
1123 .any(|new| new.dimension_index == old.dimension_index)
1124 })
1125 });
1126
1127 if removes_sort_column {
1128 let binding = existing
1133 .table_id
1134 .as_ref()
1135 .map(|id| format!("table {id}"))
1136 .or_else(|| {
1137 existing
1138 .named_range_id
1139 .as_ref()
1140 .map(|id| format!("named range {id}"))
1141 });
1142 let range = match (binding, range) {
1143 (Some(binding), Some(_)) => {
1144 return Err(format!(
1145 "removing a sort column re-creates filter view {filter_view_id}, which is \
1146 bound to {binding}; re-creating it with --sheet/--range would unbind it, so \
1147 drop --sheet/--range, or change the range in a separate update first"
1148 ))
1149 }
1150 (Some(_), None) => None,
1151 (None, range) => Some(range.or(existing.range).ok_or_else(|| {
1152 format!(
1153 "removing a sort column re-creates filter view {filter_view_id}, but it has \
1154 no grid range to re-create it over; pass --sheet/--range"
1155 )
1156 })?),
1157 };
1158 let mut criteria: BTreeMap<String, FilterCriteria> = if clear_criteria {
1159 BTreeMap::new()
1160 } else {
1161 existing
1162 .criteria
1163 .iter()
1164 .filter(|(_, criteria)| !criteria.is_empty())
1165 .map(|(col, criteria)| (col.clone(), criteria.clone()))
1166 .collect()
1167 };
1168 criteria.extend(hide_values);
1169 return Ok(FilterViewWrite::Replace(FilterView {
1170 filter_view_id: Some(filter_view_id),
1171 title: title.clone().or_else(|| existing.title.clone()),
1172 range,
1173 named_range_id: existing.named_range_id.clone(),
1174 table_id: existing.table_id.clone(),
1175 sort_specs: sort_specs.unwrap_or_default(),
1176 criteria,
1177 }));
1178 }
1179
1180 let criteria = (clear_criteria || !hide_values.is_empty()).then(|| {
1181 let mut changes: BTreeMap<String, FilterCriteria> = if clear_criteria {
1182 existing
1183 .criteria
1184 .iter()
1185 .filter(|(_, criteria)| !criteria.is_empty())
1186 .map(|(col, _)| (col.clone(), FilterCriteria::default()))
1187 .collect()
1188 } else {
1189 BTreeMap::new()
1190 };
1191 changes.extend(hide_values);
1192 changes
1193 });
1194
1195 let update = FilterViewUpdate {
1196 filter_view_id,
1197 title: title.clone(),
1198 range,
1199 sort_specs,
1200 criteria,
1201 };
1202 let fields = [
1203 ("title", update.title.is_some()),
1204 ("range", update.range.is_some()),
1205 ("sortSpecs", update.sort_specs.is_some()),
1206 ("criteria", update.criteria.is_some()),
1207 ]
1208 .into_iter()
1209 .filter_map(|(name, present)| present.then_some(name))
1210 .collect::<Vec<_>>()
1211 .join(",");
1212 Ok(FilterViewWrite::Update(UpdateFilterViewRequest {
1213 filter: update,
1214 fields,
1215 }))
1216}
1217
1218fn added_filter_view_id(
1219 response: &crate::drive::sheets::types::BatchUpdateResponse,
1220) -> Option<i64> {
1221 response
1222 .replies
1223 .iter()
1224 .find_map(|reply| reply.add_filter_view.as_ref())
1225 .and_then(|added| added.filter.as_ref())
1226 .and_then(|filter| filter.filter_view_id)
1227}
1228
1229fn describe_effect(verb: &FilterVerb) -> String {
1230 match verb {
1231 FilterVerb::SetBasicFilter {
1232 sort_by,
1233 hide_values,
1234 ..
1235 } => describe_filter_effect("set basic filter", sort_by, hide_values),
1236 FilterVerb::ClearBasicFilter { .. } => "clear basic filter".to_string(),
1237 FilterVerb::AddFilterView {
1238 title,
1239 sort_by,
1240 hide_values,
1241 ..
1242 } => {
1243 let named = title
1244 .as_deref()
1245 .map_or_else(String::new, |t| format!(" '{t}'"));
1246 describe_filter_effect(&format!("add filter view{named}"), sort_by, hide_values)
1247 }
1248 FilterVerb::UpdateFilterView {
1249 title,
1250 sort_by,
1251 hide_values,
1252 clear_sort,
1253 clear_criteria,
1254 ..
1255 } => {
1256 let mut parts = Vec::new();
1257 if let Some(title) = title {
1258 parts.push(format!("title='{title}'"));
1259 }
1260 if *clear_sort {
1261 parts.push("clear sort".to_string());
1262 }
1263 if !sort_by.is_empty() {
1264 parts.push(format!("sort {}", sort_by.join(",")));
1265 }
1266 if *clear_criteria {
1267 parts.push("clear criteria".to_string());
1268 }
1269 if !hide_values.is_empty() {
1270 parts.push(format!("hide {}", hide_values.join(",")));
1271 }
1272 if parts.is_empty() {
1273 "update filter view".to_string()
1274 } else {
1275 format!("update filter view ({})", parts.join(" "))
1276 }
1277 }
1278 FilterVerb::DeleteFilterView { .. } => "delete filter view".to_string(),
1279 }
1280}
1281
1282fn describe_existing_view(workbook: &Spreadsheet, view: &FilterView) -> String {
1287 let mut text = String::new();
1288 if let Some(title) = view.title.as_deref().filter(|t| !t.is_empty()) {
1289 text.push_str(&format!(" '{title}'"));
1290 }
1291 if let Some(grid) = &view.range {
1292 let a1 = grid_range::sheet_title_by_id(workbook, grid.sheet_id)
1293 .and_then(|sheet| grid_range::bounded_range_to_a1(&sheet, grid))
1294 .unwrap_or_else(|| grid_range::render_grid_range(grid));
1295 text.push_str(&format!(" over {a1}"));
1296 }
1297 text
1298}
1299
1300fn describe_filter_effect(prefix: &str, sort_by: &[String], hide_values: &[String]) -> String {
1301 let mut parts = Vec::new();
1302 if !sort_by.is_empty() {
1303 parts.push(format!("sort {}", sort_by.join(",")));
1304 }
1305 if !hide_values.is_empty() {
1306 parts.push(format!("hide {}", hide_values.join(",")));
1307 }
1308 if parts.is_empty() {
1309 prefix.to_string()
1310 } else {
1311 format!("{prefix} ({})", parts.join(" "))
1312 }
1313}
1314
1315fn record_attempt(outcome: &FilterOutcome, opts: &FilterOptions, duration: Duration) {
1316 request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
1317}
1318
1319fn mutation_record(
1322 outcome: &FilterOutcome,
1323 opts: &FilterOptions,
1324 duration: Duration,
1325) -> DriveMutationOutcome {
1326 let error = match &outcome.result {
1327 FilterResult::Failed { detail } | FilterResult::AppliedReplyUnreadable { detail, .. } => {
1328 Some(detail.clone())
1329 }
1330 _ => None,
1331 };
1332 let decided_by = match &outcome.result {
1333 FilterResult::Blocked { decided_by, .. } => decided_by.as_ref(),
1334 _ => None,
1335 };
1336 let decided_by = write_gate::decided_by_log_fields(decided_by);
1337 let filter_view_id = match &outcome.result {
1338 FilterResult::Changed { filter_view_id, .. }
1339 | FilterResult::AppliedReplyUnreadable { filter_view_id, .. } => *filter_view_id,
1340 _ => None,
1341 };
1342 let fields_changed = match &outcome.result {
1343 FilterResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
1345 FilterResult::Changed { summary, .. }
1346 if matches!(opts.verb, FilterVerb::UpdateFilterView { .. }) =>
1347 {
1348 Some(summary.clone())
1349 }
1350 _ => None,
1351 };
1352
1353 DriveMutationOutcome {
1354 operation: opts.verb.log_operation(),
1355 file_id: outcome.spreadsheet_id.clone(),
1356 file_name: outcome.file_name.clone().unwrap_or_default(),
1357 status: outcome.result.log_status().to_string(),
1358 resolved_folder_id: outcome.resolved_folder_id.clone(),
1359 decided_by_folder_id: decided_by.folder_id,
1360 decided_by_depth: decided_by.depth,
1361 decided_by_file_id: decided_by.file_id,
1362 sheet_id: outcome.sheet_id,
1363 filter_view_id,
1364 fields_changed,
1365 error,
1366 duration,
1367 ..Default::default()
1368 }
1369}
1370
1371#[must_use]
1373pub fn describe(outcome: &FilterOutcome) -> String {
1374 describe_lines(outcome).join("\n")
1375}
1376
1377#[must_use]
1380pub fn describe_lines(outcome: &FilterOutcome) -> Vec<String> {
1381 let verb = &outcome.verb;
1382 let book = outcome.file_name.as_deref().map_or_else(
1383 || format!("'{}'", outcome.spreadsheet_id),
1384 |n| format!("'{n}'"),
1385 );
1386 match &outcome.result {
1387 FilterResult::WouldChange {
1388 summary,
1389 width_warning,
1390 } => change_lines(
1391 verb,
1392 format!("Would {summary} in {book}"),
1393 width_warning.as_deref(),
1394 false,
1395 ),
1396 FilterResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1397 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
1398 `drive sheets {}` only works on spreadsheets",
1399 verb.label()
1400 )],
1401 FilterResult::RefusedShortcut => vec![format!(
1402 "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
1403 verb.label()
1404 )],
1405 FilterResult::RefusedNoVisibleParents => {
1406 let ops = verb
1407 .gate_operations()
1408 .iter()
1409 .map(|op| format!("\"{op}\""))
1410 .collect::<Vec<_>>()
1411 .join(", ");
1412 vec![format!(
1413 "Refused: {book} has no parent folder visible to this account, so no folder \
1414 rule can apply to it. Grant it by id instead: add {{\"file_id\": \
1415 \"<spreadsheet id>\", \"allow\": [{ops}]}} to write_permissions.rules."
1416 )]
1417 }
1418 FilterResult::RefusedSheetNotFound { title, available } => {
1419 vec![grid_range::no_sheet_refusal(&book, title, available)]
1420 }
1421 FilterResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
1422 FilterResult::RefusedFilterViewNotFound { filter_view_id } => vec![format!(
1423 "Refused: {book} has no filter view with id {filter_view_id}; run \
1424 `drive sheets list-filter-views` to see what exists"
1425 )],
1426 FilterResult::Blocked {
1427 operation,
1428 decided_by,
1429 } => vec![match decided_by {
1430 Some(rule) => format!(
1431 "Blocked: {} on {book} refused by rule on {} {}{}",
1432 verb.label(),
1433 rule.kind_label(),
1434 rule.id(),
1435 rule.depth_suffix()
1436 ),
1437 None => format!(
1438 "Blocked: {} on {book} refused by default policy (no matching rule for \
1439 {operation})",
1440 verb.label()
1441 ),
1442 }],
1443 FilterResult::RefusedNoLease => LeaseGateRefusal::NoLease
1444 .describe_line(&outcome.spreadsheet_id, &book)
1445 .into_iter()
1446 .collect(),
1447 FilterResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1448 .describe_line(&outcome.spreadsheet_id, &book)
1449 .into_iter()
1450 .collect(),
1451 FilterResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1452 .describe_line(&outcome.spreadsheet_id, &book)
1453 .into_iter()
1454 .collect(),
1455 FilterResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1456 .describe_line(&outcome.spreadsheet_id, &book)
1457 .into_iter()
1458 .collect(),
1459 FilterResult::Changed {
1460 summary,
1461 filter_view_id,
1462 width_warning,
1463 } => {
1464 let id = filter_view_id.map_or_else(String::new, |id| format!(" (id {id})"));
1465 change_lines(
1466 verb,
1467 format!("Applied: {summary}{id} in {book}"),
1468 width_warning.as_deref(),
1469 true,
1470 )
1471 }
1472 FilterResult::Unchanged { detail } => vec![format!("Unchanged: {detail} in {book}")],
1473 FilterResult::AppliedReplyUnreadable {
1474 summary,
1475 filter_view_id,
1476 width_warning,
1477 detail,
1478 } => {
1479 let id = filter_view_id.map_or_else(String::new, |id| format!(" (id {id})"));
1480 let mut lines = vec![applied_reply_unreadable_line(
1481 &format!("{summary}{id}"),
1482 &book,
1483 RetryHint::NotIdempotent,
1484 detail,
1485 )];
1486 if matches!(verb, FilterVerb::AddFilterView { .. }) {
1487 lines.push(
1488 " the new filter view's id was in the unreadable reply; run \
1489 `list-filter-views` to find it"
1490 .to_string(),
1491 );
1492 }
1493 lines.extend(
1496 change_lines(verb, String::new(), width_warning.as_deref(), true)
1497 .into_iter()
1498 .filter(|line| !line.is_empty()),
1499 );
1500 lines
1501 }
1502 FilterResult::Failed { detail } => vec![format!("Failed: {detail}")],
1503 }
1504}
1505
1506fn change_lines(
1512 verb: &FilterVerb,
1513 summary: String,
1514 width_warning: Option<&str>,
1515 changed: bool,
1516) -> Vec<String> {
1517 if !verb.reorders_rows() {
1518 return vec![summary];
1519 }
1520 grid_range::reorder_caveat_lines("sorting", summary, width_warning, changed, None)
1521}
1522
1523#[cfg(test)]
1524#[allow(clippy::unwrap_used, clippy::expect_used)]
1525mod tests {
1526 use super::*;
1527 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1528 use crate::drive::sheets::client::SHEETS_API_URL;
1529 use crate::drive::sheets::types::Sheet;
1530 use crate::drive::test_support::seed_lease;
1531 use crate::test_support::env::MapEnv;
1532 use crate::utils::secret::Secret;
1533 use std::collections::HashSet;
1534
1535 #[test]
1538 fn parse_sort_specs_accepts_asc_and_desc() {
1539 let specs = parse_sort_specs(&["0:asc".to_string(), "2:desc".to_string()]).unwrap();
1540 assert_eq!(
1541 specs,
1542 vec![
1543 SortSpec {
1544 dimension_index: 0,
1545 sort_order: SortOrder::Ascending
1546 },
1547 SortSpec {
1548 dimension_index: 2,
1549 sort_order: SortOrder::Descending
1550 },
1551 ]
1552 );
1553 }
1554
1555 #[test]
1556 fn parse_sort_specs_rejects_missing_colon() {
1557 let err = parse_sort_specs(&["0asc".to_string()]).unwrap_err();
1558 assert!(err.contains("COLUMN:asc|desc"), "{err}");
1559 }
1560
1561 #[test]
1562 fn parse_sort_specs_rejects_non_numeric_column() {
1563 let err = parse_sort_specs(&["x:asc".to_string()]).unwrap_err();
1564 assert!(err.contains("not a column index"), "{err}");
1565 }
1566
1567 #[test]
1568 fn parse_sort_specs_rejects_unknown_direction() {
1569 let err = parse_sort_specs(&["0:sideways".to_string()]).unwrap_err();
1570 assert!(err.contains("not 'asc' or 'desc'"), "{err}");
1571 }
1572
1573 #[test]
1574 fn parse_hidden_values_builds_a_criteria_map() {
1575 let criteria = parse_hidden_values(&["1:Foo,Bar".to_string()]).unwrap();
1576 assert_eq!(
1577 criteria.get("1").unwrap().hidden_values,
1578 vec!["Foo".to_string(), "Bar".to_string()]
1579 );
1580 }
1581
1582 #[test]
1583 fn parse_hidden_values_rejects_missing_colon() {
1584 let err = parse_hidden_values(&["1Foo".to_string()]).unwrap_err();
1585 assert!(err.contains("COLUMN:VALUE"), "{err}");
1586 }
1587
1588 #[test]
1589 fn parse_hidden_values_rejects_empty_value_list() {
1590 let err = parse_hidden_values(&["1:".to_string()]).unwrap_err();
1591 assert!(err.contains("names no values"), "{err}");
1592 }
1593
1594 #[test]
1595 fn parse_hidden_values_rejects_a_repeated_column() {
1596 let err =
1600 parse_hidden_values(&["1:Cherry".to_string(), "1:Apple".to_string()]).unwrap_err();
1601 assert!(err.contains("column 1 is given more than once"), "{err}");
1602 }
1603
1604 #[test]
1607 fn every_verb_has_a_distinct_log_operation_and_label() {
1608 let verbs = [
1609 FilterVerb::SetBasicFilter {
1610 sheet: String::new(),
1611 range: String::new(),
1612 sort_by: Vec::new(),
1613 hide_values: Vec::new(),
1614 },
1615 FilterVerb::ClearBasicFilter {
1616 sheet: String::new(),
1617 },
1618 FilterVerb::AddFilterView {
1619 sheet: String::new(),
1620 range: String::new(),
1621 title: None,
1622 sort_by: Vec::new(),
1623 hide_values: Vec::new(),
1624 },
1625 FilterVerb::UpdateFilterView {
1626 filter_view_id: 1,
1627 sheet: None,
1628 range: None,
1629 title: None,
1630 sort_by: Vec::new(),
1631 hide_values: Vec::new(),
1632 clear_sort: false,
1633 clear_criteria: false,
1634 },
1635 FilterVerb::DeleteFilterView { filter_view_id: 1 },
1636 ];
1637 let ops: HashSet<&str> = verbs.iter().map(FilterVerb::log_operation).collect();
1638 let labels: HashSet<&str> = verbs.iter().map(FilterVerb::label).collect();
1639 assert_eq!(ops.len(), verbs.len());
1640 assert_eq!(labels.len(), verbs.len());
1641 }
1642
1643 #[test]
1646 fn validate_verb_rejects_update_filter_view_with_nothing_to_change() {
1647 let verb = FilterVerb::UpdateFilterView {
1648 filter_view_id: 1,
1649 sheet: None,
1650 range: None,
1651 title: None,
1652 sort_by: Vec::new(),
1653 hide_values: Vec::new(),
1654 clear_sort: false,
1655 clear_criteria: false,
1656 };
1657 let err = validate_verb(&verb).unwrap_err();
1658 assert!(err.contains("nothing to change"), "{err}");
1659 }
1660
1661 #[test]
1662 fn validate_verb_accepts_update_filter_view_with_only_clear_sort() {
1663 let verb = FilterVerb::UpdateFilterView {
1664 filter_view_id: 1,
1665 sheet: None,
1666 range: None,
1667 title: None,
1668 sort_by: Vec::new(),
1669 hide_values: Vec::new(),
1670 clear_sort: true,
1671 clear_criteria: false,
1672 };
1673 assert!(validate_verb(&verb).is_ok());
1674 }
1675
1676 fn filter_view(id: i64) -> FilterView {
1679 FilterView {
1680 filter_view_id: Some(id),
1681 ..Default::default()
1682 }
1683 }
1684
1685 fn spec(dimension_index: i64, sort_order: SortOrder) -> SortSpec {
1686 SortSpec {
1687 dimension_index,
1688 sort_order,
1689 }
1690 }
1691
1692 fn hiding(col: &str, values: &[&str]) -> BTreeMap<String, FilterCriteria> {
1693 let mut criteria = BTreeMap::new();
1694 criteria.insert(
1695 col.to_string(),
1696 FilterCriteria::hiding(values.iter().map(|v| (*v).to_string()).collect()),
1697 );
1698 criteria
1699 }
1700
1701 fn sorted_filtered_view(id: i64) -> FilterView {
1704 FilterView {
1705 filter_view_id: Some(id),
1706 title: Some("Old".to_string()),
1707 range: Some(GridRange {
1708 sheet_id: 0,
1709 start_row_index: Some(0),
1710 end_row_index: Some(6),
1711 start_column_index: Some(0),
1712 end_column_index: Some(4),
1713 }),
1714 named_range_id: None,
1715 table_id: None,
1716 sort_specs: vec![
1717 spec(2, SortOrder::Descending),
1718 spec(3, SortOrder::Descending),
1719 ],
1720 criteria: hiding("1", &["b"]),
1721 }
1722 }
1723
1724 fn wire(write: FilterViewWrite, filter_view_id: i64) -> serde_json::Value {
1727 serde_json::to_value(write.into_requests(filter_view_id)).unwrap()
1728 }
1729
1730 #[test]
1731 fn build_update_sends_only_the_changed_criteria_columns() {
1732 let mut existing = filter_view(3);
1733 existing.criteria = hiding("0", &["Keep"]);
1734 let write = build_update(
1735 &existing,
1736 3,
1737 &None,
1738 None,
1739 Vec::new(),
1740 hiding("1", &["New"]),
1741 false,
1742 false,
1743 )
1744 .unwrap();
1745 assert_eq!(
1749 wire(write, 3),
1750 serde_json::json!([{"updateFilterView": {
1751 "fields": "criteria",
1752 "filter": {"filterViewId": 3, "criteria": {"1": {"hiddenValues": ["New"]}}},
1753 }}])
1754 );
1755 }
1756
1757 #[test]
1758 fn build_update_clear_criteria_resets_each_filtered_column_with_an_empty_object() {
1759 let mut existing = sorted_filtered_view(3);
1760 existing.criteria.extend(hiding("3", &["x"]));
1761 existing
1764 .criteria
1765 .insert("0".to_string(), FilterCriteria::default());
1766 let write = build_update(
1767 &existing,
1768 3,
1769 &None,
1770 None,
1771 Vec::new(),
1772 hiding("3", &["y"]),
1773 false,
1774 true,
1775 )
1776 .unwrap();
1777 assert_eq!(
1778 wire(write, 3),
1779 serde_json::json!([{"updateFilterView": {
1780 "fields": "criteria",
1781 "filter": {"filterViewId": 3, "criteria": {
1782 "1": {},
1783 "3": {"hiddenValues": ["y"]},
1784 }},
1785 }}])
1786 );
1787 }
1788
1789 #[test]
1790 fn build_update_clear_criteria_on_an_unfiltered_view_still_names_the_field() {
1791 let write = build_update(
1792 &filter_view(3),
1793 3,
1794 &None,
1795 None,
1796 Vec::new(),
1797 BTreeMap::new(),
1798 false,
1799 true,
1800 )
1801 .unwrap();
1802 assert_eq!(
1803 wire(write, 3),
1804 serde_json::json!([{"updateFilterView": {
1805 "fields": "criteria",
1806 "filter": {"filterViewId": 3, "criteria": {}},
1807 }}])
1808 );
1809 }
1810
1811 #[test]
1812 fn build_update_sort_spec_replaces_matching_column_and_appends_others() {
1813 let mut existing = filter_view(3);
1814 existing.sort_specs = vec![spec(0, SortOrder::Ascending), spec(1, SortOrder::Ascending)];
1815 let write = build_update(
1816 &existing,
1817 3,
1818 &None,
1819 None,
1820 vec![
1821 spec(0, SortOrder::Descending),
1822 spec(2, SortOrder::Ascending),
1823 ],
1824 BTreeMap::new(),
1825 false,
1826 false,
1827 )
1828 .unwrap();
1829 assert_eq!(
1830 wire(write, 3),
1831 serde_json::json!([{"updateFilterView": {
1832 "fields": "sortSpecs",
1833 "filter": {"filterViewId": 3, "sortSpecs": [
1834 {"dimensionIndex": 0, "sortOrder": "DESCENDING"},
1835 {"dimensionIndex": 1, "sortOrder": "ASCENDING"},
1836 {"dimensionIndex": 2, "sortOrder": "ASCENDING"},
1837 ]},
1838 }}])
1839 );
1840 }
1841
1842 #[test]
1843 fn build_update_sort_by_a_new_column_sends_the_whole_list() {
1844 let mut existing = filter_view(3);
1846 existing.sort_specs = vec![spec(2, SortOrder::Descending)];
1847 let write = build_update(
1848 &existing,
1849 3,
1850 &None,
1851 None,
1852 vec![spec(1, SortOrder::Ascending)],
1853 BTreeMap::new(),
1854 false,
1855 false,
1856 )
1857 .unwrap();
1858 assert_eq!(
1859 wire(write, 3),
1860 serde_json::json!([{"updateFilterView": {
1861 "fields": "sortSpecs",
1862 "filter": {"filterViewId": 3, "sortSpecs": [
1863 {"dimensionIndex": 2, "sortOrder": "DESCENDING"},
1864 {"dimensionIndex": 1, "sortOrder": "ASCENDING"},
1865 ]},
1866 }}])
1867 );
1868 }
1869
1870 #[test]
1871 fn build_update_title_only_sends_no_sort_or_criteria() {
1872 let write = build_update(
1873 &sorted_filtered_view(3),
1874 3,
1875 &Some("New title".to_string()),
1876 None,
1877 Vec::new(),
1878 BTreeMap::new(),
1879 false,
1880 false,
1881 )
1882 .unwrap();
1883 assert_eq!(
1884 wire(write, 3),
1885 serde_json::json!([{"updateFilterView": {
1886 "fields": "title",
1887 "filter": {"filterViewId": 3, "title": "New title"},
1888 }}])
1889 );
1890 }
1891
1892 #[test]
1893 fn build_update_clear_sort_then_sort_by_recreates_the_view_with_exactly_that_order() {
1894 let write = build_update(
1899 &sorted_filtered_view(3),
1900 3,
1901 &None,
1902 None,
1903 vec![spec(0, SortOrder::Ascending)],
1904 BTreeMap::new(),
1905 true,
1906 false,
1907 )
1908 .unwrap();
1909 assert_eq!(
1910 wire(write, 3),
1911 serde_json::json!([
1912 {"deleteFilterView": {"filterId": 3}},
1913 {"addFilterView": {"filter": {
1914 "filterViewId": 3,
1915 "title": "Old",
1916 "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 6,
1917 "startColumnIndex": 0, "endColumnIndex": 4},
1918 "sortSpecs": [{"dimensionIndex": 0, "sortOrder": "ASCENDING"}],
1919 "criteria": {"1": {"hiddenValues": ["b"]}},
1920 }}},
1921 ])
1922 );
1923 }
1924
1925 #[test]
1926 fn build_update_clear_sort_alone_recreates_the_view_with_no_sort() {
1927 let write = build_update(
1928 &sorted_filtered_view(3),
1929 3,
1930 &None,
1931 None,
1932 Vec::new(),
1933 BTreeMap::new(),
1934 true,
1935 false,
1936 )
1937 .unwrap();
1938 let FilterViewWrite::Replace(view) = write else {
1939 panic!("expected a re-creation, got {write:?}");
1940 };
1941 assert!(view.sort_specs.is_empty());
1942 assert_eq!(view.criteria, hiding("1", &["b"]));
1943 assert_eq!(view.filter_view_id, Some(3));
1944 }
1945
1946 #[test]
1947 fn build_update_clear_sort_on_an_unsorted_view_is_a_plain_update() {
1948 let mut existing = sorted_filtered_view(3);
1949 existing.sort_specs.clear();
1950 let write = build_update(
1951 &existing,
1952 3,
1953 &None,
1954 None,
1955 Vec::new(),
1956 BTreeMap::new(),
1957 true,
1958 false,
1959 )
1960 .unwrap();
1961 assert_eq!(
1962 wire(write, 3),
1963 serde_json::json!([{"updateFilterView": {
1964 "fields": "sortSpecs",
1965 "filter": {"filterViewId": 3, "sortSpecs": []},
1966 }}])
1967 );
1968 }
1969
1970 #[test]
1971 fn build_update_clear_sort_keeping_every_column_reorders_in_place() {
1972 let write = build_update(
1976 &sorted_filtered_view(3),
1977 3,
1978 &None,
1979 None,
1980 vec![
1981 spec(3, SortOrder::Descending),
1982 spec(2, SortOrder::Ascending),
1983 ],
1984 BTreeMap::new(),
1985 true,
1986 false,
1987 )
1988 .unwrap();
1989 assert_eq!(
1990 wire(write, 3),
1991 serde_json::json!([{"updateFilterView": {
1992 "fields": "sortSpecs",
1993 "filter": {"filterViewId": 3, "sortSpecs": [
1994 {"dimensionIndex": 3, "sortOrder": "DESCENDING"},
1995 {"dimensionIndex": 2, "sortOrder": "ASCENDING"},
1996 ]},
1997 }}])
1998 );
1999 }
2000
2001 #[test]
2002 fn build_update_recreation_applies_every_other_change_too() {
2003 let mut existing = sorted_filtered_view(3);
2004 existing
2007 .criteria
2008 .insert("0".to_string(), FilterCriteria::default());
2009 let condition: FilterCriteria = serde_json::from_value(serde_json::json!({
2010 "condition": {"type": "NUMBER_GREATER", "values": [{"userEnteredValue": "2"}]}
2011 }))
2012 .unwrap();
2013 existing.criteria.insert("2".to_string(), condition.clone());
2014 let range = GridRange {
2015 sheet_id: 0,
2016 start_row_index: Some(0),
2017 end_row_index: Some(20),
2018 start_column_index: Some(0),
2019 end_column_index: Some(4),
2020 };
2021 let write = build_update(
2022 &existing,
2023 3,
2024 &Some("Renamed".to_string()),
2025 Some(range),
2026 Vec::new(),
2027 hiding("3", &["x"]),
2028 true,
2029 false,
2030 )
2031 .unwrap();
2032 let mut criteria = hiding("1", &["b"]);
2033 criteria.insert("2".to_string(), condition);
2034 criteria.extend(hiding("3", &["x"]));
2035 assert_eq!(
2036 write,
2037 FilterViewWrite::Replace(FilterView {
2038 filter_view_id: Some(3),
2039 title: Some("Renamed".to_string()),
2040 range: Some(range),
2041 named_range_id: None,
2042 table_id: None,
2043 sort_specs: Vec::new(),
2044 criteria,
2045 })
2046 );
2047 }
2048
2049 #[test]
2050 fn build_update_recreation_with_clear_criteria_keeps_only_the_new_columns() {
2051 let write = build_update(
2052 &sorted_filtered_view(3),
2053 3,
2054 &None,
2055 None,
2056 Vec::new(),
2057 hiding("3", &["x"]),
2058 true,
2059 true,
2060 )
2061 .unwrap();
2062 let FilterViewWrite::Replace(view) = write else {
2063 panic!("expected a re-creation, got {write:?}");
2064 };
2065 assert_eq!(view.criteria, hiding("3", &["x"]));
2066 }
2067
2068 #[test]
2069 fn build_update_recreation_without_a_range_is_refused() {
2070 let mut existing = sorted_filtered_view(3);
2071 existing.range = None;
2072 let detail = build_update(
2073 &existing,
2074 3,
2075 &None,
2076 None,
2077 Vec::new(),
2078 BTreeMap::new(),
2079 true,
2080 false,
2081 )
2082 .unwrap_err();
2083 assert!(detail.contains("--sheet/--range"), "{detail}");
2084 }
2085
2086 #[test]
2087 fn build_update_recreation_rebinds_a_table_bound_view_without_its_range() {
2088 let mut existing = sorted_filtered_view(3);
2092 existing.table_id = Some("454645209".to_string());
2093 let write = build_update(
2094 &existing,
2095 3,
2096 &None,
2097 None,
2098 vec![spec(0, SortOrder::Ascending)],
2099 BTreeMap::new(),
2100 true,
2101 false,
2102 )
2103 .unwrap();
2104 assert_eq!(
2105 wire(write, 3),
2106 serde_json::json!([
2107 {"deleteFilterView": {"filterId": 3}},
2108 {"addFilterView": {"filter": {
2109 "filterViewId": 3,
2110 "title": "Old",
2111 "tableId": "454645209",
2112 "sortSpecs": [{"dimensionIndex": 0, "sortOrder": "ASCENDING"}],
2113 "criteria": {"1": {"hiddenValues": ["b"]}},
2114 }}},
2115 ])
2116 );
2117 }
2118
2119 #[test]
2120 fn build_update_recreation_rebinds_a_named_range_bound_view_without_its_range() {
2121 let mut existing = sorted_filtered_view(3);
2122 existing.named_range_id = Some("2014020481".to_string());
2123 let write = build_update(
2124 &existing,
2125 3,
2126 &None,
2127 None,
2128 Vec::new(),
2129 BTreeMap::new(),
2130 true,
2131 false,
2132 )
2133 .unwrap();
2134 let FilterViewWrite::Replace(view) = write else {
2135 panic!("expected a re-creation, got {write:?}");
2136 };
2137 assert_eq!(view.named_range_id.as_deref(), Some("2014020481"));
2138 assert_eq!(view.table_id, None);
2139 assert_eq!(view.range, None);
2140 }
2141
2142 #[test]
2143 fn build_update_recreation_of_a_bound_view_with_a_new_range_is_refused() {
2144 let mut existing = sorted_filtered_view(3);
2145 existing.table_id = Some("454645209".to_string());
2146 let detail = build_update(
2147 &existing,
2148 3,
2149 &None,
2150 sorted_filtered_view(3).range,
2151 Vec::new(),
2152 BTreeMap::new(),
2153 true,
2154 false,
2155 )
2156 .unwrap_err();
2157 assert!(detail.contains("bound to table 454645209"), "{detail}");
2158 assert!(detail.contains("would unbind it"), "{detail}");
2159 }
2160
2161 #[test]
2162 fn build_update_plain_update_of_a_bound_view_sends_no_binding() {
2163 let mut existing = sorted_filtered_view(3);
2166 existing.table_id = Some("454645209".to_string());
2167 let write = build_update(
2168 &existing,
2169 3,
2170 &None,
2171 None,
2172 vec![spec(1, SortOrder::Ascending)],
2173 BTreeMap::new(),
2174 false,
2175 false,
2176 )
2177 .unwrap();
2178 let FilterViewWrite::Update(update) = write else {
2179 panic!("expected an in-place update, got {write:?}");
2180 };
2181 assert_eq!(update.fields, "sortSpecs");
2182 }
2183
2184 #[test]
2185 fn filter_criteria_keeps_unmodelled_fields_across_a_round_trip() {
2186 let json = serde_json::json!({
2187 "hiddenValues": ["a"],
2188 "condition": {"type": "TEXT_CONTAINS", "values": [{"userEnteredValue": "x"}]},
2189 });
2190 let criteria: FilterCriteria = serde_json::from_value(json.clone()).unwrap();
2191 assert!(!criteria.is_empty());
2192 assert_eq!(serde_json::to_value(&criteria).unwrap(), json);
2193 assert!(FilterCriteria::default().is_empty());
2194 assert_eq!(
2195 serde_json::to_value(FilterCriteria::default()).unwrap(),
2196 serde_json::json!({})
2197 );
2198 }
2199
2200 #[test]
2201 fn add_filter_view_wire_shape_is_unchanged() {
2202 let request = AddFilterViewRequest {
2203 filter: FilterView {
2204 filter_view_id: None,
2205 title: Some("T".to_string()),
2206 range: sorted_filtered_view(3).range,
2207 named_range_id: None,
2208 table_id: None,
2209 sort_specs: vec![spec(2, SortOrder::Descending)],
2210 criteria: hiding("1", &["b"]),
2211 },
2212 };
2213 assert_eq!(
2214 serde_json::to_string(&request).unwrap(),
2215 r#"{"filter":{"title":"T","range":{"sheetId":0,"startRowIndex":0,"endRowIndex":6,"startColumnIndex":0,"endColumnIndex":4},"sortSpecs":[{"dimensionIndex":2,"sortOrder":"DESCENDING"}],"criteria":{"1":{"hiddenValues":["b"]}}}}"#
2216 );
2217 }
2218
2219 fn workbook_with_filter_views(views: Vec<FilterView>) -> Spreadsheet {
2222 Spreadsheet {
2223 sheets: vec![Sheet {
2224 properties: None,
2225 filter_views: views,
2226 ..Default::default()
2227 }],
2228 ..Default::default()
2229 }
2230 }
2231
2232 #[tokio::test]
2235 async fn clearing_an_absent_basic_filter_is_unchanged_in_dry_run_and_real_run() {
2236 for dry_run in [true, false] {
2237 let server = wiremock::MockServer::start().await;
2238 let (drive, sheets) = clients(&server).await;
2239 mount_file(
2240 "sheet-1",
2241 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2242 &["folder-1"],
2243 )
2244 .mount(&server)
2245 .await;
2246 mount_folder("folder-1").mount(&server).await;
2247 mount_workbook(serde_json::json!([
2248 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2249 ]))
2250 .mount(&server)
2251 .await;
2252 let mut opts = dry_run_opts(FilterVerb::ClearBasicFilter {
2253 sheet: "Q1".to_string(),
2254 });
2255 opts.dry_run = dry_run;
2256 let outcome = filter(&drive, &sheets, &opts, &[allow_rule("folder-1")]).await;
2257 assert_eq!(
2258 outcome.result,
2259 FilterResult::Unchanged {
2260 detail: "sheet 'Q1' has no basic filter to clear".to_string()
2261 },
2262 "dry_run={dry_run}"
2263 );
2264 assert!(describe(&outcome).starts_with("Unchanged: sheet 'Q1' has no basic filter"));
2265 let requests = server.received_requests().await.unwrap();
2266 assert!(!requests
2267 .iter()
2268 .any(|r| r.url.path().ends_with(":batchUpdate")));
2269 }
2270 }
2271
2272 #[test]
2273 fn describe_existing_view_names_the_title_and_the_range() {
2274 let workbook = Spreadsheet {
2275 sheets: vec![Sheet {
2276 properties: Some(crate::drive::sheets::types::SheetProperties {
2277 sheet_id: Some(0),
2278 title: "Q1".to_string(),
2279 ..Default::default()
2280 }),
2281 ..Default::default()
2282 }],
2283 ..Default::default()
2284 };
2285 let view = FilterView {
2286 filter_view_id: Some(7),
2287 title: Some("Open items".to_string()),
2288 range: Some(GridRange {
2289 sheet_id: 0,
2290 start_row_index: Some(0),
2291 end_row_index: Some(10),
2292 start_column_index: Some(0),
2293 end_column_index: Some(4),
2294 }),
2295 ..Default::default()
2296 };
2297 assert_eq!(
2298 describe_existing_view(&workbook, &view),
2299 " 'Open items' over 'Q1'!A1:D10"
2300 );
2301 let open_ended = FilterView {
2302 title: None,
2303 range: Some(GridRange {
2304 sheet_id: 0,
2305 start_column_index: Some(0),
2306 end_column_index: Some(1),
2307 ..Default::default()
2308 }),
2309 ..view
2310 };
2311 assert_eq!(
2312 describe_existing_view(&workbook, &open_ended),
2313 " over sheetId 0, cols 1-1"
2314 );
2315 assert_eq!(describe_existing_view(&workbook, &filter_view(7)), "");
2316 }
2317
2318 #[test]
2319 fn find_existing_filter_view_matches_by_id() {
2320 let workbook = workbook_with_filter_views(vec![filter_view(3), filter_view(5)]);
2321 let found = find_existing_filter_view(&workbook, 5).unwrap();
2322 assert_eq!(found.filter_view_id, Some(5));
2323 }
2324
2325 #[test]
2326 fn find_existing_filter_view_refuses_when_absent() {
2327 let workbook = workbook_with_filter_views(vec![filter_view(3)]);
2328 let err = find_existing_filter_view(&workbook, 99).unwrap_err();
2329 assert!(matches!(
2330 err,
2331 FilterResult::RefusedFilterViewNotFound { filter_view_id: 99 }
2332 ));
2333 }
2334
2335 #[test]
2338 fn write_jsonl_emits_one_line_of_json() {
2339 let outcome = FilterOutcome {
2340 spreadsheet_id: "sheet-1".to_string(),
2341 file_name: Some("Budget".to_string()),
2342 resolved_folder_id: None,
2343 sheet_id: None,
2344 verb: FilterVerb::DeleteFilterView { filter_view_id: 3 },
2345 result: FilterResult::Changed {
2346 summary: "delete filter view".to_string(),
2347 filter_view_id: Some(3),
2348 width_warning: None,
2349 },
2350 };
2351 let mut buf = Vec::new();
2352 outcome.write_jsonl(&mut buf).unwrap();
2353 let text = String::from_utf8(buf).unwrap();
2354 assert_eq!(text.matches('\n').count(), 1);
2355 let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
2356 assert_eq!(parsed["result"]["status"], "changed");
2357 }
2358
2359 #[test]
2360 fn filter_result_log_status_names_every_variant() {
2361 assert_eq!(
2362 FilterResult::WouldChange {
2363 summary: String::new(),
2364 width_warning: None,
2365 }
2366 .log_status(),
2367 "would-change"
2368 );
2369 assert_eq!(
2370 FilterResult::RefusedFilterViewNotFound { filter_view_id: 1 }.log_status(),
2371 "refused-filter-view-not-found"
2372 );
2373 assert_eq!(
2374 FilterResult::AppliedReplyUnreadable {
2375 summary: String::new(),
2376 filter_view_id: None,
2377 width_warning: None,
2378 detail: String::new(),
2379 }
2380 .log_status(),
2381 "applied-reply-unreadable"
2382 );
2383 assert_eq!(
2384 FilterResult::Failed {
2385 detail: String::new()
2386 }
2387 .log_status(),
2388 "failed"
2389 );
2390 }
2391
2392 fn test_credentials() -> DriveCredentials {
2395 DriveCredentials {
2396 client_id: "client-1".to_string(),
2397 client_secret: Secret::new("secret-1"),
2398 refresh_token: Secret::new("refresh-1"),
2399 scope: DriveGrantedScopes::READONLY,
2400 }
2401 }
2402
2403 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
2404 wiremock::Mock::given(wiremock::matchers::method("POST"))
2405 .and(wiremock::matchers::path("/token"))
2406 .respond_with(
2407 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2408 "access_token": "test-token", "expires_in": 3600,
2409 })),
2410 )
2411 .mount(server)
2412 .await;
2413 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
2414 crate::drive::client::test_support::replace_session(
2415 &mut drive,
2416 &test_credentials(),
2417 &format!("{}/token", server.uri()),
2418 );
2419 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
2420 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
2421 (drive, sheets)
2422 }
2423
2424 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
2425 let parents: Vec<&str> = parents.to_vec();
2426 wiremock::Mock::given(wiremock::matchers::method("GET"))
2427 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
2428 .respond_with(
2429 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2430 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
2431 "version": "1",
2432 })),
2433 )
2434 }
2435
2436 fn mount_folder(id: &str) -> wiremock::Mock {
2437 mount_file(id, "application/vnd.google-apps.folder", &[])
2438 }
2439
2440 fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
2441 let ledger_path = tempfile::tempdir()
2442 .unwrap()
2443 .keep()
2444 .join("lease-ledger.jsonl");
2445 let token = seed_lease(&ledger_path, spreadsheet_id, "1");
2446 (Some(token), ledger_path)
2447 }
2448
2449 fn allow_rule(folder: &str) -> FolderPermissionRule {
2450 allow_rule_for(folder, &[DriveOperation::SheetsStructure])
2451 }
2452
2453 fn allow_rule_for(folder: &str, operations: &[DriveOperation]) -> FolderPermissionRule {
2454 FolderPermissionRule {
2455 folder_id: Some(folder.to_string()),
2456 file_id: None,
2457 recursive: true,
2458 allow: operations.iter().copied().collect(),
2459 deny: HashSet::default(),
2460 require_lease: true,
2461 }
2462 }
2463
2464 fn mount_workbook(sheets: serde_json::Value) -> wiremock::Mock {
2465 wiremock::Mock::given(wiremock::matchers::method("GET"))
2466 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2467 .respond_with(
2468 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2469 "spreadsheetId": "sheet-1",
2470 "properties": {"title": "Budget"},
2471 "sheets": sheets,
2472 })),
2473 )
2474 }
2475
2476 #[tokio::test]
2477 async fn a_denied_gate_blocks_before_any_read_or_batch_update_call() {
2478 let server = wiremock::MockServer::start().await;
2479 let (drive, sheets) = clients(&server).await;
2480 mount_file(
2481 "sheet-1",
2482 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2483 &["folder-1"],
2484 )
2485 .mount(&server)
2486 .await;
2487 mount_folder("folder-1").mount(&server).await;
2488 let rules: Vec<FolderPermissionRule> = Vec::new();
2489 let opts = FilterOptions {
2490 spreadsheet_id: "sheet-1".to_string(),
2491 verb: FilterVerb::SetBasicFilter {
2492 sheet: "Q1".to_string(),
2493 range: "A1:D10".to_string(),
2494 sort_by: Vec::new(),
2495 hide_values: Vec::new(),
2496 },
2497 dry_run: false,
2498 lease_token: None,
2499 ledger_path: std::path::PathBuf::new(),
2500 };
2501 let outcome = filter(&drive, &sheets, &opts, &rules).await;
2502 assert!(matches!(outcome.result, FilterResult::Blocked { .. }));
2503 }
2504
2505 #[test]
2508 fn gate_operations_are_the_union_only_for_a_sorting_basic_filter() {
2509 let sort = || vec!["2:desc".to_string()];
2510 assert_eq!(
2511 set_verb(sort(), Vec::new()).gate_operations(),
2512 GATE_WRITE_AND_STRUCTURE
2513 );
2514 assert_eq!(
2515 set_verb(Vec::new(), vec!["1:Foo".to_string()]).gate_operations(),
2516 GATE_STRUCTURE_ONLY
2517 );
2518 for verb in [
2520 FilterVerb::ClearBasicFilter {
2521 sheet: "Q1".to_string(),
2522 },
2523 FilterVerb::AddFilterView {
2524 sheet: "Q1".to_string(),
2525 range: "A1:D10".to_string(),
2526 title: None,
2527 sort_by: sort(),
2528 hide_values: Vec::new(),
2529 },
2530 FilterVerb::UpdateFilterView {
2531 filter_view_id: 7,
2532 sheet: None,
2533 range: None,
2534 title: None,
2535 sort_by: sort(),
2536 hide_values: Vec::new(),
2537 clear_sort: false,
2538 clear_criteria: false,
2539 },
2540 FilterVerb::DeleteFilterView { filter_view_id: 7 },
2541 ] {
2542 assert_eq!(verb.gate_operations(), GATE_STRUCTURE_ONLY, "{verb:?}");
2543 }
2544 }
2545
2546 async fn gated_server() -> (wiremock::MockServer, DriveClient, SheetsClient) {
2547 let server = wiremock::MockServer::start().await;
2548 let (drive, sheets) = clients(&server).await;
2549 mount_file(
2550 "sheet-1",
2551 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2552 &["folder-1"],
2553 )
2554 .mount(&server)
2555 .await;
2556 mount_folder("folder-1").mount(&server).await;
2557 (server, drive, sheets)
2558 }
2559
2560 fn dry_run_opts(verb: FilterVerb) -> FilterOptions {
2561 FilterOptions {
2562 spreadsheet_id: "sheet-1".to_string(),
2563 verb,
2564 dry_run: true,
2565 lease_token: None,
2566 ledger_path: std::path::PathBuf::new(),
2567 }
2568 }
2569
2570 #[tokio::test]
2571 async fn a_sorting_basic_filter_under_a_structure_only_grant_is_blocked_on_sheets_write() {
2572 let (_server, drive, sheets) = gated_server().await;
2575 let rules = vec![allow_rule("folder-1")];
2576 let opts = dry_run_opts(set_verb(vec!["2:desc".to_string()], Vec::new()));
2577 let outcome = filter(&drive, &sheets, &opts, &rules).await;
2578 assert_eq!(
2579 outcome.result,
2580 FilterResult::Blocked {
2581 operation: DriveOperation::SheetsWrite,
2582 decided_by: None,
2583 }
2584 );
2585 }
2586
2587 #[tokio::test]
2588 async fn a_sorting_basic_filter_under_a_write_only_grant_is_blocked_on_sheets_structure() {
2589 let (_server, drive, sheets) = gated_server().await;
2590 let rules = vec![allow_rule_for("folder-1", &[DriveOperation::SheetsWrite])];
2591 let opts = dry_run_opts(set_verb(vec!["2:desc".to_string()], Vec::new()));
2592 let outcome = filter(&drive, &sheets, &opts, &rules).await;
2593 assert_eq!(
2594 outcome.result,
2595 FilterResult::Blocked {
2596 operation: DriveOperation::SheetsStructure,
2597 decided_by: None,
2598 }
2599 );
2600 }
2601
2602 #[tokio::test]
2603 async fn a_non_sorting_basic_filter_and_a_sorting_filter_view_still_pass_a_structure_only_grant(
2604 ) {
2605 let (server, drive, sheets) = gated_server().await;
2606 mount_workbook(serde_json::json!([
2607 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2608 ]))
2609 .mount(&server)
2610 .await;
2611 let rules = vec![allow_rule("folder-1")];
2612 for verb in [
2613 set_verb(Vec::new(), vec!["1:Foo".to_string()]),
2614 FilterVerb::AddFilterView {
2615 sheet: "Q1".to_string(),
2616 range: "A1:D10".to_string(),
2617 title: None,
2618 sort_by: vec!["2:desc".to_string()],
2619 hide_values: Vec::new(),
2620 },
2621 ] {
2622 let outcome = filter(&drive, &sheets, &dry_run_opts(verb), &rules).await;
2623 assert!(
2624 matches!(
2625 outcome.result,
2626 FilterResult::WouldChange {
2627 width_warning: None,
2628 ..
2629 }
2630 ),
2631 "{:?}",
2632 outcome.result
2633 );
2634 let text = describe(&outcome);
2635 assert!(!text.contains("different row"), "{text}");
2636 }
2637 }
2638
2639 #[tokio::test]
2640 async fn a_sorting_basic_filter_narrower_than_its_sheet_previews_the_reorder_caveats() {
2641 let (server, drive, sheets) = gated_server().await;
2642 mount_workbook(serde_json::json!([
2643 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2644 "gridProperties": {"rowCount": 100, "columnCount": 10}}},
2645 ]))
2646 .mount(&server)
2647 .await;
2648 let rules = vec![allow_rule_for("folder-1", GATE_WRITE_AND_STRUCTURE)];
2649 let opts = dry_run_opts(set_verb(vec!["2:desc".to_string()], Vec::new()));
2650 let outcome = filter(&drive, &sheets, &opts, &rules).await;
2651 assert_eq!(
2652 outcome.result,
2653 FilterResult::WouldChange {
2654 summary: "set basic filter (sort 2:desc)".to_string(),
2655 width_warning: grid_range::width_warning("sorting", 0, 4, Some(10)),
2656 }
2657 );
2658 let lines = describe_lines(&outcome);
2659 assert_eq!(lines.len(), 3, "{lines:?}");
2660 assert!(
2661 lines[0].starts_with("Warning: selected columns 0..4"),
2662 "{lines:?}"
2663 );
2664 assert_eq!(
2665 lines[1],
2666 "Would set basic filter (sort 2:desc) in 'sheet-1'"
2667 );
2668 assert!(lines[2].ends_with("after sorting"), "{lines:?}");
2669 }
2670
2671 #[tokio::test]
2672 async fn a_sorting_basic_filter_spanning_every_column_has_no_width_warning() {
2673 let (server, drive, sheets) = gated_server().await;
2674 mount_workbook(serde_json::json!([
2675 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2676 "gridProperties": {"rowCount": 100, "columnCount": 4}}},
2677 ]))
2678 .mount(&server)
2679 .await;
2680 let rules = vec![allow_rule_for("folder-1", GATE_WRITE_AND_STRUCTURE)];
2681 let opts = dry_run_opts(set_verb(vec!["2:desc".to_string()], Vec::new()));
2682 let outcome = filter(&drive, &sheets, &opts, &rules).await;
2683 assert!(matches!(
2684 outcome.result,
2685 FilterResult::WouldChange {
2686 width_warning: None,
2687 ..
2688 }
2689 ));
2690 let lines = describe_lines(&outcome);
2691 assert_eq!(lines.len(), 2, "{lines:?}");
2692 }
2693
2694 #[test]
2695 fn reorder_width_warning_handles_open_ended_column_spans() {
2696 let workbook = |column_count: Option<i64>| -> Spreadsheet {
2697 serde_json::from_value(serde_json::json!({
2698 "spreadsheetId": "sheet-1",
2699 "sheets": [{"properties": {
2700 "sheetId": 0, "title": "Q1", "index": 0,
2701 "gridProperties": {"columnCount": column_count},
2702 }}],
2703 }))
2704 .unwrap()
2705 };
2706 let grid = |start: Option<i64>, end: Option<i64>| GridRange {
2707 sheet_id: 0,
2708 start_column_index: start,
2709 end_column_index: end,
2710 ..Default::default()
2711 };
2712 assert_eq!(
2714 reorder_width_warning(&workbook(Some(5)), &grid(None, None)),
2715 None
2716 );
2717 assert!(reorder_width_warning(&workbook(Some(5)), &grid(Some(1), None)).is_some());
2718 assert_eq!(
2720 reorder_width_warning(&workbook(None), &grid(None, None)),
2721 None
2722 );
2723 assert!(reorder_width_warning(&workbook(None), &grid(Some(1), None)).is_some());
2724 assert!(reorder_width_warning(&workbook(None), &grid(None, Some(3))).is_some());
2725 }
2726
2727 #[test]
2728 fn describe_lines_adds_the_reorder_caveats_only_for_a_sorting_basic_filter() {
2729 let changed = outcome_with(
2730 set_verb(vec!["2:desc".to_string()], Vec::new()),
2731 Some("Budget"),
2732 FilterResult::Changed {
2733 summary: "set basic filter (sort 2:desc)".to_string(),
2734 filter_view_id: None,
2735 width_warning: Some("narrow".to_string()),
2736 },
2737 );
2738 assert_eq!(
2739 describe_lines(&changed),
2740 vec![
2741 "Warning: narrow".to_string(),
2742 "Applied: set basic filter (sort 2:desc) in 'Budget'".to_string(),
2743 " references outside the range may now observe values from a different row"
2744 .to_string(),
2745 ]
2746 );
2747
2748 let would = outcome_with(
2749 set_verb(vec!["2:desc".to_string()], Vec::new()),
2750 Some("Budget"),
2751 FilterResult::WouldChange {
2752 summary: "set basic filter (sort 2:desc)".to_string(),
2753 width_warning: None,
2754 },
2755 );
2756 assert_eq!(
2757 describe_lines(&would),
2758 vec![
2759 "Would set basic filter (sort 2:desc) in 'Budget'".to_string(),
2760 " references outside the range may observe values from a different row after sorting"
2761 .to_string(),
2762 ]
2763 );
2764 }
2765
2766 #[test]
2767 fn describe_lines_names_the_denied_operation_and_the_union_grant_hint() {
2768 let blocked = outcome_with(
2769 set_verb(vec!["2:desc".to_string()], Vec::new()),
2770 Some("Budget"),
2771 FilterResult::Blocked {
2772 operation: DriveOperation::SheetsWrite,
2773 decided_by: None,
2774 },
2775 );
2776 let text = describe(&blocked);
2777 assert!(text.contains("no matching rule for sheets-write"), "{text}");
2778
2779 let orphan = outcome_with(
2780 set_verb(vec!["2:desc".to_string()], Vec::new()),
2781 Some("Budget"),
2782 FilterResult::RefusedNoVisibleParents,
2783 );
2784 let text = describe(&orphan);
2785 assert!(
2786 text.contains(r#""allow": ["sheets-write", "sheets-structure"]"#),
2787 "{text}"
2788 );
2789 let orphan = outcome_with(
2790 set_verb(Vec::new(), Vec::new()),
2791 Some("Budget"),
2792 FilterResult::RefusedNoVisibleParents,
2793 );
2794 let text = describe(&orphan);
2795 assert!(text.contains(r#""allow": ["sheets-structure"]"#), "{text}");
2796 }
2797
2798 #[tokio::test]
2799 async fn set_basic_filter_sends_a_set_basic_filter_request() {
2800 let server = wiremock::MockServer::start().await;
2801 let (drive, sheets) = clients(&server).await;
2802 mount_file(
2803 "sheet-1",
2804 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2805 &["folder-1"],
2806 )
2807 .mount(&server)
2808 .await;
2809 mount_folder("folder-1").mount(&server).await;
2810 mount_workbook(serde_json::json!([
2811 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2812 ]))
2813 .mount(&server)
2814 .await;
2815 wiremock::Mock::given(wiremock::matchers::method("POST"))
2816 .and(wiremock::matchers::path(
2817 "/v4/spreadsheets/sheet-1:batchUpdate",
2818 ))
2819 .respond_with(
2820 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2821 "replies": [{}]
2822 })),
2823 )
2824 .mount(&server)
2825 .await;
2826 let rules = vec![allow_rule_for("folder-1", GATE_WRITE_AND_STRUCTURE)];
2829 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2830 let opts = FilterOptions {
2831 spreadsheet_id: "sheet-1".to_string(),
2832 verb: FilterVerb::SetBasicFilter {
2833 sheet: "Q1".to_string(),
2834 range: "A1:D10".to_string(),
2835 sort_by: vec!["0:asc".to_string()],
2836 hide_values: vec!["1:Foo,Bar".to_string()],
2837 },
2838 dry_run: false,
2839 lease_token,
2840 ledger_path,
2841 };
2842 let outcome = filter(&drive, &sheets, &opts, &rules).await;
2843 assert!(matches!(outcome.result, FilterResult::Changed { .. }));
2844 assert_eq!(outcome.sheet_id, Some(0));
2849 }
2850
2851 #[tokio::test]
2852 async fn set_basic_filter_refuses_a_sort_column_outside_the_range() {
2853 let server = wiremock::MockServer::start().await;
2858 let (drive, sheets) = clients(&server).await;
2859 mount_file(
2860 "sheet-1",
2861 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2862 &["folder-1"],
2863 )
2864 .mount(&server)
2865 .await;
2866 mount_folder("folder-1").mount(&server).await;
2867 mount_workbook(serde_json::json!([
2868 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2869 ]))
2870 .mount(&server)
2871 .await;
2872 let rules = vec![allow_rule_for("folder-1", GATE_WRITE_AND_STRUCTURE)];
2874 let opts = unleased_opts(FilterVerb::SetBasicFilter {
2875 sheet: "Q1".to_string(),
2876 range: "A1:E10".to_string(),
2877 sort_by: vec!["9:asc".to_string()],
2878 hide_values: Vec::new(),
2879 });
2880 let outcome = filter(&drive, &sheets, &opts, &rules).await;
2881 match outcome.result {
2882 FilterResult::RefusedInvalidRange { detail } => {
2883 assert!(
2884 detail.contains("sort column 9 is outside the selected range's columns 0..5"),
2885 "{detail}"
2886 );
2887 }
2888 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2889 }
2890 }
2891
2892 #[tokio::test]
2893 async fn clear_basic_filter_reports_the_resolved_sheet_id() {
2894 let server = wiremock::MockServer::start().await;
2895 let (drive, sheets) = clients(&server).await;
2896 mount_file(
2897 "sheet-1",
2898 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2899 &["folder-1"],
2900 )
2901 .mount(&server)
2902 .await;
2903 mount_folder("folder-1").mount(&server).await;
2904 mount_workbook(serde_json::json!([
2905 {"properties": {"sheetId": 7, "title": "Q1", "index": 0},
2906 "basicFilter": {"range": {"sheetId": 7}}},
2907 ]))
2908 .mount(&server)
2909 .await;
2910 wiremock::Mock::given(wiremock::matchers::method("POST"))
2911 .and(wiremock::matchers::path(
2912 "/v4/spreadsheets/sheet-1:batchUpdate",
2913 ))
2914 .respond_with(
2915 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2916 "replies": [{}]
2917 })),
2918 )
2919 .mount(&server)
2920 .await;
2921 let rules = vec![allow_rule("folder-1")];
2922 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2923 let opts = FilterOptions {
2924 spreadsheet_id: "sheet-1".to_string(),
2925 verb: FilterVerb::ClearBasicFilter {
2926 sheet: "Q1".to_string(),
2927 },
2928 dry_run: false,
2929 lease_token,
2930 ledger_path,
2931 };
2932 let outcome = filter(&drive, &sheets, &opts, &rules).await;
2933 assert!(matches!(outcome.result, FilterResult::Changed { .. }));
2934 assert_eq!(outcome.sheet_id, Some(7));
2935 }
2936
2937 #[tokio::test]
2938 async fn add_filter_view_reports_the_server_assigned_id() {
2939 let server = wiremock::MockServer::start().await;
2940 let (drive, sheets) = clients(&server).await;
2941 mount_file(
2942 "sheet-1",
2943 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2944 &["folder-1"],
2945 )
2946 .mount(&server)
2947 .await;
2948 mount_folder("folder-1").mount(&server).await;
2949 mount_workbook(serde_json::json!([
2950 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2951 ]))
2952 .mount(&server)
2953 .await;
2954 wiremock::Mock::given(wiremock::matchers::method("POST"))
2955 .and(wiremock::matchers::path(
2956 "/v4/spreadsheets/sheet-1:batchUpdate",
2957 ))
2958 .respond_with(
2959 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2960 "replies": [{"addFilterView": {"filter": {"filterViewId": 42}}}]
2961 })),
2962 )
2963 .mount(&server)
2964 .await;
2965 let rules = vec![allow_rule("folder-1")];
2966 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2967 let opts = FilterOptions {
2968 spreadsheet_id: "sheet-1".to_string(),
2969 verb: FilterVerb::AddFilterView {
2970 sheet: "Q1".to_string(),
2971 range: "A1:D10".to_string(),
2972 title: Some("Open only".to_string()),
2973 sort_by: Vec::new(),
2974 hide_values: Vec::new(),
2975 },
2976 dry_run: false,
2977 lease_token,
2978 ledger_path,
2979 };
2980 let outcome = filter(&drive, &sheets, &opts, &rules).await;
2981 match outcome.result {
2982 FilterResult::Changed { filter_view_id, .. } => {
2983 assert_eq!(filter_view_id, Some(42));
2984 }
2985 other => panic!("expected Changed, got {other:?}"),
2986 }
2987 assert_eq!(outcome.sheet_id, None);
2992 }
2993
2994 #[tokio::test]
2995 async fn update_filter_view_refuses_an_unknown_id() {
2996 let server = wiremock::MockServer::start().await;
2997 let (drive, sheets) = clients(&server).await;
2998 mount_file(
2999 "sheet-1",
3000 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3001 &["folder-1"],
3002 )
3003 .mount(&server)
3004 .await;
3005 mount_folder("folder-1").mount(&server).await;
3006 mount_workbook(serde_json::json!([
3007 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}, "filterViews": []},
3008 ]))
3009 .mount(&server)
3010 .await;
3011 let rules = vec![allow_rule("folder-1")];
3012 let opts = FilterOptions {
3013 spreadsheet_id: "sheet-1".to_string(),
3014 verb: FilterVerb::UpdateFilterView {
3015 filter_view_id: 99,
3016 sheet: None,
3017 range: None,
3018 title: Some("New".to_string()),
3019 sort_by: Vec::new(),
3020 hide_values: Vec::new(),
3021 clear_sort: false,
3022 clear_criteria: false,
3023 },
3024 dry_run: false,
3025 lease_token: None,
3026 ledger_path: std::path::PathBuf::new(),
3027 };
3028 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3029 assert!(matches!(
3030 outcome.result,
3031 FilterResult::RefusedFilterViewNotFound { filter_view_id: 99 }
3032 ));
3033 }
3034
3035 #[tokio::test]
3038 async fn update_filter_view_range_without_sheet_defaults_to_the_views_sheet() {
3039 let server = wiremock::MockServer::start().await;
3040 let (drive, sheets) = clients(&server).await;
3041 mount_file(
3042 "sheet-1",
3043 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3044 &["folder-1"],
3045 )
3046 .mount(&server)
3047 .await;
3048 mount_folder("folder-1").mount(&server).await;
3049 mount_workbook(serde_json::json!([
3050 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
3051 {"properties": {"sheetId": 5, "title": "Q2", "index": 1},
3052 "filterViews": [{"filterViewId": 7, "range": {"sheetId": 5}}]},
3053 ]))
3054 .mount(&server)
3055 .await;
3056 let opts = dry_run_opts(FilterVerb::UpdateFilterView {
3057 filter_view_id: 7,
3058 sheet: None,
3059 range: Some("A1:C9".to_string()),
3060 title: None,
3061 sort_by: Vec::new(),
3062 hide_values: Vec::new(),
3063 clear_sort: false,
3064 clear_criteria: false,
3065 });
3066 let outcome = filter(&drive, &sheets, &opts, &[allow_rule("folder-1")]).await;
3067 assert!(
3068 matches!(outcome.result, FilterResult::WouldChange { .. }),
3069 "{:?}",
3070 outcome.result
3071 );
3072 }
3073
3074 #[tokio::test]
3077 async fn update_filter_view_range_without_sheet_reports_an_unknown_id_as_not_found() {
3078 let server = wiremock::MockServer::start().await;
3079 let (drive, sheets) = clients(&server).await;
3080 mount_file(
3081 "sheet-1",
3082 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3083 &["folder-1"],
3084 )
3085 .mount(&server)
3086 .await;
3087 mount_folder("folder-1").mount(&server).await;
3088 mount_workbook(serde_json::json!([
3089 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
3090 {"properties": {"sheetId": 5, "title": "Q2", "index": 1},
3091 "filterViews": [{"filterViewId": 7, "range": {"sheetId": 5}}]},
3092 ]))
3093 .mount(&server)
3094 .await;
3095 let opts = dry_run_opts(FilterVerb::UpdateFilterView {
3096 filter_view_id: 99,
3097 sheet: None,
3098 range: Some("A1:C9".to_string()),
3099 title: None,
3100 sort_by: Vec::new(),
3101 hide_values: Vec::new(),
3102 clear_sort: false,
3103 clear_criteria: false,
3104 });
3105 let outcome = filter(&drive, &sheets, &opts, &[allow_rule("folder-1")]).await;
3106 assert_eq!(
3107 outcome.result,
3108 FilterResult::RefusedFilterViewNotFound { filter_view_id: 99 }
3109 );
3110 }
3111
3112 #[tokio::test]
3113 async fn delete_filter_view_sends_a_delete_filter_view_request() {
3114 let server = wiremock::MockServer::start().await;
3115 let (drive, sheets) = clients(&server).await;
3116 mount_file(
3117 "sheet-1",
3118 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3119 &["folder-1"],
3120 )
3121 .mount(&server)
3122 .await;
3123 mount_folder("folder-1").mount(&server).await;
3124 mount_workbook(serde_json::json!([
3125 {"properties": {"sheetId": 0, "title": "Q1", "index": 0},
3126 "filterViews": [{"filterViewId": 7}]},
3127 ]))
3128 .mount(&server)
3129 .await;
3130 wiremock::Mock::given(wiremock::matchers::method("POST"))
3131 .and(wiremock::matchers::path(
3132 "/v4/spreadsheets/sheet-1:batchUpdate",
3133 ))
3134 .respond_with(
3135 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3136 "replies": [{}]
3137 })),
3138 )
3139 .mount(&server)
3140 .await;
3141 let rules = vec![allow_rule("folder-1")];
3142 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3143 let opts = FilterOptions {
3144 spreadsheet_id: "sheet-1".to_string(),
3145 verb: FilterVerb::DeleteFilterView { filter_view_id: 7 },
3146 dry_run: false,
3147 lease_token,
3148 ledger_path,
3149 };
3150 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3151 match outcome.result {
3152 FilterResult::Changed { filter_view_id, .. } => {
3153 assert_eq!(filter_view_id, Some(7));
3154 }
3155 other => panic!("expected Changed, got {other:?}"),
3156 }
3157 }
3158
3159 #[tokio::test]
3160 async fn dry_run_makes_no_batch_update_call() {
3161 let server = wiremock::MockServer::start().await;
3162 let (drive, sheets) = clients(&server).await;
3163 mount_file(
3164 "sheet-1",
3165 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3166 &["folder-1"],
3167 )
3168 .mount(&server)
3169 .await;
3170 mount_folder("folder-1").mount(&server).await;
3171 mount_workbook(serde_json::json!([
3172 {"properties": {"sheetId": 0, "title": "Q1", "index": 0},
3173 "basicFilter": {"range": {"sheetId": 0}}},
3174 ]))
3175 .mount(&server)
3176 .await;
3177 let rules = vec![allow_rule("folder-1")];
3181 let opts = FilterOptions {
3182 spreadsheet_id: "sheet-1".to_string(),
3183 verb: FilterVerb::ClearBasicFilter {
3184 sheet: "Q1".to_string(),
3185 },
3186 dry_run: true,
3187 lease_token: None,
3188 ledger_path: std::path::PathBuf::new(),
3189 };
3190 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3191 assert!(matches!(outcome.result, FilterResult::WouldChange { .. }));
3192 }
3193
3194 fn set_verb(sort_by: Vec<String>, hide_values: Vec<String>) -> FilterVerb {
3197 FilterVerb::SetBasicFilter {
3198 sheet: "Q1".to_string(),
3199 range: "A1:D10".to_string(),
3200 sort_by,
3201 hide_values,
3202 }
3203 }
3204
3205 fn update_verb() -> FilterVerb {
3206 FilterVerb::UpdateFilterView {
3207 filter_view_id: 7,
3208 sheet: None,
3209 range: None,
3210 title: Some("Renamed".to_string()),
3211 sort_by: Vec::new(),
3212 hide_values: Vec::new(),
3213 clear_sort: false,
3214 clear_criteria: false,
3215 }
3216 }
3217
3218 fn unleased_opts(verb: FilterVerb) -> FilterOptions {
3219 FilterOptions {
3220 spreadsheet_id: "sheet-1".to_string(),
3221 verb,
3222 dry_run: false,
3223 lease_token: None,
3224 ledger_path: std::path::PathBuf::new(),
3225 }
3226 }
3227
3228 async fn refused_from_flags_alone(verb: FilterVerb) -> FilterOutcome {
3232 let server = wiremock::MockServer::start().await;
3233 let (drive, sheets) = clients(&server).await;
3234 let rules = vec![allow_rule("folder-1")];
3235 let outcome = filter(&drive, &sheets, &unleased_opts(verb), &rules).await;
3236 assert_eq!(outcome.file_name, None);
3237 outcome
3238 }
3239
3240 #[tokio::test]
3241 async fn an_invalid_sort_spec_is_refused_before_any_call() {
3242 let outcome =
3243 refused_from_flags_alone(set_verb(vec!["0:sideways".to_string()], Vec::new())).await;
3244 match outcome.result {
3245 FilterResult::RefusedInvalidRange { detail } => {
3246 assert!(detail.contains("not 'asc' or 'desc'"), "{detail}");
3247 }
3248 other => panic!("expected RefusedInvalidRange, got {other:?}"),
3249 }
3250 }
3251
3252 #[tokio::test]
3253 async fn an_invalid_hidden_values_flag_is_refused_before_any_call() {
3254 let outcome = refused_from_flags_alone(set_verb(Vec::new(), vec!["1:".to_string()])).await;
3255 match outcome.result {
3256 FilterResult::RefusedInvalidRange { detail } => {
3257 assert!(detail.contains("names no values"), "{detail}");
3258 }
3259 other => panic!("expected RefusedInvalidRange, got {other:?}"),
3260 }
3261 }
3262
3263 #[tokio::test]
3264 async fn a_sheet_beside_a_prefixed_range_is_refused_before_any_call() {
3265 let verb = FilterVerb::AddFilterView {
3266 sheet: "Q1".to_string(),
3267 range: "Q2!A1:D10".to_string(),
3268 title: None,
3269 sort_by: Vec::new(),
3270 hide_values: Vec::new(),
3271 };
3272 let outcome = refused_from_flags_alone(verb).await;
3273 match outcome.result {
3274 FilterResult::RefusedInvalidRange { detail } => {
3275 assert!(detail.contains("already names a sheet"), "{detail}");
3276 }
3277 other => panic!("expected RefusedInvalidRange, got {other:?}"),
3278 }
3279 }
3280
3281 #[tokio::test]
3282 async fn update_filter_view_with_nothing_to_change_is_refused_before_any_call() {
3283 let verb = FilterVerb::UpdateFilterView {
3284 filter_view_id: 7,
3285 sheet: None,
3286 range: None,
3287 title: None,
3288 sort_by: Vec::new(),
3289 hide_values: Vec::new(),
3290 clear_sort: false,
3291 clear_criteria: false,
3292 };
3293 let outcome = refused_from_flags_alone(verb).await;
3294 match outcome.result {
3295 FilterResult::RefusedInvalidRange { detail } => {
3296 assert!(detail.contains("nothing to change"), "{detail}");
3297 }
3298 other => panic!("expected RefusedInvalidRange, got {other:?}"),
3299 }
3300 }
3301
3302 #[tokio::test]
3305 async fn a_metadata_fetch_failure_surfaces_as_failed() {
3306 let server = wiremock::MockServer::start().await;
3307 let (drive, sheets) = clients(&server).await;
3308 wiremock::Mock::given(wiremock::matchers::method("GET"))
3309 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
3310 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
3311 .mount(&server)
3312 .await;
3313 let rules: Vec<FolderPermissionRule> = Vec::new();
3314 let opts = unleased_opts(set_verb(Vec::new(), Vec::new()));
3315 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3316 assert!(matches!(outcome.result, FilterResult::Failed { .. }));
3317 assert_eq!(outcome.file_name, None);
3318 }
3319
3320 #[tokio::test]
3321 async fn a_shortcut_target_is_refused() {
3322 let server = wiremock::MockServer::start().await;
3323 let (drive, sheets) = clients(&server).await;
3324 mount_file(
3325 "sheet-1",
3326 "application/vnd.google-apps.shortcut",
3327 &["folder-1"],
3328 )
3329 .mount(&server)
3330 .await;
3331 let rules = vec![allow_rule("folder-1")];
3332 let opts = unleased_opts(set_verb(Vec::new(), Vec::new()));
3333 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3334 assert!(matches!(outcome.result, FilterResult::RefusedShortcut));
3335 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
3336 }
3337
3338 #[tokio::test]
3339 async fn a_non_spreadsheet_target_is_refused() {
3340 let server = wiremock::MockServer::start().await;
3341 let (drive, sheets) = clients(&server).await;
3342 mount_file(
3343 "sheet-1",
3344 "application/vnd.google-apps.document",
3345 &["folder-1"],
3346 )
3347 .mount(&server)
3348 .await;
3349 let rules = vec![allow_rule("folder-1")];
3350 let opts = unleased_opts(FilterVerb::DeleteFilterView { filter_view_id: 7 });
3351 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3352 match outcome.result {
3353 FilterResult::RefusedNotASpreadsheet { mime_type } => {
3354 assert_eq!(mime_type, "application/vnd.google-apps.document");
3355 }
3356 other => panic!("expected RefusedNotASpreadsheet, got {other:?}"),
3357 }
3358 }
3359
3360 #[tokio::test]
3361 async fn a_target_with_no_visible_parents_is_refused() {
3362 let server = wiremock::MockServer::start().await;
3363 let (drive, sheets) = clients(&server).await;
3364 mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
3365 .mount(&server)
3366 .await;
3367 let rules: Vec<FolderPermissionRule> = Vec::new();
3368 let opts = unleased_opts(set_verb(Vec::new(), Vec::new()));
3369 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3370 assert!(matches!(
3371 outcome.result,
3372 FilterResult::RefusedNoVisibleParents
3373 ));
3374 }
3375
3376 #[tokio::test]
3377 async fn a_gate_ancestor_fetch_failure_surfaces_as_failed() {
3378 let server = wiremock::MockServer::start().await;
3379 let (drive, sheets) = clients(&server).await;
3380 mount_file(
3381 "sheet-1",
3382 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3383 &["folder-1"],
3384 )
3385 .mount(&server)
3386 .await;
3387 wiremock::Mock::given(wiremock::matchers::method("GET"))
3388 .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
3389 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
3390 .mount(&server)
3391 .await;
3392 let rules = vec![allow_rule("folder-1")];
3393 let opts = unleased_opts(set_verb(Vec::new(), Vec::new()));
3394 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3395 assert!(matches!(outcome.result, FilterResult::Failed { .. }));
3396 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
3397 }
3398
3399 #[tokio::test]
3400 async fn a_workbook_fetch_failure_after_a_granted_gate_surfaces_as_failed() {
3401 let server = wiremock::MockServer::start().await;
3402 let (drive, sheets) = clients(&server).await;
3403 mount_file(
3404 "sheet-1",
3405 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3406 &["folder-1"],
3407 )
3408 .mount(&server)
3409 .await;
3410 mount_folder("folder-1").mount(&server).await;
3411 wiremock::Mock::given(wiremock::matchers::method("GET"))
3412 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
3413 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
3414 .mount(&server)
3415 .await;
3416 let rules = vec![allow_rule("folder-1")];
3417 let opts = unleased_opts(set_verb(Vec::new(), Vec::new()));
3418 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3419 assert!(matches!(outcome.result, FilterResult::Failed { .. }));
3420 assert_eq!(outcome.sheet_id, None);
3421 }
3422
3423 #[tokio::test]
3424 async fn set_basic_filter_refuses_an_unknown_sheet() {
3425 let server = wiremock::MockServer::start().await;
3426 let (drive, sheets) = clients(&server).await;
3427 mount_file(
3428 "sheet-1",
3429 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3430 &["folder-1"],
3431 )
3432 .mount(&server)
3433 .await;
3434 mount_folder("folder-1").mount(&server).await;
3435 mount_workbook(serde_json::json!([
3436 {"properties": {"sheetId": 0, "title": "Q2", "index": 0}},
3437 ]))
3438 .mount(&server)
3439 .await;
3440 let rules = vec![allow_rule("folder-1")];
3441 let opts = unleased_opts(set_verb(Vec::new(), Vec::new()));
3442 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3443 match outcome.result {
3444 FilterResult::RefusedSheetNotFound { title, available } => {
3445 assert_eq!(title, "Q1");
3446 assert_eq!(available, vec!["Q2".to_string()]);
3447 }
3448 other => panic!("expected RefusedSheetNotFound, got {other:?}"),
3449 }
3450 }
3451
3452 fn mount_workbook_with_view_seven() -> wiremock::Mock {
3455 mount_workbook(serde_json::json!([
3456 {"properties": {"sheetId": 0, "title": "Q1", "index": 0},
3457 "filterViews": [{
3458 "filterViewId": 7,
3459 "title": "Old",
3460 "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 10,
3461 "startColumnIndex": 0, "endColumnIndex": 4},
3462 "sortSpecs": [{"dimensionIndex": 0, "sortOrder": "ASCENDING"}],
3463 "criteria": {"0": {"hiddenValues": ["Old"]}},
3464 }]},
3465 ]))
3466 }
3467
3468 #[tokio::test]
3469 async fn update_filter_view_merges_onto_the_existing_view_and_reports_its_id() {
3470 let server = wiremock::MockServer::start().await;
3471 let (drive, sheets) = clients(&server).await;
3472 mount_file(
3473 "sheet-1",
3474 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3475 &["folder-1"],
3476 )
3477 .mount(&server)
3478 .await;
3479 mount_folder("folder-1").mount(&server).await;
3480 mount_workbook_with_view_seven().mount(&server).await;
3481 wiremock::Mock::given(wiremock::matchers::method("POST"))
3487 .and(wiremock::matchers::path(
3488 "/v4/spreadsheets/sheet-1:batchUpdate",
3489 ))
3490 .and(wiremock::matchers::body_partial_json(serde_json::json!({
3491 "requests": [{"updateFilterView": {
3492 "fields": "title,range,sortSpecs,criteria",
3493 "filter": {
3494 "filterViewId": 7,
3495 "title": "Renamed",
3496 "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 20,
3497 "startColumnIndex": 0, "endColumnIndex": 4},
3498 "sortSpecs": [
3499 {"dimensionIndex": 0, "sortOrder": "ASCENDING"},
3500 {"dimensionIndex": 2, "sortOrder": "DESCENDING"},
3501 ],
3502 "criteria": {
3503 "1": {"hiddenValues": ["Closed"]},
3504 },
3505 },
3506 }}]
3507 })))
3508 .respond_with(
3509 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3510 "replies": [{}]
3511 })),
3512 )
3513 .mount(&server)
3514 .await;
3515 let rules = vec![allow_rule("folder-1")];
3516 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3517 let opts = FilterOptions {
3518 spreadsheet_id: "sheet-1".to_string(),
3519 verb: FilterVerb::UpdateFilterView {
3520 filter_view_id: 7,
3521 sheet: Some("Q1".to_string()),
3522 range: Some("A1:D20".to_string()),
3523 title: Some("Renamed".to_string()),
3524 sort_by: vec!["2:desc".to_string()],
3525 hide_values: vec!["1:Closed".to_string()],
3526 clear_sort: false,
3527 clear_criteria: false,
3528 },
3529 dry_run: false,
3530 lease_token,
3531 ledger_path,
3532 };
3533 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3534 match outcome.result {
3535 FilterResult::Changed {
3536 filter_view_id,
3537 summary,
3538 ..
3539 } => {
3540 assert_eq!(filter_view_id, Some(7));
3541 assert_eq!(
3542 summary,
3543 "update filter view (title='Renamed' sort 2:desc hide 1:Closed)"
3544 );
3545 }
3546 other => panic!("expected Changed, got {other:?}"),
3547 }
3548 assert_eq!(outcome.sheet_id, None);
3551 }
3552
3553 async fn run_update_on_view_seven(
3557 verb: FilterVerb,
3558 reply: serde_json::Value,
3559 ) -> (FilterOutcome, serde_json::Value) {
3560 let server = wiremock::MockServer::start().await;
3561 let (drive, sheets) = clients(&server).await;
3562 mount_granted_gate_with_view_seven(&server).await;
3563 wiremock::Mock::given(wiremock::matchers::method("POST"))
3564 .and(wiremock::matchers::path(
3565 "/v4/spreadsheets/sheet-1:batchUpdate",
3566 ))
3567 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(reply))
3568 .expect(1)
3569 .mount(&server)
3570 .await;
3571 let rules = vec![allow_rule("folder-1")];
3572 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3573 let opts = FilterOptions {
3574 spreadsheet_id: "sheet-1".to_string(),
3575 verb,
3576 dry_run: false,
3577 lease_token,
3578 ledger_path,
3579 };
3580 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3581 let body = server
3582 .received_requests()
3583 .await
3584 .unwrap()
3585 .into_iter()
3586 .find(|request| request.url.path().ends_with(":batchUpdate"))
3587 .map(|request| serde_json::from_slice(&request.body).unwrap())
3588 .unwrap();
3589 (outcome, body)
3590 }
3591
3592 fn update_view_seven(
3593 sort_by: &[&str],
3594 hide_values: &[&str],
3595 clear_sort: bool,
3596 clear_criteria: bool,
3597 ) -> FilterVerb {
3598 FilterVerb::UpdateFilterView {
3599 filter_view_id: 7,
3600 sheet: None,
3601 range: None,
3602 title: None,
3603 sort_by: sort_by.iter().map(|s| (*s).to_string()).collect(),
3604 hide_values: hide_values.iter().map(|s| (*s).to_string()).collect(),
3605 clear_sort,
3606 clear_criteria,
3607 }
3608 }
3609
3610 #[tokio::test]
3611 async fn update_filter_view_clear_criteria_sends_an_explicit_reset() {
3612 let (outcome, body) = run_update_on_view_seven(
3613 update_view_seven(&[], &[], false, true),
3614 serde_json::json!({"replies": [{}]}),
3615 )
3616 .await;
3617 assert!(
3618 matches!(outcome.result, FilterResult::Changed { .. }),
3619 "{:?}",
3620 outcome.result
3621 );
3622 assert_eq!(
3623 body,
3624 serde_json::json!({"requests": [{"updateFilterView": {
3625 "fields": "criteria",
3626 "filter": {"filterViewId": 7, "criteria": {"0": {}}},
3627 }}]})
3628 );
3629 }
3630
3631 #[tokio::test]
3632 async fn update_filter_view_clear_sort_recreates_the_view_under_its_own_id() {
3633 let (outcome, body) = run_update_on_view_seven(
3634 update_view_seven(&["2:desc"], &[], true, false),
3635 serde_json::json!({"replies": [
3636 {},
3637 {"addFilterView": {"filter": {"filterViewId": 7}}},
3638 ]}),
3639 )
3640 .await;
3641 match outcome.result {
3642 FilterResult::Changed {
3643 filter_view_id,
3644 summary,
3645 ..
3646 } => {
3647 assert_eq!(filter_view_id, Some(7));
3648 assert_eq!(
3649 summary,
3650 format!("update filter view (clear sort sort 2:desc){REPLACE_NOTE}")
3651 );
3652 }
3653 other => panic!("expected Changed, got {other:?}"),
3654 }
3655 assert_eq!(
3656 body,
3657 serde_json::json!({"requests": [
3658 {"deleteFilterView": {"filterId": 7}},
3659 {"addFilterView": {"filter": {
3660 "filterViewId": 7,
3661 "title": "Old",
3662 "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 10,
3663 "startColumnIndex": 0, "endColumnIndex": 4},
3664 "sortSpecs": [{"dimensionIndex": 2, "sortOrder": "DESCENDING"}],
3665 "criteria": {"0": {"hiddenValues": ["Old"]}},
3666 }}},
3667 ]})
3668 );
3669 }
3670
3671 async fn run_clear_sort_on_bound_view_with_condition() -> (FilterOutcome, serde_json::Value) {
3674 let server = wiremock::MockServer::start().await;
3675 let (drive, sheets) = clients(&server).await;
3676 mount_file(
3677 "sheet-1",
3678 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3679 &["folder-1"],
3680 )
3681 .mount(&server)
3682 .await;
3683 mount_folder("folder-1").mount(&server).await;
3684 mount_workbook(serde_json::json!([
3685 {"properties": {"sheetId": 0, "title": "Q1", "index": 0},
3686 "filterViews": [{
3687 "filterViewId": 7,
3688 "title": "Bound",
3689 "range": {"sheetId": 0, "startRowIndex": 10, "endRowIndex": 16,
3690 "startColumnIndex": 0, "endColumnIndex": 4},
3691 "tableId": "454645209",
3692 "sortSpecs": [{"dimensionIndex": 2, "sortOrder": "DESCENDING"}],
3693 "criteria": {
3694 "1": {"hiddenValues": ["b"]},
3695 "2": {"condition": {"type": "NUMBER_GREATER",
3696 "values": [{"userEnteredValue": "2"}]}},
3697 },
3698 }]},
3699 ]))
3700 .mount(&server)
3701 .await;
3702 wiremock::Mock::given(wiremock::matchers::method("POST"))
3703 .and(wiremock::matchers::path(
3704 "/v4/spreadsheets/sheet-1:batchUpdate",
3705 ))
3706 .respond_with(
3707 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3708 "replies": [{}, {"addFilterView": {"filter": {"filterViewId": 7}}}]
3709 })),
3710 )
3711 .expect(1)
3712 .mount(&server)
3713 .await;
3714 let rules = vec![allow_rule("folder-1")];
3715 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3716 let opts = FilterOptions {
3717 spreadsheet_id: "sheet-1".to_string(),
3718 verb: update_view_seven(&["0:asc"], &[], true, false),
3719 dry_run: false,
3720 lease_token,
3721 ledger_path,
3722 };
3723 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3724 let body = server
3725 .received_requests()
3726 .await
3727 .unwrap()
3728 .into_iter()
3729 .find(|request| request.url.path().ends_with(":batchUpdate"))
3730 .map(|request| serde_json::from_slice(&request.body).unwrap())
3731 .unwrap();
3732 (outcome, body)
3733 }
3734
3735 #[tokio::test]
3736 async fn update_filter_view_recreation_carries_unmodelled_criteria_and_the_table_binding() {
3737 let (outcome, body) = run_clear_sort_on_bound_view_with_condition().await;
3738 assert!(
3739 matches!(
3740 outcome.result,
3741 FilterResult::Changed {
3742 filter_view_id: Some(7),
3743 ..
3744 }
3745 ),
3746 "{:?}",
3747 outcome.result
3748 );
3749 assert_eq!(
3750 body,
3751 serde_json::json!({"requests": [
3752 {"deleteFilterView": {"filterId": 7}},
3753 {"addFilterView": {"filter": {
3754 "filterViewId": 7,
3755 "title": "Bound",
3756 "tableId": "454645209",
3757 "sortSpecs": [{"dimensionIndex": 0, "sortOrder": "ASCENDING"}],
3758 "criteria": {
3759 "1": {"hiddenValues": ["b"]},
3760 "2": {"condition": {"type": "NUMBER_GREATER",
3761 "values": [{"userEnteredValue": "2"}]}},
3762 },
3763 }}},
3764 ]})
3765 );
3766 }
3767
3768 #[tokio::test]
3769 async fn update_filter_view_dry_run_says_when_it_would_recreate_the_view() {
3770 let server = wiremock::MockServer::start().await;
3771 let (drive, sheets) = clients(&server).await;
3772 mount_granted_gate_with_view_seven(&server).await;
3773 let rules = vec![allow_rule("folder-1")];
3774 let mut opts = unleased_opts(update_view_seven(&[], &[], true, false));
3775 opts.dry_run = true;
3776 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3777 match outcome.result {
3778 FilterResult::WouldChange { summary, .. } => {
3779 assert_eq!(
3780 summary,
3781 format!("update filter view (clear sort){REPLACE_NOTE}")
3782 );
3783 }
3784 other => panic!("expected WouldChange, got {other:?}"),
3785 }
3786 }
3787
3788 #[tokio::test]
3789 async fn update_filter_view_recreation_with_no_range_is_refused_before_any_write() {
3790 let server = wiremock::MockServer::start().await;
3791 let (drive, sheets) = clients(&server).await;
3792 mount_file(
3793 "sheet-1",
3794 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3795 &["folder-1"],
3796 )
3797 .mount(&server)
3798 .await;
3799 mount_folder("folder-1").mount(&server).await;
3800 mount_workbook(serde_json::json!([
3801 {"properties": {"sheetId": 0, "title": "Q1", "index": 0},
3802 "filterViews": [{
3803 "filterViewId": 7,
3804 "sortSpecs": [{"dimensionIndex": 0, "sortOrder": "ASCENDING"}],
3805 }]},
3806 ]))
3807 .mount(&server)
3808 .await;
3809 let rules = vec![allow_rule("folder-1")];
3810 let opts = unleased_opts(update_view_seven(&[], &[], true, false));
3811 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3812 match outcome.result {
3813 FilterResult::RefusedInvalidRange { detail } => {
3814 assert!(detail.contains("re-creates filter view 7"), "{detail}");
3815 }
3816 other => panic!("expected RefusedInvalidRange, got {other:?}"),
3817 }
3818 }
3819
3820 #[tokio::test]
3821 async fn update_filter_view_with_only_a_range_change_is_described_bare() {
3822 let server = wiremock::MockServer::start().await;
3823 let (drive, sheets) = clients(&server).await;
3824 mount_file(
3825 "sheet-1",
3826 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3827 &["folder-1"],
3828 )
3829 .mount(&server)
3830 .await;
3831 mount_folder("folder-1").mount(&server).await;
3832 mount_workbook_with_view_seven().mount(&server).await;
3833 let rules = vec![allow_rule("folder-1")];
3834 let opts = FilterOptions {
3835 spreadsheet_id: "sheet-1".to_string(),
3836 verb: FilterVerb::UpdateFilterView {
3837 filter_view_id: 7,
3838 sheet: None,
3839 range: Some("Q1!A1:D20".to_string()),
3840 title: None,
3841 sort_by: Vec::new(),
3842 hide_values: Vec::new(),
3843 clear_sort: false,
3844 clear_criteria: false,
3845 },
3846 dry_run: true,
3847 lease_token: None,
3848 ledger_path: std::path::PathBuf::new(),
3849 };
3850 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3851 match outcome.result {
3852 FilterResult::WouldChange { summary, .. } => {
3853 assert_eq!(summary, "update filter view");
3854 }
3855 other => panic!("expected WouldChange, got {other:?}"),
3856 }
3857 }
3858
3859 #[tokio::test]
3860 async fn a_batch_update_failure_surfaces_as_failed() {
3861 let server = wiremock::MockServer::start().await;
3862 let (drive, sheets) = clients(&server).await;
3863 mount_file(
3864 "sheet-1",
3865 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3866 &["folder-1"],
3867 )
3868 .mount(&server)
3869 .await;
3870 mount_folder("folder-1").mount(&server).await;
3871 mount_workbook_with_view_seven().mount(&server).await;
3872 wiremock::Mock::given(wiremock::matchers::method("POST"))
3873 .and(wiremock::matchers::path(
3874 "/v4/spreadsheets/sheet-1:batchUpdate",
3875 ))
3876 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
3877 .mount(&server)
3878 .await;
3879 let rules = vec![allow_rule("folder-1")];
3880 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3881 let opts = FilterOptions {
3882 spreadsheet_id: "sheet-1".to_string(),
3883 verb: update_verb(),
3884 dry_run: false,
3885 lease_token,
3886 ledger_path,
3887 };
3888 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3889 match outcome.result {
3890 FilterResult::Failed { detail } => assert!(detail.contains("500"), "{detail}"),
3891 other => panic!("expected Failed, got {other:?}"),
3892 }
3893 }
3894
3895 #[tokio::test]
3896 async fn update_filter_view_with_an_unreadable_reply_reports_applied_reply_unreadable() {
3897 let server = wiremock::MockServer::start().await;
3898 let (drive, sheets) = clients(&server).await;
3899 mount_file(
3900 "sheet-1",
3901 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3902 &["folder-1"],
3903 )
3904 .mount(&server)
3905 .await;
3906 mount_folder("folder-1").mount(&server).await;
3907 mount_workbook_with_view_seven().mount(&server).await;
3908 wiremock::Mock::given(wiremock::matchers::method("POST"))
3910 .and(wiremock::matchers::path(
3911 "/v4/spreadsheets/sheet-1:batchUpdate",
3912 ))
3913 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
3914 .expect(1)
3915 .mount(&server)
3916 .await;
3917 let rules = vec![allow_rule("folder-1")];
3918 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3919 let opts = FilterOptions {
3920 spreadsheet_id: "sheet-1".to_string(),
3921 verb: update_verb(),
3922 dry_run: false,
3923 lease_token,
3924 ledger_path,
3925 };
3926 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3927 match &outcome.result {
3928 FilterResult::AppliedReplyUnreadable { filter_view_id, .. } => {
3930 assert_eq!(*filter_view_id, Some(7));
3931 }
3932 other => panic!("expected AppliedReplyUnreadable, got {other:?}"), }
3934 assert_eq!(outcome.result.log_status(), "applied-reply-unreadable");
3935 assert!(
3936 describe(&outcome).starts_with("Applied, but the reply could not be read"),
3937 "{}",
3938 describe(&outcome)
3939 );
3940 }
3941
3942 #[tokio::test]
3945 async fn refuses_without_a_lease_when_the_rule_requires_one() {
3946 let server = wiremock::MockServer::start().await;
3947 let (drive, sheets) = clients(&server).await;
3948 mount_file(
3949 "sheet-1",
3950 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3951 &["folder-1"],
3952 )
3953 .mount(&server)
3954 .await;
3955 mount_folder("folder-1").mount(&server).await;
3956 mount_workbook_with_view_seven().mount(&server).await;
3957 let rules = vec![allow_rule("folder-1")];
3960 let opts = unleased_opts(FilterVerb::DeleteFilterView { filter_view_id: 7 });
3961 let outcome = filter(&drive, &sheets, &opts, &rules).await;
3962 assert!(matches!(outcome.result, FilterResult::RefusedNoLease));
3963 assert_eq!(outcome.result.log_status(), "refused-no-lease");
3964 }
3965
3966 #[test]
3969 fn describe_effect_names_the_view_and_its_sort_and_hidden_values() {
3970 let verb = FilterVerb::AddFilterView {
3971 sheet: "Q1".to_string(),
3972 range: "A1:D10".to_string(),
3973 title: Some("Open only".to_string()),
3974 sort_by: vec!["0:asc".to_string()],
3975 hide_values: vec!["1:Closed".to_string()],
3976 };
3977 assert_eq!(
3978 describe_effect(&verb),
3979 "add filter view 'Open only' (sort 0:asc hide 1:Closed)"
3980 );
3981 assert_eq!(
3982 describe_effect(&set_verb(Vec::new(), Vec::new())),
3983 "set basic filter"
3984 );
3985 }
3986
3987 #[test]
3988 fn describe_effect_lists_every_update_filter_view_part_in_order() {
3989 let verb = FilterVerb::UpdateFilterView {
3990 filter_view_id: 7,
3991 sheet: None,
3992 range: None,
3993 title: Some("Renamed".to_string()),
3994 sort_by: vec!["0:asc".to_string()],
3995 hide_values: vec!["1:Closed".to_string()],
3996 clear_sort: true,
3997 clear_criteria: true,
3998 };
3999 assert_eq!(
4000 describe_effect(&verb),
4001 "update filter view (title='Renamed' clear sort sort 0:asc clear criteria hide 1:Closed)"
4002 );
4003 }
4004
4005 fn outcome_with(
4008 verb: FilterVerb,
4009 file_name: Option<&str>,
4010 result: FilterResult,
4011 ) -> FilterOutcome {
4012 FilterOutcome {
4013 spreadsheet_id: "sheet-1".to_string(),
4014 file_name: file_name.map(str::to_string),
4015 resolved_folder_id: None,
4016 sheet_id: None,
4017 verb,
4018 result,
4019 }
4020 }
4021
4022 #[test]
4023 fn describe_lines_renders_would_change() {
4024 let out = outcome_with(
4025 set_verb(Vec::new(), Vec::new()),
4026 Some("Budget"),
4027 FilterResult::WouldChange {
4028 summary: "set basic filter".to_string(),
4029 width_warning: None,
4030 },
4031 );
4032 assert_eq!(describe(&out), "Would set basic filter in 'Budget'");
4033 }
4034
4035 #[test]
4036 fn describe_lines_renders_not_a_spreadsheet_with_no_file_name() {
4037 let out = outcome_with(
4038 set_verb(Vec::new(), Vec::new()),
4039 None,
4040 FilterResult::RefusedNotASpreadsheet {
4041 mime_type: "text/plain".to_string(),
4042 },
4043 );
4044 let text = describe(&out);
4045 assert!(text.contains("'sheet-1'"), "{text}");
4046 assert!(text.contains("set-basic-filter"), "{text}");
4047 assert!(text.contains("text/plain"), "{text}");
4048 }
4049
4050 #[test]
4051 fn describe_lines_renders_shortcut_and_no_visible_parents() {
4052 let shortcut = outcome_with(
4053 FilterVerb::DeleteFilterView { filter_view_id: 7 },
4054 Some("Budget"),
4055 FilterResult::RefusedShortcut,
4056 );
4057 let text = describe(&shortcut);
4058 assert!(text.contains("shortcut"), "{text}");
4059 assert!(text.contains("delete-filter-view"), "{text}");
4060
4061 let orphan = outcome_with(
4062 set_verb(Vec::new(), Vec::new()),
4063 Some("Budget"),
4064 FilterResult::RefusedNoVisibleParents,
4065 );
4066 assert!(describe(&orphan).contains("sheets-structure"));
4067 }
4068
4069 #[test]
4070 fn describe_lines_renders_sheet_not_found_with_and_without_available_titles() {
4071 let none = outcome_with(
4072 set_verb(Vec::new(), Vec::new()),
4073 Some("Budget"),
4074 FilterResult::RefusedSheetNotFound {
4075 title: "Q1".to_string(),
4076 available: Vec::new(),
4077 },
4078 );
4079 assert!(describe(&none).contains("Available: none"));
4080
4081 let some = outcome_with(
4082 set_verb(Vec::new(), Vec::new()),
4083 Some("Budget"),
4084 FilterResult::RefusedSheetNotFound {
4085 title: "Q1".to_string(),
4086 available: vec!["Q2".to_string(), "Q3".to_string()],
4087 },
4088 );
4089 assert!(describe(&some).contains("Available: 'Q2', 'Q3'"));
4090 }
4091
4092 #[test]
4093 fn describe_lines_renders_invalid_range_and_filter_view_not_found() {
4094 let invalid = outcome_with(
4095 set_verb(Vec::new(), Vec::new()),
4096 Some("Budget"),
4097 FilterResult::RefusedInvalidRange {
4098 detail: "bad range".to_string(),
4099 },
4100 );
4101 assert_eq!(describe(&invalid), "Refused: bad range");
4102
4103 let missing = outcome_with(
4104 update_verb(),
4105 Some("Budget"),
4106 FilterResult::RefusedFilterViewNotFound { filter_view_id: 7 },
4107 );
4108 let text = describe(&missing);
4109 assert!(text.contains("no filter view with id 7"), "{text}");
4110 assert!(text.contains("list-filter-views"), "{text}");
4111 }
4112
4113 #[test]
4114 fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
4115 let folder_rule = outcome_with(
4116 update_verb(),
4117 Some("Budget"),
4118 FilterResult::Blocked {
4119 operation: DriveOperation::SheetsStructure,
4120 decided_by: Some(DecidingRule::Folder {
4121 folder_id: "folder-1".to_string(),
4122 depth: 2,
4123 }),
4124 },
4125 );
4126 let text = describe(&folder_rule);
4127 assert!(text.contains("update-filter-view"), "{text}");
4128 assert!(text.contains("folder folder-1 (depth 2)"), "{text}");
4129
4130 let default_policy = outcome_with(
4131 FilterVerb::ClearBasicFilter {
4132 sheet: "Q1".to_string(),
4133 },
4134 Some("Budget"),
4135 FilterResult::Blocked {
4136 operation: DriveOperation::SheetsStructure,
4137 decided_by: None,
4138 },
4139 );
4140 let text = describe(&default_policy);
4141 assert!(text.contains("default policy"), "{text}");
4142 assert!(text.contains("sheets-structure"), "{text}");
4143 }
4144
4145 #[test]
4146 fn describe_lines_renders_every_lease_refusal_with_the_lease_acquire_hint() {
4147 for (result, phrase) in [
4148 (FilterResult::RefusedNoLease, "requires a Drive write lease"),
4149 (
4150 FilterResult::RefusedLeaseExpired,
4151 "expired, released, or unknown",
4152 ),
4153 (
4154 FilterResult::RefusedLeaseWrongFile,
4155 "acquired for a different file",
4156 ),
4157 (
4158 FilterResult::RefusedLeaseStale,
4159 "changed since the lease was acquired",
4160 ),
4161 ] {
4162 let out = outcome_with(update_verb(), Some("Budget"), result);
4163 let text = describe(&out);
4164 assert!(text.contains(phrase), "{text}");
4165 assert!(text.contains("drive lease acquire sheet-1"), "{text}");
4166 }
4167 }
4168
4169 #[test]
4170 fn describe_lines_renders_changed_with_and_without_an_id() {
4171 let with_id = outcome_with(
4172 FilterVerb::DeleteFilterView { filter_view_id: 7 },
4173 Some("Budget"),
4174 FilterResult::Changed {
4175 summary: "delete filter view".to_string(),
4176 filter_view_id: Some(7),
4177 width_warning: None,
4178 },
4179 );
4180 assert_eq!(
4181 describe(&with_id),
4182 "Applied: delete filter view (id 7) in 'Budget'"
4183 );
4184
4185 let without_id = outcome_with(
4186 set_verb(Vec::new(), Vec::new()),
4187 Some("Budget"),
4188 FilterResult::Changed {
4189 summary: "set basic filter".to_string(),
4190 filter_view_id: None,
4191 width_warning: None,
4192 },
4193 );
4194 assert_eq!(
4195 describe(&without_id),
4196 "Applied: set basic filter in 'Budget'"
4197 );
4198 }
4199
4200 #[test]
4201 fn describe_lines_renders_applied_reply_unreadable_with_an_id_hint_only_for_adds() {
4202 let added = outcome_with(
4203 FilterVerb::AddFilterView {
4204 sheet: "Q1".to_string(),
4205 range: "A1:D10".to_string(),
4206 title: None,
4207 sort_by: Vec::new(),
4208 hide_values: Vec::new(),
4209 },
4210 Some("Budget"),
4211 FilterResult::AppliedReplyUnreadable {
4212 summary: "add filter view".to_string(),
4213 filter_view_id: None,
4214 width_warning: None,
4215 detail: "bad reply".to_string(),
4216 },
4217 );
4218 assert_eq!(
4219 describe_lines(&added),
4220 vec![
4221 "Applied, but the reply could not be read: add filter view in 'Budget' — \
4222 do not retry; check the spreadsheet first (bad reply)"
4223 .to_string(),
4224 " the new filter view's id was in the unreadable reply; run \
4225 `list-filter-views` to find it"
4226 .to_string(),
4227 ]
4228 );
4229
4230 let updated = outcome_with(
4231 update_verb(),
4232 Some("Budget"),
4233 FilterResult::AppliedReplyUnreadable {
4234 summary: "update filter view 7".to_string(),
4235 filter_view_id: Some(7),
4236 width_warning: None,
4237 detail: "bad reply".to_string(),
4238 },
4239 );
4240 assert_eq!(
4242 describe_lines(&updated),
4243 vec![
4244 "Applied, but the reply could not be read: update filter view 7 (id 7) in \
4245 'Budget' — do not retry; check the spreadsheet first (bad reply)"
4246 .to_string()
4247 ]
4248 );
4249 }
4250
4251 #[test]
4254 fn describe_lines_keeps_the_reorder_caveats_for_an_unreadable_reply() {
4255 let out = outcome_with(
4256 set_verb(vec!["0:asc".to_string()], Vec::new()),
4257 Some("Budget"),
4258 FilterResult::AppliedReplyUnreadable {
4259 summary: "set basic filter (sort 0:asc)".to_string(),
4260 filter_view_id: None,
4261 width_warning: Some("selected columns 0..4 of 9".to_string()),
4262 detail: "bad reply".to_string(),
4263 },
4264 );
4265 assert_eq!(
4266 describe_lines(&out),
4267 vec![
4268 "Applied, but the reply could not be read: set basic filter (sort 0:asc) in \
4269 'Budget' — do not retry; check the spreadsheet first (bad reply)"
4270 .to_string(),
4271 "Warning: selected columns 0..4 of 9".to_string(),
4272 " references outside the range may now observe values from a different row"
4273 .to_string(),
4274 ]
4275 );
4276 }
4277
4278 #[test]
4279 fn mutation_record_logs_the_same_fields_for_an_unreadable_reply_as_for_changed() {
4280 let opts = unleased_opts(FilterVerb::DeleteFilterView { filter_view_id: 7 });
4281 let record = |result| {
4282 let mut out = outcome_with(opts.verb.clone(), Some("Budget"), result);
4283 out.sheet_id = Some(3);
4284 mutation_record(&out, &opts, Duration::ZERO)
4285 };
4286 let changed = record(FilterResult::Changed {
4287 summary: "delete filter view".to_string(),
4288 filter_view_id: Some(7),
4289 width_warning: None,
4290 });
4291 let unreadable = record(FilterResult::AppliedReplyUnreadable {
4292 summary: "delete filter view".to_string(),
4293 filter_view_id: Some(7),
4294 width_warning: None,
4295 detail: "bad reply".to_string(),
4296 });
4297 assert_eq!(unreadable.sheet_id, Some(3));
4298 assert_eq!(unreadable.filter_view_id, changed.filter_view_id);
4299 assert_eq!(unreadable.status, "applied-reply-unreadable");
4300 assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
4301 assert_eq!(
4303 unreadable.fields_changed.as_deref(),
4304 Some("delete filter view")
4305 );
4306 }
4307
4308 #[test]
4309 fn describe_lines_renders_failed() {
4310 let out = outcome_with(
4311 set_verb(Vec::new(), Vec::new()),
4312 Some("Budget"),
4313 FilterResult::Failed {
4314 detail: "boom".to_string(),
4315 },
4316 );
4317 assert_eq!(describe(&out), "Failed: boom");
4318 }
4319
4320 async fn mount_granted_gate_with_view_seven(server: &wiremock::MockServer) {
4325 mount_file(
4326 "sheet-1",
4327 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4328 &["folder-1"],
4329 )
4330 .mount(server)
4331 .await;
4332 mount_folder("folder-1").mount(server).await;
4333 mount_workbook_with_view_seven().mount(server).await;
4334 }
4335
4336 fn fresh_ledger_path() -> std::path::PathBuf {
4337 tempfile::tempdir()
4338 .unwrap()
4339 .keep()
4340 .join("lease-ledger.jsonl")
4341 }
4342
4343 #[tokio::test]
4344 async fn refuses_an_unknown_lease_token() {
4345 let server = wiremock::MockServer::start().await;
4346 let (drive, sheets) = clients(&server).await;
4347 mount_granted_gate_with_view_seven(&server).await;
4348 let rules = vec![allow_rule("folder-1")];
4349 let opts = FilterOptions {
4351 spreadsheet_id: "sheet-1".to_string(),
4352 verb: update_verb(),
4353 dry_run: false,
4354 lease_token: Some("bogus-token".to_string()),
4355 ledger_path: fresh_ledger_path(),
4356 };
4357 let outcome = filter(&drive, &sheets, &opts, &rules).await;
4358 assert!(matches!(outcome.result, FilterResult::RefusedLeaseExpired));
4359 assert_eq!(outcome.result.log_status(), "refused-lease-expired");
4360 }
4361
4362 #[tokio::test]
4363 async fn refuses_a_lease_bound_to_a_different_file() {
4364 let server = wiremock::MockServer::start().await;
4365 let (drive, sheets) = clients(&server).await;
4366 mount_granted_gate_with_view_seven(&server).await;
4367 let rules = vec![allow_rule("folder-1")];
4368 let ledger_path = fresh_ledger_path();
4369 let token = seed_lease(&ledger_path, "some-other-sheet", "1");
4371 let opts = FilterOptions {
4372 spreadsheet_id: "sheet-1".to_string(),
4373 verb: update_verb(),
4374 dry_run: false,
4375 lease_token: Some(token),
4376 ledger_path,
4377 };
4378 let outcome = filter(&drive, &sheets, &opts, &rules).await;
4379 assert!(matches!(
4380 outcome.result,
4381 FilterResult::RefusedLeaseWrongFile
4382 ));
4383 assert_eq!(outcome.result.log_status(), "refused-lease-wrong-file");
4384 }
4385
4386 #[tokio::test]
4387 async fn refuses_a_stale_lease_when_the_file_has_moved() {
4388 let server = wiremock::MockServer::start().await;
4389 let (drive, sheets) = clients(&server).await;
4390 mount_granted_gate_with_view_seven(&server).await;
4393 let rules = vec![allow_rule("folder-1")];
4394 let ledger_path = fresh_ledger_path();
4395 let token = seed_lease(&ledger_path, "sheet-1", "0");
4396 let opts = FilterOptions {
4397 spreadsheet_id: "sheet-1".to_string(),
4398 verb: update_verb(),
4399 dry_run: false,
4400 lease_token: Some(token),
4401 ledger_path,
4402 };
4403 let outcome = filter(&drive, &sheets, &opts, &rules).await;
4404 assert!(matches!(outcome.result, FilterResult::RefusedLeaseStale));
4405 assert_eq!(outcome.result.log_status(), "refused-lease-stale");
4406 }
4407
4408 #[tokio::test]
4411 async fn clear_basic_filter_refuses_an_unknown_sheet() {
4412 let server = wiremock::MockServer::start().await;
4413 let (drive, sheets) = clients(&server).await;
4414 mount_granted_gate_with_view_seven(&server).await;
4415 let rules = vec![allow_rule("folder-1")];
4416 let opts = unleased_opts(FilterVerb::ClearBasicFilter {
4417 sheet: "Nope".to_string(),
4418 });
4419 let outcome = filter(&drive, &sheets, &opts, &rules).await;
4420 match outcome.result {
4421 FilterResult::RefusedSheetNotFound { title, available } => {
4422 assert_eq!(title, "Nope");
4423 assert_eq!(available, vec!["Q1".to_string()]);
4424 }
4425 other => panic!("expected RefusedSheetNotFound, got {other:?}"),
4426 }
4427 }
4428
4429 #[tokio::test]
4430 async fn update_filter_view_refuses_a_range_on_an_unknown_sheet() {
4431 let server = wiremock::MockServer::start().await;
4432 let (drive, sheets) = clients(&server).await;
4433 mount_granted_gate_with_view_seven(&server).await;
4434 let rules = vec![allow_rule("folder-1")];
4435 let opts = unleased_opts(FilterVerb::UpdateFilterView {
4436 filter_view_id: 7,
4437 sheet: Some("Nope".to_string()),
4438 range: Some("A1:D20".to_string()),
4439 title: None,
4440 sort_by: Vec::new(),
4441 hide_values: Vec::new(),
4442 clear_sort: false,
4443 clear_criteria: false,
4444 });
4445 let outcome = filter(&drive, &sheets, &opts, &rules).await;
4446 assert!(matches!(
4447 outcome.result,
4448 FilterResult::RefusedSheetNotFound { .. }
4449 ));
4450 }
4451
4452 #[tokio::test]
4453 async fn set_basic_filter_refuses_an_unparseable_range() {
4454 let server = wiremock::MockServer::start().await;
4455 let (drive, sheets) = clients(&server).await;
4456 mount_granted_gate_with_view_seven(&server).await;
4457 let rules = vec![allow_rule("folder-1")];
4458 let opts = unleased_opts(FilterVerb::SetBasicFilter {
4463 sheet: "Q1".to_string(),
4464 range: "A1:B".to_string(),
4465 sort_by: Vec::new(),
4466 hide_values: Vec::new(),
4467 });
4468 let outcome = filter(&drive, &sheets, &opts, &rules).await;
4469 match outcome.result {
4470 FilterResult::RefusedInvalidRange { detail } => {
4471 assert!(detail.contains("not a recognised A1 range"), "{detail}");
4472 }
4473 other => panic!("expected RefusedInvalidRange, got {other:?}"),
4474 }
4475 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
4476 }
4477
4478 #[tokio::test]
4479 async fn update_filter_view_refuses_an_unparseable_range() {
4480 let server = wiremock::MockServer::start().await;
4481 let (drive, sheets) = clients(&server).await;
4482 mount_granted_gate_with_view_seven(&server).await;
4483 let rules = vec![allow_rule("folder-1")];
4484 let opts = unleased_opts(FilterVerb::UpdateFilterView {
4485 filter_view_id: 7,
4486 sheet: Some("Q1".to_string()),
4487 range: Some("A1:B".to_string()),
4488 title: None,
4489 sort_by: Vec::new(),
4490 hide_values: Vec::new(),
4491 clear_sort: false,
4492 clear_criteria: false,
4493 });
4494 let outcome = filter(&drive, &sheets, &opts, &rules).await;
4495 assert!(matches!(
4496 outcome.result,
4497 FilterResult::RefusedInvalidRange { .. }
4498 ));
4499 }
4500
4501 #[tokio::test]
4502 async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
4503 let server = wiremock::MockServer::start().await;
4508 let (drive, sheets) = clients(&server).await;
4509 mount_file(
4510 "sheet-1",
4511 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4512 &["folder-1"],
4513 )
4514 .up_to_n_times(1)
4515 .with_priority(1)
4516 .mount(&server)
4517 .await;
4518 wiremock::Mock::given(wiremock::matchers::method("GET"))
4519 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
4520 .respond_with(wiremock::ResponseTemplate::new(500))
4521 .with_priority(2)
4522 .mount(&server)
4523 .await;
4524 mount_folder("folder-1").mount(&server).await;
4525 mount_workbook_with_view_seven().mount(&server).await;
4526 wiremock::Mock::given(wiremock::matchers::method("POST"))
4527 .and(wiremock::matchers::path(
4528 "/v4/spreadsheets/sheet-1:batchUpdate",
4529 ))
4530 .respond_with(wiremock::ResponseTemplate::new(200))
4531 .expect(0)
4532 .mount(&server)
4533 .await;
4534 let rules = vec![allow_rule("folder-1")];
4535 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
4536 let opts = FilterOptions {
4537 spreadsheet_id: "sheet-1".to_string(),
4538 verb: FilterVerb::DeleteFilterView { filter_view_id: 7 },
4539 dry_run: false,
4540 lease_token,
4541 ledger_path,
4542 };
4543 let outcome = filter(&drive, &sheets, &opts, &rules).await;
4544 assert!(matches!(outcome.result, FilterResult::Failed { .. }));
4545 }
4546}