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