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