1use std::path::PathBuf;
56use std::time::{Duration, Instant};
57
58use serde::Serialize;
59
60use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
61use crate::drive::client::DriveClient;
62use crate::drive::files_api::FilesApi;
63use crate::drive::lease::check::{
64 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
65 LeasedWrite,
66};
67use crate::drive::sheets::api::{
68 applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
69};
70use crate::drive::sheets::client::SheetsClient;
71use crate::drive::sheets::grid_range;
72use crate::drive::sheets::target_gate;
73use crate::drive::sheets::types::{
74 AddDimensionGroupRequest, BatchUpdateRequestItem, DeleteDimensionGroupRequest, Dimension,
75 DimensionGroup, DimensionRange, Sheet, Spreadsheet, UpdateDimensionGroupRequest,
76};
77use crate::drive::types::SheetTargetRefusal;
78use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
79use crate::request_log::{self, DriveMutationOutcome};
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum DimensionGroupVerb {
84 AddDimensionGroup {
86 sheet: String,
88 dimension: Dimension,
90 start: i64,
92 end: i64,
94 },
95 UpdateDimensionGroup {
97 sheet: String,
99 dimension: Dimension,
101 start: i64,
104 end: i64,
106 depth: Option<i64>,
110 collapsed: bool,
112 },
113 DeleteDimensionGroup {
115 sheet: String,
117 dimension: Dimension,
119 start: i64,
122 end: i64,
124 },
125}
126
127impl DimensionGroupVerb {
128 const fn log_operation(&self) -> &'static str {
129 match self {
130 Self::AddDimensionGroup { .. } => "sheets-add-dimension-group",
131 Self::UpdateDimensionGroup { .. } => "sheets-update-dimension-group",
132 Self::DeleteDimensionGroup { .. } => "sheets-delete-dimension-group",
133 }
134 }
135
136 const fn label(&self) -> &'static str {
137 match self {
138 Self::AddDimensionGroup { .. } => "add-dimension-group",
139 Self::UpdateDimensionGroup { .. } => "update-dimension-group",
140 Self::DeleteDimensionGroup { .. } => "delete-dimension-group",
141 }
142 }
143
144 const fn sheet_dimension_span(&self) -> (&str, Dimension, i64, i64) {
146 match self {
147 Self::AddDimensionGroup {
148 sheet,
149 dimension,
150 start,
151 end,
152 }
153 | Self::UpdateDimensionGroup {
154 sheet,
155 dimension,
156 start,
157 end,
158 ..
159 }
160 | Self::DeleteDimensionGroup {
161 sheet,
162 dimension,
163 start,
164 end,
165 } => (sheet.as_str(), *dimension, *start, *end),
166 }
167 }
168}
169
170#[derive(Debug, Clone)]
172pub struct DimensionGroupOptions {
173 pub spreadsheet_id: String,
175 pub verb: DimensionGroupVerb,
177 pub dry_run: bool,
179 pub lease_token: Option<String>,
184 pub ledger_path: PathBuf,
188}
189
190#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
192#[serde(tag = "status", rename_all = "kebab-case")]
193pub enum DimensionGroupResult {
194 WouldChange {
196 summary: String,
198 },
199 RefusedNotASpreadsheet {
201 mime_type: String,
203 },
204 RefusedShortcut,
206 RefusedNoVisibleParents,
208 RefusedSheetNotFound {
210 title: String,
212 available: Vec<String>,
214 },
215 RefusedInvalidRange {
218 detail: String,
220 },
221 RefusedDimensionGroupNotFound {
224 detail: String,
226 },
227 RefusedAmbiguousDimensionGroup {
230 depths: Vec<i64>,
232 },
233 Blocked {
235 decided_by: Option<DecidingRule>,
237 },
238 RefusedNoLease,
241 RefusedLeaseExpired,
244 RefusedLeaseWrongFile,
246 RefusedLeaseStale,
249 Changed {
251 summary: String,
253 depth: Option<i64>,
258 },
259 AppliedReplyUnreadable {
263 summary: String,
265 depth: Option<i64>,
268 detail: String,
270 },
271 Failed {
273 detail: String,
275 },
276}
277
278impl FromLeaseRefusal for DimensionGroupResult {
279 fn from_no_lease() -> Self {
280 Self::RefusedNoLease
281 }
282 fn from_lease_expired() -> Self {
283 Self::RefusedLeaseExpired
284 }
285 fn from_lease_wrong_file() -> Self {
286 Self::RefusedLeaseWrongFile
287 }
288 fn from_lease_stale() -> Self {
289 Self::RefusedLeaseStale
290 }
291 fn from_lease_failed(detail: String) -> Self {
292 Self::Failed { detail }
293 }
294}
295
296impl DimensionGroupResult {
297 fn log_status(&self) -> &'static str {
298 match self {
299 Self::WouldChange { .. } => "would-change",
300 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
301 Self::RefusedShortcut => "refused-shortcut",
302 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
303 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
304 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
305 Self::RefusedDimensionGroupNotFound { .. } => "refused-dimension-group-not-found",
306 Self::RefusedAmbiguousDimensionGroup { .. } => "refused-ambiguous-dimension-group",
307 Self::Blocked { .. } => "blocked",
308 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
309 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
310 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
311 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
312 Self::Changed { .. } => "changed",
313 Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
314 Self::Failed { .. } => "failed",
315 }
316 }
317}
318
319#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
321pub struct DimensionGroupOutcome {
322 pub spreadsheet_id: String,
324 #[serde(skip_serializing_if = "Option::is_none")]
326 pub file_name: Option<String>,
327 #[serde(skip_serializing_if = "Option::is_none")]
329 pub resolved_folder_id: Option<String>,
330 #[serde(skip_serializing_if = "Option::is_none")]
332 pub sheet_id: Option<i64>,
333 #[serde(skip_serializing_if = "Option::is_none")]
336 pub dimension_range_label: Option<String>,
337 #[serde(skip)]
339 pub verb: DimensionGroupVerb,
340 pub result: DimensionGroupResult,
342}
343
344impl JsonlSerialize for DimensionGroupOutcome {
345 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
346 write_scalar_jsonl(self, out)
347 }
348}
349
350pub async fn dimension_group(
353 drive: &DriveClient,
354 sheets: &SheetsClient,
355 opts: &DimensionGroupOptions,
356 rules: &[FolderPermissionRule],
357) -> DimensionGroupOutcome {
358 let started = Instant::now();
359 let outcome = dimension_group_inner(drive, sheets, opts, rules).await;
360 if !opts.dry_run {
361 record_attempt(&outcome, opts, started.elapsed());
362 }
363 outcome
364}
365
366async fn dimension_group_inner(
367 drive: &DriveClient,
368 sheets: &SheetsClient,
369 opts: &DimensionGroupOptions,
370 rules: &[FolderPermissionRule],
371) -> DimensionGroupOutcome {
372 let bare = |result| DimensionGroupOutcome {
373 spreadsheet_id: opts.spreadsheet_id.clone(),
374 file_name: None,
375 resolved_folder_id: None,
376 sheet_id: None,
377 dimension_range_label: None,
378 verb: opts.verb.clone(),
379 result,
380 };
381
382 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
383 drive,
384 &opts.spreadsheet_id,
385 DriveOperation::SheetsStructure,
386 rules,
387 )
388 .await
389 {
390 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
391 return bare(DimensionGroupResult::Failed { detail })
392 }
393 target_gate::TargetGateOutcome::Refused { target, refusal } => {
394 let result = match refusal {
395 SheetTargetRefusal::Shortcut => DimensionGroupResult::RefusedShortcut,
396 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
397 DimensionGroupResult::RefusedNotASpreadsheet { mime_type }
398 }
399 SheetTargetRefusal::NoVisibleParents => {
400 DimensionGroupResult::RefusedNoVisibleParents
401 }
402 };
403 return DimensionGroupOutcome {
404 spreadsheet_id: opts.spreadsheet_id.clone(),
405 file_name: Some(target.name),
406 resolved_folder_id: None,
407 sheet_id: None,
408 dimension_range_label: None,
409 verb: opts.verb.clone(),
410 result,
411 };
412 }
413 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
414 return DimensionGroupOutcome {
415 spreadsheet_id: opts.spreadsheet_id.clone(),
416 file_name: Some(target.name),
417 resolved_folder_id: None,
418 sheet_id: None,
419 dimension_range_label: None,
420 verb: opts.verb.clone(),
421 result: DimensionGroupResult::Failed { detail },
422 };
423 }
424 target_gate::TargetGateOutcome::Gated {
425 target,
426 decision,
427 resolved_folder_id,
428 requires_lease,
429 } => (target, decision, resolved_folder_id, requires_lease),
430 };
431
432 let pre_gated = |result| DimensionGroupOutcome {
433 spreadsheet_id: opts.spreadsheet_id.clone(),
434 file_name: Some(target.name.clone()),
435 resolved_folder_id: resolved_folder_id.clone(),
436 sheet_id: None,
437 dimension_range_label: None,
438 verb: opts.verb.clone(),
439 result,
440 };
441
442 if decision.verdict == write_gate::Verdict::Deny {
443 return pre_gated(DimensionGroupResult::Blocked {
444 decided_by: decision.decided_by,
445 });
446 }
447
448 let api = SheetsApi::new(sheets);
449 let workbook = match api
450 .get_spreadsheet_with_dimension_groups(&opts.spreadsheet_id)
451 .await
452 {
453 Ok(workbook) => workbook,
454 Err(err) => {
455 return pre_gated(DimensionGroupResult::Failed {
456 detail: format!("{err:#}"),
457 })
458 }
459 };
460
461 let (sheet_title, dimension, start, end) = opts.verb.sheet_dimension_span();
462 let sheet_id = match grid_range::find_sheet_id(&workbook, sheet_title, |title, available| {
463 DimensionGroupResult::RefusedSheetNotFound { title, available }
464 }) {
465 Ok(sheet_id) => sheet_id,
466 Err(result) => return pre_gated(result),
467 };
468 let Some(sheet) = find_sheet(&workbook, sheet_id) else {
469 unreachable!("find_sheet_id resolved this id from this same workbook") };
471
472 let range = match validate_span(dimension, start, end, sheet, sheet_id) {
473 Ok(range) => range,
474 Err(detail) => return pre_gated(DimensionGroupResult::RefusedInvalidRange { detail }),
475 };
476 let range_label = dimension_range_label(&range);
477
478 let gated = |sheet_id, result| DimensionGroupOutcome {
479 spreadsheet_id: opts.spreadsheet_id.clone(),
480 file_name: Some(target.name.clone()),
481 resolved_folder_id: resolved_folder_id.clone(),
482 sheet_id: Some(sheet_id),
483 dimension_range_label: Some(range_label.clone()),
484 verb: opts.verb.clone(),
485 result,
486 };
487
488 let existing = match &opts.verb {
489 DimensionGroupVerb::AddDimensionGroup { .. } => ExistingGroups::None,
490 DimensionGroupVerb::UpdateDimensionGroup { depth, .. } => {
491 match resolve_for_update(candidates(sheet, dimension, &range), *depth) {
492 Ok(group) => ExistingGroups::One(group),
493 Err(result) => return gated(sheet_id, result),
494 }
495 }
496 DimensionGroupVerb::DeleteDimensionGroup { .. } => {
497 match resolve_for_delete(candidates(sheet, dimension, &range)) {
498 Ok(depth) => ExistingGroups::MaybeOne(depth),
499 Err(result) => return gated(sheet_id, result),
500 }
501 }
502 };
503
504 let summary = describe_effect(&opts.verb, &range_label);
505
506 if opts.dry_run {
507 return gated(sheet_id, DimensionGroupResult::WouldChange { summary });
508 }
509
510 let (request, depth) = build_request(&opts.verb, range, existing);
511
512 let files_api = FilesApi::new(drive);
513 let leased = LeasedWrite {
514 log_prefix: "drive sheets dimension_group",
515 operation: opts.verb.log_operation(),
516 ledger_path: &opts.ledger_path,
517 file_id: &opts.spreadsheet_id,
518 };
519 let lease_grant = match gate_optional_leased_write(
520 leased,
521 &files_api,
522 requires_lease,
523 opts.lease_token.as_deref(),
524 )
525 .await
526 {
527 Ok(grant) => grant,
528 Err(err) => return gated(sheet_id, err.into_result()),
529 };
530
531 let result = match conclude_native_leased_write(
532 leased,
533 &lease_grant,
534 &files_api,
535 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
536 |err| format!("{err:#}"),
537 )
538 .await
539 {
540 Ok(BatchUpdateOutcome::Applied(_response)) => {
541 DimensionGroupResult::Changed { summary, depth }
542 }
543 Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
546 DimensionGroupResult::AppliedReplyUnreadable {
547 summary,
548 depth,
549 detail,
550 }
551 }
552 Err(err) => DimensionGroupResult::Failed {
553 detail: format!("{err:#}"),
554 },
555 };
556 drop(lease_grant);
557 gated(sheet_id, result)
558}
559
560enum ExistingGroups<'a> {
566 None,
567 One(&'a DimensionGroup),
568 MaybeOne(Option<i64>),
569}
570
571fn find_sheet(workbook: &Spreadsheet, sheet_id: i64) -> Option<&Sheet> {
575 workbook
576 .sheets
577 .iter()
578 .find(|s| s.sheet_id() == Some(sheet_id))
579}
580
581fn candidates<'a>(
584 sheet: &'a Sheet,
585 dimension: Dimension,
586 range: &DimensionRange,
587) -> Vec<&'a DimensionGroup> {
588 let groups = match dimension {
589 Dimension::Rows => &sheet.row_groups,
590 Dimension::Columns => &sheet.column_groups,
591 };
592 groups
593 .iter()
594 .filter(|group| &group.range == range)
595 .collect()
596}
597
598fn resolve_for_update(
601 candidates: Vec<&DimensionGroup>,
602 depth: Option<i64>,
603) -> Result<&DimensionGroup, DimensionGroupResult> {
604 if let Some(depth) = depth {
605 return candidates
606 .into_iter()
607 .find(|group| group.depth == depth)
608 .ok_or_else(|| DimensionGroupResult::RefusedDimensionGroupNotFound {
609 detail: format!("no dimension group over that span at depth {depth}"),
610 });
611 }
612 match candidates.len() {
613 0 => Err(DimensionGroupResult::RefusedDimensionGroupNotFound {
614 detail: "no dimension group exists over that span; run \
615 `drive sheets list-dimension-groups` to see what exists"
616 .to_string(),
617 }),
618 1 => Ok(candidates[0]),
619 _ => Err(DimensionGroupResult::RefusedAmbiguousDimensionGroup {
620 depths: candidates.iter().map(|group| group.depth).collect(),
621 }),
622 }
623}
624
625fn resolve_for_delete(
631 candidates: Vec<&DimensionGroup>,
632) -> Result<Option<i64>, DimensionGroupResult> {
633 match candidates.len() {
634 0 => Err(DimensionGroupResult::RefusedDimensionGroupNotFound {
635 detail: "no dimension group exists over that span; run \
636 `drive sheets list-dimension-groups` to see what exists"
637 .to_string(),
638 }),
639 1 => Ok(Some(candidates[0].depth)),
640 _ => Ok(None),
641 }
642}
643
644fn validate_span(
651 dimension: Dimension,
652 start: i64,
653 end: i64,
654 sheet: &Sheet,
655 sheet_id: i64,
656) -> Result<DimensionRange, String> {
657 if start < 1 {
658 return Err(format!("--start must be at least 1, got {start}"));
659 }
660 if end < start {
661 return Err(format!(
662 "--end must be >= --start (got --start {start} --end {end})"
663 ));
664 }
665 let current = sheet
666 .properties
667 .as_ref()
668 .and_then(|props| props.grid_properties.as_ref())
669 .and_then(|grid| match dimension {
670 Dimension::Rows => grid.row_count,
671 Dimension::Columns => grid.column_count,
672 });
673 if let Some(current) = current {
674 if end > current {
675 return Err(format!(
676 "--end {end} is past the end of the sheet, which has {current} {noun}(s)",
677 noun = dimension.noun(),
678 ));
679 }
680 }
681 Ok(DimensionRange {
682 sheet_id,
683 dimension,
684 start_index: start - 1,
685 end_index: end,
686 })
687}
688
689fn dimension_range_label(range: &DimensionRange) -> String {
695 range
696 .dimension
697 .span_label(range.start_index + 1, range.end_index)
698}
699
700fn build_request(
705 verb: &DimensionGroupVerb,
706 range: DimensionRange,
707 existing: ExistingGroups<'_>,
708) -> (BatchUpdateRequestItem, Option<i64>) {
709 match verb {
710 DimensionGroupVerb::AddDimensionGroup { .. } => (
711 BatchUpdateRequestItem::AddDimensionGroup(AddDimensionGroupRequest { range }),
712 None,
713 ),
714 DimensionGroupVerb::UpdateDimensionGroup { collapsed, .. } => {
715 let ExistingGroups::One(existing) = existing else {
716 unreachable!("existing is resolved for UpdateDimensionGroup above")
718 };
720 let dimension_group = DimensionGroup {
721 range,
722 depth: existing.depth,
723 collapsed: *collapsed,
724 };
725 (
726 BatchUpdateRequestItem::UpdateDimensionGroup(UpdateDimensionGroupRequest {
727 dimension_group,
728 fields: "collapsed".to_string(),
729 }),
730 Some(existing.depth),
731 )
732 }
733 DimensionGroupVerb::DeleteDimensionGroup { .. } => {
734 let ExistingGroups::MaybeOne(depth) = existing else {
735 unreachable!("existing is resolved for DeleteDimensionGroup above")
737 };
739 (
740 BatchUpdateRequestItem::DeleteDimensionGroup(DeleteDimensionGroupRequest { range }),
741 depth,
742 )
743 }
744 }
745}
746
747fn describe_effect(verb: &DimensionGroupVerb, range_label: &str) -> String {
748 match verb {
749 DimensionGroupVerb::AddDimensionGroup { dimension, .. } => {
750 format!("add a {} group over {range_label}", dimension.noun())
751 }
752 DimensionGroupVerb::UpdateDimensionGroup { collapsed, .. } => {
753 format!("set collapsed={collapsed} on the group over {range_label}")
754 }
755 DimensionGroupVerb::DeleteDimensionGroup { .. } => {
756 format!("delete the group over {range_label}")
757 }
758 }
759}
760
761fn record_attempt(
762 outcome: &DimensionGroupOutcome,
763 opts: &DimensionGroupOptions,
764 duration: Duration,
765) {
766 request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
767}
768
769fn mutation_record(
772 outcome: &DimensionGroupOutcome,
773 opts: &DimensionGroupOptions,
774 duration: Duration,
775) -> DriveMutationOutcome {
776 let error = match &outcome.result {
777 DimensionGroupResult::Failed { detail }
778 | DimensionGroupResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
779 _ => None,
780 };
781 let decided_by = match &outcome.result {
782 DimensionGroupResult::Blocked { decided_by } => decided_by.as_ref(),
783 _ => None,
784 };
785 let decided_by = write_gate::decided_by_log_fields(decided_by);
786 let fields_changed = match (&opts.verb, &outcome.result) {
787 (_, DimensionGroupResult::AppliedReplyUnreadable { summary, .. }) => Some(summary.clone()),
789 (
790 DimensionGroupVerb::UpdateDimensionGroup { collapsed, .. },
791 DimensionGroupResult::Changed { .. },
792 ) => Some(format!("collapsed={collapsed}")),
793 _ => None,
794 };
795
796 DriveMutationOutcome {
797 operation: opts.verb.log_operation(),
798 file_id: outcome.spreadsheet_id.clone(),
799 file_name: outcome.file_name.clone().unwrap_or_default(),
800 status: outcome.result.log_status().to_string(),
801 resolved_folder_id: outcome.resolved_folder_id.clone(),
802 decided_by_folder_id: decided_by.folder_id,
803 decided_by_depth: decided_by.depth,
804 decided_by_file_id: decided_by.file_id,
805 sheet_id: outcome.sheet_id,
806 dimension_range: outcome.dimension_range_label.clone(),
807 fields_changed,
808 error,
809 duration,
810 ..Default::default()
811 }
812}
813
814#[must_use]
816pub fn describe(outcome: &DimensionGroupOutcome) -> String {
817 describe_lines(outcome).join("\n")
818}
819
820#[must_use]
823pub fn describe_lines(outcome: &DimensionGroupOutcome) -> Vec<String> {
824 let verb = &outcome.verb;
825 let book = outcome.file_name.as_deref().map_or_else(
826 || format!("'{}'", outcome.spreadsheet_id),
827 |n| format!("'{n}'"),
828 );
829 match &outcome.result {
830 DimensionGroupResult::WouldChange { summary } => {
831 vec![format!("Would {summary} in {book}")]
832 }
833 DimensionGroupResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
834 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
835 `drive sheets {}` only works on spreadsheets",
836 verb.label()
837 )],
838 DimensionGroupResult::RefusedShortcut => vec![format!(
839 "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
840 verb.label()
841 )],
842 DimensionGroupResult::RefusedNoVisibleParents => vec![format!(
843 "Refused: {book} has no parent folder visible to this account, so no folder \
844 rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
845 id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
846 )],
847 DimensionGroupResult::RefusedSheetNotFound { title, available } => {
848 vec![grid_range::no_sheet_refusal(&book, title, available)]
849 }
850 DimensionGroupResult::RefusedInvalidRange { detail }
851 | DimensionGroupResult::RefusedDimensionGroupNotFound { detail } => {
852 vec![format!("Refused: {detail}")]
853 }
854 DimensionGroupResult::RefusedAmbiguousDimensionGroup { depths } => {
855 let depths = depths
856 .iter()
857 .map(i64::to_string)
858 .collect::<Vec<_>>()
859 .join(", ");
860 vec![format!(
861 "Refused: more than one dimension group exists over that span, at depths \
862 {depths}; pass --depth to pick one"
863 )]
864 }
865 DimensionGroupResult::Blocked { decided_by } => vec![match decided_by {
866 Some(rule) => format!(
867 "Blocked: {} on {book} refused by rule on {} {}{}",
868 verb.label(),
869 rule.kind_label(),
870 rule.id(),
871 rule.depth_suffix()
872 ),
873 None => format!(
874 "Blocked: {} on {book} refused by default policy (no matching rule for \
875 sheets-structure)",
876 verb.label()
877 ),
878 }],
879 DimensionGroupResult::RefusedNoLease => LeaseGateRefusal::NoLease
880 .describe_line(&outcome.spreadsheet_id, &book)
881 .into_iter()
882 .collect(),
883 DimensionGroupResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
884 .describe_line(&outcome.spreadsheet_id, &book)
885 .into_iter()
886 .collect(),
887 DimensionGroupResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
888 .describe_line(&outcome.spreadsheet_id, &book)
889 .into_iter()
890 .collect(),
891 DimensionGroupResult::RefusedLeaseStale => LeaseGateRefusal::Stale
892 .describe_line(&outcome.spreadsheet_id, &book)
893 .into_iter()
894 .collect(),
895 DimensionGroupResult::Changed { summary, depth } => {
896 let id = depth.map_or_else(String::new, |depth| format!(" (depth {depth})"));
897 vec![format!("Applied: {summary}{id} in {book}")]
898 }
899 DimensionGroupResult::AppliedReplyUnreadable {
900 summary,
901 depth,
902 detail,
903 } => {
904 let id = depth.map_or_else(String::new, |depth| format!(" (depth {depth})"));
906 vec![applied_reply_unreadable_line(
907 &format!("{summary}{id}"),
908 &book,
909 RetryHint::NotIdempotent,
910 detail,
911 )]
912 }
913 DimensionGroupResult::Failed { detail } => vec![format!("Failed: {detail}")],
914 }
915}
916
917#[cfg(test)]
918#[allow(clippy::unwrap_used, clippy::expect_used)]
919mod tests {
920 use super::*;
921 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
922 use crate::drive::sheets::client::SHEETS_API_URL;
923 use crate::drive::sheets::types::{GridProperties, SheetProperties};
924 use crate::drive::test_support::seed_lease;
925 use crate::test_support::env::MapEnv;
926 use crate::utils::secret::Secret;
927 use std::collections::HashSet;
928
929 #[test]
932 fn log_operation_and_label_are_distinct_for_every_verb() {
933 let verbs = [add_verb(), update_verb(), delete_verb()];
934 let mut operations: Vec<&str> = verbs
935 .iter()
936 .map(DimensionGroupVerb::log_operation)
937 .collect();
938 let mut labels: Vec<&str> = verbs.iter().map(DimensionGroupVerb::label).collect();
939 operations.sort_unstable();
940 operations.dedup();
941 labels.sort_unstable();
942 labels.dedup();
943 assert_eq!(operations.len(), verbs.len());
944 assert_eq!(labels.len(), verbs.len());
945 }
946
947 #[test]
948 fn dimension_group_result_log_status_names_every_variant() {
949 assert_eq!(
950 DimensionGroupResult::WouldChange {
951 summary: String::new()
952 }
953 .log_status(),
954 "would-change"
955 );
956 assert_eq!(
957 DimensionGroupResult::RefusedNotASpreadsheet {
958 mime_type: String::new()
959 }
960 .log_status(),
961 "refused-not-a-spreadsheet"
962 );
963 assert_eq!(
964 DimensionGroupResult::RefusedShortcut.log_status(),
965 "refused-shortcut"
966 );
967 assert_eq!(
968 DimensionGroupResult::RefusedNoVisibleParents.log_status(),
969 "refused-no-visible-parents"
970 );
971 assert_eq!(
972 DimensionGroupResult::RefusedSheetNotFound {
973 title: String::new(),
974 available: Vec::new(),
975 }
976 .log_status(),
977 "refused-sheet-not-found"
978 );
979 assert_eq!(
980 DimensionGroupResult::RefusedInvalidRange {
981 detail: String::new()
982 }
983 .log_status(),
984 "refused-invalid-range"
985 );
986 assert_eq!(
987 DimensionGroupResult::RefusedDimensionGroupNotFound {
988 detail: String::new()
989 }
990 .log_status(),
991 "refused-dimension-group-not-found"
992 );
993 assert_eq!(
994 DimensionGroupResult::RefusedAmbiguousDimensionGroup { depths: Vec::new() }
995 .log_status(),
996 "refused-ambiguous-dimension-group"
997 );
998 assert_eq!(
999 DimensionGroupResult::Blocked { decided_by: None }.log_status(),
1000 "blocked"
1001 );
1002 assert_eq!(
1003 DimensionGroupResult::RefusedNoLease.log_status(),
1004 LeaseGateRefusal::NoLease.log_status()
1005 );
1006 assert_eq!(
1007 DimensionGroupResult::RefusedLeaseExpired.log_status(),
1008 LeaseGateRefusal::Expired.log_status()
1009 );
1010 assert_eq!(
1011 DimensionGroupResult::RefusedLeaseWrongFile.log_status(),
1012 LeaseGateRefusal::WrongFile.log_status()
1013 );
1014 assert_eq!(
1015 DimensionGroupResult::RefusedLeaseStale.log_status(),
1016 LeaseGateRefusal::Stale.log_status()
1017 );
1018 assert_eq!(
1019 DimensionGroupResult::Changed {
1020 summary: String::new(),
1021 depth: None,
1022 }
1023 .log_status(),
1024 "changed"
1025 );
1026 assert_eq!(
1027 DimensionGroupResult::AppliedReplyUnreadable {
1028 summary: String::new(),
1029 depth: None,
1030 detail: String::new(),
1031 }
1032 .log_status(),
1033 "applied-reply-unreadable"
1034 );
1035 assert_eq!(
1036 DimensionGroupResult::Failed {
1037 detail: String::new()
1038 }
1039 .log_status(),
1040 "failed"
1041 );
1042 }
1043
1044 fn range(
1045 sheet_id: i64,
1046 dimension: Dimension,
1047 start_index: i64,
1048 end_index: i64,
1049 ) -> DimensionRange {
1050 DimensionRange {
1051 sheet_id,
1052 dimension,
1053 start_index,
1054 end_index,
1055 }
1056 }
1057
1058 fn group(
1059 sheet_id: i64,
1060 start_index: i64,
1061 end_index: i64,
1062 depth: i64,
1063 collapsed: bool,
1064 ) -> DimensionGroup {
1065 DimensionGroup {
1066 range: range(sheet_id, Dimension::Rows, start_index, end_index),
1067 depth,
1068 collapsed,
1069 }
1070 }
1071
1072 fn sheet_with_row_groups(sheet_id: i64, groups: Vec<DimensionGroup>) -> Sheet {
1073 Sheet {
1074 properties: Some(SheetProperties {
1075 sheet_id: Some(sheet_id),
1076 title: "Q1".to_string(),
1077 index: Some(0),
1078 hidden: None,
1079 grid_properties: Some(GridProperties {
1080 row_count: Some(1000),
1081 column_count: Some(26),
1082 frozen_row_count: None,
1083 frozen_column_count: None,
1084 hide_gridlines: None,
1085 }),
1086 right_to_left: None,
1087 }),
1088 row_groups: groups,
1089 ..Default::default()
1090 }
1091 }
1092
1093 #[test]
1094 fn validate_span_rejects_start_below_one() {
1095 let sheet = sheet_with_row_groups(0, Vec::new());
1096 let err = validate_span(Dimension::Rows, 0, 5, &sheet, 0).unwrap_err();
1097 assert!(err.contains("--start"), "{err}");
1098 }
1099
1100 #[test]
1101 fn validate_span_rejects_end_before_start() {
1102 let sheet = sheet_with_row_groups(0, Vec::new());
1103 let err = validate_span(Dimension::Rows, 5, 3, &sheet, 0).unwrap_err();
1104 assert!(err.contains("--end"), "{err}");
1105 }
1106
1107 #[test]
1108 fn validate_span_rejects_end_past_the_sheet() {
1109 let sheet = sheet_with_row_groups(0, Vec::new());
1110 let err = validate_span(Dimension::Rows, 998, 1001, &sheet, 0).unwrap_err();
1111 assert!(err.contains("past the end"), "{err}");
1112 }
1113
1114 #[test]
1115 fn validate_span_allows_end_equal_to_the_sheet_count() {
1116 let sheet = sheet_with_row_groups(0, Vec::new());
1117 let result = validate_span(Dimension::Rows, 995, 1000, &sheet, 0).unwrap();
1118 assert_eq!(result, range(0, Dimension::Rows, 994, 1000));
1119 }
1120
1121 #[test]
1122 fn validate_span_checks_the_columns_extent_too() {
1123 let sheet = sheet_with_row_groups(0, Vec::new());
1124 let err = validate_span(Dimension::Columns, 20, 30, &sheet, 0).unwrap_err();
1125 assert!(err.contains("past the end") && err.contains("26"), "{err}");
1126
1127 let result = validate_span(Dimension::Columns, 20, 26, &sheet, 0).unwrap();
1128 assert_eq!(result, range(0, Dimension::Columns, 19, 26));
1129 }
1130
1131 #[test]
1132 fn validate_span_allows_any_span_when_the_sheet_extent_is_unknown() {
1133 let sheet = Sheet {
1134 properties: Some(SheetProperties {
1135 sheet_id: Some(0),
1136 title: "Q1".to_string(),
1137 index: Some(0),
1138 hidden: None,
1139 grid_properties: None,
1140 right_to_left: None,
1141 }),
1142 ..Default::default()
1143 };
1144 let result = validate_span(Dimension::Rows, 1, 1_000_000, &sheet, 0).unwrap();
1145 assert_eq!(result, range(0, Dimension::Rows, 0, 1_000_000));
1146 }
1147
1148 #[test]
1149 fn validate_span_converts_one_based_inclusive_to_zero_based_half_open() {
1150 let sheet = sheet_with_row_groups(0, Vec::new());
1151 let result = validate_span(Dimension::Rows, 5, 7, &sheet, 0).unwrap();
1152 assert_eq!(result, range(0, Dimension::Rows, 4, 7));
1153 }
1154
1155 #[test]
1156 fn dimension_range_label_is_one_based_inclusive() {
1157 assert_eq!(
1158 dimension_range_label(&range(0, Dimension::Rows, 4, 7)),
1159 "ROWS 5:7"
1160 );
1161 assert_eq!(
1162 dimension_range_label(&range(0, Dimension::Columns, 0, 3)),
1163 "COLUMNS 1:3"
1164 );
1165 }
1166
1167 #[test]
1168 fn candidates_filters_by_exact_range_on_the_named_axis() {
1169 let target = range(0, Dimension::Rows, 4, 9);
1170 let sheet = sheet_with_row_groups(
1171 0,
1172 vec![
1173 group(0, 4, 9, 1, false),
1174 group(0, 4, 9, 2, true),
1175 group(0, 10, 20, 1, false),
1176 ],
1177 );
1178 let found = candidates(&sheet, Dimension::Rows, &target);
1179 assert_eq!(found.len(), 2);
1180 assert!(found.iter().all(|g| g.range == target));
1181 }
1182
1183 #[test]
1184 fn candidates_reads_the_column_groups_for_the_column_axis() {
1185 let target = range(0, Dimension::Columns, 0, 3);
1186 let mut sheet = sheet_with_row_groups(0, Vec::new());
1187 sheet.column_groups = vec![DimensionGroup {
1188 range: target.clone(),
1189 depth: 1,
1190 collapsed: false,
1191 }];
1192 let found = candidates(&sheet, Dimension::Columns, &target);
1193 assert_eq!(found.len(), 1);
1194 }
1195
1196 #[test]
1197 fn resolve_for_update_refuses_not_found() {
1198 let err = resolve_for_update(Vec::new(), None).unwrap_err();
1199 assert!(matches!(
1200 err,
1201 DimensionGroupResult::RefusedDimensionGroupNotFound { .. }
1202 ));
1203 }
1204
1205 #[test]
1206 fn resolve_for_update_resolves_the_single_match() {
1207 let g = group(0, 4, 9, 1, false);
1208 let found = resolve_for_update(vec![&g], None).unwrap();
1209 assert_eq!(found.depth, 1);
1210 }
1211
1212 #[test]
1213 fn resolve_for_update_refuses_ambiguous_without_depth() {
1214 let shallow = group(0, 4, 9, 1, false);
1215 let deep = group(0, 4, 9, 2, true);
1216 let err = resolve_for_update(vec![&shallow, &deep], None).unwrap_err();
1217 let DimensionGroupResult::RefusedAmbiguousDimensionGroup { depths } = err else {
1218 panic!("expected RefusedAmbiguousDimensionGroup"); };
1220 assert_eq!(depths, vec![1, 2]);
1221 }
1222
1223 #[test]
1224 fn resolve_for_update_disambiguates_by_depth() {
1225 let shallow = group(0, 4, 9, 1, false);
1226 let deep = group(0, 4, 9, 2, true);
1227 let found = resolve_for_update(vec![&shallow, &deep], Some(2)).unwrap();
1228 assert!(found.collapsed);
1229 assert_eq!(found.depth, 2);
1230 }
1231
1232 #[test]
1233 fn resolve_for_update_refuses_a_depth_that_does_not_exist() {
1234 let shallow = group(0, 4, 9, 1, false);
1235 let err = resolve_for_update(vec![&shallow], Some(9)).unwrap_err();
1236 assert!(matches!(
1237 err,
1238 DimensionGroupResult::RefusedDimensionGroupNotFound { .. }
1239 ));
1240 }
1241
1242 #[test]
1243 fn resolve_for_delete_refuses_not_found() {
1244 let err = resolve_for_delete(Vec::new()).unwrap_err();
1245 assert!(matches!(
1246 err,
1247 DimensionGroupResult::RefusedDimensionGroupNotFound { .. }
1248 ));
1249 }
1250
1251 #[test]
1252 fn resolve_for_delete_reports_the_depth_when_unambiguous() {
1253 let g = group(0, 4, 9, 2, false);
1254 let depth = resolve_for_delete(vec![&g]).unwrap();
1255 assert_eq!(depth, Some(2));
1256 }
1257
1258 #[test]
1259 fn resolve_for_delete_accepts_an_ambiguous_match_reporting_no_depth() {
1260 let shallow = group(0, 4, 9, 1, false);
1261 let deep = group(0, 4, 9, 2, true);
1262 let depth = resolve_for_delete(vec![&shallow, &deep]).unwrap();
1263 assert_eq!(depth, None);
1264 }
1265
1266 fn add_verb() -> DimensionGroupVerb {
1267 DimensionGroupVerb::AddDimensionGroup {
1268 sheet: "Q1".to_string(),
1269 dimension: Dimension::Rows,
1270 start: 5,
1271 end: 9,
1272 }
1273 }
1274
1275 fn update_verb() -> DimensionGroupVerb {
1276 DimensionGroupVerb::UpdateDimensionGroup {
1277 sheet: "Q1".to_string(),
1278 dimension: Dimension::Rows,
1279 start: 5,
1280 end: 9,
1281 depth: None,
1282 collapsed: true,
1283 }
1284 }
1285
1286 fn delete_verb() -> DimensionGroupVerb {
1287 DimensionGroupVerb::DeleteDimensionGroup {
1288 sheet: "Q1".to_string(),
1289 dimension: Dimension::Rows,
1290 start: 5,
1291 end: 9,
1292 }
1293 }
1294
1295 #[test]
1296 fn build_request_add_dimension_group_carries_the_range() {
1297 let target = range(0, Dimension::Rows, 4, 9);
1298 let (request, depth) = build_request(&add_verb(), target.clone(), ExistingGroups::None);
1299 assert_eq!(depth, None);
1300 let BatchUpdateRequestItem::AddDimensionGroup(add) = request else {
1301 panic!("expected AddDimensionGroup"); };
1303 assert_eq!(add.range, target);
1304 }
1305
1306 #[test]
1307 fn build_request_update_dimension_group_sets_collapsed_and_names_the_field_mask() {
1308 let target = range(0, Dimension::Rows, 4, 9);
1309 let existing = group(0, 4, 9, 3, false);
1310 let verb = DimensionGroupVerb::UpdateDimensionGroup {
1311 sheet: "Q1".to_string(),
1312 dimension: Dimension::Rows,
1313 start: 5,
1314 end: 9,
1315 depth: Some(3),
1316 collapsed: true,
1317 };
1318 let (request, depth) = build_request(&verb, target.clone(), ExistingGroups::One(&existing));
1319 assert_eq!(depth, Some(3));
1320 let BatchUpdateRequestItem::UpdateDimensionGroup(update) = request else {
1321 panic!("expected UpdateDimensionGroup"); };
1323 assert_eq!(update.fields, "collapsed");
1324 assert_eq!(update.dimension_group.depth, 3);
1325 assert!(update.dimension_group.collapsed);
1326 assert_eq!(update.dimension_group.range, target);
1327 }
1328
1329 #[test]
1330 fn build_request_delete_dimension_group_carries_the_range_and_resolved_depth() {
1331 let target = range(0, Dimension::Rows, 4, 9);
1332 let (request, depth) = build_request(
1333 &delete_verb(),
1334 target.clone(),
1335 ExistingGroups::MaybeOne(Some(2)),
1336 );
1337 assert_eq!(depth, Some(2));
1338 let BatchUpdateRequestItem::DeleteDimensionGroup(delete) = request else {
1339 panic!("expected DeleteDimensionGroup"); };
1341 assert_eq!(delete.range, target);
1342 }
1343
1344 #[test]
1345 fn describe_effect_names_the_axis_and_span() {
1346 let label = "ROWS 5:9";
1347 assert_eq!(
1348 describe_effect(&add_verb(), label),
1349 "add a row group over ROWS 5:9"
1350 );
1351 assert_eq!(
1352 describe_effect(&update_verb(), label),
1353 "set collapsed=true on the group over ROWS 5:9"
1354 );
1355 assert_eq!(
1356 describe_effect(&delete_verb(), label),
1357 "delete the group over ROWS 5:9"
1358 );
1359 }
1360
1361 fn test_credentials() -> DriveCredentials {
1364 DriveCredentials {
1365 client_id: "client-1".to_string(),
1366 client_secret: Secret::new("secret-1"),
1367 refresh_token: Secret::new("refresh-1"),
1368 scope: DriveGrantedScopes::READONLY,
1369 }
1370 }
1371
1372 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1373 wiremock::Mock::given(wiremock::matchers::method("POST"))
1374 .and(wiremock::matchers::path("/token"))
1375 .respond_with(
1376 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1377 "access_token": "test-token", "expires_in": 3600,
1378 })),
1379 )
1380 .mount(server)
1381 .await;
1382 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1383 crate::drive::client::test_support::replace_session(
1384 &mut drive,
1385 &test_credentials(),
1386 &format!("{}/token", server.uri()),
1387 );
1388 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1389 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1390 (drive, sheets)
1391 }
1392
1393 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1394 let parents: Vec<&str> = parents.to_vec();
1395 wiremock::Mock::given(wiremock::matchers::method("GET"))
1396 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1397 .respond_with(
1398 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1399 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1400 "version": "1",
1401 })),
1402 )
1403 }
1404
1405 fn mount_folder(id: &str) -> wiremock::Mock {
1406 mount_file(id, "application/vnd.google-apps.folder", &[])
1407 }
1408
1409 fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
1410 let ledger_path = tempfile::tempdir()
1411 .unwrap()
1412 .keep()
1413 .join("lease-ledger.jsonl");
1414 let token = seed_lease(&ledger_path, spreadsheet_id, "1");
1415 (Some(token), ledger_path)
1416 }
1417
1418 fn allow_rule(folder: &str) -> FolderPermissionRule {
1419 FolderPermissionRule {
1420 folder_id: Some(folder.to_string()),
1421 file_id: None,
1422 recursive: true,
1423 allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
1424 deny: HashSet::default(),
1425 require_lease: true,
1426 }
1427 }
1428
1429 fn mount_workbook(sheets: serde_json::Value) -> wiremock::Mock {
1430 wiremock::Mock::given(wiremock::matchers::method("GET"))
1431 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1432 .respond_with(
1433 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1434 "spreadsheetId": "sheet-1",
1435 "properties": {"title": "Budget"},
1436 "sheets": sheets,
1437 })),
1438 )
1439 }
1440
1441 fn base_opts(verb: DimensionGroupVerb, dry_run: bool) -> DimensionGroupOptions {
1442 DimensionGroupOptions {
1443 spreadsheet_id: "sheet-1".to_string(),
1444 verb,
1445 dry_run,
1446 lease_token: None,
1447 ledger_path: std::path::PathBuf::new(),
1448 }
1449 }
1450
1451 #[tokio::test]
1452 async fn a_denied_gate_blocks_before_any_read_or_batch_update_call() {
1453 let server = wiremock::MockServer::start().await;
1454 let (drive, sheets) = clients(&server).await;
1455 mount_file(
1456 "sheet-1",
1457 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1458 &["folder-1"],
1459 )
1460 .mount(&server)
1461 .await;
1462 mount_folder("folder-1").mount(&server).await;
1463 let rules: Vec<FolderPermissionRule> = Vec::new();
1464 let opts = base_opts(add_verb(), false);
1465 let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
1466 assert!(matches!(
1467 outcome.result,
1468 DimensionGroupResult::Blocked { .. }
1469 ));
1470 }
1471
1472 #[tokio::test]
1473 async fn add_dimension_group_dry_run_reports_would_change_and_makes_no_batch_update_call() {
1474 let server = wiremock::MockServer::start().await;
1475 let (drive, sheets) = clients(&server).await;
1476 mount_file(
1477 "sheet-1",
1478 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1479 &["folder-1"],
1480 )
1481 .mount(&server)
1482 .await;
1483 mount_folder("folder-1").mount(&server).await;
1484 mount_workbook(serde_json::json!([
1485 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1486 "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
1487 ]))
1488 .mount(&server)
1489 .await;
1490 let rules = vec![allow_rule("folder-1")];
1491 let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), true), &rules).await;
1492 assert!(matches!(
1493 outcome.result,
1494 DimensionGroupResult::WouldChange { .. }
1495 ));
1496 }
1497
1498 #[tokio::test]
1499 async fn add_dimension_group_sends_an_add_dimension_group_request() {
1500 let server = wiremock::MockServer::start().await;
1501 let (drive, sheets) = clients(&server).await;
1502 mount_file(
1503 "sheet-1",
1504 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1505 &["folder-1"],
1506 )
1507 .mount(&server)
1508 .await;
1509 mount_folder("folder-1").mount(&server).await;
1510 mount_workbook(serde_json::json!([
1511 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1512 "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
1513 ]))
1514 .mount(&server)
1515 .await;
1516 wiremock::Mock::given(wiremock::matchers::method("POST"))
1517 .and(wiremock::matchers::path(
1518 "/v4/spreadsheets/sheet-1:batchUpdate",
1519 ))
1520 .and(wiremock::matchers::body_json(serde_json::json!({
1521 "requests": [{
1522 "addDimensionGroup": {
1523 "range": {
1524 "sheetId": 0,
1525 "dimension": "ROWS",
1526 "startIndex": 4,
1527 "endIndex": 9,
1528 }
1529 }
1530 }]
1531 })))
1532 .respond_with(
1533 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1534 "replies": [{}]
1535 })),
1536 )
1537 .mount(&server)
1538 .await;
1539 let rules = vec![allow_rule("folder-1")];
1540 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1541 let mut opts = base_opts(add_verb(), false);
1542 opts.lease_token = lease_token;
1543 opts.ledger_path = ledger_path;
1544 let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
1545 assert_eq!(
1546 outcome.result,
1547 DimensionGroupResult::Changed {
1548 summary: "add a row group over ROWS 5:9".to_string(),
1549 depth: None,
1550 }
1551 );
1552 assert_eq!(outcome.sheet_id, Some(0));
1553 assert_eq!(outcome.dimension_range_label, Some("ROWS 5:9".to_string()));
1554 }
1555
1556 #[tokio::test]
1557 async fn add_with_an_unreadable_reply_reports_applied_reply_unreadable() {
1558 let server = wiremock::MockServer::start().await;
1559 let (drive, sheets) = clients(&server).await;
1560 mount_file(
1561 "sheet-1",
1562 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1563 &["folder-1"],
1564 )
1565 .mount(&server)
1566 .await;
1567 mount_folder("folder-1").mount(&server).await;
1568 mount_workbook(serde_json::json!([
1569 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1570 "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
1571 ]))
1572 .mount(&server)
1573 .await;
1574 wiremock::Mock::given(wiremock::matchers::method("POST"))
1576 .and(wiremock::matchers::path(
1577 "/v4/spreadsheets/sheet-1:batchUpdate",
1578 ))
1579 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
1580 .expect(1)
1581 .mount(&server)
1582 .await;
1583 let rules = vec![allow_rule("folder-1")];
1584 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1585 let mut opts = base_opts(add_verb(), false);
1586 opts.lease_token = lease_token;
1587 opts.ledger_path = ledger_path;
1588 let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
1589 assert_eq!(
1590 outcome.result.log_status(),
1591 "applied-reply-unreadable",
1592 "{:?}",
1593 outcome.result
1594 );
1595 assert!(
1596 describe(&outcome).starts_with("Applied, but the reply could not be read"),
1597 "{}",
1598 describe(&outcome)
1599 );
1600 }
1601
1602 #[tokio::test]
1603 async fn update_dimension_group_refuses_when_no_group_exists() {
1604 let server = wiremock::MockServer::start().await;
1605 let (drive, sheets) = clients(&server).await;
1606 mount_file(
1607 "sheet-1",
1608 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1609 &["folder-1"],
1610 )
1611 .mount(&server)
1612 .await;
1613 mount_folder("folder-1").mount(&server).await;
1614 mount_workbook(serde_json::json!([
1615 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1616 "gridProperties": {"rowCount": 1000, "columnCount": 26}},
1617 "rowGroups": []},
1618 ]))
1619 .mount(&server)
1620 .await;
1621 let rules = vec![allow_rule("folder-1")];
1622 let outcome =
1623 dimension_group(&drive, &sheets, &base_opts(update_verb(), false), &rules).await;
1624 assert!(matches!(
1625 outcome.result,
1626 DimensionGroupResult::RefusedDimensionGroupNotFound { .. }
1627 ));
1628 }
1629
1630 #[tokio::test]
1631 async fn update_dimension_group_refuses_an_ambiguous_match() {
1632 let server = wiremock::MockServer::start().await;
1633 let (drive, sheets) = clients(&server).await;
1634 mount_file(
1635 "sheet-1",
1636 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1637 &["folder-1"],
1638 )
1639 .mount(&server)
1640 .await;
1641 mount_folder("folder-1").mount(&server).await;
1642 mount_workbook(serde_json::json!([
1643 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1644 "gridProperties": {"rowCount": 1000, "columnCount": 26}},
1645 "rowGroups": [
1646 {"range": {"sheetId": 0, "dimension": "ROWS", "startIndex": 4, "endIndex": 9}, "depth": 1},
1647 {"range": {"sheetId": 0, "dimension": "ROWS", "startIndex": 4, "endIndex": 9}, "depth": 2, "collapsed": true},
1648 ]},
1649 ]))
1650 .mount(&server)
1651 .await;
1652 let rules = vec![allow_rule("folder-1")];
1653 let outcome =
1654 dimension_group(&drive, &sheets, &base_opts(update_verb(), false), &rules).await;
1655 assert!(matches!(
1656 outcome.result,
1657 DimensionGroupResult::RefusedAmbiguousDimensionGroup { .. }
1658 ));
1659 }
1660
1661 #[tokio::test]
1662 async fn update_dimension_group_disambiguated_by_depth_sends_the_update() {
1663 let server = wiremock::MockServer::start().await;
1664 let (drive, sheets) = clients(&server).await;
1665 mount_file(
1666 "sheet-1",
1667 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1668 &["folder-1"],
1669 )
1670 .mount(&server)
1671 .await;
1672 mount_folder("folder-1").mount(&server).await;
1673 mount_workbook(serde_json::json!([
1674 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1675 "gridProperties": {"rowCount": 1000, "columnCount": 26}},
1676 "rowGroups": [
1677 {"range": {"sheetId": 0, "dimension": "ROWS", "startIndex": 4, "endIndex": 9}, "depth": 1},
1678 {"range": {"sheetId": 0, "dimension": "ROWS", "startIndex": 4, "endIndex": 9}, "depth": 2, "collapsed": true},
1679 ]},
1680 ]))
1681 .mount(&server)
1682 .await;
1683 wiremock::Mock::given(wiremock::matchers::method("POST"))
1684 .and(wiremock::matchers::path(
1685 "/v4/spreadsheets/sheet-1:batchUpdate",
1686 ))
1687 .and(wiremock::matchers::body_json(serde_json::json!({
1688 "requests": [{
1689 "updateDimensionGroup": {
1690 "dimensionGroup": {
1691 "range": {
1692 "sheetId": 0,
1693 "dimension": "ROWS",
1694 "startIndex": 4,
1695 "endIndex": 9,
1696 },
1697 "depth": 2,
1698 "collapsed": false,
1699 },
1700 "fields": "collapsed",
1701 }
1702 }]
1703 })))
1704 .respond_with(
1705 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1706 "replies": [{}]
1707 })),
1708 )
1709 .mount(&server)
1710 .await;
1711 let rules = vec![allow_rule("folder-1")];
1712 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1713 let verb = DimensionGroupVerb::UpdateDimensionGroup {
1714 sheet: "Q1".to_string(),
1715 dimension: Dimension::Rows,
1716 start: 5,
1717 end: 9,
1718 depth: Some(2),
1719 collapsed: false,
1720 };
1721 let mut opts = base_opts(verb, false);
1722 opts.lease_token = lease_token;
1723 opts.ledger_path = ledger_path;
1724 let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
1725 assert_eq!(
1726 outcome.result,
1727 DimensionGroupResult::Changed {
1728 summary: "set collapsed=false on the group over ROWS 5:9".to_string(),
1729 depth: Some(2),
1730 }
1731 );
1732 }
1733
1734 #[tokio::test]
1735 async fn delete_dimension_group_sends_a_delete_dimension_group_request() {
1736 let server = wiremock::MockServer::start().await;
1737 let (drive, sheets) = clients(&server).await;
1738 mount_file(
1739 "sheet-1",
1740 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1741 &["folder-1"],
1742 )
1743 .mount(&server)
1744 .await;
1745 mount_folder("folder-1").mount(&server).await;
1746 mount_workbook(serde_json::json!([
1747 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1748 "gridProperties": {"rowCount": 1000, "columnCount": 26}},
1749 "rowGroups": [
1750 {"range": {"sheetId": 0, "dimension": "ROWS", "startIndex": 4, "endIndex": 9}, "depth": 1},
1751 ]},
1752 ]))
1753 .mount(&server)
1754 .await;
1755 wiremock::Mock::given(wiremock::matchers::method("POST"))
1756 .and(wiremock::matchers::path(
1757 "/v4/spreadsheets/sheet-1:batchUpdate",
1758 ))
1759 .and(wiremock::matchers::body_json(serde_json::json!({
1760 "requests": [{
1761 "deleteDimensionGroup": {
1762 "range": {
1763 "sheetId": 0,
1764 "dimension": "ROWS",
1765 "startIndex": 4,
1766 "endIndex": 9,
1767 }
1768 }
1769 }]
1770 })))
1771 .respond_with(
1772 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1773 "replies": [{}]
1774 })),
1775 )
1776 .mount(&server)
1777 .await;
1778 let rules = vec![allow_rule("folder-1")];
1779 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1780 let mut opts = base_opts(delete_verb(), false);
1781 opts.lease_token = lease_token;
1782 opts.ledger_path = ledger_path;
1783 let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
1784 assert_eq!(
1785 outcome.result,
1786 DimensionGroupResult::Changed {
1787 summary: "delete the group over ROWS 5:9".to_string(),
1788 depth: Some(1),
1789 }
1790 );
1791 }
1792
1793 #[tokio::test]
1794 async fn delete_dimension_group_refuses_when_no_group_exists() {
1795 let server = wiremock::MockServer::start().await;
1796 let (drive, sheets) = clients(&server).await;
1797 mount_file(
1798 "sheet-1",
1799 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1800 &["folder-1"],
1801 )
1802 .mount(&server)
1803 .await;
1804 mount_folder("folder-1").mount(&server).await;
1805 mount_workbook(serde_json::json!([
1806 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1807 "gridProperties": {"rowCount": 1000, "columnCount": 26}},
1808 "rowGroups": []},
1809 ]))
1810 .mount(&server)
1811 .await;
1812 let rules = vec![allow_rule("folder-1")];
1813 let outcome =
1814 dimension_group(&drive, &sheets, &base_opts(delete_verb(), false), &rules).await;
1815 assert!(matches!(
1816 outcome.result,
1817 DimensionGroupResult::RefusedDimensionGroupNotFound { .. }
1818 ));
1819 }
1820
1821 #[tokio::test]
1822 async fn add_dimension_group_reports_sheet_not_found_for_an_unknown_sheet() {
1823 let server = wiremock::MockServer::start().await;
1824 let (drive, sheets) = clients(&server).await;
1825 mount_file(
1826 "sheet-1",
1827 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1828 &["folder-1"],
1829 )
1830 .mount(&server)
1831 .await;
1832 mount_folder("folder-1").mount(&server).await;
1833 mount_workbook(serde_json::json!([
1834 {"properties": {"sheetId": 0, "title": "Other", "index": 0}},
1835 ]))
1836 .mount(&server)
1837 .await;
1838 let rules = vec![allow_rule("folder-1")];
1839 let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
1840 let DimensionGroupResult::RefusedSheetNotFound { title, available } = outcome.result else {
1841 panic!("expected RefusedSheetNotFound"); };
1843 assert_eq!(title, "Q1");
1844 assert_eq!(available, vec!["Other".to_string()]);
1845 }
1846
1847 #[tokio::test]
1848 async fn add_dimension_group_reports_invalid_range_for_a_span_past_the_sheet() {
1849 let server = wiremock::MockServer::start().await;
1850 let (drive, sheets) = clients(&server).await;
1851 mount_file(
1852 "sheet-1",
1853 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1854 &["folder-1"],
1855 )
1856 .mount(&server)
1857 .await;
1858 mount_folder("folder-1").mount(&server).await;
1859 mount_workbook(serde_json::json!([
1860 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1861 "gridProperties": {"rowCount": 10, "columnCount": 26}}},
1862 ]))
1863 .mount(&server)
1864 .await;
1865 let rules = vec![allow_rule("folder-1")];
1866 let verb = DimensionGroupVerb::AddDimensionGroup {
1867 sheet: "Q1".to_string(),
1868 dimension: Dimension::Rows,
1869 start: 5,
1870 end: 50,
1871 };
1872 let outcome = dimension_group(&drive, &sheets, &base_opts(verb, false), &rules).await;
1873 assert!(matches!(
1874 outcome.result,
1875 DimensionGroupResult::RefusedInvalidRange { .. }
1876 ));
1877 }
1878
1879 #[tokio::test]
1880 async fn a_metadata_fetch_failure_surfaces_as_failed() {
1881 let server = wiremock::MockServer::start().await;
1882 let (drive, sheets) = clients(&server).await;
1883 wiremock::Mock::given(wiremock::matchers::method("GET"))
1884 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1885 .respond_with(wiremock::ResponseTemplate::new(500))
1886 .mount(&server)
1887 .await;
1888 let rules: Vec<FolderPermissionRule> = Vec::new();
1889 let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
1890 assert!(matches!(
1891 outcome.result,
1892 DimensionGroupResult::Failed { .. }
1893 ));
1894 }
1895
1896 #[tokio::test]
1897 async fn a_shortcut_target_is_refused() {
1898 let server = wiremock::MockServer::start().await;
1899 let (drive, sheets) = clients(&server).await;
1900 mount_file("sheet-1", "application/vnd.google-apps.shortcut", &[])
1901 .mount(&server)
1902 .await;
1903 let rules: Vec<FolderPermissionRule> = Vec::new();
1904 let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
1905 assert!(matches!(
1906 outcome.result,
1907 DimensionGroupResult::RefusedShortcut
1908 ));
1909 }
1910
1911 #[tokio::test]
1912 async fn a_non_spreadsheet_target_is_refused() {
1913 let server = wiremock::MockServer::start().await;
1914 let (drive, sheets) = clients(&server).await;
1915 mount_file("sheet-1", "application/vnd.google-apps.document", &[])
1916 .mount(&server)
1917 .await;
1918 let rules: Vec<FolderPermissionRule> = Vec::new();
1919 let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
1920 assert!(matches!(
1921 outcome.result,
1922 DimensionGroupResult::RefusedNotASpreadsheet { .. }
1923 ));
1924 }
1925
1926 #[tokio::test]
1927 async fn a_target_with_no_visible_parents_is_refused() {
1928 let server = wiremock::MockServer::start().await;
1929 let (drive, sheets) = clients(&server).await;
1930 mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
1931 .mount(&server)
1932 .await;
1933 let rules: Vec<FolderPermissionRule> = Vec::new();
1934 let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
1935 assert!(matches!(
1936 outcome.result,
1937 DimensionGroupResult::RefusedNoVisibleParents
1938 ));
1939 }
1940
1941 #[tokio::test]
1942 async fn a_gate_ancestor_fetch_failure_surfaces_as_failed() {
1943 let server = wiremock::MockServer::start().await;
1944 let (drive, sheets) = clients(&server).await;
1945 mount_file(
1946 "sheet-1",
1947 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1948 &["folder-1"],
1949 )
1950 .mount(&server)
1951 .await;
1952 wiremock::Mock::given(wiremock::matchers::method("GET"))
1953 .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
1954 .respond_with(wiremock::ResponseTemplate::new(500))
1955 .mount(&server)
1956 .await;
1957 let rules: Vec<FolderPermissionRule> = Vec::new();
1958 let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
1959 assert!(matches!(
1960 outcome.result,
1961 DimensionGroupResult::Failed { .. }
1962 ));
1963 }
1964
1965 #[tokio::test]
1966 async fn a_workbook_fetch_failure_after_a_granted_gate_surfaces_as_failed() {
1967 let server = wiremock::MockServer::start().await;
1968 let (drive, sheets) = clients(&server).await;
1969 mount_file(
1970 "sheet-1",
1971 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1972 &["folder-1"],
1973 )
1974 .mount(&server)
1975 .await;
1976 mount_folder("folder-1").mount(&server).await;
1977 wiremock::Mock::given(wiremock::matchers::method("GET"))
1978 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1979 .respond_with(wiremock::ResponseTemplate::new(500))
1980 .mount(&server)
1981 .await;
1982 let rules = vec![allow_rule("folder-1")];
1983 let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
1984 assert!(matches!(
1985 outcome.result,
1986 DimensionGroupResult::Failed { .. }
1987 ));
1988 }
1989
1990 #[tokio::test]
1991 async fn a_batch_update_failure_surfaces_as_failed() {
1992 let server = wiremock::MockServer::start().await;
1993 let (drive, sheets) = clients(&server).await;
1994 mount_file(
1995 "sheet-1",
1996 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1997 &["folder-1"],
1998 )
1999 .mount(&server)
2000 .await;
2001 mount_folder("folder-1").mount(&server).await;
2002 mount_workbook(serde_json::json!([
2003 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2004 "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
2005 ]))
2006 .mount(&server)
2007 .await;
2008 wiremock::Mock::given(wiremock::matchers::method("POST"))
2009 .and(wiremock::matchers::path(
2010 "/v4/spreadsheets/sheet-1:batchUpdate",
2011 ))
2012 .respond_with(wiremock::ResponseTemplate::new(500))
2013 .mount(&server)
2014 .await;
2015 let rules = vec![allow_rule("folder-1")];
2016 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2017 let mut opts = base_opts(add_verb(), false);
2018 opts.lease_token = lease_token;
2019 opts.ledger_path = ledger_path;
2020 let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
2021 assert!(matches!(
2022 outcome.result,
2023 DimensionGroupResult::Failed { .. }
2024 ));
2025 }
2026
2027 #[tokio::test]
2028 async fn refuses_without_a_lease_when_the_rule_requires_one() {
2029 let server = wiremock::MockServer::start().await;
2030 let (drive, sheets) = clients(&server).await;
2031 mount_file(
2032 "sheet-1",
2033 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2034 &["folder-1"],
2035 )
2036 .mount(&server)
2037 .await;
2038 mount_folder("folder-1").mount(&server).await;
2039 mount_workbook(serde_json::json!([
2040 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2041 "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
2042 ]))
2043 .mount(&server)
2044 .await;
2045 let rules = vec![allow_rule("folder-1")];
2046 let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
2047 assert!(matches!(
2048 outcome.result,
2049 DimensionGroupResult::RefusedNoLease
2050 ));
2051 }
2052
2053 #[tokio::test]
2054 async fn refuses_an_unknown_lease_token() {
2055 let server = wiremock::MockServer::start().await;
2056 let (drive, sheets) = clients(&server).await;
2057 mount_file(
2058 "sheet-1",
2059 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2060 &["folder-1"],
2061 )
2062 .mount(&server)
2063 .await;
2064 mount_folder("folder-1").mount(&server).await;
2065 mount_workbook(serde_json::json!([
2066 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2067 "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
2068 ]))
2069 .mount(&server)
2070 .await;
2071 let rules = vec![allow_rule("folder-1")];
2072 let ledger_path = tempfile::tempdir()
2073 .unwrap()
2074 .keep()
2075 .join("lease-ledger.jsonl");
2076 let mut opts = base_opts(add_verb(), false);
2077 opts.lease_token = Some("not-a-real-token".to_string());
2078 opts.ledger_path = ledger_path;
2079 let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
2080 assert!(matches!(
2081 outcome.result,
2082 DimensionGroupResult::RefusedLeaseExpired
2083 ));
2084 }
2085
2086 #[tokio::test]
2087 async fn refuses_a_lease_bound_to_a_different_file() {
2088 let server = wiremock::MockServer::start().await;
2089 let (drive, sheets) = clients(&server).await;
2090 mount_file(
2091 "sheet-1",
2092 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2093 &["folder-1"],
2094 )
2095 .mount(&server)
2096 .await;
2097 mount_folder("folder-1").mount(&server).await;
2098 mount_workbook(serde_json::json!([
2099 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2100 "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
2101 ]))
2102 .mount(&server)
2103 .await;
2104 let rules = vec![allow_rule("folder-1")];
2105 let (lease_token, ledger_path) = leased_opts_for("a-different-sheet");
2106 let mut opts = base_opts(add_verb(), false);
2107 opts.lease_token = lease_token;
2108 opts.ledger_path = ledger_path;
2109 let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
2110 assert!(matches!(
2111 outcome.result,
2112 DimensionGroupResult::RefusedLeaseWrongFile
2113 ));
2114 }
2115
2116 #[tokio::test]
2117 async fn refuses_a_stale_lease_when_the_file_has_moved() {
2118 let server = wiremock::MockServer::start().await;
2119 let (drive, sheets) = clients(&server).await;
2120 mount_file(
2121 "sheet-1",
2122 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2123 &["folder-1"],
2124 )
2125 .mount(&server)
2126 .await;
2127 mount_folder("folder-1").mount(&server).await;
2128 mount_workbook(serde_json::json!([
2129 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2130 "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
2131 ]))
2132 .mount(&server)
2133 .await;
2134 let rules = vec![allow_rule("folder-1")];
2135 let ledger_path = tempfile::tempdir()
2136 .unwrap()
2137 .keep()
2138 .join("lease-ledger.jsonl");
2139 let token = seed_lease(&ledger_path, "sheet-1", "0");
2140 let mut opts = base_opts(add_verb(), false);
2141 opts.lease_token = Some(token);
2142 opts.ledger_path = ledger_path;
2143 let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
2144 assert!(matches!(
2145 outcome.result,
2146 DimensionGroupResult::RefusedLeaseStale
2147 ));
2148 }
2149
2150 fn outcome_with(
2153 verb: DimensionGroupVerb,
2154 file_name: Option<&str>,
2155 result: DimensionGroupResult,
2156 ) -> DimensionGroupOutcome {
2157 DimensionGroupOutcome {
2158 spreadsheet_id: "sheet-1".to_string(),
2159 file_name: file_name.map(str::to_string),
2160 resolved_folder_id: None,
2161 sheet_id: None,
2162 dimension_range_label: None,
2163 verb,
2164 result,
2165 }
2166 }
2167
2168 #[test]
2169 fn describe_lines_renders_would_change() {
2170 let out = outcome_with(
2171 add_verb(),
2172 Some("Budget"),
2173 DimensionGroupResult::WouldChange {
2174 summary: "add a row group over ROWS 5:9".to_string(),
2175 },
2176 );
2177 assert_eq!(
2178 describe(&out),
2179 "Would add a row group over ROWS 5:9 in 'Budget'"
2180 );
2181 }
2182
2183 #[test]
2184 fn describe_lines_renders_not_a_spreadsheet_with_no_file_name() {
2185 let out = outcome_with(
2186 add_verb(),
2187 None,
2188 DimensionGroupResult::RefusedNotASpreadsheet {
2189 mime_type: "application/vnd.google-apps.document".to_string(),
2190 },
2191 );
2192 let text = describe(&out);
2193 assert!(text.contains("'sheet-1'"), "{text}");
2194 assert!(text.contains("not a Google Sheet"), "{text}");
2195 }
2196
2197 #[test]
2198 fn describe_lines_renders_shortcut() {
2199 let out = outcome_with(
2200 add_verb(),
2201 Some("Budget"),
2202 DimensionGroupResult::RefusedShortcut,
2203 );
2204 assert!(describe(&out).contains("shortcut"));
2205 }
2206
2207 #[test]
2208 fn describe_lines_renders_no_visible_parents() {
2209 let out = outcome_with(
2210 add_verb(),
2211 Some("Budget"),
2212 DimensionGroupResult::RefusedNoVisibleParents,
2213 );
2214 assert!(describe(&out).contains("sheets-structure"));
2215 }
2216
2217 #[test]
2218 fn describe_lines_renders_sheet_not_found_with_and_without_available_titles() {
2219 let with_titles = outcome_with(
2220 add_verb(),
2221 Some("Budget"),
2222 DimensionGroupResult::RefusedSheetNotFound {
2223 title: "Q1".to_string(),
2224 available: vec!["Q2".to_string()],
2225 },
2226 );
2227 assert!(describe(&with_titles).contains("'Q2'"));
2228
2229 let without_titles = outcome_with(
2230 add_verb(),
2231 Some("Budget"),
2232 DimensionGroupResult::RefusedSheetNotFound {
2233 title: "Q1".to_string(),
2234 available: Vec::new(),
2235 },
2236 );
2237 assert!(describe(&without_titles).contains("Available: none"));
2238 }
2239
2240 #[test]
2241 fn describe_lines_renders_invalid_range_and_dimension_group_not_found() {
2242 let invalid = outcome_with(
2243 add_verb(),
2244 Some("Budget"),
2245 DimensionGroupResult::RefusedInvalidRange {
2246 detail: "--start must be at least 1, got 0".to_string(),
2247 },
2248 );
2249 assert_eq!(
2250 describe(&invalid),
2251 "Refused: --start must be at least 1, got 0"
2252 );
2253
2254 let not_found = outcome_with(
2255 update_verb(),
2256 Some("Budget"),
2257 DimensionGroupResult::RefusedDimensionGroupNotFound {
2258 detail: "no dimension group exists over that span".to_string(),
2259 },
2260 );
2261 assert_eq!(
2262 describe(¬_found),
2263 "Refused: no dimension group exists over that span"
2264 );
2265 }
2266
2267 #[test]
2268 fn describe_lines_renders_ambiguous() {
2269 let out = outcome_with(
2270 update_verb(),
2271 Some("Budget"),
2272 DimensionGroupResult::RefusedAmbiguousDimensionGroup { depths: vec![0, 1] },
2273 );
2274 let text = describe(&out);
2275 assert!(text.contains("0, 1"), "{text}");
2276 assert!(text.contains("--depth"), "{text}");
2277 }
2278
2279 #[test]
2280 fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
2281 let without_rule = outcome_with(
2282 add_verb(),
2283 Some("Budget"),
2284 DimensionGroupResult::Blocked { decided_by: None },
2285 );
2286 assert!(describe(&without_rule).contains("default policy"));
2287
2288 let with_folder_rule = outcome_with(
2289 update_verb(),
2290 Some("Budget"),
2291 DimensionGroupResult::Blocked {
2292 decided_by: Some(DecidingRule::Folder {
2293 folder_id: "folder-1".to_string(),
2294 depth: 2,
2295 }),
2296 },
2297 );
2298 let text = describe(&with_folder_rule);
2299 assert!(text.contains("update-dimension-group"), "{text}");
2300 assert!(text.contains("folder folder-1 (depth 2)"), "{text}");
2301 }
2302
2303 #[test]
2304 fn describe_lines_renders_every_lease_refusal() {
2305 for result in [
2306 DimensionGroupResult::RefusedNoLease,
2307 DimensionGroupResult::RefusedLeaseExpired,
2308 DimensionGroupResult::RefusedLeaseWrongFile,
2309 DimensionGroupResult::RefusedLeaseStale,
2310 ] {
2311 let out = outcome_with(add_verb(), Some("Budget"), result);
2312 assert!(!describe(&out).is_empty());
2313 }
2314 }
2315
2316 #[test]
2317 fn describe_lines_renders_changed_with_and_without_a_depth() {
2318 let with_depth = outcome_with(
2319 update_verb(),
2320 Some("Budget"),
2321 DimensionGroupResult::Changed {
2322 summary: "set collapsed=true on the group over ROWS 5:9".to_string(),
2323 depth: Some(1),
2324 },
2325 );
2326 assert!(describe(&with_depth).contains("(depth 1)"));
2327
2328 let without_depth = outcome_with(
2329 add_verb(),
2330 Some("Budget"),
2331 DimensionGroupResult::Changed {
2332 summary: "add a row group over ROWS 5:9".to_string(),
2333 depth: None,
2334 },
2335 );
2336 let text = describe(&without_depth);
2337 assert!(!text.contains("(depth"), "{text}");
2338 }
2339
2340 #[test]
2341 fn describe_lines_renders_applied_reply_unreadable_as_not_idempotent() {
2342 let out = outcome_with(
2343 add_verb(),
2344 Some("Budget"),
2345 DimensionGroupResult::AppliedReplyUnreadable {
2346 summary: "add a row group over ROWS 5:9".to_string(),
2347 depth: None,
2348 detail: "bad reply".to_string(),
2349 },
2350 );
2351 assert_eq!(
2352 describe_lines(&out),
2353 vec![
2354 "Applied, but the reply could not be read: add a row group over ROWS 5:9 in \
2355 'Budget' — do not retry; check the spreadsheet first (bad reply)"
2356 .to_string()
2357 ]
2358 );
2359
2360 let with_depth = outcome_with(
2362 update_verb(),
2363 Some("Budget"),
2364 DimensionGroupResult::AppliedReplyUnreadable {
2365 summary: "set collapsed=true on the group over ROWS 5:9".to_string(),
2366 depth: Some(1),
2367 detail: "bad reply".to_string(),
2368 },
2369 );
2370 assert_eq!(
2371 describe_lines(&with_depth),
2372 vec![
2373 "Applied, but the reply could not be read: set collapsed=true on the group \
2374 over ROWS 5:9 (depth 1) in 'Budget' — do not retry; check the spreadsheet \
2375 first (bad reply)"
2376 .to_string()
2377 ]
2378 );
2379 }
2380
2381 #[test]
2384 fn an_unreadable_reply_logs_the_summary_sheet_and_span_for_every_verb() {
2385 for verb in [add_verb(), update_verb()] {
2386 let opts = base_opts(verb.clone(), false);
2387 let mut outcome = outcome_with(
2388 verb,
2389 Some("Budget"),
2390 DimensionGroupResult::AppliedReplyUnreadable {
2391 summary: "the summary".to_string(),
2392 depth: Some(1),
2393 detail: "bad reply".to_string(),
2394 },
2395 );
2396 outcome.sheet_id = Some(3);
2397 outcome.dimension_range_label = Some("ROWS 5:9".to_string());
2398 let record = mutation_record(&outcome, &opts, Duration::from_millis(1));
2399 assert_eq!(record.fields_changed.as_deref(), Some("the summary"));
2400 assert_eq!(record.sheet_id, Some(3));
2401 assert_eq!(record.dimension_range.as_deref(), Some("ROWS 5:9"));
2402 assert_eq!(record.error.as_deref(), Some("bad reply"));
2403 }
2404 }
2405
2406 #[test]
2407 fn describe_lines_renders_failed() {
2408 let out = outcome_with(
2409 add_verb(),
2410 Some("Budget"),
2411 DimensionGroupResult::Failed {
2412 detail: "boom".to_string(),
2413 },
2414 );
2415 assert_eq!(describe(&out), "Failed: boom");
2416 }
2417
2418 #[test]
2419 fn from_lease_failed_maps_to_the_failed_variant() {
2420 assert_eq!(
2421 <DimensionGroupResult as FromLeaseRefusal>::from_lease_failed("boom".to_string()),
2422 DimensionGroupResult::Failed {
2423 detail: "boom".to_string()
2424 }
2425 );
2426 }
2427
2428 #[test]
2429 fn write_jsonl_emits_one_line_of_json() {
2430 let outcome = outcome_with(
2431 add_verb(),
2432 Some("Budget"),
2433 DimensionGroupResult::Changed {
2434 summary: "add a row group over ROWS 5:9".to_string(),
2435 depth: None,
2436 },
2437 );
2438 let mut buf = Vec::new();
2439 outcome.write_jsonl(&mut buf).unwrap();
2440 let text = String::from_utf8(buf).unwrap();
2441 assert_eq!(text.matches('\n').count(), 1);
2442 let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
2443 assert_eq!(parsed["result"]["status"], "changed");
2444 }
2445}