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