1use std::path::PathBuf;
59use std::time::{Duration, Instant};
60
61use serde::Serialize;
62
63use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
64use crate::drive::client::DriveClient;
65use crate::drive::files_api::FilesApi;
66use crate::drive::lease::check::{
67 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
68 LeasedWrite,
69};
70use crate::drive::sheets::a1;
71use crate::drive::sheets::api::{
72 applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi, ValueRenderOption,
73};
74use crate::drive::sheets::client::SheetsClient;
75use crate::drive::sheets::grid_range;
76use crate::drive::sheets::target_gate;
77use crate::drive::sheets::types::{
78 AddNamedRangeRequest, BatchUpdateRequestItem, BatchUpdateResponse, DeleteNamedRangeRequest,
79 GridRange, NamedRange, Spreadsheet, UpdateNamedRangeRequest,
80};
81use crate::drive::types::SheetTargetRefusal;
82use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
83use crate::request_log::{self, DriveMutationOutcome};
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum NamedRangeVerb {
88 AddNamedRange {
91 name: String,
94 sheet: Option<String>,
97 range: Option<String>,
100 whole_sheet: bool,
103 },
104 UpdateNamedRange {
106 name: Option<String>,
109 id: Option<String>,
113 new_name: Option<String>,
115 sheet: Option<String>,
120 range: Option<String>,
123 whole_sheet: bool,
126 },
127 DeleteNamedRange {
129 name: Option<String>,
132 id: Option<String>,
136 },
137}
138
139impl NamedRangeVerb {
140 const fn log_operation(&self) -> &'static str {
141 match self {
142 Self::AddNamedRange { .. } => "sheets-add-named-range",
143 Self::UpdateNamedRange { .. } => "sheets-update-named-range",
144 Self::DeleteNamedRange { .. } => "sheets-delete-named-range",
145 }
146 }
147
148 const fn label(&self) -> &'static str {
149 match self {
150 Self::AddNamedRange { .. } => "add-named-range",
151 Self::UpdateNamedRange { .. } => "update-named-range",
152 Self::DeleteNamedRange { .. } => "delete-named-range",
153 }
154 }
155}
156
157#[derive(Debug, Clone)]
159pub struct NamedRangeOptions {
160 pub spreadsheet_id: String,
162 pub verb: NamedRangeVerb,
164 pub dry_run: bool,
166 pub lease_token: Option<String>,
171 pub ledger_path: PathBuf,
175}
176
177#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
179#[serde(tag = "status", rename_all = "kebab-case")]
180pub enum NamedRangeResult {
181 WouldChange {
183 summary: String,
185 #[serde(skip_serializing_if = "Vec::is_empty")]
189 referencing_formulas: Vec<String>,
190 },
191 RefusedNotASpreadsheet {
193 mime_type: String,
195 },
196 RefusedShortcut,
198 RefusedNoVisibleParents,
200 RefusedSheetNotFound {
202 title: String,
204 available: Vec<String>,
206 },
207 RefusedInvalidRange {
210 detail: String,
212 },
213 RefusedNotFound {
216 name: String,
218 },
219 RefusedIdNotFound {
222 id: String,
224 },
225 RefusedAmbiguousName {
230 name: String,
232 candidates: Vec<String>,
235 },
236 RefusedDuplicateName {
240 new_name: String,
242 existing_ids: Vec<String>,
245 },
246 Blocked {
248 decided_by: Option<DecidingRule>,
250 },
251 RefusedNoLease,
254 RefusedLeaseExpired,
257 RefusedLeaseWrongFile,
259 RefusedLeaseStale,
262 Changed {
264 summary: String,
266 named_range_id: Option<String>,
269 #[serde(skip_serializing_if = "Vec::is_empty")]
272 referencing_formulas: Vec<String>,
273 },
274 AppliedReplyUnreadable {
280 summary: String,
282 named_range_id: Option<String>,
287 #[serde(skip_serializing_if = "Vec::is_empty")]
289 referencing_formulas: Vec<String>,
290 detail: String,
292 },
293 Failed {
295 detail: String,
297 },
298}
299
300impl FromLeaseRefusal for NamedRangeResult {
301 fn from_no_lease() -> Self {
302 Self::RefusedNoLease
303 }
304 fn from_lease_expired() -> Self {
305 Self::RefusedLeaseExpired
306 }
307 fn from_lease_wrong_file() -> Self {
308 Self::RefusedLeaseWrongFile
309 }
310 fn from_lease_stale() -> Self {
311 Self::RefusedLeaseStale
312 }
313 fn from_lease_failed(detail: String) -> Self {
314 Self::Failed { detail }
315 }
316}
317
318impl NamedRangeResult {
319 fn log_status(&self) -> &'static str {
320 match self {
321 Self::WouldChange { .. } => "would-change",
322 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
323 Self::RefusedShortcut => "refused-shortcut",
324 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
325 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
326 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
327 Self::RefusedNotFound { .. } => "refused-not-found",
328 Self::RefusedIdNotFound { .. } => "refused-id-not-found",
329 Self::RefusedAmbiguousName { .. } => "refused-ambiguous-name",
330 Self::RefusedDuplicateName { .. } => "refused-duplicate-name",
331 Self::Blocked { .. } => "blocked",
332 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
333 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
334 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
335 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
336 Self::Changed { .. } => "changed",
337 Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
338 Self::Failed { .. } => "failed",
339 }
340 }
341}
342
343#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
345pub struct NamedRangeOutcome {
346 pub spreadsheet_id: String,
348 #[serde(skip_serializing_if = "Option::is_none")]
350 pub file_name: Option<String>,
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub resolved_folder_id: Option<String>,
354 #[serde(skip)]
356 pub verb: NamedRangeVerb,
357 pub result: NamedRangeResult,
359}
360
361impl JsonlSerialize for NamedRangeOutcome {
362 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
363 write_scalar_jsonl(self, out)
364 }
365}
366
367pub async fn named_range(
370 drive: &DriveClient,
371 sheets: &SheetsClient,
372 opts: &NamedRangeOptions,
373 rules: &[FolderPermissionRule],
374) -> NamedRangeOutcome {
375 let started = Instant::now();
376 let outcome = named_range_inner(drive, sheets, opts, rules).await;
377 if !opts.dry_run {
378 record_attempt(&outcome, opts, started.elapsed());
379 }
380 outcome
381}
382
383async fn named_range_inner(
384 drive: &DriveClient,
385 sheets: &SheetsClient,
386 opts: &NamedRangeOptions,
387 rules: &[FolderPermissionRule],
388) -> NamedRangeOutcome {
389 let bare = |result| NamedRangeOutcome {
390 spreadsheet_id: opts.spreadsheet_id.clone(),
391 file_name: None,
392 resolved_folder_id: None,
393 verb: opts.verb.clone(),
394 result,
395 };
396
397 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
398 drive,
399 &opts.spreadsheet_id,
400 DriveOperation::SheetsStructure,
401 rules,
402 )
403 .await
404 {
405 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
406 return bare(NamedRangeResult::Failed { detail })
407 }
408 target_gate::TargetGateOutcome::Refused { target, refusal } => {
409 let result = match refusal {
410 SheetTargetRefusal::Shortcut => NamedRangeResult::RefusedShortcut,
411 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
412 NamedRangeResult::RefusedNotASpreadsheet { mime_type }
413 }
414 SheetTargetRefusal::NoVisibleParents => NamedRangeResult::RefusedNoVisibleParents,
415 };
416 return NamedRangeOutcome {
417 spreadsheet_id: opts.spreadsheet_id.clone(),
418 file_name: Some(target.name),
419 resolved_folder_id: None,
420 verb: opts.verb.clone(),
421 result,
422 };
423 }
424 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
425 return NamedRangeOutcome {
426 spreadsheet_id: opts.spreadsheet_id.clone(),
427 file_name: Some(target.name),
428 resolved_folder_id: None,
429 verb: opts.verb.clone(),
430 result: NamedRangeResult::Failed { detail },
431 };
432 }
433 target_gate::TargetGateOutcome::Gated {
434 target,
435 decision,
436 resolved_folder_id,
437 requires_lease,
438 } => (target, decision, resolved_folder_id, requires_lease),
439 };
440
441 let gated = |result| NamedRangeOutcome {
442 spreadsheet_id: opts.spreadsheet_id.clone(),
443 file_name: Some(target.name.clone()),
444 resolved_folder_id: resolved_folder_id.clone(),
445 verb: opts.verb.clone(),
446 result,
447 };
448
449 if decision.verdict == write_gate::Verdict::Deny {
450 return gated(NamedRangeResult::Blocked {
451 decided_by: decision.decided_by,
452 });
453 }
454
455 let api = SheetsApi::new(sheets);
456 let workbook = match api
457 .get_spreadsheet_with_named_ranges(&opts.spreadsheet_id)
458 .await
459 {
460 Ok(workbook) => workbook,
461 Err(err) => {
462 return gated(NamedRangeResult::Failed {
463 detail: format!("{err:#}"),
464 })
465 }
466 };
467
468 let existing = match &opts.verb {
473 NamedRangeVerb::AddNamedRange { .. } => None,
474 NamedRangeVerb::UpdateNamedRange { name, id, .. }
475 | NamedRangeVerb::DeleteNamedRange { name, id } => {
476 match find_existing_named_range_target(&workbook, name.as_deref(), id.as_deref()) {
477 Ok(existing) => Some(existing),
478 Err(result) => return gated(result),
479 }
480 }
481 };
482
483 if let NamedRangeVerb::UpdateNamedRange {
484 new_name: Some(new_name),
485 ..
486 } = &opts.verb
487 {
488 let Some(existing) = existing else {
489 unreachable!("existing is resolved for UpdateNamedRange above") };
491 if let Err(result) =
492 check_new_name_available(&workbook, existing.named_range_id.as_deref(), new_name)
493 {
494 return gated(result);
495 }
496 }
497
498 let new_grid = match &opts.verb {
499 NamedRangeVerb::AddNamedRange {
500 sheet,
501 range,
502 whole_sheet,
503 ..
504 } => match resolve_grid(&workbook, sheet.as_deref(), range.as_deref(), *whole_sheet) {
505 Ok(grid) => Some(grid),
506 Err(result) => return gated(result),
507 },
508 NamedRangeVerb::UpdateNamedRange {
509 sheet,
510 range,
511 whole_sheet,
512 ..
513 } => {
514 match resolve_optional_grid(&workbook, sheet.as_deref(), range.as_deref(), *whole_sheet)
515 {
516 Ok(grid) => grid,
517 Err(result) => return gated(result),
518 }
519 }
520 NamedRangeVerb::DeleteNamedRange { .. } => None,
521 };
522
523 if let NamedRangeVerb::UpdateNamedRange { new_name, .. } = &opts.verb {
524 if new_name.is_none() && new_grid.is_none() {
525 return gated(NamedRangeResult::RefusedInvalidRange {
526 detail: "update-named-range needs --new-name and/or a new range \
527 (--range/--sheet/--whole-sheet); nothing to change"
528 .to_string(),
529 });
530 }
531 }
532
533 let referencing_formulas = if let NamedRangeVerb::DeleteNamedRange { .. } = &opts.verb {
540 let Some(existing) = existing else {
541 unreachable!("existing is resolved for DeleteNamedRange above") };
543 match scan_referencing_formulas(&api, &opts.spreadsheet_id, &workbook, &existing.name).await
544 {
545 Ok(locations) => locations,
546 Err(detail) => return gated(NamedRangeResult::Failed { detail }),
547 }
548 } else {
549 Vec::new()
550 };
551
552 let summary = describe_effect(&opts.verb);
553
554 if opts.dry_run {
555 return gated(NamedRangeResult::WouldChange {
556 summary,
557 referencing_formulas,
558 });
559 }
560
561 let (request, existing_id) = match &opts.verb {
562 NamedRangeVerb::AddNamedRange { name, .. } => {
563 let Some(grid) = new_grid else {
564 unreachable!("new_grid is resolved for AddNamedRange above") };
566 (
567 BatchUpdateRequestItem::AddNamedRange(AddNamedRangeRequest {
568 named_range: NamedRange {
569 named_range_id: None,
570 name: name.clone(),
571 range: grid,
572 },
573 }),
574 None,
575 )
576 }
577 NamedRangeVerb::UpdateNamedRange { new_name, .. } => {
578 let Some(existing) = existing else {
579 unreachable!("existing is resolved for UpdateNamedRange above") };
581 (
582 BatchUpdateRequestItem::UpdateNamedRange(build_update(
583 existing, new_name, new_grid,
584 )),
585 existing.named_range_id.clone(),
586 )
587 }
588 NamedRangeVerb::DeleteNamedRange { .. } => {
589 let Some(existing) = existing else {
590 unreachable!("existing is resolved for DeleteNamedRange above") };
592 let id = existing.named_range_id.clone();
593 (
594 BatchUpdateRequestItem::DeleteNamedRange(DeleteNamedRangeRequest {
595 named_range_id: id.clone().unwrap_or_default(),
596 }),
597 id,
598 )
599 }
600 };
601
602 let files_api = FilesApi::new(drive);
607 let leased = LeasedWrite {
608 log_prefix: "drive sheets named-range",
609 operation: opts.verb.log_operation(),
610 ledger_path: &opts.ledger_path,
611 file_id: &opts.spreadsheet_id,
612 };
613 let lease_grant = match gate_optional_leased_write(
614 leased,
615 &files_api,
616 requires_lease,
617 opts.lease_token.as_deref(),
618 )
619 .await
620 {
621 Ok(grant) => grant,
622 Err(err) => return gated(err.into_result()),
623 };
624
625 let result = match conclude_native_leased_write(
626 leased,
627 &lease_grant,
628 &files_api,
629 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
630 |err| format!("{err:#}"),
631 )
632 .await
633 {
634 Ok(BatchUpdateOutcome::Applied(response)) => {
635 let named_range_id = added_named_range_id(&response).or(existing_id);
636 NamedRangeResult::Changed {
637 summary,
638 named_range_id,
639 referencing_formulas,
640 }
641 }
642 Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
645 NamedRangeResult::AppliedReplyUnreadable {
646 summary,
647 named_range_id: existing_id,
648 referencing_formulas,
649 detail,
650 }
651 }
652 Err(err) => NamedRangeResult::Failed {
653 detail: format!("{err:#}"),
654 },
655 };
656 drop(lease_grant);
657 gated(result)
658}
659
660fn resolve_grid(
665 workbook: &Spreadsheet,
666 sheet: Option<&str>,
667 range: Option<&str>,
668 whole_sheet: bool,
669) -> Result<GridRange, NamedRangeResult> {
670 if whole_sheet {
671 if range.is_some() {
672 return Err(NamedRangeResult::RefusedInvalidRange {
673 detail: "--whole-sheet and --range are mutually exclusive".to_string(),
674 });
675 }
676 let Some(sheet) = sheet else {
677 return Err(NamedRangeResult::RefusedInvalidRange {
678 detail: "--whole-sheet needs --sheet to name the sheet".to_string(),
679 });
680 };
681 let sheet_id = find_sheet_id(workbook, sheet)?;
682 return Ok(GridRange {
683 sheet_id,
684 ..Default::default()
685 });
686 }
687 let composed =
688 a1::compose(sheet, range).map_err(|err| NamedRangeResult::RefusedInvalidRange {
689 detail: err.to_string(),
690 })?;
691 let (_, grid) = grid_range::resolve_grid_range(
692 workbook,
693 &composed,
694 |detail| NamedRangeResult::RefusedInvalidRange { detail },
695 |title, available| NamedRangeResult::RefusedSheetNotFound { title, available },
696 )?;
697 Ok(grid)
698}
699
700fn resolve_optional_grid(
705 workbook: &Spreadsheet,
706 sheet: Option<&str>,
707 range: Option<&str>,
708 whole_sheet: bool,
709) -> Result<Option<GridRange>, NamedRangeResult> {
710 if !whole_sheet && sheet.is_none() && range.is_none() {
711 return Ok(None);
712 }
713 resolve_grid(workbook, sheet, range, whole_sheet).map(Some)
714}
715
716fn find_sheet_id(workbook: &Spreadsheet, title: &str) -> Result<i64, NamedRangeResult> {
717 grid_range::find_sheet_id(workbook, title, |title, available| {
718 NamedRangeResult::RefusedSheetNotFound { title, available }
719 })
720}
721
722fn find_existing_named_range<'a>(
745 workbook: &'a Spreadsheet,
746 name: &str,
747) -> Result<&'a NamedRange, NamedRangeResult> {
748 let matches: Vec<&NamedRange> = workbook
749 .named_ranges
750 .iter()
751 .filter(|nr| nr.name.eq_ignore_ascii_case(name))
752 .collect();
753 match matches.as_slice() {
754 [one] => Ok(one),
755 [] => Err(NamedRangeResult::RefusedNotFound {
756 name: name.to_string(),
757 }),
758 many => Err(NamedRangeResult::RefusedAmbiguousName {
759 name: name.to_string(),
760 candidates: many
761 .iter()
762 .filter_map(|nr| nr.named_range_id.clone())
763 .collect(),
764 }),
765 }
766}
767
768fn find_existing_named_range_by_id<'a>(
778 workbook: &'a Spreadsheet,
779 id: &str,
780) -> Result<&'a NamedRange, NamedRangeResult> {
781 workbook
782 .named_ranges
783 .iter()
784 .find(|nr| nr.named_range_id.as_deref() == Some(id))
785 .ok_or_else(|| NamedRangeResult::RefusedIdNotFound { id: id.to_string() })
786}
787
788fn find_existing_named_range_target<'a>(
793 workbook: &'a Spreadsheet,
794 name: Option<&str>,
795 id: Option<&str>,
796) -> Result<&'a NamedRange, NamedRangeResult> {
797 if let Some(id) = id {
798 find_existing_named_range_by_id(workbook, id)
799 } else if let Some(name) = name {
800 find_existing_named_range(workbook, name)
801 } else {
802 Err(NamedRangeResult::RefusedInvalidRange {
803 detail: "one of --name/--id is required".to_string(),
804 })
805 }
806}
807
808fn check_new_name_available(
829 workbook: &Spreadsheet,
830 own_id: Option<&str>,
831 new_name: &str,
832) -> Result<(), NamedRangeResult> {
833 let collisions: Vec<String> = workbook
834 .named_ranges
835 .iter()
836 .filter(|nr| {
837 nr.name.eq_ignore_ascii_case(new_name) && nr.named_range_id.as_deref() != own_id
838 })
839 .filter_map(|nr| nr.named_range_id.clone())
840 .collect();
841 if collisions.is_empty() {
842 Ok(())
843 } else {
844 Err(NamedRangeResult::RefusedDuplicateName {
845 new_name: new_name.to_string(),
846 existing_ids: collisions,
847 })
848 }
849}
850
851fn build_update(
858 existing: &NamedRange,
859 new_name: &Option<String>,
860 new_grid: Option<GridRange>,
861) -> UpdateNamedRangeRequest {
862 let mut fields = Vec::new();
863 let mut update = existing.clone();
864 if let Some(new_name) = new_name {
865 update.name.clone_from(new_name);
866 fields.push("name");
867 }
868 if let Some(grid) = new_grid {
869 update.range = grid;
870 fields.push("range");
871 }
872 UpdateNamedRangeRequest {
873 named_range: update,
874 fields: fields.join(","),
875 }
876}
877
878fn added_named_range_id(response: &BatchUpdateResponse) -> Option<String> {
879 response
880 .replies
881 .iter()
882 .find_map(|reply| reply.add_named_range.as_ref())
883 .and_then(|added| added.named_range.as_ref())
884 .and_then(|nr| nr.named_range_id.clone())
885}
886
887async fn scan_referencing_formulas(
895 api: &SheetsApi<'_>,
896 spreadsheet_id: &str,
897 workbook: &Spreadsheet,
898 name: &str,
899) -> Result<Vec<String>, String> {
900 let titles = workbook.sheet_titles();
901 if titles.is_empty() {
902 return Ok(Vec::new());
903 }
904 let pattern = format!(r"(?i)\b{}\b", regex::escape(name));
905 let re = regex::Regex::new(&pattern).map_err(|err| format!("{err:#}"))?;
906
907 let mut locations = Vec::new();
908 let results = api
909 .batch_get_every_sheet(spreadsheet_id, &titles, ValueRenderOption::Formula)
910 .await
911 .map_err(|err| format!("{err:#}"))?;
912 for (title, value_range) in results {
913 let Some(title) = title else {
914 continue;
915 };
916 for (row_idx, row) in value_range.values.iter().enumerate() {
917 for (col_idx, cell) in row.iter().enumerate() {
918 let Some(formula) = cell.as_str() else {
919 continue;
920 };
921 if !formula.starts_with('=') || !re.is_match(formula) {
925 continue;
926 }
927 let address = format!(
928 "{}{}",
929 grid_range::column_index_to_letters(col_idx as i64),
930 row_idx + 1
931 );
932 let location = a1::compose(Some(&title), Some(&address))
933 .unwrap_or_else(|_| format!("{title}!{address}"));
934 locations.push(location);
935 }
936 }
937 }
938 Ok(locations)
939}
940
941fn describe_effect(verb: &NamedRangeVerb) -> String {
942 match verb {
943 NamedRangeVerb::AddNamedRange { name, .. } => format!("add named range '{name}'"),
944 NamedRangeVerb::UpdateNamedRange {
945 name, id, new_name, ..
946 } => {
947 let target = target_descriptor(name.as_deref(), id.as_deref());
948 match new_name {
949 Some(new_name) => format!("rename named range {target} to '{new_name}'"),
950 None => format!("update named range {target}"),
951 }
952 }
953 NamedRangeVerb::DeleteNamedRange { name, id } => format!(
954 "delete named range {}",
955 target_descriptor(name.as_deref(), id.as_deref())
956 ),
957 }
958}
959
960fn target_descriptor(name: Option<&str>, id: Option<&str>) -> String {
966 match (name, id) {
967 (Some(name), _) => format!("'{name}'"),
968 (None, Some(id)) => format!("(id '{id}')"),
969 (None, None) => "?".to_string(),
970 }
971}
972
973fn record_attempt(outcome: &NamedRangeOutcome, opts: &NamedRangeOptions, duration: Duration) {
974 request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
975}
976
977fn mutation_record(
980 outcome: &NamedRangeOutcome,
981 opts: &NamedRangeOptions,
982 duration: Duration,
983) -> DriveMutationOutcome {
984 let error = match &outcome.result {
985 NamedRangeResult::Failed { detail }
986 | NamedRangeResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
987 _ => None,
988 };
989 let decided_by = match &outcome.result {
990 NamedRangeResult::Blocked { decided_by } => decided_by.as_ref(),
991 _ => None,
992 };
993 let decided_by = write_gate::decided_by_log_fields(decided_by);
994 let (named_range_id, referencing_formula_locations) = match &outcome.result {
995 NamedRangeResult::Changed {
996 named_range_id,
997 referencing_formulas,
998 ..
999 }
1000 | NamedRangeResult::AppliedReplyUnreadable {
1001 named_range_id,
1002 referencing_formulas,
1003 ..
1004 } => (named_range_id.clone(), referencing_formulas.clone()),
1005 _ => (None, Vec::new()),
1006 };
1007 let fields_changed = match &outcome.result {
1008 NamedRangeResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
1009 _ => None,
1010 };
1011
1012 DriveMutationOutcome {
1013 operation: opts.verb.log_operation(),
1014 file_id: outcome.spreadsheet_id.clone(),
1015 file_name: outcome.file_name.clone().unwrap_or_default(),
1016 status: outcome.result.log_status().to_string(),
1017 resolved_folder_id: outcome.resolved_folder_id.clone(),
1018 decided_by_folder_id: decided_by.folder_id,
1019 decided_by_depth: decided_by.depth,
1020 decided_by_file_id: decided_by.file_id,
1021 named_range_id,
1022 referencing_formula_locations,
1023 fields_changed,
1024 error,
1025 duration,
1026 ..Default::default()
1027 }
1028}
1029
1030#[must_use]
1032pub fn describe(outcome: &NamedRangeOutcome) -> String {
1033 describe_lines(outcome).join("\n")
1034}
1035
1036#[must_use]
1039pub fn describe_lines(outcome: &NamedRangeOutcome) -> Vec<String> {
1040 let verb = &outcome.verb;
1041 let book = outcome.file_name.as_deref().map_or_else(
1042 || format!("'{}'", outcome.spreadsheet_id),
1043 |n| format!("'{n}'"),
1044 );
1045 match &outcome.result {
1046 NamedRangeResult::WouldChange {
1047 summary,
1048 referencing_formulas,
1049 } => {
1050 let mut lines = vec![format!("Would {summary} in {book}")];
1051 lines.extend(referencing_formula_lines(referencing_formulas));
1052 lines
1053 }
1054 NamedRangeResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1055 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
1056 `drive sheets {}` only works on spreadsheets",
1057 verb.label()
1058 )],
1059 NamedRangeResult::RefusedShortcut => vec![format!(
1060 "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
1061 verb.label()
1062 )],
1063 NamedRangeResult::RefusedNoVisibleParents => vec![format!(
1064 "Refused: {book} has no parent folder visible to this account, so no folder \
1065 rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
1066 id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
1067 )],
1068 NamedRangeResult::RefusedSheetNotFound { title, available } => {
1069 vec![grid_range::no_sheet_refusal(&book, title, available)]
1070 }
1071 NamedRangeResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
1072 NamedRangeResult::RefusedNotFound { name } => vec![format!(
1073 "Refused: {book} has no named range '{name}'; run \
1074 `drive sheets list-named-ranges` to see what exists"
1075 )],
1076 NamedRangeResult::RefusedIdNotFound { id } => vec![format!(
1077 "Refused: {book} has no named range with id '{id}'; run \
1078 `drive sheets list-named-ranges` to see what exists"
1079 )],
1080 NamedRangeResult::RefusedAmbiguousName { name, candidates } => vec![format!(
1081 "Refused: more than one named range in {book} is named '{name}' (ids: {}); \
1082 this is ambiguous and nothing was changed. Retry with `--id` naming one \
1083 of those ids, or rename/delete all but one in the Sheets UI \
1084 (Data > Named ranges)",
1085 candidates.join(", ")
1086 )],
1087 NamedRangeResult::RefusedDuplicateName {
1088 new_name,
1089 existing_ids,
1090 } => {
1091 let ids = if existing_ids.is_empty() {
1092 String::new()
1093 } else {
1094 format!(" (ids: {})", existing_ids.join(", "))
1095 };
1096 vec![format!(
1097 "Refused: {book} already has a named range called '{new_name}'{ids}; \
1098 rename would create a duplicate"
1099 )]
1100 }
1101 NamedRangeResult::Blocked { decided_by } => vec![match decided_by {
1102 Some(rule) => format!(
1103 "Blocked: {} on {book} refused by rule on {} {}{}",
1104 verb.label(),
1105 rule.kind_label(),
1106 rule.id(),
1107 rule.depth_suffix()
1108 ),
1109 None => format!(
1110 "Blocked: {} on {book} refused by default policy (no matching rule for \
1111 sheets-structure)",
1112 verb.label()
1113 ),
1114 }],
1115 NamedRangeResult::RefusedNoLease => LeaseGateRefusal::NoLease
1116 .describe_line(&outcome.spreadsheet_id, &book)
1117 .into_iter()
1118 .collect(),
1119 NamedRangeResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1120 .describe_line(&outcome.spreadsheet_id, &book)
1121 .into_iter()
1122 .collect(),
1123 NamedRangeResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1124 .describe_line(&outcome.spreadsheet_id, &book)
1125 .into_iter()
1126 .collect(),
1127 NamedRangeResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1128 .describe_line(&outcome.spreadsheet_id, &book)
1129 .into_iter()
1130 .collect(),
1131 NamedRangeResult::Changed {
1132 summary,
1133 named_range_id,
1134 referencing_formulas,
1135 } => {
1136 let id = named_range_id
1137 .as_deref()
1138 .map_or_else(String::new, |id| format!(" (id {id})"));
1139 let mut lines = vec![format!("Applied: {summary}{id} in {book}")];
1140 lines.extend(referencing_formula_lines(referencing_formulas));
1141 lines
1142 }
1143 NamedRangeResult::AppliedReplyUnreadable {
1144 summary,
1145 named_range_id,
1146 referencing_formulas,
1147 detail,
1148 } => {
1149 let id = named_range_id
1150 .as_deref()
1151 .map_or_else(String::new, |id| format!(" (id {id})"));
1152 let mut lines = vec![applied_reply_unreadable_line(
1153 &format!("{summary}{id}"),
1154 &book,
1155 RetryHint::NotIdempotent,
1156 detail,
1157 )];
1158 if matches!(verb, NamedRangeVerb::AddNamedRange { .. }) {
1159 lines.push(
1160 " the new named range's id was in the unreadable reply; run \
1161 `list-named-ranges` to find it"
1162 .to_string(),
1163 );
1164 }
1165 lines.extend(referencing_formula_lines(referencing_formulas));
1167 lines
1168 }
1169 NamedRangeResult::Failed { detail } => vec![format!("Failed: {detail}")],
1170 }
1171}
1172
1173fn referencing_formula_lines(referencing_formulas: &[String]) -> Vec<String> {
1181 if referencing_formulas.is_empty() {
1182 return Vec::new();
1183 }
1184 vec![format!(
1185 "{} cell formula(s) reference this name and will start evaluating to #REF! once \
1186 it's removed (conditional formatting, data validation and chart references are not \
1187 scanned): {}",
1188 referencing_formulas.len(),
1189 grid_range::render_locations(referencing_formulas)
1190 )]
1191}
1192
1193#[cfg(test)]
1194#[allow(clippy::unwrap_used, clippy::expect_used)]
1195mod tests {
1196 use super::*;
1197 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1198 use crate::drive::sheets::client::SHEETS_API_URL;
1199 use crate::drive::sheets::types::Sheet;
1200 use crate::drive::test_support::seed_lease;
1201 use crate::test_support::env::MapEnv;
1202 use crate::utils::secret::Secret;
1203 use std::collections::HashSet;
1204
1205 fn grid(sheet_id: i64) -> GridRange {
1206 GridRange {
1207 sheet_id,
1208 start_row_index: Some(0),
1209 end_row_index: Some(5),
1210 start_column_index: Some(0),
1211 end_column_index: Some(1),
1212 }
1213 }
1214
1215 fn named(id: &str, name: &str, range: GridRange) -> NamedRange {
1216 NamedRange {
1217 named_range_id: Some(id.to_string()),
1218 name: name.to_string(),
1219 range,
1220 }
1221 }
1222
1223 fn workbook_with(named_ranges: Vec<NamedRange>) -> Spreadsheet {
1224 Spreadsheet {
1225 sheets: vec![Sheet::default()],
1226 named_ranges,
1227 ..Default::default()
1228 }
1229 }
1230
1231 #[test]
1232 fn find_existing_named_range_matches_by_exact_name() {
1233 let workbook = workbook_with(vec![named("id-1", "Foo", grid(0))]);
1234 let found = find_existing_named_range(&workbook, "Foo").unwrap();
1235 assert_eq!(found.named_range_id.as_deref(), Some("id-1"));
1236 }
1237
1238 #[test]
1239 fn find_existing_named_range_refuses_when_none_matches() {
1240 let workbook = workbook_with(vec![named("id-1", "Foo", grid(0))]);
1241 let err = find_existing_named_range(&workbook, "Bar").unwrap_err();
1242 assert!(matches!(err, NamedRangeResult::RefusedNotFound { name } if name == "Bar"));
1243 }
1244
1245 #[test]
1246 fn find_existing_named_range_matches_case_insensitively() {
1247 let workbook = workbook_with(vec![named("id-1", "Foo", grid(0))]);
1248 let found = find_existing_named_range(&workbook, "foo").unwrap();
1249 assert_eq!(found.named_range_id.as_deref(), Some("id-1"));
1250 }
1251
1252 #[test]
1253 fn find_existing_named_range_refuses_as_ambiguous_when_two_share_a_name() {
1254 let workbook = workbook_with(vec![
1257 named("id-1", "AllS1", grid(0)),
1258 named("id-2", "AllS1", grid(1)),
1259 ]);
1260 let err = find_existing_named_range(&workbook, "AllS1").unwrap_err();
1261 match err {
1262 NamedRangeResult::RefusedAmbiguousName {
1263 name,
1264 mut candidates,
1265 } => {
1266 assert_eq!(name, "AllS1");
1267 candidates.sort();
1268 assert_eq!(candidates, ["id-1", "id-2"]);
1269 }
1270 other => panic!("expected RefusedAmbiguousName, got {other:?}"), }
1272 }
1273
1274 #[test]
1275 fn find_existing_named_range_refuses_as_ambiguous_case_insensitively() {
1276 let workbook = workbook_with(vec![
1277 named("id-1", "Units", grid(0)),
1278 named("id-2", "units", grid(1)),
1279 ]);
1280 let err = find_existing_named_range(&workbook, "UNITS").unwrap_err();
1281 assert!(matches!(err, NamedRangeResult::RefusedAmbiguousName { .. }));
1282 }
1283
1284 #[test]
1287 fn find_existing_named_range_by_id_matches_by_exact_id() {
1288 let workbook = workbook_with(vec![
1289 named("id-1", "AllS1", grid(0)),
1290 named("id-2", "AllS1", grid(1)),
1291 ]);
1292 let found = find_existing_named_range_by_id(&workbook, "id-2").unwrap();
1295 assert_eq!(found.named_range_id.as_deref(), Some("id-2"));
1296 }
1297
1298 #[test]
1299 fn find_existing_named_range_by_id_refuses_when_none_matches() {
1300 let workbook = workbook_with(vec![named("id-1", "Foo", grid(0))]);
1301 let err = find_existing_named_range_by_id(&workbook, "id-99").unwrap_err();
1302 assert!(matches!(err, NamedRangeResult::RefusedIdNotFound { id } if id == "id-99"));
1303 }
1304
1305 #[test]
1306 fn find_existing_named_range_by_id_is_case_sensitive() {
1307 let workbook = workbook_with(vec![named("id-1", "Foo", grid(0))]);
1310 let err = find_existing_named_range_by_id(&workbook, "ID-1").unwrap_err();
1311 assert!(matches!(err, NamedRangeResult::RefusedIdNotFound { id } if id == "ID-1"));
1312 }
1313
1314 #[test]
1315 fn find_existing_named_range_target_prefers_id_when_both_are_set() {
1316 let workbook = workbook_with(vec![
1319 named("id-1", "Foo", grid(0)),
1320 named("id-2", "Bar", grid(1)),
1321 ]);
1322 let found = find_existing_named_range_target(&workbook, Some("Bar"), Some("id-1")).unwrap();
1323 assert_eq!(found.named_range_id.as_deref(), Some("id-1"));
1324 }
1325
1326 #[test]
1327 fn find_existing_named_range_target_refuses_when_neither_is_set() {
1328 let workbook = workbook_with(vec![named("id-1", "Foo", grid(0))]);
1329 let err = find_existing_named_range_target(&workbook, None, None).unwrap_err();
1330 assert!(matches!(err, NamedRangeResult::RefusedInvalidRange { .. }));
1331 }
1332
1333 #[test]
1334 fn check_new_name_available_allows_renaming_to_its_own_current_name() {
1335 let workbook = workbook_with(vec![named("id-1", "Foo", grid(0))]);
1336 assert!(check_new_name_available(&workbook, Some("id-1"), "Foo").is_ok());
1337 assert!(check_new_name_available(&workbook, Some("id-1"), "foo").is_ok());
1339 }
1340
1341 #[test]
1342 fn check_new_name_available_refuses_a_case_insensitive_collision() {
1343 let workbook = workbook_with(vec![
1344 named("id-1", "Qty", grid(0)),
1345 named("id-2", "AllS1", grid(1)),
1346 ]);
1347 let err = check_new_name_available(&workbook, Some("id-1"), "allS1").unwrap_err();
1348 match err {
1349 NamedRangeResult::RefusedDuplicateName {
1350 new_name,
1351 existing_ids,
1352 } => {
1353 assert_eq!(new_name, "allS1");
1354 assert_eq!(existing_ids, ["id-2"]);
1355 }
1356 other => panic!("expected RefusedDuplicateName, got {other:?}"), }
1358 }
1359
1360 #[test]
1361 fn check_new_name_available_lists_every_colliding_id_when_the_workbook_already_has_duplicates()
1362 {
1363 let workbook = workbook_with(vec![
1367 named("id-1", "Qty", grid(0)),
1368 named("id-2", "AllS1", grid(1)),
1369 named("id-3", "AllS1", grid(2)),
1370 ]);
1371 let err = check_new_name_available(&workbook, Some("id-1"), "AllS1").unwrap_err();
1372 match err {
1373 NamedRangeResult::RefusedDuplicateName {
1374 new_name,
1375 mut existing_ids,
1376 } => {
1377 assert_eq!(new_name, "AllS1");
1378 existing_ids.sort();
1379 assert_eq!(existing_ids, ["id-2", "id-3"]);
1380 }
1381 other => panic!("expected RefusedDuplicateName, got {other:?}"), }
1383 }
1384
1385 #[test]
1386 fn check_new_name_available_allows_a_genuinely_new_name() {
1387 let workbook = workbook_with(vec![named("id-1", "Qty", grid(0))]);
1388 assert!(check_new_name_available(&workbook, Some("id-1"), "Units").is_ok());
1389 }
1390
1391 #[test]
1392 fn resolve_grid_rejects_whole_sheet_with_range() {
1393 let workbook = workbook_with(Vec::new());
1394 let err = resolve_grid(&workbook, Some("Q1"), Some("A1"), true).unwrap_err();
1395 assert!(matches!(err, NamedRangeResult::RefusedInvalidRange { .. }));
1396 }
1397
1398 #[test]
1399 fn resolve_grid_rejects_whole_sheet_without_a_sheet() {
1400 let workbook = workbook_with(Vec::new());
1401 let err = resolve_grid(&workbook, None, None, true).unwrap_err();
1402 assert!(matches!(err, NamedRangeResult::RefusedInvalidRange { .. }));
1403 }
1404
1405 #[test]
1406 fn resolve_optional_grid_is_none_when_nothing_is_given() {
1407 let workbook = workbook_with(Vec::new());
1408 assert_eq!(
1409 resolve_optional_grid(&workbook, None, None, false).unwrap(),
1410 None
1411 );
1412 }
1413
1414 #[test]
1415 fn build_update_only_sets_fields_that_changed() {
1416 let existing = named("id-1", "Foo", grid(0));
1417 let request = build_update(&existing, &None, None);
1418 assert_eq!(request.fields, "");
1419 assert_eq!(request.named_range.name, "Foo");
1420
1421 let request = build_update(&existing, &Some("Bar".to_string()), None);
1422 assert_eq!(request.fields, "name");
1423 assert_eq!(request.named_range.name, "Bar");
1424
1425 let new_grid = grid(1);
1426 let request = build_update(&existing, &None, Some(new_grid));
1427 assert_eq!(request.fields, "range");
1428 assert_eq!(request.named_range.range, new_grid);
1429 }
1430
1431 #[test]
1434 fn regex_matches_the_name_but_not_a_longer_identifier_containing_it() {
1435 let re = regex::Regex::new(&format!(r"(?i)\b{}\b", regex::escape("Foo"))).unwrap();
1436 assert!(re.is_match("=SUM(Foo)"));
1437 assert!(re.is_match("=SUM(foo)"));
1438 assert!(!re.is_match("=SUM(Foobar)"));
1439 }
1440
1441 fn test_credentials() -> DriveCredentials {
1442 DriveCredentials {
1443 client_id: "client-1".to_string(),
1444 client_secret: Secret::new("secret-1"),
1445 refresh_token: Secret::new("refresh-1"),
1446 scope: DriveGrantedScopes::READONLY,
1447 }
1448 }
1449
1450 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1451 wiremock::Mock::given(wiremock::matchers::method("POST"))
1452 .and(wiremock::matchers::path("/token"))
1453 .respond_with(
1454 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1455 "access_token": "test-token", "expires_in": 3600,
1456 })),
1457 )
1458 .mount(server)
1459 .await;
1460 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1461 crate::drive::client::test_support::replace_session(
1462 &mut drive,
1463 &test_credentials(),
1464 &format!("{}/token", server.uri()),
1465 );
1466 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1467 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1468 (drive, sheets)
1469 }
1470
1471 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1474 let parents: Vec<&str> = parents.to_vec();
1475 wiremock::Mock::given(wiremock::matchers::method("GET"))
1476 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1477 .respond_with(
1478 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1479 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1480 "version": "1",
1481 })),
1482 )
1483 }
1484
1485 fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
1486 let ledger_path = tempfile::tempdir()
1487 .unwrap()
1488 .keep()
1489 .join("lease-ledger.jsonl");
1490 let token = seed_lease(&ledger_path, spreadsheet_id, "1");
1491 (Some(token), ledger_path)
1492 }
1493
1494 fn mount_folder(id: &str) -> wiremock::Mock {
1495 mount_file(id, "application/vnd.google-apps.folder", &[])
1496 }
1497
1498 fn allow_rule(folder: &str) -> FolderPermissionRule {
1499 FolderPermissionRule {
1500 folder_id: Some(folder.to_string()),
1501 file_id: None,
1502 recursive: true,
1503 allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
1504 deny: HashSet::default(),
1505 require_lease: true,
1506 }
1507 }
1508
1509 fn mount_workbook(named_ranges: serde_json::Value) -> wiremock::Mock {
1510 wiremock::Mock::given(wiremock::matchers::method("GET"))
1511 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1512 .respond_with(
1513 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1514 "spreadsheetId": "sheet-1",
1515 "properties": {"title": "Budget"},
1516 "sheets": [
1517 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1518 {"properties": {"sheetId": 1, "title": "Q2", "index": 1}},
1519 ],
1520 "namedRanges": named_ranges,
1521 })),
1522 )
1523 }
1524
1525 fn mount_batch_get(sheets: &[(&str, serde_json::Value)]) -> wiremock::Mock {
1536 let value_ranges: Vec<serde_json::Value> = sheets
1537 .iter()
1538 .map(|(sheet, values)| {
1539 serde_json::json!({"range": format!("{sheet}!A1:Z1000"), "values": values})
1540 })
1541 .collect();
1542 wiremock::Mock::given(wiremock::matchers::method("GET"))
1543 .and(wiremock::matchers::path(
1544 "/v4/spreadsheets/sheet-1/values:batchGet",
1545 ))
1546 .respond_with(
1547 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1548 "valueRanges": value_ranges
1549 })),
1550 )
1551 }
1552
1553 #[tokio::test]
1554 async fn a_denied_gate_blocks_before_any_read_or_batch_update_call() {
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 let rules: Vec<FolderPermissionRule> = Vec::new();
1566 let opts = NamedRangeOptions {
1567 spreadsheet_id: "sheet-1".to_string(),
1568 verb: NamedRangeVerb::AddNamedRange {
1569 name: "Foo".to_string(),
1570 sheet: Some("Q1".to_string()),
1571 range: Some("A1:A5".to_string()),
1572 whole_sheet: false,
1573 },
1574 dry_run: false,
1575 lease_token: None,
1576 ledger_path: std::path::PathBuf::new(),
1577 };
1578 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1579 assert!(matches!(outcome.result, NamedRangeResult::Blocked { .. }));
1580 }
1581
1582 #[tokio::test]
1583 async fn add_named_range_sends_an_add_named_range_request() {
1584 let server = wiremock::MockServer::start().await;
1585 let (drive, sheets) = clients(&server).await;
1586 mount_file(
1587 "sheet-1",
1588 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1589 &["folder-1"],
1590 )
1591 .mount(&server)
1592 .await;
1593 mount_folder("folder-1").mount(&server).await;
1594 mount_workbook(serde_json::json!([])).mount(&server).await;
1595 wiremock::Mock::given(wiremock::matchers::method("POST"))
1596 .and(wiremock::matchers::path(
1597 "/v4/spreadsheets/sheet-1:batchUpdate",
1598 ))
1599 .respond_with(
1600 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1601 "replies": [{"addNamedRange": {"namedRange": {"namedRangeId": "id-99"}}}]
1602 })),
1603 )
1604 .mount(&server)
1605 .await;
1606 let rules = vec![allow_rule("folder-1")];
1607 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1608 let opts = NamedRangeOptions {
1609 spreadsheet_id: "sheet-1".to_string(),
1610 verb: NamedRangeVerb::AddNamedRange {
1611 name: "Foo".to_string(),
1612 sheet: Some("Q1".to_string()),
1613 range: Some("A1:A5".to_string()),
1614 whole_sheet: false,
1615 },
1616 dry_run: false,
1617 lease_token,
1618 ledger_path,
1619 };
1620 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1621 match outcome.result {
1622 NamedRangeResult::Changed { named_range_id, .. } => {
1623 assert_eq!(named_range_id.as_deref(), Some("id-99"));
1624 }
1625 other => panic!("expected Changed, got {other:?}"),
1626 }
1627 }
1628
1629 #[tokio::test]
1630 async fn add_named_range_with_an_unreadable_reply_reports_applied_reply_unreadable() {
1631 let server = wiremock::MockServer::start().await;
1632 let (drive, sheets) = clients(&server).await;
1633 mount_file(
1634 "sheet-1",
1635 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1636 &["folder-1"],
1637 )
1638 .mount(&server)
1639 .await;
1640 mount_folder("folder-1").mount(&server).await;
1641 mount_workbook(serde_json::json!([])).mount(&server).await;
1642 wiremock::Mock::given(wiremock::matchers::method("POST"))
1644 .and(wiremock::matchers::path(
1645 "/v4/spreadsheets/sheet-1:batchUpdate",
1646 ))
1647 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
1648 .expect(1)
1649 .mount(&server)
1650 .await;
1651 let rules = vec![allow_rule("folder-1")];
1652 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1653 let opts = NamedRangeOptions {
1654 spreadsheet_id: "sheet-1".to_string(),
1655 verb: NamedRangeVerb::AddNamedRange {
1656 name: "Foo".to_string(),
1657 sheet: Some("Q1".to_string()),
1658 range: Some("A1:A5".to_string()),
1659 whole_sheet: false,
1660 },
1661 dry_run: false,
1662 lease_token,
1663 ledger_path,
1664 };
1665 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1666 assert_eq!(
1667 outcome.result.log_status(),
1668 "applied-reply-unreadable",
1669 "{:?}",
1670 outcome.result
1671 );
1672 assert!(
1673 describe(&outcome).starts_with("Applied, but the reply could not be read"),
1674 "{}",
1675 describe(&outcome)
1676 );
1677 }
1678
1679 #[tokio::test]
1680 async fn update_named_range_renames_and_re_ranges() {
1681 let server = wiremock::MockServer::start().await;
1682 let (drive, sheets) = clients(&server).await;
1683 mount_file(
1684 "sheet-1",
1685 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1686 &["folder-1"],
1687 )
1688 .mount(&server)
1689 .await;
1690 mount_folder("folder-1").mount(&server).await;
1691 mount_workbook(serde_json::json!([{
1692 "namedRangeId": "id-1",
1693 "name": "Foo",
1694 "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 5,
1695 "startColumnIndex": 0, "endColumnIndex": 1},
1696 }]))
1697 .mount(&server)
1698 .await;
1699 wiremock::Mock::given(wiremock::matchers::method("POST"))
1700 .and(wiremock::matchers::path(
1701 "/v4/spreadsheets/sheet-1:batchUpdate",
1702 ))
1703 .respond_with(
1704 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1705 "replies": [{}]
1706 })),
1707 )
1708 .mount(&server)
1709 .await;
1710 let rules = vec![allow_rule("folder-1")];
1711 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1712 let opts = NamedRangeOptions {
1713 spreadsheet_id: "sheet-1".to_string(),
1714 verb: NamedRangeVerb::UpdateNamedRange {
1715 name: Some("Foo".to_string()),
1716 id: None,
1717 new_name: Some("Bar".to_string()),
1718 sheet: Some("Q2".to_string()),
1719 range: None,
1720 whole_sheet: true,
1721 },
1722 dry_run: false,
1723 lease_token,
1724 ledger_path,
1725 };
1726 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1727 match outcome.result {
1728 NamedRangeResult::Changed { named_range_id, .. } => {
1729 assert_eq!(named_range_id.as_deref(), Some("id-1"));
1730 }
1731 other => panic!("expected Changed, got {other:?}"),
1732 }
1733 }
1734
1735 #[tokio::test]
1736 async fn update_named_range_refuses_when_no_name_matches() {
1737 let server = wiremock::MockServer::start().await;
1738 let (drive, sheets) = clients(&server).await;
1739 mount_file(
1740 "sheet-1",
1741 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1742 &["folder-1"],
1743 )
1744 .mount(&server)
1745 .await;
1746 mount_folder("folder-1").mount(&server).await;
1747 mount_workbook(serde_json::json!([])).mount(&server).await;
1748 let rules = vec![allow_rule("folder-1")];
1749 let opts = NamedRangeOptions {
1750 spreadsheet_id: "sheet-1".to_string(),
1751 verb: NamedRangeVerb::UpdateNamedRange {
1752 name: Some("Foo".to_string()),
1753 id: None,
1754 new_name: Some("Bar".to_string()),
1755 sheet: None,
1756 range: None,
1757 whole_sheet: false,
1758 },
1759 dry_run: false,
1760 lease_token: None,
1761 ledger_path: std::path::PathBuf::new(),
1762 };
1763 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1764 assert!(matches!(
1765 outcome.result,
1766 NamedRangeResult::RefusedNotFound { .. }
1767 ));
1768 }
1769
1770 #[tokio::test]
1771 async fn update_named_range_by_id_refuses_when_no_id_matches() {
1772 let server = wiremock::MockServer::start().await;
1773 let (drive, sheets) = clients(&server).await;
1774 mount_file(
1775 "sheet-1",
1776 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1777 &["folder-1"],
1778 )
1779 .mount(&server)
1780 .await;
1781 mount_folder("folder-1").mount(&server).await;
1782 mount_workbook(serde_json::json!([])).mount(&server).await;
1783 let rules = vec![allow_rule("folder-1")];
1784 let opts = NamedRangeOptions {
1785 spreadsheet_id: "sheet-1".to_string(),
1786 verb: NamedRangeVerb::UpdateNamedRange {
1787 name: None,
1788 id: Some("id-99".to_string()),
1789 new_name: Some("Bar".to_string()),
1790 sheet: None,
1791 range: None,
1792 whole_sheet: false,
1793 },
1794 dry_run: false,
1795 lease_token: None,
1796 ledger_path: std::path::PathBuf::new(),
1797 };
1798 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1799 assert!(matches!(
1800 outcome.result,
1801 NamedRangeResult::RefusedIdNotFound { id } if id == "id-99"
1802 ));
1803 }
1804
1805 #[tokio::test]
1806 async fn update_named_range_refuses_a_new_name_that_collides_with_another_range() {
1807 let server = wiremock::MockServer::start().await;
1812 let (drive, sheets) = clients(&server).await;
1813 mount_file(
1814 "sheet-1",
1815 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1816 &["folder-1"],
1817 )
1818 .mount(&server)
1819 .await;
1820 mount_folder("folder-1").mount(&server).await;
1821 mount_workbook(serde_json::json!([
1822 {
1823 "namedRangeId": "id-1",
1824 "name": "AllS1",
1825 "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 5,
1826 "startColumnIndex": 0, "endColumnIndex": 1},
1827 },
1828 {
1829 "namedRangeId": "id-2",
1830 "name": "Qty",
1831 "range": {"sheetId": 0, "startRowIndex": 5, "endRowIndex": 10,
1832 "startColumnIndex": 0, "endColumnIndex": 1},
1833 },
1834 ]))
1835 .mount(&server)
1836 .await;
1837 let rules = vec![allow_rule("folder-1")];
1838 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1839 let opts = NamedRangeOptions {
1840 spreadsheet_id: "sheet-1".to_string(),
1841 verb: NamedRangeVerb::UpdateNamedRange {
1842 name: Some("qty".to_string()),
1843 id: None,
1844 new_name: Some("AllS1".to_string()),
1845 sheet: None,
1846 range: None,
1847 whole_sheet: false,
1848 },
1849 dry_run: false,
1850 lease_token,
1851 ledger_path,
1852 };
1853 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1854 match outcome.result {
1855 NamedRangeResult::RefusedDuplicateName {
1856 new_name,
1857 existing_ids,
1858 } => {
1859 assert_eq!(new_name, "AllS1");
1860 assert_eq!(existing_ids, ["id-1"]);
1861 }
1862 other => panic!("expected RefusedDuplicateName, got {other:?}"),
1863 }
1864 }
1865
1866 #[tokio::test]
1867 async fn update_named_range_allows_renaming_to_its_own_current_name() {
1868 let server = wiremock::MockServer::start().await;
1872 let (drive, sheets) = clients(&server).await;
1873 mount_file(
1874 "sheet-1",
1875 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1876 &["folder-1"],
1877 )
1878 .mount(&server)
1879 .await;
1880 mount_folder("folder-1").mount(&server).await;
1881 mount_workbook(serde_json::json!([{
1882 "namedRangeId": "id-1",
1883 "name": "Foo",
1884 "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 5,
1885 "startColumnIndex": 0, "endColumnIndex": 1},
1886 }]))
1887 .mount(&server)
1888 .await;
1889 wiremock::Mock::given(wiremock::matchers::method("POST"))
1890 .and(wiremock::matchers::path(
1891 "/v4/spreadsheets/sheet-1:batchUpdate",
1892 ))
1893 .respond_with(
1894 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1895 "replies": [{}]
1896 })),
1897 )
1898 .mount(&server)
1899 .await;
1900 let rules = vec![allow_rule("folder-1")];
1901 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1902 let opts = NamedRangeOptions {
1903 spreadsheet_id: "sheet-1".to_string(),
1904 verb: NamedRangeVerb::UpdateNamedRange {
1905 name: Some("Foo".to_string()),
1906 id: None,
1907 new_name: Some("foo".to_string()),
1908 sheet: Some("Q2".to_string()),
1909 range: None,
1910 whole_sheet: true,
1911 },
1912 dry_run: false,
1913 lease_token,
1914 ledger_path,
1915 };
1916 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1917 assert!(
1918 matches!(outcome.result, NamedRangeResult::Changed { .. }),
1919 "{:?}",
1920 outcome.result
1921 );
1922 }
1923
1924 #[tokio::test]
1925 async fn update_and_delete_named_range_refuse_as_ambiguous_when_name_matches_two_ranges() {
1926 let verbs = [
1927 NamedRangeVerb::UpdateNamedRange {
1928 name: Some("AllS1".to_string()),
1929 id: None,
1930 new_name: Some("Totals".to_string()),
1931 sheet: None,
1932 range: None,
1933 whole_sheet: false,
1934 },
1935 NamedRangeVerb::DeleteNamedRange {
1936 name: Some("AllS1".to_string()),
1937 id: None,
1938 },
1939 ];
1940 for verb in verbs {
1941 let server = wiremock::MockServer::start().await;
1942 let (drive, sheets) = clients(&server).await;
1943 mount_file(
1944 "sheet-1",
1945 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1946 &["folder-1"],
1947 )
1948 .mount(&server)
1949 .await;
1950 mount_folder("folder-1").mount(&server).await;
1951 mount_workbook(serde_json::json!([
1952 {
1953 "namedRangeId": "id-1",
1954 "name": "AllS1",
1955 "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 5,
1956 "startColumnIndex": 0, "endColumnIndex": 1},
1957 },
1958 {
1959 "namedRangeId": "id-2",
1960 "name": "AllS1",
1961 "range": {"sheetId": 0, "startRowIndex": 5, "endRowIndex": 10,
1962 "startColumnIndex": 0, "endColumnIndex": 1},
1963 },
1964 ]))
1965 .mount(&server)
1966 .await;
1967 let rules = vec![allow_rule("folder-1")];
1968 let opts = NamedRangeOptions {
1969 spreadsheet_id: "sheet-1".to_string(),
1970 verb,
1971 dry_run: false,
1972 lease_token: None,
1973 ledger_path: std::path::PathBuf::new(),
1974 };
1975 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1978 assert!(
1979 matches!(
1980 outcome.result,
1981 NamedRangeResult::RefusedAmbiguousName { .. }
1982 ),
1983 "{:?}",
1984 outcome.result
1985 );
1986 }
1987 }
1988
1989 #[tokio::test]
1994 async fn delete_named_range_by_id_succeeds_when_name_would_be_ambiguous() {
1995 let server = wiremock::MockServer::start().await;
1996 let (drive, sheets) = clients(&server).await;
1997 mount_file(
1998 "sheet-1",
1999 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2000 &["folder-1"],
2001 )
2002 .mount(&server)
2003 .await;
2004 mount_folder("folder-1").mount(&server).await;
2005 mount_workbook(serde_json::json!([
2006 {
2007 "namedRangeId": "id-1",
2008 "name": "AllS1",
2009 "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 5,
2010 "startColumnIndex": 0, "endColumnIndex": 1},
2011 },
2012 {
2013 "namedRangeId": "id-2",
2014 "name": "AllS1",
2015 "range": {"sheetId": 0, "startRowIndex": 5, "endRowIndex": 10,
2016 "startColumnIndex": 0, "endColumnIndex": 1},
2017 },
2018 ]))
2019 .mount(&server)
2020 .await;
2021 mount_batch_get(&[("Q1", serde_json::json!([])), ("Q2", serde_json::json!([]))])
2022 .mount(&server)
2023 .await;
2024 wiremock::Mock::given(wiremock::matchers::method("POST"))
2025 .and(wiremock::matchers::path(
2026 "/v4/spreadsheets/sheet-1:batchUpdate",
2027 ))
2028 .respond_with(
2029 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2030 "replies": [{}]
2031 })),
2032 )
2033 .mount(&server)
2034 .await;
2035 let rules = vec![allow_rule("folder-1")];
2036 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2037 let opts = NamedRangeOptions {
2038 spreadsheet_id: "sheet-1".to_string(),
2039 verb: NamedRangeVerb::DeleteNamedRange {
2040 name: None,
2041 id: Some("id-2".to_string()),
2042 },
2043 dry_run: false,
2044 lease_token,
2045 ledger_path,
2046 };
2047 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2048 match outcome.result {
2049 NamedRangeResult::Changed { named_range_id, .. } => {
2050 assert_eq!(named_range_id.as_deref(), Some("id-2"));
2051 }
2052 other => panic!("expected Changed, got {other:?}"),
2053 }
2054 }
2055
2056 #[tokio::test]
2057 async fn delete_named_range_dry_run_reports_referencing_formulas_without_a_decoy_match() {
2058 let server = wiremock::MockServer::start().await;
2059 let (drive, sheets) = clients(&server).await;
2060 mount_file(
2061 "sheet-1",
2062 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2063 &["folder-1"],
2064 )
2065 .mount(&server)
2066 .await;
2067 mount_folder("folder-1").mount(&server).await;
2068 mount_workbook(serde_json::json!([{
2069 "namedRangeId": "id-1",
2070 "name": "Foo",
2071 "range": {"sheetId": 0},
2072 }]))
2073 .mount(&server)
2074 .await;
2075 mount_batch_get(&[
2079 (
2080 "Q1",
2081 serde_json::json!([["=SUM(Foo)", "plain text with Foo in it"]]),
2082 ),
2083 ("Q2", serde_json::json!([["=Foobar+1"]])),
2084 ])
2085 .mount(&server)
2086 .await;
2087 let rules = vec![allow_rule("folder-1")];
2088 let opts = NamedRangeOptions {
2089 spreadsheet_id: "sheet-1".to_string(),
2090 verb: NamedRangeVerb::DeleteNamedRange {
2091 name: Some("Foo".to_string()),
2092 id: None,
2093 },
2094 dry_run: true,
2095 lease_token: None,
2096 ledger_path: std::path::PathBuf::new(),
2097 };
2098 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2099 match outcome.result {
2100 NamedRangeResult::WouldChange {
2101 referencing_formulas,
2102 ..
2103 } => {
2104 assert_eq!(referencing_formulas, vec!["'Q1'!A1".to_string()]);
2105 }
2106 other => panic!("expected WouldChange, got {other:?}"),
2107 }
2108 }
2109
2110 #[tokio::test]
2111 async fn delete_named_range_with_zero_references_reports_an_empty_preview() {
2112 let server = wiremock::MockServer::start().await;
2113 let (drive, sheets) = clients(&server).await;
2114 mount_file(
2115 "sheet-1",
2116 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2117 &["folder-1"],
2118 )
2119 .mount(&server)
2120 .await;
2121 mount_folder("folder-1").mount(&server).await;
2122 mount_workbook(serde_json::json!([{
2123 "namedRangeId": "id-1",
2124 "name": "Foo",
2125 "range": {"sheetId": 0},
2126 }]))
2127 .mount(&server)
2128 .await;
2129 mount_batch_get(&[("Q1", serde_json::json!([])), ("Q2", serde_json::json!([]))])
2130 .mount(&server)
2131 .await;
2132 let rules = vec![allow_rule("folder-1")];
2133 let opts = NamedRangeOptions {
2134 spreadsheet_id: "sheet-1".to_string(),
2135 verb: NamedRangeVerb::DeleteNamedRange {
2136 name: Some("Foo".to_string()),
2137 id: None,
2138 },
2139 dry_run: true,
2140 lease_token: None,
2141 ledger_path: std::path::PathBuf::new(),
2142 };
2143 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2144 match outcome.result {
2145 NamedRangeResult::WouldChange {
2146 referencing_formulas,
2147 ..
2148 } => assert!(referencing_formulas.is_empty()),
2149 other => panic!("expected WouldChange, got {other:?}"),
2150 }
2151 }
2152
2153 #[tokio::test]
2154 async fn delete_named_range_real_run_sends_the_same_preview_as_dry_run() {
2155 let server = wiremock::MockServer::start().await;
2156 let (drive, sheets) = clients(&server).await;
2157 mount_file(
2158 "sheet-1",
2159 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2160 &["folder-1"],
2161 )
2162 .mount(&server)
2163 .await;
2164 mount_folder("folder-1").mount(&server).await;
2165 mount_workbook(serde_json::json!([{
2166 "namedRangeId": "id-1",
2167 "name": "Foo",
2168 "range": {"sheetId": 0},
2169 }]))
2170 .mount(&server)
2171 .await;
2172 mount_batch_get(&[
2173 ("Q1", serde_json::json!([["=SUM(Foo)"]])),
2174 ("Q2", serde_json::json!([])),
2175 ])
2176 .mount(&server)
2177 .await;
2178 wiremock::Mock::given(wiremock::matchers::method("POST"))
2179 .and(wiremock::matchers::path(
2180 "/v4/spreadsheets/sheet-1:batchUpdate",
2181 ))
2182 .respond_with(
2183 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2184 "replies": [{}]
2185 })),
2186 )
2187 .mount(&server)
2188 .await;
2189 let rules = vec![allow_rule("folder-1")];
2190 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2191 let opts = NamedRangeOptions {
2192 spreadsheet_id: "sheet-1".to_string(),
2193 verb: NamedRangeVerb::DeleteNamedRange {
2194 name: Some("Foo".to_string()),
2195 id: None,
2196 },
2197 dry_run: false,
2198 lease_token,
2199 ledger_path,
2200 };
2201 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2202 match outcome.result {
2203 NamedRangeResult::Changed {
2204 named_range_id,
2205 referencing_formulas,
2206 ..
2207 } => {
2208 assert_eq!(named_range_id.as_deref(), Some("id-1"));
2209 assert_eq!(referencing_formulas, vec!["'Q1'!A1".to_string()]);
2210 }
2211 other => panic!("expected Changed, got {other:?}"),
2212 }
2213 }
2214
2215 fn outcome_with(
2218 verb: NamedRangeVerb,
2219 file_name: Option<&str>,
2220 result: NamedRangeResult,
2221 ) -> NamedRangeOutcome {
2222 NamedRangeOutcome {
2223 spreadsheet_id: "sheet-1".to_string(),
2224 file_name: file_name.map(str::to_string),
2225 resolved_folder_id: None,
2226 verb,
2227 result,
2228 }
2229 }
2230
2231 fn add_verb() -> NamedRangeVerb {
2232 NamedRangeVerb::AddNamedRange {
2233 name: "Foo".to_string(),
2234 sheet: Some("Q1".to_string()),
2235 range: Some("A1:A5".to_string()),
2236 whole_sheet: false,
2237 }
2238 }
2239
2240 fn update_verb() -> NamedRangeVerb {
2241 NamedRangeVerb::UpdateNamedRange {
2242 name: Some("Foo".to_string()),
2243 id: None,
2244 new_name: Some("Bar".to_string()),
2245 sheet: None,
2246 range: None,
2247 whole_sheet: false,
2248 }
2249 }
2250
2251 fn delete_verb() -> NamedRangeVerb {
2252 NamedRangeVerb::DeleteNamedRange {
2253 name: Some("Foo".to_string()),
2254 id: None,
2255 }
2256 }
2257
2258 #[test]
2259 fn describe_lines_renders_would_change_with_and_without_referencing_formulas() {
2260 let none = outcome_with(
2261 add_verb(),
2262 Some("Budget"),
2263 NamedRangeResult::WouldChange {
2264 summary: "add named range 'Foo'".to_string(),
2265 referencing_formulas: Vec::new(),
2266 },
2267 );
2268 assert_eq!(describe(&none), "Would add named range 'Foo' in 'Budget'");
2269
2270 let some = outcome_with(
2271 delete_verb(),
2272 Some("Budget"),
2273 NamedRangeResult::WouldChange {
2274 summary: "delete named range 'Foo'".to_string(),
2275 referencing_formulas: vec!["'Q1'!A1".to_string(), "'Q2'!B2".to_string()],
2276 },
2277 );
2278 let text = describe(&some);
2279 assert!(
2280 text.contains("2 cell formula(s) reference this name"),
2281 "{text}"
2282 );
2283 assert!(
2284 text.contains(
2285 "this name and will start evaluating to #REF! once it's removed \
2286 (conditional formatting, data validation and chart references are not \
2287 scanned): 'Q1'!A1, 'Q2'!B2"
2288 ),
2289 "{text}"
2290 );
2291 }
2292
2293 #[test]
2294 fn referencing_formula_lines_elides_past_the_render_limit_but_keeps_the_true_count() {
2295 let formulas: Vec<String> = (1..=60).map(|row| format!("'Q1'!A{row}")).collect();
2296 let some = outcome_with(
2297 delete_verb(),
2298 Some("Budget"),
2299 NamedRangeResult::WouldChange {
2300 summary: "delete named range 'Foo'".to_string(),
2301 referencing_formulas: formulas.clone(),
2302 },
2303 );
2304 let text = describe(&some);
2305 assert!(
2306 text.contains("60 cell formula(s) reference this name"),
2307 "{text}"
2308 );
2309 assert!(text.contains("… and 10 more"), "{text}");
2310 assert_eq!(formulas.len(), 60);
2311 }
2312
2313 #[test]
2314 fn describe_lines_renders_not_a_spreadsheet_with_no_file_name() {
2315 let out = outcome_with(
2316 add_verb(),
2317 None,
2318 NamedRangeResult::RefusedNotASpreadsheet {
2319 mime_type: "text/plain".to_string(),
2320 },
2321 );
2322 let text = describe(&out);
2323 assert!(text.contains("'sheet-1'"), "{text}");
2324 assert!(text.contains("add-named-range"), "{text}");
2325 assert!(text.contains("text/plain"), "{text}");
2326 }
2327
2328 #[test]
2329 fn describe_lines_renders_shortcut() {
2330 let out = outcome_with(
2331 delete_verb(),
2332 Some("Budget"),
2333 NamedRangeResult::RefusedShortcut,
2334 );
2335 let text = describe(&out);
2336 assert!(text.contains("shortcut"), "{text}");
2337 assert!(text.contains("delete-named-range"), "{text}");
2338 }
2339
2340 #[test]
2341 fn describe_lines_renders_no_visible_parents() {
2342 let out = outcome_with(
2343 add_verb(),
2344 Some("Budget"),
2345 NamedRangeResult::RefusedNoVisibleParents,
2346 );
2347 assert!(describe(&out).contains("sheets-structure"));
2348 }
2349
2350 #[test]
2351 fn describe_lines_renders_ambiguous_name_with_candidates() {
2352 let out = outcome_with(
2353 update_verb(),
2354 Some("Budget"),
2355 NamedRangeResult::RefusedAmbiguousName {
2356 name: "AllS1".to_string(),
2357 candidates: vec!["id-1".to_string(), "id-2".to_string()],
2358 },
2359 );
2360 let text = describe(&out);
2361 assert!(text.contains("AllS1"), "{text}");
2362 assert!(text.contains("id-1"), "{text}");
2363 assert!(text.contains("id-2"), "{text}");
2364 assert!(text.contains("ambiguous"), "{text}");
2365 assert!(text.contains("Data > Named ranges"), "{text}");
2366 }
2367
2368 #[test]
2369 fn describe_lines_renders_duplicate_name_with_one_and_several_ids() {
2370 let with_id = outcome_with(
2371 update_verb(),
2372 Some("Budget"),
2373 NamedRangeResult::RefusedDuplicateName {
2374 new_name: "AllS1".to_string(),
2375 existing_ids: vec!["id-1".to_string()],
2376 },
2377 );
2378 let text = describe(&with_id);
2379 assert!(text.contains("AllS1"), "{text}");
2380 assert!(text.contains("id-1"), "{text}");
2381
2382 let with_several = outcome_with(
2383 update_verb(),
2384 Some("Budget"),
2385 NamedRangeResult::RefusedDuplicateName {
2386 new_name: "AllS1".to_string(),
2387 existing_ids: vec!["id-1".to_string(), "id-2".to_string()],
2388 },
2389 );
2390 let text = describe(&with_several);
2391 assert!(text.contains("id-1"), "{text}");
2392 assert!(text.contains("id-2"), "{text}");
2393
2394 let without_id = outcome_with(
2395 update_verb(),
2396 Some("Budget"),
2397 NamedRangeResult::RefusedDuplicateName {
2398 new_name: "AllS1".to_string(),
2399 existing_ids: Vec::new(),
2400 },
2401 );
2402 let text = describe(&without_id);
2403 assert!(text.contains("AllS1"), "{text}");
2404 assert!(!text.contains("(ids"), "{text}");
2405 }
2406
2407 #[test]
2408 fn describe_lines_renders_sheet_not_found_with_and_without_available_titles() {
2409 let none = outcome_with(
2410 add_verb(),
2411 Some("Budget"),
2412 NamedRangeResult::RefusedSheetNotFound {
2413 title: "Q2".to_string(),
2414 available: Vec::new(),
2415 },
2416 );
2417 assert!(describe(&none).contains("Available: none"));
2418
2419 let some = outcome_with(
2420 add_verb(),
2421 Some("Budget"),
2422 NamedRangeResult::RefusedSheetNotFound {
2423 title: "Q2".to_string(),
2424 available: vec!["Q1".to_string(), "Q3".to_string()],
2425 },
2426 );
2427 assert!(describe(&some).contains("'Q1', 'Q3'"));
2428 }
2429
2430 #[test]
2431 fn describe_lines_renders_invalid_range_and_not_found() {
2432 let invalid = outcome_with(
2433 add_verb(),
2434 Some("Budget"),
2435 NamedRangeResult::RefusedInvalidRange {
2436 detail: "bad range".to_string(),
2437 },
2438 );
2439 assert_eq!(describe(&invalid), "Refused: bad range");
2440
2441 let not_found = outcome_with(
2442 update_verb(),
2443 Some("Budget"),
2444 NamedRangeResult::RefusedNotFound {
2445 name: "Foo".to_string(),
2446 },
2447 );
2448 let text = describe(¬_found);
2449 assert!(text.contains("no named range 'Foo'"), "{text}");
2450 assert!(text.contains("list-named-ranges"), "{text}");
2451
2452 let id_not_found = outcome_with(
2453 update_verb(),
2454 Some("Budget"),
2455 NamedRangeResult::RefusedIdNotFound {
2456 id: "id-99".to_string(),
2457 },
2458 );
2459 let text = describe(&id_not_found);
2460 assert!(text.contains("no named range with id 'id-99'"), "{text}");
2461 assert!(text.contains("list-named-ranges"), "{text}");
2462 }
2463
2464 #[test]
2465 fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
2466 let folder_rule = outcome_with(
2467 update_verb(),
2468 Some("Budget"),
2469 NamedRangeResult::Blocked {
2470 decided_by: Some(DecidingRule::Folder {
2471 folder_id: "folder-1".to_string(),
2472 depth: 2,
2473 }),
2474 },
2475 );
2476 let text = describe(&folder_rule);
2477 assert!(text.contains("update-named-range"), "{text}");
2478 assert!(text.contains("folder folder-1 (depth 2)"), "{text}");
2479
2480 let default_policy = outcome_with(
2481 delete_verb(),
2482 Some("Budget"),
2483 NamedRangeResult::Blocked { decided_by: None },
2484 );
2485 assert!(describe(&default_policy).contains("default policy"));
2486 }
2487
2488 #[test]
2489 fn describe_lines_renders_every_lease_refusal() {
2490 for (result, needle) in [
2491 (
2492 NamedRangeResult::RefusedNoLease,
2493 "requires a Drive write lease",
2494 ),
2495 (
2496 NamedRangeResult::RefusedLeaseExpired,
2497 "expired, released, or unknown",
2498 ),
2499 (
2500 NamedRangeResult::RefusedLeaseWrongFile,
2501 "acquired for a different file",
2502 ),
2503 (
2504 NamedRangeResult::RefusedLeaseStale,
2505 "changed since the lease was acquired",
2506 ),
2507 ] {
2508 let out = outcome_with(add_verb(), Some("Budget"), result);
2509 let text = describe(&out);
2510 assert!(text.contains(needle), "{text}");
2511 assert!(text.contains("drive lease acquire sheet-1"), "{text}");
2512 }
2513 }
2514
2515 #[test]
2516 fn describe_lines_renders_changed_with_and_without_an_id_and_formulas() {
2517 let with_id = outcome_with(
2518 add_verb(),
2519 Some("Budget"),
2520 NamedRangeResult::Changed {
2521 summary: "add named range 'Foo'".to_string(),
2522 named_range_id: Some("id-7".to_string()),
2523 referencing_formulas: Vec::new(),
2524 },
2525 );
2526 assert!(describe(&with_id).contains("(id id-7)"));
2527
2528 let without_id = outcome_with(
2529 delete_verb(),
2530 Some("Budget"),
2531 NamedRangeResult::Changed {
2532 summary: "delete named range 'Foo'".to_string(),
2533 named_range_id: None,
2534 referencing_formulas: vec!["'Q1'!A1".to_string()],
2535 },
2536 );
2537 let text = describe(&without_id);
2538 assert!(!text.contains("(id"), "{text}");
2539 assert!(text.contains("1 cell formula(s)"), "{text}");
2540 assert!(text.contains("scanned): 'Q1'!A1"), "{text}");
2541 }
2542
2543 #[test]
2544 fn describe_lines_renders_applied_reply_unreadable_with_an_id_hint_only_for_adds() {
2545 let added = outcome_with(
2546 add_verb(),
2547 Some("Budget"),
2548 NamedRangeResult::AppliedReplyUnreadable {
2549 summary: "add named range 'Foo'".to_string(),
2550 named_range_id: None,
2551 referencing_formulas: Vec::new(),
2552 detail: "bad reply".to_string(),
2553 },
2554 );
2555 assert_eq!(
2556 describe_lines(&added),
2557 vec![
2558 "Applied, but the reply could not be read: add named range 'Foo' in 'Budget' — \
2559 do not retry; check the spreadsheet first (bad reply)"
2560 .to_string(),
2561 " the new named range's id was in the unreadable reply; run \
2562 `list-named-ranges` to find it"
2563 .to_string(),
2564 ]
2565 );
2566
2567 let deleted = outcome_with(
2568 delete_verb(),
2569 Some("Budget"),
2570 NamedRangeResult::AppliedReplyUnreadable {
2571 summary: "delete named range 'Foo'".to_string(),
2572 named_range_id: Some("id-7".to_string()),
2573 referencing_formulas: vec!["'Q1'!A1".to_string()],
2574 detail: "bad reply".to_string(),
2575 },
2576 );
2577 assert_eq!(
2580 describe_lines(&deleted),
2581 vec![
2582 "Applied, but the reply could not be read: delete named range 'Foo' (id id-7) \
2583 in 'Budget' — do not retry; check the spreadsheet first (bad reply)"
2584 .to_string(),
2585 "1 cell formula(s) reference this name and will start evaluating to #REF! \
2586 once it's removed (conditional formatting, data validation and chart \
2587 references are not scanned): 'Q1'!A1"
2588 .to_string(),
2589 ]
2590 );
2591 }
2592
2593 #[test]
2594 fn mutation_record_logs_the_same_fields_for_an_unreadable_reply_as_for_changed() {
2595 let opts = NamedRangeOptions {
2596 spreadsheet_id: "sheet-1".to_string(),
2597 verb: delete_verb(),
2598 dry_run: false,
2599 lease_token: None,
2600 ledger_path: std::path::PathBuf::new(),
2601 };
2602 let record = |result| {
2603 let out = outcome_with(delete_verb(), Some("Budget"), result);
2604 mutation_record(&out, &opts, Duration::ZERO)
2605 };
2606 let changed = record(NamedRangeResult::Changed {
2607 summary: "delete named range 'Foo'".to_string(),
2608 named_range_id: Some("id-7".to_string()),
2609 referencing_formulas: vec!["'Q1'!A1".to_string()],
2610 });
2611 let unreadable = record(NamedRangeResult::AppliedReplyUnreadable {
2612 summary: "delete named range 'Foo'".to_string(),
2613 named_range_id: Some("id-7".to_string()),
2614 referencing_formulas: vec!["'Q1'!A1".to_string()],
2615 detail: "bad reply".to_string(),
2616 });
2617 assert_eq!(unreadable.named_range_id.as_deref(), Some("id-7"));
2618 assert_eq!(unreadable.named_range_id, changed.named_range_id);
2619 assert_eq!(
2620 unreadable.referencing_formula_locations,
2621 changed.referencing_formula_locations
2622 );
2623 assert_eq!(
2624 unreadable.referencing_formula_locations,
2625 vec!["'Q1'!A1".to_string()]
2626 );
2627 assert_eq!(unreadable.status, "applied-reply-unreadable");
2628 assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
2629 assert_eq!(
2630 unreadable.fields_changed.as_deref(),
2631 Some("delete named range 'Foo'")
2632 );
2633 }
2634
2635 #[test]
2636 fn describe_lines_renders_failed() {
2637 let out = outcome_with(
2638 add_verb(),
2639 Some("Budget"),
2640 NamedRangeResult::Failed {
2641 detail: "boom".to_string(),
2642 },
2643 );
2644 assert_eq!(describe(&out), "Failed: boom");
2645 }
2646
2647 #[test]
2648 fn describe_effect_update_without_a_new_name_says_update_not_rename() {
2649 let verb = NamedRangeVerb::UpdateNamedRange {
2650 name: Some("Foo".to_string()),
2651 id: None,
2652 new_name: None,
2653 sheet: Some("Q1".to_string()),
2654 range: Some("A1:A5".to_string()),
2655 whole_sheet: false,
2656 };
2657 assert_eq!(describe_effect(&verb), "update named range 'Foo'");
2658 }
2659
2660 #[test]
2661 fn describe_effect_uses_the_id_target_descriptor_when_selected_by_id() {
2662 let update = NamedRangeVerb::UpdateNamedRange {
2663 name: None,
2664 id: Some("id-1".to_string()),
2665 new_name: Some("Bar".to_string()),
2666 sheet: None,
2667 range: None,
2668 whole_sheet: false,
2669 };
2670 assert_eq!(
2671 describe_effect(&update),
2672 "rename named range (id 'id-1') to 'Bar'"
2673 );
2674
2675 let delete = NamedRangeVerb::DeleteNamedRange {
2676 name: None,
2677 id: Some("id-1".to_string()),
2678 };
2679 assert_eq!(describe_effect(&delete), "delete named range (id 'id-1')");
2680 }
2681
2682 #[test]
2685 fn resolve_grid_rejects_a_bare_range_with_no_sheet_prefix() {
2686 let workbook = workbook_with(Vec::new());
2687 let err = resolve_grid(&workbook, None, Some("A1:B2"), false).unwrap_err();
2688 assert!(matches!(err, NamedRangeResult::RefusedInvalidRange { .. }));
2689 }
2690
2691 #[test]
2692 fn resolve_grid_rejects_neither_sheet_nor_range() {
2693 let workbook = workbook_with(Vec::new());
2694 let err = resolve_grid(&workbook, None, None, false).unwrap_err();
2695 assert!(matches!(err, NamedRangeResult::RefusedInvalidRange { .. }));
2696 }
2697
2698 #[test]
2699 fn resolve_grid_reports_sheet_not_found_for_a_prefixed_range() {
2700 let workbook = workbook_with(Vec::new());
2701 let err = resolve_grid(&workbook, Some("Nope"), Some("A1:B2"), false).unwrap_err();
2702 assert!(
2703 matches!(err, NamedRangeResult::RefusedSheetNotFound { title, .. } if title == "Nope")
2704 );
2705 }
2706
2707 #[test]
2708 fn resolve_grid_reports_sheet_not_found_for_whole_sheet() {
2709 let workbook = workbook_with(Vec::new());
2710 let err = resolve_grid(&workbook, Some("Nope"), None, true).unwrap_err();
2711 assert!(
2712 matches!(err, NamedRangeResult::RefusedSheetNotFound { title, .. } if title == "Nope")
2713 );
2714 }
2715
2716 #[tokio::test]
2719 async fn scan_referencing_formulas_with_no_sheets_makes_no_api_call() {
2720 let server = wiremock::MockServer::start().await;
2721 let (_drive, sheets) = clients(&server).await;
2722 let api = SheetsApi::new(&sheets);
2723 let workbook = Spreadsheet::default();
2724 let locations = scan_referencing_formulas(&api, "sheet-1", &workbook, "Foo")
2725 .await
2726 .unwrap();
2727 assert!(locations.is_empty());
2728 }
2729
2730 #[tokio::test]
2731 async fn scan_referencing_formulas_skips_untitled_ranges_and_non_string_cells() {
2732 let server = wiremock::MockServer::start().await;
2733 let (_drive, sheets) = clients(&server).await;
2734 wiremock::Mock::given(wiremock::matchers::method("GET"))
2735 .and(wiremock::matchers::path(
2736 "/v4/spreadsheets/sheet-1/values:batchGet",
2737 ))
2738 .respond_with(
2739 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2740 "valueRanges": [
2741 {"range": "A1:Z1000", "values": [["=SUM(Foo)"]]},
2745 {"range": "Q1!A1:Z1000", "values": [[42, "=SUM(Foo)"]]},
2746 ]
2747 })),
2748 )
2749 .mount(&server)
2750 .await;
2751 let api = SheetsApi::new(&sheets);
2752 let workbook = workbook_with(Vec::new());
2753 let locations = scan_referencing_formulas(&api, "sheet-1", &workbook, "Foo")
2754 .await
2755 .unwrap();
2756 assert_eq!(locations, vec!["'Q1'!B1".to_string()]);
2757 }
2758
2759 #[tokio::test]
2762 async fn a_metadata_fetch_failure_surfaces_as_failed() {
2763 let server = wiremock::MockServer::start().await;
2764 let (drive, sheets) = clients(&server).await;
2765 wiremock::Mock::given(wiremock::matchers::method("GET"))
2766 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2767 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
2768 .mount(&server)
2769 .await;
2770 let rules: Vec<FolderPermissionRule> = Vec::new();
2771 let opts = NamedRangeOptions {
2772 spreadsheet_id: "sheet-1".to_string(),
2773 verb: add_verb(),
2774 dry_run: false,
2775 lease_token: None,
2776 ledger_path: std::path::PathBuf::new(),
2777 };
2778 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2779 assert!(matches!(outcome.result, NamedRangeResult::Failed { .. }));
2780 }
2781
2782 #[tokio::test]
2783 async fn a_shortcut_target_is_refused() {
2784 let server = wiremock::MockServer::start().await;
2785 let (drive, sheets) = clients(&server).await;
2786 mount_file(
2787 "sheet-1",
2788 "application/vnd.google-apps.shortcut",
2789 &["folder-1"],
2790 )
2791 .mount(&server)
2792 .await;
2793 let rules = vec![allow_rule("folder-1")];
2794 let opts = NamedRangeOptions {
2795 spreadsheet_id: "sheet-1".to_string(),
2796 verb: add_verb(),
2797 dry_run: false,
2798 lease_token: None,
2799 ledger_path: std::path::PathBuf::new(),
2800 };
2801 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2802 assert!(matches!(outcome.result, NamedRangeResult::RefusedShortcut));
2803 }
2804
2805 #[tokio::test]
2806 async fn a_non_spreadsheet_target_is_refused() {
2807 let server = wiremock::MockServer::start().await;
2808 let (drive, sheets) = clients(&server).await;
2809 mount_file(
2810 "sheet-1",
2811 "application/vnd.google-apps.document",
2812 &["folder-1"],
2813 )
2814 .mount(&server)
2815 .await;
2816 let rules = vec![allow_rule("folder-1")];
2817 let opts = NamedRangeOptions {
2818 spreadsheet_id: "sheet-1".to_string(),
2819 verb: add_verb(),
2820 dry_run: false,
2821 lease_token: None,
2822 ledger_path: std::path::PathBuf::new(),
2823 };
2824 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2825 assert!(matches!(
2826 outcome.result,
2827 NamedRangeResult::RefusedNotASpreadsheet { .. }
2828 ));
2829 }
2830
2831 #[tokio::test]
2832 async fn a_target_with_no_visible_parents_is_refused() {
2833 let server = wiremock::MockServer::start().await;
2834 let (drive, sheets) = clients(&server).await;
2835 mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
2836 .mount(&server)
2837 .await;
2838 let rules: Vec<FolderPermissionRule> = Vec::new();
2839 let opts = NamedRangeOptions {
2840 spreadsheet_id: "sheet-1".to_string(),
2841 verb: add_verb(),
2842 dry_run: false,
2843 lease_token: None,
2844 ledger_path: std::path::PathBuf::new(),
2845 };
2846 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2847 assert!(matches!(
2848 outcome.result,
2849 NamedRangeResult::RefusedNoVisibleParents
2850 ));
2851 }
2852
2853 #[tokio::test]
2854 async fn a_gate_ancestor_fetch_failure_surfaces_as_failed() {
2855 let server = wiremock::MockServer::start().await;
2856 let (drive, sheets) = clients(&server).await;
2857 mount_file(
2858 "sheet-1",
2859 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2860 &["folder-1"],
2861 )
2862 .mount(&server)
2863 .await;
2864 wiremock::Mock::given(wiremock::matchers::method("GET"))
2865 .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
2866 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2867 .mount(&server)
2868 .await;
2869 let rules = vec![allow_rule("folder-1")];
2870 let opts = NamedRangeOptions {
2871 spreadsheet_id: "sheet-1".to_string(),
2872 verb: add_verb(),
2873 dry_run: false,
2874 lease_token: None,
2875 ledger_path: std::path::PathBuf::new(),
2876 };
2877 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2878 assert!(matches!(outcome.result, NamedRangeResult::Failed { .. }));
2879 }
2880
2881 #[tokio::test]
2882 async fn a_workbook_fetch_failure_after_a_granted_gate_surfaces_as_failed() {
2883 let server = wiremock::MockServer::start().await;
2884 let (drive, sheets) = clients(&server).await;
2885 mount_file(
2886 "sheet-1",
2887 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2888 &["folder-1"],
2889 )
2890 .mount(&server)
2891 .await;
2892 mount_folder("folder-1").mount(&server).await;
2893 wiremock::Mock::given(wiremock::matchers::method("GET"))
2894 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2895 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2896 .mount(&server)
2897 .await;
2898 let rules = vec![allow_rule("folder-1")];
2899 let opts = NamedRangeOptions {
2900 spreadsheet_id: "sheet-1".to_string(),
2901 verb: add_verb(),
2902 dry_run: false,
2903 lease_token: None,
2904 ledger_path: std::path::PathBuf::new(),
2905 };
2906 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2907 assert!(matches!(outcome.result, NamedRangeResult::Failed { .. }));
2908 }
2909
2910 #[tokio::test]
2913 async fn refuses_without_a_lease_when_the_rule_requires_one() {
2914 let server = wiremock::MockServer::start().await;
2915 let (drive, sheets) = clients(&server).await;
2916 mount_file(
2917 "sheet-1",
2918 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2919 &["folder-1"],
2920 )
2921 .mount(&server)
2922 .await;
2923 mount_folder("folder-1").mount(&server).await;
2924 mount_workbook(serde_json::json!([])).mount(&server).await;
2925 let rules = vec![allow_rule("folder-1")];
2926 let opts = NamedRangeOptions {
2927 spreadsheet_id: "sheet-1".to_string(),
2928 verb: add_verb(),
2929 dry_run: false,
2930 lease_token: None,
2931 ledger_path: std::path::PathBuf::new(),
2932 };
2933 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2934 assert!(matches!(outcome.result, NamedRangeResult::RefusedNoLease));
2935 }
2936
2937 #[tokio::test]
2938 async fn refuses_an_unknown_lease_token() {
2939 let server = wiremock::MockServer::start().await;
2940 let (drive, sheets) = clients(&server).await;
2941 mount_file(
2942 "sheet-1",
2943 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2944 &["folder-1"],
2945 )
2946 .mount(&server)
2947 .await;
2948 mount_folder("folder-1").mount(&server).await;
2949 mount_workbook(serde_json::json!([])).mount(&server).await;
2950 let rules = vec![allow_rule("folder-1")];
2951 let ledger_path = tempfile::tempdir()
2952 .unwrap()
2953 .keep()
2954 .join("lease-ledger.jsonl");
2955 let opts = NamedRangeOptions {
2956 spreadsheet_id: "sheet-1".to_string(),
2957 verb: add_verb(),
2958 dry_run: false,
2959 lease_token: Some("bogus-token".to_string()),
2960 ledger_path,
2961 };
2962 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2963 assert!(matches!(
2964 outcome.result,
2965 NamedRangeResult::RefusedLeaseExpired
2966 ));
2967 }
2968
2969 #[tokio::test]
2970 async fn refuses_a_lease_bound_to_a_different_file() {
2971 let server = wiremock::MockServer::start().await;
2972 let (drive, sheets) = clients(&server).await;
2973 mount_file(
2974 "sheet-1",
2975 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2976 &["folder-1"],
2977 )
2978 .mount(&server)
2979 .await;
2980 mount_folder("folder-1").mount(&server).await;
2981 mount_workbook(serde_json::json!([])).mount(&server).await;
2982 let rules = vec![allow_rule("folder-1")];
2983 let ledger_path = tempfile::tempdir()
2984 .unwrap()
2985 .keep()
2986 .join("lease-ledger.jsonl");
2987 let token = seed_lease(&ledger_path, "some-other-sheet", "1");
2988 let opts = NamedRangeOptions {
2989 spreadsheet_id: "sheet-1".to_string(),
2990 verb: add_verb(),
2991 dry_run: false,
2992 lease_token: Some(token),
2993 ledger_path,
2994 };
2995 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2996 assert!(matches!(
2997 outcome.result,
2998 NamedRangeResult::RefusedLeaseWrongFile
2999 ));
3000 }
3001
3002 #[tokio::test]
3003 async fn refuses_a_stale_lease_when_the_file_has_moved() {
3004 let server = wiremock::MockServer::start().await;
3005 let (drive, sheets) = clients(&server).await;
3006 mount_file(
3007 "sheet-1",
3008 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3009 &["folder-1"],
3010 )
3011 .mount(&server)
3012 .await;
3013 mount_folder("folder-1").mount(&server).await;
3014 mount_workbook(serde_json::json!([])).mount(&server).await;
3015 let rules = vec![allow_rule("folder-1")];
3016 let ledger_path = tempfile::tempdir()
3017 .unwrap()
3018 .keep()
3019 .join("lease-ledger.jsonl");
3020 let token = seed_lease(&ledger_path, "sheet-1", "0");
3021 let opts = NamedRangeOptions {
3022 spreadsheet_id: "sheet-1".to_string(),
3023 verb: add_verb(),
3024 dry_run: false,
3025 lease_token: Some(token),
3026 ledger_path,
3027 };
3028 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
3029 assert!(matches!(
3030 outcome.result,
3031 NamedRangeResult::RefusedLeaseStale
3032 ));
3033 }
3034
3035 #[tokio::test]
3038 async fn update_named_range_refuses_when_nothing_is_named_to_change() {
3039 let server = wiremock::MockServer::start().await;
3040 let (drive, sheets) = clients(&server).await;
3041 mount_file(
3042 "sheet-1",
3043 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3044 &["folder-1"],
3045 )
3046 .mount(&server)
3047 .await;
3048 mount_folder("folder-1").mount(&server).await;
3049 mount_workbook(serde_json::json!([{
3050 "namedRangeId": "id-1",
3051 "name": "Foo",
3052 "range": {"sheetId": 0},
3053 }]))
3054 .mount(&server)
3055 .await;
3056 let rules = vec![allow_rule("folder-1")];
3057 let opts = NamedRangeOptions {
3058 spreadsheet_id: "sheet-1".to_string(),
3059 verb: NamedRangeVerb::UpdateNamedRange {
3060 name: Some("Foo".to_string()),
3061 id: None,
3062 new_name: None,
3063 sheet: None,
3064 range: None,
3065 whole_sheet: false,
3066 },
3067 dry_run: false,
3068 lease_token: None,
3069 ledger_path: std::path::PathBuf::new(),
3070 };
3071 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
3072 assert!(matches!(
3073 outcome.result,
3074 NamedRangeResult::RefusedInvalidRange { .. }
3075 ));
3076 }
3077
3078 #[tokio::test]
3079 async fn update_named_range_reports_sheet_not_found_when_re_pointing() {
3080 let server = wiremock::MockServer::start().await;
3081 let (drive, sheets) = clients(&server).await;
3082 mount_file(
3083 "sheet-1",
3084 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3085 &["folder-1"],
3086 )
3087 .mount(&server)
3088 .await;
3089 mount_folder("folder-1").mount(&server).await;
3090 mount_workbook(serde_json::json!([{
3091 "namedRangeId": "id-1",
3092 "name": "Foo",
3093 "range": {"sheetId": 0},
3094 }]))
3095 .mount(&server)
3096 .await;
3097 let rules = vec![allow_rule("folder-1")];
3098 let opts = NamedRangeOptions {
3099 spreadsheet_id: "sheet-1".to_string(),
3100 verb: NamedRangeVerb::UpdateNamedRange {
3101 name: Some("Foo".to_string()),
3102 id: None,
3103 new_name: None,
3104 sheet: Some("Missing".to_string()),
3105 range: None,
3106 whole_sheet: true,
3107 },
3108 dry_run: false,
3109 lease_token: None,
3110 ledger_path: std::path::PathBuf::new(),
3111 };
3112 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
3113 assert!(matches!(
3114 outcome.result,
3115 NamedRangeResult::RefusedSheetNotFound { .. }
3116 ));
3117 }
3118
3119 #[tokio::test]
3122 async fn a_batch_update_failure_surfaces_as_failed() {
3123 let server = wiremock::MockServer::start().await;
3124 let (drive, sheets) = clients(&server).await;
3125 mount_file(
3126 "sheet-1",
3127 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3128 &["folder-1"],
3129 )
3130 .mount(&server)
3131 .await;
3132 mount_folder("folder-1").mount(&server).await;
3133 mount_workbook(serde_json::json!([])).mount(&server).await;
3134 wiremock::Mock::given(wiremock::matchers::method("POST"))
3135 .and(wiremock::matchers::path(
3136 "/v4/spreadsheets/sheet-1:batchUpdate",
3137 ))
3138 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("bad request"))
3139 .mount(&server)
3140 .await;
3141 let rules = vec![allow_rule("folder-1")];
3142 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3143 let opts = NamedRangeOptions {
3144 spreadsheet_id: "sheet-1".to_string(),
3145 verb: add_verb(),
3146 dry_run: false,
3147 lease_token,
3148 ledger_path,
3149 };
3150 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
3151 assert!(matches!(outcome.result, NamedRangeResult::Failed { .. }));
3152 }
3153
3154 #[tokio::test]
3155 async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
3156 let server = wiremock::MockServer::start().await;
3161 let (drive, sheets) = clients(&server).await;
3162 mount_file(
3163 "sheet-1",
3164 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3165 &["folder-1"],
3166 )
3167 .up_to_n_times(1)
3168 .with_priority(1)
3169 .mount(&server)
3170 .await;
3171 wiremock::Mock::given(wiremock::matchers::method("GET"))
3172 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
3173 .respond_with(wiremock::ResponseTemplate::new(500))
3174 .with_priority(2)
3175 .mount(&server)
3176 .await;
3177 mount_folder("folder-1").mount(&server).await;
3178 mount_workbook(serde_json::json!([])).mount(&server).await;
3179 wiremock::Mock::given(wiremock::matchers::method("POST"))
3180 .and(wiremock::matchers::path(
3181 "/v4/spreadsheets/sheet-1:batchUpdate",
3182 ))
3183 .respond_with(wiremock::ResponseTemplate::new(200))
3184 .expect(0)
3185 .mount(&server)
3186 .await;
3187 let rules = vec![allow_rule("folder-1")];
3188 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3189 let opts = NamedRangeOptions {
3190 spreadsheet_id: "sheet-1".to_string(),
3191 verb: add_verb(),
3192 dry_run: false,
3193 lease_token,
3194 ledger_path,
3195 };
3196 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
3197 assert!(matches!(outcome.result, NamedRangeResult::Failed { .. }));
3198 }
3199
3200 #[tokio::test]
3204 async fn add_named_range_reports_sheet_not_found_for_an_unknown_sheet() {
3205 let server = wiremock::MockServer::start().await;
3206 let (drive, sheets) = clients(&server).await;
3207 mount_file(
3208 "sheet-1",
3209 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3210 &["folder-1"],
3211 )
3212 .mount(&server)
3213 .await;
3214 mount_folder("folder-1").mount(&server).await;
3215 mount_workbook(serde_json::json!([])).mount(&server).await;
3216 let rules = vec![allow_rule("folder-1")];
3217 let opts = NamedRangeOptions {
3218 spreadsheet_id: "sheet-1".to_string(),
3219 verb: NamedRangeVerb::AddNamedRange {
3220 name: "Foo".to_string(),
3221 sheet: Some("Missing".to_string()),
3222 range: None,
3223 whole_sheet: true,
3224 },
3225 dry_run: false,
3226 lease_token: None,
3227 ledger_path: std::path::PathBuf::new(),
3228 };
3229 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
3230 assert!(matches!(
3231 outcome.result,
3232 NamedRangeResult::RefusedSheetNotFound { .. }
3233 ));
3234 }
3235
3236 #[tokio::test]
3239 async fn a_referencing_formula_scan_failure_surfaces_as_failed() {
3240 let server = wiremock::MockServer::start().await;
3241 let (drive, sheets) = clients(&server).await;
3242 mount_file(
3243 "sheet-1",
3244 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3245 &["folder-1"],
3246 )
3247 .mount(&server)
3248 .await;
3249 mount_folder("folder-1").mount(&server).await;
3250 mount_workbook(serde_json::json!([{
3251 "namedRangeId": "id-1",
3252 "name": "Foo",
3253 "range": {"sheetId": 0},
3254 }]))
3255 .mount(&server)
3256 .await;
3257 wiremock::Mock::given(wiremock::matchers::method("GET"))
3258 .and(wiremock::matchers::path(
3259 "/v4/spreadsheets/sheet-1/values:batchGet",
3260 ))
3261 .respond_with(wiremock::ResponseTemplate::new(500))
3262 .mount(&server)
3263 .await;
3264 let rules = vec![allow_rule("folder-1")];
3265 let opts = NamedRangeOptions {
3266 spreadsheet_id: "sheet-1".to_string(),
3267 verb: NamedRangeVerb::DeleteNamedRange {
3268 name: Some("Foo".to_string()),
3269 id: None,
3270 },
3271 dry_run: true,
3272 lease_token: None,
3273 ledger_path: std::path::PathBuf::new(),
3274 };
3275 let outcome = named_range(&drive, &sheets, &opts, &rules).await;
3276 assert!(matches!(outcome.result, NamedRangeResult::Failed { .. }));
3277 }
3278
3279 #[test]
3282 fn write_jsonl_emits_one_line_of_json() {
3283 let outcome = outcome_with(
3284 add_verb(),
3285 Some("Budget"),
3286 NamedRangeResult::Changed {
3287 summary: "add named range 'Foo'".to_string(),
3288 named_range_id: Some("id-1".to_string()),
3289 referencing_formulas: Vec::new(),
3290 },
3291 );
3292 let mut buf = Vec::new();
3293 outcome.write_jsonl(&mut buf).unwrap();
3294 let text = String::from_utf8(buf).unwrap();
3295 assert_eq!(text.matches('\n').count(), 1);
3296 let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
3297 assert_eq!(parsed["result"]["status"], "changed");
3298 }
3299
3300 #[test]
3301 fn named_range_result_log_status_names_every_variant() {
3302 assert_eq!(
3303 NamedRangeResult::WouldChange {
3304 summary: String::new(),
3305 referencing_formulas: Vec::new(),
3306 }
3307 .log_status(),
3308 "would-change"
3309 );
3310 assert_eq!(
3311 NamedRangeResult::RefusedNotASpreadsheet {
3312 mime_type: String::new(),
3313 }
3314 .log_status(),
3315 "refused-not-a-spreadsheet"
3316 );
3317 assert_eq!(
3318 NamedRangeResult::RefusedShortcut.log_status(),
3319 "refused-shortcut"
3320 );
3321 assert_eq!(
3322 NamedRangeResult::RefusedNoVisibleParents.log_status(),
3323 "refused-no-visible-parents"
3324 );
3325 assert_eq!(
3326 NamedRangeResult::RefusedSheetNotFound {
3327 title: String::new(),
3328 available: Vec::new(),
3329 }
3330 .log_status(),
3331 "refused-sheet-not-found"
3332 );
3333 assert_eq!(
3334 NamedRangeResult::RefusedInvalidRange {
3335 detail: String::new(),
3336 }
3337 .log_status(),
3338 "refused-invalid-range"
3339 );
3340 assert_eq!(
3341 NamedRangeResult::RefusedNotFound {
3342 name: String::new(),
3343 }
3344 .log_status(),
3345 "refused-not-found"
3346 );
3347 assert_eq!(
3348 NamedRangeResult::RefusedAmbiguousName {
3349 name: String::new(),
3350 candidates: Vec::new(),
3351 }
3352 .log_status(),
3353 "refused-ambiguous-name"
3354 );
3355 assert_eq!(
3356 NamedRangeResult::RefusedDuplicateName {
3357 new_name: String::new(),
3358 existing_ids: Vec::new(),
3359 }
3360 .log_status(),
3361 "refused-duplicate-name"
3362 );
3363 assert_eq!(
3364 NamedRangeResult::AppliedReplyUnreadable {
3365 summary: String::new(),
3366 named_range_id: None,
3367 referencing_formulas: Vec::new(),
3368 detail: String::new(),
3369 }
3370 .log_status(),
3371 "applied-reply-unreadable"
3372 );
3373 assert_eq!(
3374 NamedRangeResult::Failed {
3375 detail: String::new(),
3376 }
3377 .log_status(),
3378 "failed"
3379 );
3380 }
3381}