1use std::path::PathBuf;
39use std::time::{Duration, Instant};
40
41use serde::Serialize;
42
43use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
44use crate::drive::client::DriveClient;
45use crate::drive::files_api::FilesApi;
46use crate::drive::lease::check::{
47 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
48 LeasedWrite,
49};
50use crate::drive::sheets::api::SheetsApi;
51use crate::drive::sheets::client::SheetsClient;
52use crate::drive::sheets::grid_range;
53use crate::drive::sheets::target_gate;
54use crate::drive::sheets::types::{
55 BatchUpdateRequestItem, CreateDeveloperMetadataRequest, DataFilter,
56 DeleteDeveloperMetadataRequest, DeveloperMetadata, DeveloperMetadataLocation,
57 DeveloperMetadataLookupFilter, DeveloperMetadataValueUpdate, Dimension, DimensionRange,
58 NewDeveloperMetadata, Spreadsheet, UpdateDeveloperMetadataRequest, DOCUMENT_VISIBILITY,
59};
60use crate::drive::types::SheetTargetRefusal;
61use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
62use crate::request_log::{self, DriveMutationOutcome};
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum DeveloperMetadataVerb {
67 Set {
70 key: String,
72 value: String,
74 sheet: Option<String>,
78 dimension: Option<Dimension>,
80 start: Option<i64>,
82 end: Option<i64>,
84 },
85 Delete {
87 key: String,
89 sheet: Option<String>,
91 dimension: Option<Dimension>,
93 start: Option<i64>,
95 end: Option<i64>,
97 },
98}
99
100impl DeveloperMetadataVerb {
101 const fn log_operation(&self) -> &'static str {
103 match self {
104 Self::Set { .. } => "sheets-set-developer-metadata",
105 Self::Delete { .. } => "sheets-delete-developer-metadata",
106 }
107 }
108
109 const fn label(&self) -> &'static str {
111 match self {
112 Self::Set { .. } => "set-developer-metadata",
113 Self::Delete { .. } => "delete-developer-metadata",
114 }
115 }
116}
117
118#[derive(Debug, Clone)]
120pub struct DeveloperMetadataOptions {
121 pub spreadsheet_id: String,
123 pub verb: DeveloperMetadataVerb,
125 pub dry_run: bool,
127 pub lease_token: Option<String>,
131 pub ledger_path: PathBuf,
133}
134
135#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
138pub struct DeveloperMetadataEntry {
139 pub metadata_id: i64,
141 pub key: String,
143 pub value: String,
145 pub location: String,
148}
149
150impl DeveloperMetadataEntry {
151 fn from_entry(workbook: &Spreadsheet, entry: &DeveloperMetadata) -> Self {
152 Self {
153 metadata_id: entry.metadata_id,
154 key: entry.metadata_key.clone(),
155 value: entry.metadata_value.clone(),
156 location: describe_location(workbook, &entry.location),
157 }
158 }
159}
160
161#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
163#[serde(tag = "status", rename_all = "kebab-case")]
164pub enum DeveloperMetadataResult {
165 WouldCreate {
167 key: String,
169 value: String,
171 location: String,
173 },
174 WouldUpdate {
177 new_value: String,
179 previous: Vec<DeveloperMetadataEntry>,
181 },
182 WouldDelete {
185 entries: Vec<DeveloperMetadataEntry>,
187 },
188 RefusedNotASpreadsheet {
190 mime_type: String,
192 },
193 RefusedShortcut,
195 RefusedNoVisibleParents,
197 RefusedSheetNotFound {
199 title: String,
201 available: Vec<String>,
203 },
204 RefusedInvalidLocation {
207 detail: String,
209 },
210 RefusedNotFound {
213 key: String,
215 },
216 Blocked {
218 decided_by: Option<DecidingRule>,
220 },
221 RefusedNoLease,
224 RefusedLeaseExpired,
227 RefusedLeaseWrongFile,
229 RefusedLeaseStale,
231 Created {
233 key: String,
235 value: String,
237 location: String,
239 },
240 Updated {
242 new_value: String,
244 previous: Vec<DeveloperMetadataEntry>,
246 },
247 Deleted {
249 entries: Vec<DeveloperMetadataEntry>,
251 },
252 Failed {
254 detail: String,
256 },
257}
258
259impl FromLeaseRefusal for DeveloperMetadataResult {
260 fn from_no_lease() -> Self {
261 Self::RefusedNoLease
262 }
263 fn from_lease_expired() -> Self {
264 Self::RefusedLeaseExpired
265 }
266 fn from_lease_wrong_file() -> Self {
267 Self::RefusedLeaseWrongFile
268 }
269 fn from_lease_stale() -> Self {
270 Self::RefusedLeaseStale
271 }
272 fn from_lease_failed(detail: String) -> Self {
273 Self::Failed { detail }
274 }
275}
276
277impl DeveloperMetadataResult {
278 fn log_status(&self) -> &'static str {
279 match self {
280 Self::WouldCreate { .. } | Self::WouldUpdate { .. } | Self::WouldDelete { .. } => {
281 "would-change"
282 }
283 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
284 Self::RefusedShortcut => "refused-shortcut",
285 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
286 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
287 Self::RefusedInvalidLocation { .. } => "refused-invalid-range",
288 Self::RefusedNotFound { .. } => "refused-not-found",
289 Self::Blocked { .. } => "blocked",
290 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
291 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
292 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
293 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
294 Self::Created { .. } | Self::Updated { .. } | Self::Deleted { .. } => "changed",
295 Self::Failed { .. } => "failed",
296 }
297 }
298}
299
300#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
302pub struct DeveloperMetadataOutcome {
303 pub spreadsheet_id: String,
305 #[serde(skip_serializing_if = "Option::is_none")]
307 pub file_name: Option<String>,
308 #[serde(skip_serializing_if = "Option::is_none")]
310 pub resolved_folder_id: Option<String>,
311 #[serde(skip)]
314 pub verb: DeveloperMetadataVerb,
315 pub result: DeveloperMetadataResult,
317}
318
319impl JsonlSerialize for DeveloperMetadataOutcome {
320 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
321 write_scalar_jsonl(self, out)
322 }
323}
324
325fn find_sheet_id(workbook: &Spreadsheet, title: &str) -> Result<i64, DeveloperMetadataResult> {
326 grid_range::find_sheet_id(workbook, title, |title, available| {
327 DeveloperMetadataResult::RefusedSheetNotFound { title, available }
328 })
329}
330
331fn resolve_location(
344 workbook: &Spreadsheet,
345 sheet: Option<&str>,
346 dimension: Option<Dimension>,
347 start: Option<i64>,
348 end: Option<i64>,
349) -> Result<DeveloperMetadataLocation, DeveloperMetadataResult> {
350 match (sheet, dimension, start, end) {
351 (None, None, None, None) => Ok(DeveloperMetadataLocation::spreadsheet()),
352 (Some(sheet), None, None, None) => {
353 let sheet_id = find_sheet_id(workbook, sheet)?;
354 Ok(DeveloperMetadataLocation::sheet(sheet_id))
355 }
356 (Some(sheet), Some(dimension), Some(start), Some(end)) => {
357 let sheet_id = find_sheet_id(workbook, sheet)?;
358 if start < 1 {
359 return Err(DeveloperMetadataResult::RefusedInvalidLocation {
360 detail: format!("--start must be at least 1 (got --start {start})"),
361 });
362 }
363 if end != start {
370 return Err(DeveloperMetadataResult::RefusedInvalidLocation {
371 detail: format!(
372 "developer metadata can only be attached to a single row or \
373 column — Sheets rejects a span of more than one. Pass the \
374 same value for --start and --end (e.g. --start {start} --end \
375 {start} selects that row/column alone); got --start {start} \
376 --end {end}"
377 ),
378 });
379 }
380 Ok(DeveloperMetadataLocation::dimension(DimensionRange {
381 sheet_id,
382 dimension,
383 start_index: start - 1,
384 end_index: end,
385 }))
386 }
387 (None, Some(_) | None, ..) if dimension.is_some() || start.is_some() || end.is_some() => {
388 Err(DeveloperMetadataResult::RefusedInvalidLocation {
389 detail: "--dimension/--start/--end need --sheet too".to_string(),
390 })
391 }
392 _ => Err(DeveloperMetadataResult::RefusedInvalidLocation {
393 detail: "--dimension, --start and --end must be given together (a row/column \
394 location), or none of the three alongside --sheet (a whole-sheet \
395 location), or none of --sheet/--dimension/--start/--end at all (a \
396 whole-spreadsheet location)"
397 .to_string(),
398 }),
399 }
400}
401
402fn location_error_to_string(result: DeveloperMetadataResult) -> String {
406 match result {
407 DeveloperMetadataResult::RefusedSheetNotFound { title, available } => format!(
408 "no sheet titled '{title}'. Available: {}",
409 grid_range::available_sheets(&available)
410 ),
411 DeveloperMetadataResult::RefusedInvalidLocation { detail } => detail,
412 other => unreachable!(
413 "resolve_location only ever returns RefusedSheetNotFound or \
414 RefusedInvalidLocation, got {other:?}"
415 ),
416 }
417}
418
419fn describe_location(workbook: &Spreadsheet, location: &DeveloperMetadataLocation) -> String {
422 if location.spreadsheet == Some(true) {
423 return "the whole spreadsheet".to_string();
424 }
425 if let Some(sheet_id) = location.sheet_id {
426 return format!("sheet {}", sheet_title_or_id(workbook, sheet_id));
427 }
428 if let Some(range) = &location.dimension_range {
429 let sheet = sheet_title_or_id(workbook, range.sheet_id);
430 if range.end_index == range.start_index + 1 {
434 return format!(
435 "{} {} of sheet {sheet}",
436 range.dimension.noun(),
437 range.end_index
438 );
439 }
440 return format!(
441 "{}(s) {}-{} of sheet {}",
442 range.dimension.noun(),
443 range.start_index + 1,
444 range.end_index,
445 sheet
446 );
447 }
448 "an unknown location".to_string()
449}
450
451fn sheet_title_or_id(workbook: &Spreadsheet, sheet_id: i64) -> String {
452 workbook
453 .sheets
454 .iter()
455 .filter_map(|sheet| sheet.properties.as_ref())
456 .find(|props| props.sheet_id == Some(sheet_id))
457 .map_or_else(
458 || format!("id {sheet_id}"),
459 |props| format!("'{}'", props.title),
460 )
461}
462
463fn assert_document_visibility(entries: &[DeveloperMetadata]) -> Result<(), String> {
471 for entry in entries {
472 if entry.visibility != DOCUMENT_VISIBILITY {
473 return Err(format!(
474 "developerMetadata.search returned an entry (id {}) with visibility \
475 '{}', not '{DOCUMENT_VISIBILITY}' — refusing to act on or display it \
476 (issue #1795, ADR-0081 §4)",
477 entry.metadata_id, entry.visibility
478 ));
479 }
480 }
481 Ok(())
482}
483
484async fn find_document_metadata(
488 api: &SheetsApi<'_>,
489 spreadsheet_id: &str,
490 key: Option<&str>,
491 location: Option<&DeveloperMetadataLocation>,
492) -> Result<Vec<DeveloperMetadata>, String> {
493 let filter = build_filter(key, location);
494 let response = api
495 .search_developer_metadata(spreadsheet_id, vec![filter])
496 .await
497 .map_err(|err| format!("{err:#}"))?;
498 let entries: Vec<DeveloperMetadata> = response
499 .matched_developer_metadata
500 .into_iter()
501 .map(|matched| matched.developer_metadata)
502 .collect();
503 assert_document_visibility(&entries)?;
504 Ok(entries)
505}
506
507fn build_filter(key: Option<&str>, location: Option<&DeveloperMetadataLocation>) -> DataFilter {
513 DataFilter {
514 developer_metadata_lookup: DeveloperMetadataLookupFilter {
515 metadata_location: location.cloned(),
516 metadata_key: key.map(str::to_string),
517 visibility: Some(DOCUMENT_VISIBILITY.to_string()),
518 location_matching_strategy: location.map(|_| "EXACT_LOCATION".to_string()),
519 },
520 }
521}
522
523pub async fn developer_metadata(
527 drive: &DriveClient,
528 sheets: &SheetsClient,
529 opts: &DeveloperMetadataOptions,
530 rules: &[FolderPermissionRule],
531) -> DeveloperMetadataOutcome {
532 let started = Instant::now();
533 let outcome = developer_metadata_inner(drive, sheets, opts, rules).await;
534 if !opts.dry_run {
535 record_attempt(&outcome, opts, started.elapsed());
536 }
537 outcome
538}
539
540async fn developer_metadata_inner(
541 drive: &DriveClient,
542 sheets: &SheetsClient,
543 opts: &DeveloperMetadataOptions,
544 rules: &[FolderPermissionRule],
545) -> DeveloperMetadataOutcome {
546 let bare = |result| DeveloperMetadataOutcome {
547 spreadsheet_id: opts.spreadsheet_id.clone(),
548 file_name: None,
549 resolved_folder_id: None,
550 verb: opts.verb.clone(),
551 result,
552 };
553
554 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
555 drive,
556 &opts.spreadsheet_id,
557 DriveOperation::SheetsStructure,
558 rules,
559 )
560 .await
561 {
562 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
563 return bare(DeveloperMetadataResult::Failed { detail })
564 }
565 target_gate::TargetGateOutcome::Refused { target, refusal } => {
566 let result = match refusal {
567 SheetTargetRefusal::Shortcut => DeveloperMetadataResult::RefusedShortcut,
568 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
569 DeveloperMetadataResult::RefusedNotASpreadsheet { mime_type }
570 }
571 SheetTargetRefusal::NoVisibleParents => {
572 DeveloperMetadataResult::RefusedNoVisibleParents
573 }
574 };
575 return DeveloperMetadataOutcome {
576 spreadsheet_id: opts.spreadsheet_id.clone(),
577 file_name: Some(target.name),
578 resolved_folder_id: None,
579 verb: opts.verb.clone(),
580 result,
581 };
582 }
583 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
584 return DeveloperMetadataOutcome {
585 spreadsheet_id: opts.spreadsheet_id.clone(),
586 file_name: Some(target.name),
587 resolved_folder_id: None,
588 verb: opts.verb.clone(),
589 result: DeveloperMetadataResult::Failed { detail },
590 };
591 }
592 target_gate::TargetGateOutcome::Gated {
593 target,
594 decision,
595 resolved_folder_id,
596 requires_lease,
597 } => (target, decision, resolved_folder_id, requires_lease),
598 };
599
600 let gated = |result| DeveloperMetadataOutcome {
601 spreadsheet_id: opts.spreadsheet_id.clone(),
602 file_name: Some(target.name.clone()),
603 resolved_folder_id: resolved_folder_id.clone(),
604 verb: opts.verb.clone(),
605 result,
606 };
607
608 if decision.verdict == write_gate::Verdict::Deny {
609 return gated(DeveloperMetadataResult::Blocked {
610 decided_by: decision.decided_by,
611 });
612 }
613
614 let api = SheetsApi::new(sheets);
615 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
616 Ok(workbook) => workbook,
617 Err(err) => {
618 return gated(DeveloperMetadataResult::Failed {
619 detail: format!("{err:#}"),
620 })
621 }
622 };
623
624 let (key, sheet, dimension, start, end): (
625 &str,
626 Option<&str>,
627 Option<Dimension>,
628 Option<i64>,
629 Option<i64>,
630 ) = match &opts.verb {
631 DeveloperMetadataVerb::Set {
632 key,
633 sheet,
634 dimension,
635 start,
636 end,
637 ..
638 }
639 | DeveloperMetadataVerb::Delete {
640 key,
641 sheet,
642 dimension,
643 start,
644 end,
645 } => (key.as_str(), sheet.as_deref(), *dimension, *start, *end),
646 };
647
648 let location = match resolve_location(&workbook, sheet, dimension, start, end) {
649 Ok(location) => location,
650 Err(result) => return gated(result),
651 };
652
653 let matches = match find_document_metadata(
658 &api,
659 &opts.spreadsheet_id,
660 Some(key),
661 Some(&location),
662 )
663 .await
664 {
665 Ok(matches) => matches,
666 Err(detail) => return gated(DeveloperMetadataResult::Failed { detail }),
667 };
668 let entries: Vec<DeveloperMetadataEntry> = matches
669 .iter()
670 .map(|entry| DeveloperMetadataEntry::from_entry(&workbook, entry))
671 .collect();
672 let filter = build_filter(Some(key), Some(&location));
673
674 let (preview_result, changed_result, request) = match &opts.verb {
679 DeveloperMetadataVerb::Set { value, .. } => {
680 if entries.is_empty() {
681 let location_desc = describe_location(&workbook, &location);
682 (
683 DeveloperMetadataResult::WouldCreate {
684 key: key.to_string(),
685 value: value.clone(),
686 location: location_desc.clone(),
687 },
688 DeveloperMetadataResult::Created {
689 key: key.to_string(),
690 value: value.clone(),
691 location: location_desc,
692 },
693 BatchUpdateRequestItem::CreateDeveloperMetadata(
694 CreateDeveloperMetadataRequest {
695 developer_metadata: NewDeveloperMetadata {
696 metadata_key: key.to_string(),
697 metadata_value: value.clone(),
698 location,
699 visibility: DOCUMENT_VISIBILITY.to_string(),
700 },
701 },
702 ),
703 )
704 } else {
705 (
706 DeveloperMetadataResult::WouldUpdate {
707 new_value: value.clone(),
708 previous: entries.clone(),
709 },
710 DeveloperMetadataResult::Updated {
711 new_value: value.clone(),
712 previous: entries.clone(),
713 },
714 BatchUpdateRequestItem::UpdateDeveloperMetadata(
715 UpdateDeveloperMetadataRequest {
716 data_filters: vec![filter],
717 developer_metadata: DeveloperMetadataValueUpdate {
718 metadata_value: value.clone(),
719 },
720 fields: "metadataValue".to_string(),
721 },
722 ),
723 )
724 }
725 }
726 DeveloperMetadataVerb::Delete { .. } => {
727 if entries.is_empty() {
728 return gated(DeveloperMetadataResult::RefusedNotFound {
729 key: key.to_string(),
730 });
731 }
732 (
733 DeveloperMetadataResult::WouldDelete {
734 entries: entries.clone(),
735 },
736 DeveloperMetadataResult::Deleted {
737 entries: entries.clone(),
738 },
739 BatchUpdateRequestItem::DeleteDeveloperMetadata(DeleteDeveloperMetadataRequest {
740 data_filter: filter,
741 }),
742 )
743 }
744 };
745
746 if opts.dry_run {
747 return gated(preview_result);
748 }
749
750 let files_api = FilesApi::new(drive);
751 let leased = LeasedWrite {
752 log_prefix: "drive sheets developer-metadata",
753 operation: opts.verb.log_operation(),
754 ledger_path: &opts.ledger_path,
755 file_id: &opts.spreadsheet_id,
756 };
757 let lease_grant = match gate_optional_leased_write(
758 leased,
759 &files_api,
760 requires_lease,
761 opts.lease_token.as_deref(),
762 )
763 .await
764 {
765 Ok(grant) => grant,
766 Err(err) => return gated(err.into_result()),
767 };
768
769 let result = match conclude_native_leased_write(
770 leased,
771 &lease_grant,
772 &files_api,
773 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
774 |err| format!("{err:#}"),
775 )
776 .await
777 {
778 Ok(_response) => changed_result,
779 Err(err) => DeveloperMetadataResult::Failed {
780 detail: format!("{err:#}"),
781 },
782 };
783 drop(lease_grant);
784 gated(result)
785}
786
787#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
794pub struct SearchLocationFilter<'a> {
795 pub sheet: Option<&'a str>,
797 pub dimension: Option<Dimension>,
799 pub start: Option<i64>,
801 pub end: Option<i64>,
803}
804
805impl SearchLocationFilter<'_> {
806 fn is_empty(self) -> bool {
807 self.sheet.is_none()
808 && self.dimension.is_none()
809 && self.start.is_none()
810 && self.end.is_none()
811 }
812}
813
814pub async fn search(
821 api: &SheetsApi<'_>,
822 spreadsheet_id: &str,
823 workbook: &Spreadsheet,
824 key: Option<&str>,
825 filter: SearchLocationFilter<'_>,
826) -> Result<Vec<DeveloperMetadataEntry>, String> {
827 let location = if filter.is_empty() {
828 None
829 } else {
830 Some(
831 resolve_location(
832 workbook,
833 filter.sheet,
834 filter.dimension,
835 filter.start,
836 filter.end,
837 )
838 .map_err(location_error_to_string)?,
839 )
840 };
841 let matches = find_document_metadata(api, spreadsheet_id, key, location.as_ref()).await?;
842 Ok(matches
843 .iter()
844 .map(|entry| DeveloperMetadataEntry::from_entry(workbook, entry))
845 .collect())
846}
847
848fn record_attempt(
849 outcome: &DeveloperMetadataOutcome,
850 opts: &DeveloperMetadataOptions,
851 duration: Duration,
852) {
853 let error = match &outcome.result {
854 DeveloperMetadataResult::Failed { detail } => Some(detail.clone()),
855 _ => None,
856 };
857 let decided_by = match &outcome.result {
858 DeveloperMetadataResult::Blocked { decided_by } => decided_by.as_ref(),
859 _ => None,
860 };
861 let decided_by = write_gate::decided_by_log_fields(decided_by);
862 let fields_changed = match &outcome.result {
863 DeveloperMetadataResult::Created { key, value, .. } => {
864 Some(format!("create key={key:?} value={value:?}"))
865 }
866 DeveloperMetadataResult::Updated {
867 new_value,
868 previous,
869 } => Some(format!(
870 "update {} entr{} to value={new_value:?}",
871 previous.len(),
872 plural(previous.len())
873 )),
874 DeveloperMetadataResult::Deleted { entries } => Some(format!(
875 "delete {} entr{}",
876 entries.len(),
877 plural(entries.len())
878 )),
879 _ => None,
880 };
881
882 request_log::record_drive_mutation(DriveMutationOutcome {
883 operation: opts.verb.log_operation(),
884 file_id: outcome.spreadsheet_id.clone(),
885 file_name: outcome.file_name.clone().unwrap_or_default(),
886 status: outcome.result.log_status().to_string(),
887 resolved_folder_id: outcome.resolved_folder_id.clone(),
888 decided_by_folder_id: decided_by.folder_id,
889 decided_by_depth: decided_by.depth,
890 decided_by_file_id: decided_by.file_id,
891 fields_changed,
892 error,
893 duration,
894 ..Default::default()
895 });
896}
897
898fn plural(n: usize) -> &'static str {
899 if n == 1 {
900 "y"
901 } else {
902 "ies"
903 }
904}
905
906fn describe_entry(entry: &DeveloperMetadataEntry) -> String {
907 format!(
908 " id {}: {:?}={:?} at {}",
909 entry.metadata_id, entry.key, entry.value, entry.location
910 )
911}
912
913fn describe_updated_entry(entry: &DeveloperMetadataEntry, new_value: &str) -> String {
919 format!(
920 " id {}: {:?}={:?} at {}",
921 entry.metadata_id, entry.key, new_value, entry.location
922 )
923}
924
925#[must_use]
927pub fn describe(outcome: &DeveloperMetadataOutcome) -> String {
928 describe_lines(outcome).join("\n")
929}
930
931#[must_use]
934pub fn describe_lines(outcome: &DeveloperMetadataOutcome) -> Vec<String> {
935 let verb = &outcome.verb;
936 let book = outcome.file_name.as_deref().map_or_else(
937 || format!("'{}'", outcome.spreadsheet_id),
938 |n| format!("'{n}'"),
939 );
940 match &outcome.result {
941 DeveloperMetadataResult::WouldCreate {
942 key,
943 value,
944 location,
945 } => vec![format!(
946 "Would create developer metadata {key:?}={value:?} at {location} in {book}"
947 )],
948 DeveloperMetadataResult::WouldUpdate {
949 new_value,
950 previous,
951 } => {
952 let mut lines = vec![format!(
953 "Would update {} existing entr{} to {new_value:?} in {book}:",
954 previous.len(),
955 plural(previous.len())
956 )];
957 lines.extend(previous.iter().map(describe_entry));
958 lines
959 }
960 DeveloperMetadataResult::WouldDelete { entries } => {
961 let mut lines = vec![format!(
962 "Would delete {} entr{} in {book}:",
963 entries.len(),
964 plural(entries.len())
965 )];
966 lines.extend(entries.iter().map(describe_entry));
967 lines
968 }
969 DeveloperMetadataResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
970 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
971 `drive sheets {}` only works on spreadsheets",
972 verb.label()
973 )],
974 DeveloperMetadataResult::RefusedShortcut => vec![format!(
975 "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
976 verb.label()
977 )],
978 DeveloperMetadataResult::RefusedNoVisibleParents => vec![format!(
979 "Refused: {book} has no parent folder visible to this account, so no folder \
980 rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
981 id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
982 )],
983 DeveloperMetadataResult::RefusedSheetNotFound { title, available } => {
984 vec![grid_range::no_sheet_refusal(&book, title, available)]
985 }
986 DeveloperMetadataResult::RefusedInvalidLocation { detail } => {
987 vec![format!("Refused: {detail}")]
988 }
989 DeveloperMetadataResult::RefusedNotFound { key } => vec![format!(
990 "Refused: no developer metadata matching key {key:?} at that location in {book}"
991 )],
992 DeveloperMetadataResult::Blocked { decided_by } => vec![match decided_by {
993 Some(rule) => format!(
994 "Blocked: {} on {book} refused by rule on {} {}{}",
995 verb.label(),
996 rule.kind_label(),
997 rule.id(),
998 rule.depth_suffix()
999 ),
1000 None => format!(
1001 "Blocked: {} on {book} refused by default policy (no matching rule for \
1002 sheets-structure)",
1003 verb.label()
1004 ),
1005 }],
1006 DeveloperMetadataResult::RefusedNoLease => LeaseGateRefusal::NoLease
1007 .describe_line(&outcome.spreadsheet_id, &book)
1008 .into_iter()
1009 .collect(),
1010 DeveloperMetadataResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1011 .describe_line(&outcome.spreadsheet_id, &book)
1012 .into_iter()
1013 .collect(),
1014 DeveloperMetadataResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1015 .describe_line(&outcome.spreadsheet_id, &book)
1016 .into_iter()
1017 .collect(),
1018 DeveloperMetadataResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1019 .describe_line(&outcome.spreadsheet_id, &book)
1020 .into_iter()
1021 .collect(),
1022 DeveloperMetadataResult::Created {
1023 key,
1024 value,
1025 location,
1026 } => vec![format!(
1027 "Created developer metadata {key:?}={value:?} at {location} in {book}"
1028 )],
1029 DeveloperMetadataResult::Updated {
1030 new_value,
1031 previous,
1032 } => {
1033 let mut lines = vec![format!(
1034 "Updated {} entr{} to {new_value:?} in {book}:",
1035 previous.len(),
1036 plural(previous.len())
1037 )];
1038 lines.extend(
1039 previous
1040 .iter()
1041 .map(|entry| describe_updated_entry(entry, new_value)),
1042 );
1043 lines
1044 }
1045 DeveloperMetadataResult::Deleted { entries } => {
1046 let mut lines = vec![format!(
1047 "Deleted {} entr{} in {book}:",
1048 entries.len(),
1049 plural(entries.len())
1050 )];
1051 lines.extend(entries.iter().map(describe_entry));
1052 lines
1053 }
1054 DeveloperMetadataResult::Failed { detail } => vec![format!("Failed: {detail}")],
1055 }
1056}
1057
1058#[cfg(test)]
1059#[allow(clippy::unwrap_used, clippy::expect_used)]
1060mod tests {
1061 use super::*;
1062 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1063 use crate::drive::sheets::client::SHEETS_API_URL;
1064 use crate::drive::test_support::seed_lease;
1065 use crate::test_support::env::MapEnv;
1066 use crate::utils::secret::Secret;
1067 use std::collections::HashSet;
1068
1069 fn test_workbook() -> Spreadsheet {
1070 serde_json::from_value(serde_json::json!({
1071 "spreadsheetId": "sheet-1",
1072 "properties": {"title": "Budget"},
1073 "sheets": [
1074 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1075 ],
1076 }))
1077 .unwrap()
1078 }
1079
1080 #[test]
1081 fn resolve_location_with_no_flags_is_spreadsheet_scoped() {
1082 let workbook = test_workbook();
1083 let location = resolve_location(&workbook, None, None, None, None).unwrap();
1084 assert_eq!(location, DeveloperMetadataLocation::spreadsheet());
1085 }
1086
1087 #[test]
1088 fn resolve_location_with_only_sheet_is_sheet_scoped() {
1089 let workbook = test_workbook();
1090 let location = resolve_location(&workbook, Some("Q1"), None, None, None).unwrap();
1091 assert_eq!(location, DeveloperMetadataLocation::sheet(0));
1092 }
1093
1094 #[test]
1095 fn resolve_location_rejects_an_unknown_sheet() {
1096 let workbook = test_workbook();
1097 let result = resolve_location(&workbook, Some("Nope"), None, None, None).unwrap_err();
1098 assert!(matches!(
1099 result,
1100 DeveloperMetadataResult::RefusedSheetNotFound { title, .. } if title == "Nope"
1101 ));
1102 }
1103
1104 #[test]
1105 fn resolve_location_with_full_dimension_converts_1_based_to_0_based() {
1106 let workbook = test_workbook();
1107 let location = resolve_location(
1108 &workbook,
1109 Some("Q1"),
1110 Some(Dimension::Rows),
1111 Some(2),
1112 Some(2),
1113 )
1114 .unwrap();
1115 assert_eq!(
1116 location,
1117 DeveloperMetadataLocation::dimension(DimensionRange {
1118 sheet_id: 0,
1119 dimension: Dimension::Rows,
1120 start_index: 1,
1121 end_index: 2,
1122 })
1123 );
1124 }
1125
1126 #[test]
1127 fn resolve_location_rejects_end_before_start() {
1128 let workbook = test_workbook();
1129 let result = resolve_location(
1130 &workbook,
1131 Some("Q1"),
1132 Some(Dimension::Rows),
1133 Some(5),
1134 Some(2),
1135 )
1136 .unwrap_err();
1137 assert!(matches!(
1138 result,
1139 DeveloperMetadataResult::RefusedInvalidLocation { .. }
1140 ));
1141 }
1142
1143 #[test]
1144 fn resolve_location_rejects_a_multi_row_span() {
1145 let workbook = test_workbook();
1149 let result = resolve_location(
1150 &workbook,
1151 Some("Q1"),
1152 Some(Dimension::Rows),
1153 Some(2),
1154 Some(7),
1155 )
1156 .unwrap_err();
1157 match result {
1158 DeveloperMetadataResult::RefusedInvalidLocation { detail } => {
1159 assert!(detail.contains("single row or column"), "{detail}");
1160 assert!(detail.contains("--start 2"), "{detail}");
1161 assert!(detail.contains("--end 7"), "{detail}");
1162 }
1163 other => panic!("expected RefusedInvalidLocation, got {other:?}"), }
1165 }
1166
1167 #[test]
1168 fn resolve_location_accepts_a_single_column() {
1169 let workbook = test_workbook();
1170 let location = resolve_location(
1171 &workbook,
1172 Some("Q1"),
1173 Some(Dimension::Columns),
1174 Some(3),
1175 Some(3),
1176 )
1177 .unwrap();
1178 assert_eq!(
1179 location,
1180 DeveloperMetadataLocation::dimension(DimensionRange {
1181 sheet_id: 0,
1182 dimension: Dimension::Columns,
1183 start_index: 2,
1184 end_index: 3,
1185 })
1186 );
1187 }
1188
1189 #[test]
1190 fn resolve_location_rejects_dimension_without_start_and_end() {
1191 let workbook = test_workbook();
1192 let result =
1193 resolve_location(&workbook, Some("Q1"), Some(Dimension::Rows), None, None).unwrap_err();
1194 assert!(matches!(
1195 result,
1196 DeveloperMetadataResult::RefusedInvalidLocation { .. }
1197 ));
1198 }
1199
1200 #[test]
1201 fn resolve_location_rejects_start_end_without_sheet() {
1202 let workbook = test_workbook();
1203 let result =
1204 resolve_location(&workbook, None, Some(Dimension::Rows), Some(1), Some(2)).unwrap_err();
1205 assert!(matches!(
1206 result,
1207 DeveloperMetadataResult::RefusedInvalidLocation { .. }
1208 ));
1209 }
1210
1211 fn document_entry(id: i64, visibility: &str) -> DeveloperMetadata {
1212 DeveloperMetadata {
1213 metadata_id: id,
1214 metadata_key: "owner".to_string(),
1215 metadata_value: "team-a".to_string(),
1216 location: DeveloperMetadataLocation::spreadsheet(),
1217 visibility: visibility.to_string(),
1218 }
1219 }
1220
1221 #[test]
1222 fn assert_document_visibility_accepts_every_document_entry() {
1223 let entries = vec![document_entry(1, "DOCUMENT"), document_entry(2, "DOCUMENT")];
1224 assert!(assert_document_visibility(&entries).is_ok());
1225 }
1226
1227 #[test]
1228 fn assert_document_visibility_rejects_a_project_entry() {
1229 let entries = vec![document_entry(1, "PROJECT")];
1234 let err = assert_document_visibility(&entries).unwrap_err();
1235 assert!(err.contains("PROJECT"), "{err}");
1236 assert!(err.contains('1'), "{err}");
1237 }
1238
1239 fn test_credentials() -> DriveCredentials {
1240 DriveCredentials {
1241 client_id: "client-1".to_string(),
1242 client_secret: Secret::new("secret-1"),
1243 refresh_token: Secret::new("refresh-1"),
1244 scope: DriveGrantedScopes::READONLY,
1245 }
1246 }
1247
1248 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1249 wiremock::Mock::given(wiremock::matchers::method("POST"))
1250 .and(wiremock::matchers::path("/token"))
1251 .respond_with(
1252 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1253 "access_token": "test-token", "expires_in": 3600,
1254 })),
1255 )
1256 .mount(server)
1257 .await;
1258 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1259 crate::drive::client::test_support::replace_session(
1260 &mut drive,
1261 &test_credentials(),
1262 &format!("{}/token", server.uri()),
1263 );
1264 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1265 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1266 (drive, sheets)
1267 }
1268
1269 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1270 let parents: Vec<&str> = parents.to_vec();
1271 wiremock::Mock::given(wiremock::matchers::method("GET"))
1272 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1273 .respond_with(
1274 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1275 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1276 "version": "1",
1277 })),
1278 )
1279 }
1280
1281 fn mount_folder(id: &str) -> wiremock::Mock {
1282 mount_file(id, "application/vnd.google-apps.folder", &[])
1283 }
1284
1285 fn allow_rule(folder: &str) -> FolderPermissionRule {
1286 FolderPermissionRule {
1287 folder_id: Some(folder.to_string()),
1288 file_id: None,
1289 recursive: true,
1290 allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
1291 deny: HashSet::default(),
1292 require_lease: true,
1293 }
1294 }
1295
1296 fn deny_rule() -> Vec<FolderPermissionRule> {
1297 Vec::new()
1298 }
1299
1300 fn mount_workbook() -> wiremock::Mock {
1301 wiremock::Mock::given(wiremock::matchers::method("GET"))
1302 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1303 .respond_with(
1304 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1305 "spreadsheetId": "sheet-1",
1306 "properties": {"title": "Budget"},
1307 "sheets": [
1308 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1309 ],
1310 })),
1311 )
1312 }
1313
1314 fn mount_search(body: serde_json::Value) -> wiremock::Mock {
1315 wiremock::Mock::given(wiremock::matchers::method("POST"))
1316 .and(wiremock::matchers::path(
1317 "/v4/spreadsheets/sheet-1/developerMetadata:search",
1318 ))
1319 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
1320 }
1321
1322 fn mount_batch_update(body: serde_json::Value) -> wiremock::Mock {
1323 wiremock::Mock::given(wiremock::matchers::method("POST"))
1324 .and(wiremock::matchers::path(
1325 "/v4/spreadsheets/sheet-1:batchUpdate",
1326 ))
1327 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
1328 }
1329
1330 fn find_request(requests: &[wiremock::Request], suffix: &str) -> serde_json::Value {
1334 let req = requests
1335 .iter()
1336 .find(|r| r.url.path().ends_with(suffix))
1337 .unwrap_or_else(|| panic!("no request ending in {suffix:?} was sent"));
1338 serde_json::from_slice(&req.body).unwrap()
1339 }
1340
1341 fn set_opts(dry_run: bool) -> DeveloperMetadataOptions {
1342 let ledger_path = tempfile::tempdir()
1343 .unwrap()
1344 .keep()
1345 .join("lease-ledger.jsonl");
1346 let token = seed_lease(&ledger_path, "sheet-1", "1");
1347 DeveloperMetadataOptions {
1348 spreadsheet_id: "sheet-1".to_string(),
1349 verb: DeveloperMetadataVerb::Set {
1350 key: "owner".to_string(),
1351 value: "team-a".to_string(),
1352 sheet: None,
1353 dimension: None,
1354 start: None,
1355 end: None,
1356 },
1357 dry_run,
1358 lease_token: Some(token),
1359 ledger_path,
1360 }
1361 }
1362
1363 fn delete_opts(dry_run: bool) -> DeveloperMetadataOptions {
1364 let mut opts = set_opts(dry_run);
1365 opts.verb = DeveloperMetadataVerb::Delete {
1366 key: "owner".to_string(),
1367 sheet: None,
1368 dimension: None,
1369 start: None,
1370 end: None,
1371 };
1372 opts
1373 }
1374
1375 async fn setup(
1376 server: &wiremock::MockServer,
1377 ) -> (DriveClient, SheetsClient, Vec<FolderPermissionRule>) {
1378 let (drive, sheets) = clients(server).await;
1379 mount_file(
1380 "sheet-1",
1381 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1382 &["folder-1"],
1383 )
1384 .mount(server)
1385 .await;
1386 mount_folder("folder-1").mount(server).await;
1387 mount_workbook().mount(server).await;
1388 (drive, sheets, vec![allow_rule("folder-1")])
1389 }
1390
1391 #[tokio::test]
1392 async fn dry_run_set_with_no_existing_entry_reports_would_create() {
1393 let server = wiremock::MockServer::start().await;
1394 let (drive, sheets, rules) = setup(&server).await;
1395 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1396 .mount(&server)
1397 .await;
1398
1399 let outcome = developer_metadata(&drive, &sheets, &set_opts(true), &rules).await;
1400 match &outcome.result {
1401 DeveloperMetadataResult::WouldCreate { key, value, .. } => {
1402 assert_eq!(key, "owner");
1403 assert_eq!(value, "team-a");
1404 }
1405 other => panic!("expected WouldCreate, got {other:?}"),
1406 }
1407 let text = describe(&outcome);
1410 assert!(text.contains("Would create developer metadata"), "{text}");
1411 }
1412
1413 #[tokio::test]
1414 async fn dry_run_set_with_an_existing_entry_reports_would_update_with_previous() {
1415 let server = wiremock::MockServer::start().await;
1416 let (drive, sheets, rules) = setup(&server).await;
1417 mount_search(serde_json::json!({"matchedDeveloperMetadata": [
1418 {"developerMetadata": {
1419 "metadataId": 42, "metadataKey": "owner", "metadataValue": "team-b",
1420 "location": {"spreadsheet": true}, "visibility": "DOCUMENT",
1421 }},
1422 ]}))
1423 .mount(&server)
1424 .await;
1425
1426 let outcome = developer_metadata(&drive, &sheets, &set_opts(true), &rules).await;
1427 match &outcome.result {
1428 DeveloperMetadataResult::WouldUpdate {
1429 new_value,
1430 previous,
1431 } => {
1432 assert_eq!(new_value, "team-a");
1433 assert_eq!(previous.len(), 1);
1434 assert_eq!(previous[0].value, "team-b");
1435 assert_eq!(previous[0].metadata_id, 42);
1436 }
1437 other => panic!("expected WouldUpdate, got {other:?}"),
1438 }
1439 let text = describe(&outcome);
1440 assert!(
1441 text.contains("Would update 1 existing entry to \"team-a\""),
1442 "{text}"
1443 );
1444 assert!(text.contains("id 42"), "{text}");
1445 }
1446
1447 #[tokio::test]
1448 async fn set_creates_when_no_existing_entry_matches() {
1449 let server = wiremock::MockServer::start().await;
1450 let (drive, sheets, rules) = setup(&server).await;
1451 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1452 .mount(&server)
1453 .await;
1454 mount_batch_update(serde_json::json!({"replies": [{}]}))
1455 .mount(&server)
1456 .await;
1457
1458 let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
1459 match &outcome.result {
1460 DeveloperMetadataResult::Created { key, value, .. } => {
1461 assert_eq!(key, "owner");
1462 assert_eq!(value, "team-a");
1463 }
1464 other => panic!("expected Created, got {other:?}"),
1465 }
1466 let text = describe(&outcome);
1467 assert!(text.contains("Created developer metadata"), "{text}");
1468 }
1469
1470 #[tokio::test]
1471 async fn set_create_hardcodes_document_visibility_on_every_outgoing_request() {
1472 let server = wiremock::MockServer::start().await;
1479 let (drive, sheets, rules) = setup(&server).await;
1480 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1481 .mount(&server)
1482 .await;
1483 mount_batch_update(serde_json::json!({"replies": [{}]}))
1484 .mount(&server)
1485 .await;
1486
1487 let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
1488 assert!(
1489 matches!(outcome.result, DeveloperMetadataResult::Created { .. }),
1490 "{:?}",
1491 outcome.result
1492 );
1493
1494 let requests = server.received_requests().await.unwrap();
1495 let search_body = find_request(&requests, "developerMetadata:search");
1496 assert_eq!(
1497 search_body["dataFilters"][0]["developerMetadataLookup"]["visibility"],
1498 "DOCUMENT"
1499 );
1500 let batch_body = find_request(&requests, ":batchUpdate");
1501 assert_eq!(
1502 batch_body["requests"][0]["createDeveloperMetadata"]["developerMetadata"]["visibility"],
1503 "DOCUMENT"
1504 );
1505 }
1506
1507 #[tokio::test]
1508 async fn delete_reports_not_found_when_nothing_matches() {
1509 let server = wiremock::MockServer::start().await;
1510 let (drive, sheets, rules) = setup(&server).await;
1511 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1512 .mount(&server)
1513 .await;
1514
1515 let outcome = developer_metadata(&drive, &sheets, &delete_opts(false), &rules).await;
1516 match &outcome.result {
1517 DeveloperMetadataResult::RefusedNotFound { key } => assert_eq!(key, "owner"),
1518 other => panic!("expected RefusedNotFound, got {other:?}"),
1519 }
1520 let text = describe(&outcome);
1524 assert!(
1525 text.contains("no developer metadata matching key \"owner\""),
1526 "{text}"
1527 );
1528 }
1529
1530 #[tokio::test]
1531 async fn delete_previews_every_matched_entry_before_deleting() {
1532 let server = wiremock::MockServer::start().await;
1533 let (drive, sheets, rules) = setup(&server).await;
1534 mount_search(serde_json::json!({"matchedDeveloperMetadata": [
1535 {"developerMetadata": {
1536 "metadataId": 1, "metadataKey": "owner", "metadataValue": "team-a",
1537 "location": {"spreadsheet": true}, "visibility": "DOCUMENT",
1538 }},
1539 {"developerMetadata": {
1540 "metadataId": 2, "metadataKey": "owner", "metadataValue": "team-b",
1541 "location": {"spreadsheet": true}, "visibility": "DOCUMENT",
1542 }},
1543 ]}))
1544 .mount(&server)
1545 .await;
1546
1547 let outcome = developer_metadata(&drive, &sheets, &delete_opts(true), &rules).await;
1548 match &outcome.result {
1549 DeveloperMetadataResult::WouldDelete { entries } => {
1550 assert_eq!(entries.len(), 2);
1551 assert_eq!(entries[0].metadata_id, 1);
1552 assert_eq!(entries[1].metadata_id, 2);
1553 }
1554 other => panic!("expected WouldDelete, got {other:?}"),
1555 }
1556 let text = describe(&outcome);
1558 assert!(text.contains("Would delete 2 entries"), "{text}");
1559 }
1560
1561 #[tokio::test]
1562 async fn delete_hardcodes_document_visibility_on_every_outgoing_request() {
1563 let server = wiremock::MockServer::start().await;
1566 let (drive, sheets, rules) = setup(&server).await;
1567 mount_search(serde_json::json!({"matchedDeveloperMetadata": [
1568 {"developerMetadata": {
1569 "metadataId": 1, "metadataKey": "owner", "metadataValue": "team-a",
1570 "location": {"spreadsheet": true}, "visibility": "DOCUMENT",
1571 }},
1572 ]}))
1573 .mount(&server)
1574 .await;
1575 mount_batch_update(serde_json::json!({"replies": [{}]}))
1576 .mount(&server)
1577 .await;
1578
1579 let outcome = developer_metadata(&drive, &sheets, &delete_opts(false), &rules).await;
1580 assert!(
1581 matches!(outcome.result, DeveloperMetadataResult::Deleted { .. }),
1582 "{:?}",
1583 outcome.result
1584 );
1585
1586 let requests = server.received_requests().await.unwrap();
1587 let search_body = find_request(&requests, "developerMetadata:search");
1588 assert_eq!(
1589 search_body["dataFilters"][0]["developerMetadataLookup"]["visibility"],
1590 "DOCUMENT"
1591 );
1592 let batch_body = find_request(&requests, ":batchUpdate");
1593 assert_eq!(
1594 batch_body["requests"][0]["deleteDeveloperMetadata"]["dataFilter"]
1595 ["developerMetadataLookup"]["visibility"],
1596 "DOCUMENT"
1597 );
1598 let text = describe(&outcome);
1599 assert!(text.contains("Deleted 1 entry"), "{text}");
1600 }
1601
1602 #[tokio::test]
1603 async fn a_denied_gate_blocks_before_any_search_or_batch_update_call() {
1604 let server = wiremock::MockServer::start().await;
1605 let (drive, sheets, _rules) = setup(&server).await;
1606
1607 let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &deny_rule()).await;
1608 assert!(
1609 matches!(
1610 outcome.result,
1611 DeveloperMetadataResult::Blocked { decided_by: None }
1612 ),
1613 "{:?}",
1614 outcome.result
1615 );
1616 let text = describe(&outcome);
1619 assert!(
1620 text.contains("refused by default policy (no matching rule for sheets-structure)"),
1621 "{text}"
1622 );
1623 }
1624
1625 #[tokio::test]
1626 async fn search_returns_every_document_visibility_match() {
1627 let server = wiremock::MockServer::start().await;
1628 let (drive, sheets) = clients(&server).await;
1629 mount_workbook().mount(&server).await;
1630 mount_search(serde_json::json!({"matchedDeveloperMetadata": [
1631 {"developerMetadata": {
1632 "metadataId": 7, "metadataKey": "owner", "metadataValue": "team-a",
1633 "location": {"sheetId": 0}, "visibility": "DOCUMENT",
1634 }},
1635 ]}))
1636 .mount(&server)
1637 .await;
1638
1639 let api = SheetsApi::new(&sheets);
1640 let workbook = api.get_spreadsheet("sheet-1").await.unwrap();
1641 let entries = search(
1642 &api,
1643 "sheet-1",
1644 &workbook,
1645 None,
1646 SearchLocationFilter::default(),
1647 )
1648 .await
1649 .unwrap();
1650 assert_eq!(entries.len(), 1);
1651 assert_eq!(entries[0].location, "sheet 'Q1'");
1652 let _ = drive;
1655 }
1656
1657 #[tokio::test]
1660 async fn non_spreadsheet_is_refused_before_any_gate_or_search_call() {
1661 let server = wiremock::MockServer::start().await;
1662 let (drive, sheets) = clients(&server).await;
1663 mount_file("sheet-1", "application/pdf", &["folder-1"])
1664 .mount(&server)
1665 .await;
1666 let outcome =
1670 developer_metadata(&drive, &sheets, &set_opts(false), &[allow_rule("folder-1")]).await;
1671 assert!(
1672 matches!(
1673 outcome.result,
1674 DeveloperMetadataResult::RefusedNotASpreadsheet { .. }
1675 ),
1676 "{:?}",
1677 outcome.result
1678 );
1679 let text = describe(&outcome);
1680 assert!(text.contains("is not a Google Sheet"), "{text}");
1681 assert!(
1682 text.contains("drive sheets set-developer-metadata"),
1683 "{text}"
1684 );
1685 }
1686
1687 #[tokio::test]
1688 async fn shortcut_is_refused_with_its_own_message_not_the_generic_one() {
1689 let server = wiremock::MockServer::start().await;
1690 let (drive, sheets) = clients(&server).await;
1691 mount_file(
1692 "sheet-1",
1693 "application/vnd.google-apps.shortcut",
1694 &["folder-1"],
1695 )
1696 .mount(&server)
1697 .await;
1698 let outcome = developer_metadata(
1699 &drive,
1700 &sheets,
1701 &delete_opts(false),
1702 &[allow_rule("folder-1")],
1703 )
1704 .await;
1705 assert!(
1706 matches!(outcome.result, DeveloperMetadataResult::RefusedShortcut),
1707 "{:?}",
1708 outcome.result
1709 );
1710 let text = describe(&outcome);
1711 assert!(text.contains("is a shortcut"), "{text}");
1712 assert!(
1713 text.contains("drive sheets delete-developer-metadata"),
1714 "{text}"
1715 );
1716 assert!(!text.contains("is not a Google Sheet"), "{text}");
1717 }
1718
1719 #[tokio::test]
1720 async fn a_sheet_with_no_visible_parents_is_refused_distinctly_from_a_blocked_one() {
1721 let server = wiremock::MockServer::start().await;
1722 let (drive, sheets) = clients(&server).await;
1723 mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
1725 .mount(&server)
1726 .await;
1727 let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &[]).await;
1728 assert!(
1729 matches!(
1730 outcome.result,
1731 DeveloperMetadataResult::RefusedNoVisibleParents
1732 ),
1733 "{:?}",
1734 outcome.result
1735 );
1736 let text = describe(&outcome);
1737 assert!(text.contains("no parent folder visible"), "{text}");
1738 }
1739
1740 #[tokio::test]
1741 async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
1742 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 wiremock::Mock::given(wiremock::matchers::method("GET"))
1752 .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
1753 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1754 .mount(&server)
1755 .await;
1756 let outcome =
1757 developer_metadata(&drive, &sheets, &set_opts(false), &[allow_rule("folder-1")]).await;
1758 assert!(
1759 matches!(outcome.result, DeveloperMetadataResult::Failed { .. }),
1760 "{:?}",
1761 outcome.result
1762 );
1763 }
1764
1765 #[tokio::test]
1768 async fn a_blocked_by_rule_names_the_deciding_folder_in_the_message() {
1769 let server = wiremock::MockServer::start().await;
1770 let (drive, sheets) = clients(&server).await;
1771 mount_file(
1772 "sheet-1",
1773 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1774 &["folder-1"],
1775 )
1776 .mount(&server)
1777 .await;
1778 mount_folder("folder-1").mount(&server).await;
1779 let deny_by_rule = FolderPermissionRule {
1780 folder_id: Some("folder-1".to_string()),
1781 file_id: None,
1782 recursive: true,
1783 allow: HashSet::default(),
1784 deny: std::iter::once(DriveOperation::SheetsStructure).collect(),
1785 require_lease: true,
1786 };
1787 let outcome =
1788 developer_metadata(&drive, &sheets, &delete_opts(false), &[deny_by_rule]).await;
1789 assert!(
1790 matches!(
1791 outcome.result,
1792 DeveloperMetadataResult::Blocked {
1793 decided_by: Some(_)
1794 }
1795 ),
1796 "{:?}",
1797 outcome.result
1798 );
1799 let text = describe(&outcome);
1800 assert!(
1801 text.contains("refused by rule on folder folder-1"),
1802 "{text}"
1803 );
1804 }
1805
1806 #[tokio::test]
1809 async fn set_with_an_unknown_sheet_is_refused_via_the_full_flow() {
1810 let server = wiremock::MockServer::start().await;
1811 let (drive, sheets, rules) = setup(&server).await;
1812 let mut o = set_opts(false);
1815 o.verb = DeveloperMetadataVerb::Set {
1816 key: "owner".to_string(),
1817 value: "team-a".to_string(),
1818 sheet: Some("Nope".to_string()),
1819 dimension: None,
1820 start: None,
1821 end: None,
1822 };
1823 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
1824 assert!(
1825 matches!(
1826 outcome.result,
1827 DeveloperMetadataResult::RefusedSheetNotFound { .. }
1828 ),
1829 "{:?}",
1830 outcome.result
1831 );
1832 let text = describe(&outcome);
1833 assert!(text.contains("no sheet titled 'Nope'"), "{text}");
1834 }
1835
1836 #[tokio::test]
1837 async fn set_with_a_dimension_but_no_span_is_refused_via_the_full_flow() {
1838 let server = wiremock::MockServer::start().await;
1839 let (drive, sheets, rules) = setup(&server).await;
1840 let mut o = set_opts(false);
1841 o.verb = DeveloperMetadataVerb::Set {
1842 key: "owner".to_string(),
1843 value: "team-a".to_string(),
1844 sheet: Some("Q1".to_string()),
1845 dimension: Some(Dimension::Rows),
1846 start: None,
1847 end: None,
1848 };
1849 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
1850 assert!(
1851 matches!(
1852 outcome.result,
1853 DeveloperMetadataResult::RefusedInvalidLocation { .. }
1854 ),
1855 "{:?}",
1856 outcome.result
1857 );
1858 let text = describe(&outcome);
1859 assert!(text.starts_with("Refused:"), "{text}");
1860 }
1861
1862 #[tokio::test]
1865 async fn a_failed_search_is_reported_as_failed() {
1866 let server = wiremock::MockServer::start().await;
1867 let (drive, sheets, rules) = setup(&server).await;
1868 wiremock::Mock::given(wiremock::matchers::method("POST"))
1869 .and(wiremock::matchers::path(
1870 "/v4/spreadsheets/sheet-1/developerMetadata:search",
1871 ))
1872 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1873 .mount(&server)
1874 .await;
1875 let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
1876 assert!(
1877 matches!(outcome.result, DeveloperMetadataResult::Failed { .. }),
1878 "{:?}",
1879 outcome.result
1880 );
1881 let text = describe(&outcome);
1882 assert!(text.starts_with("Failed:"), "{text}");
1883 }
1884
1885 #[tokio::test]
1886 async fn a_failed_batch_update_is_reported_as_failed() {
1887 let server = wiremock::MockServer::start().await;
1888 let (drive, sheets, rules) = setup(&server).await;
1889 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1890 .mount(&server)
1891 .await;
1892 wiremock::Mock::given(wiremock::matchers::method("POST"))
1893 .and(wiremock::matchers::path(
1894 "/v4/spreadsheets/sheet-1:batchUpdate",
1895 ))
1896 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1897 .mount(&server)
1898 .await;
1899 let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
1900 assert!(
1901 matches!(outcome.result, DeveloperMetadataResult::Failed { .. }),
1902 "{:?}",
1903 outcome.result
1904 );
1905 }
1906
1907 #[tokio::test]
1908 async fn set_updates_every_matching_entry_when_one_exists() {
1909 let server = wiremock::MockServer::start().await;
1910 let (drive, sheets, rules) = setup(&server).await;
1911 mount_search(serde_json::json!({"matchedDeveloperMetadata": [
1912 {"developerMetadata": {
1913 "metadataId": 42, "metadataKey": "owner", "metadataValue": "team-b",
1914 "location": {"spreadsheet": true}, "visibility": "DOCUMENT",
1915 }},
1916 ]}))
1917 .mount(&server)
1918 .await;
1919 mount_batch_update(serde_json::json!({"replies": [{}]}))
1920 .mount(&server)
1921 .await;
1922
1923 let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
1924 match &outcome.result {
1925 DeveloperMetadataResult::Updated {
1926 new_value,
1927 previous,
1928 } => {
1929 assert_eq!(new_value, "team-a");
1930 assert_eq!(previous.len(), 1);
1931 }
1932 other => panic!("expected Updated, got {other:?}"),
1933 }
1934 let text = describe(&outcome);
1935 assert!(text.contains("Updated 1 entry to \"team-a\""), "{text}");
1936 assert!(text.contains(r#"id 42: "owner"="team-a""#), "{text}");
1939 assert!(!text.contains(r#"id 42: "owner"="team-b""#), "{text}");
1940 }
1941
1942 #[tokio::test]
1945 async fn refuses_without_a_lease_when_the_rule_requires_one() {
1946 let server = wiremock::MockServer::start().await;
1947 let (drive, sheets, rules) = setup(&server).await;
1948 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1949 .mount(&server)
1950 .await;
1951 let mut o = set_opts(false);
1954 o.lease_token = None;
1955 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
1956 assert_eq!(outcome.result, DeveloperMetadataResult::RefusedNoLease);
1957 let text = describe(&outcome);
1958 assert!(text.contains("lease"), "{text}");
1959 }
1960
1961 #[tokio::test]
1962 async fn refuses_an_unknown_lease_token() {
1963 let server = wiremock::MockServer::start().await;
1964 let (drive, sheets, rules) = setup(&server).await;
1965 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1966 .mount(&server)
1967 .await;
1968 let mut o = set_opts(false);
1969 o.lease_token = Some("bogus-token".to_string());
1970 o.ledger_path = tempfile::tempdir()
1972 .unwrap()
1973 .keep()
1974 .join("lease-ledger.jsonl");
1975 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
1976 assert_eq!(outcome.result, DeveloperMetadataResult::RefusedLeaseExpired);
1977 }
1978
1979 #[tokio::test]
1980 async fn refuses_a_lease_bound_to_a_different_file() {
1981 let server = wiremock::MockServer::start().await;
1982 let (drive, sheets, rules) = setup(&server).await;
1983 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1984 .mount(&server)
1985 .await;
1986 let ledger_path = tempfile::tempdir()
1987 .unwrap()
1988 .keep()
1989 .join("lease-ledger.jsonl");
1990 let token = seed_lease(&ledger_path, "other-sheet", "1");
1991 let mut o = set_opts(false);
1992 o.lease_token = Some(token);
1993 o.ledger_path = ledger_path;
1994 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
1995 assert_eq!(
1996 outcome.result,
1997 DeveloperMetadataResult::RefusedLeaseWrongFile
1998 );
1999 }
2000
2001 #[tokio::test]
2002 async fn refuses_a_stale_lease() {
2003 let server = wiremock::MockServer::start().await;
2004 let (drive, sheets, rules) = setup(&server).await;
2005 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2006 .mount(&server)
2007 .await;
2008 let ledger_path = tempfile::tempdir()
2011 .unwrap()
2012 .keep()
2013 .join("lease-ledger.jsonl");
2014 let token = seed_lease(&ledger_path, "sheet-1", "0");
2015 let mut o = set_opts(false);
2016 o.lease_token = Some(token);
2017 o.ledger_path = ledger_path;
2018 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2019 assert_eq!(outcome.result, DeveloperMetadataResult::RefusedLeaseStale);
2020 }
2021
2022 #[test]
2025 fn write_jsonl_serializes_the_outcome_as_one_json_line() {
2026 let outcome = DeveloperMetadataOutcome {
2027 spreadsheet_id: "sheet-1".to_string(),
2028 file_name: Some("Budget".to_string()),
2029 resolved_folder_id: None,
2030 verb: DeveloperMetadataVerb::Set {
2031 key: "owner".to_string(),
2032 value: "team-a".to_string(),
2033 sheet: None,
2034 dimension: None,
2035 start: None,
2036 end: None,
2037 },
2038 result: DeveloperMetadataResult::Created {
2039 key: "owner".to_string(),
2040 value: "team-a".to_string(),
2041 location: "the whole spreadsheet".to_string(),
2042 },
2043 };
2044 let mut buf = Vec::new();
2045 outcome.write_jsonl(&mut buf).unwrap();
2046 let text = String::from_utf8(buf).unwrap();
2047 assert!(text.contains("\"status\":\"created\""), "{text}");
2048 assert!(text.contains("\"spreadsheet_id\":\"sheet-1\""), "{text}");
2049 }
2050
2051 #[tokio::test]
2054 async fn describe_of_an_unknown_lease_token_names_it_a_lease_refusal() {
2055 let server = wiremock::MockServer::start().await;
2056 let (drive, sheets, rules) = setup(&server).await;
2057 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2058 .mount(&server)
2059 .await;
2060 let mut o = set_opts(false);
2061 o.lease_token = Some("bogus-token".to_string());
2062 o.ledger_path = tempfile::tempdir()
2063 .unwrap()
2064 .keep()
2065 .join("lease-ledger.jsonl");
2066 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2067 let text = describe(&outcome);
2068 assert!(text.contains("lease"), "{text}");
2069 }
2070
2071 #[tokio::test]
2072 async fn describe_of_a_lease_bound_to_a_different_file_names_it_a_lease_refusal() {
2073 let server = wiremock::MockServer::start().await;
2074 let (drive, sheets, rules) = setup(&server).await;
2075 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2076 .mount(&server)
2077 .await;
2078 let ledger_path = tempfile::tempdir()
2079 .unwrap()
2080 .keep()
2081 .join("lease-ledger.jsonl");
2082 let token = seed_lease(&ledger_path, "other-sheet", "1");
2083 let mut o = set_opts(false);
2084 o.lease_token = Some(token);
2085 o.ledger_path = ledger_path;
2086 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2087 let text = describe(&outcome);
2088 assert!(text.contains("lease"), "{text}");
2089 }
2090
2091 #[tokio::test]
2092 async fn describe_of_a_stale_lease_names_it_a_lease_refusal() {
2093 let server = wiremock::MockServer::start().await;
2094 let (drive, sheets, rules) = setup(&server).await;
2095 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2096 .mount(&server)
2097 .await;
2098 let ledger_path = tempfile::tempdir()
2099 .unwrap()
2100 .keep()
2101 .join("lease-ledger.jsonl");
2102 let token = seed_lease(&ledger_path, "sheet-1", "0");
2103 let mut o = set_opts(false);
2104 o.lease_token = Some(token);
2105 o.ledger_path = ledger_path;
2106 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2107 let text = describe(&outcome);
2108 assert!(text.contains("lease"), "{text}");
2109 }
2110
2111 #[tokio::test]
2114 async fn a_lock_acquisition_failure_is_reported_as_failed() {
2115 let server = wiremock::MockServer::start().await;
2121 let (drive, sheets, rules) = setup(&server).await;
2122 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2123 .mount(&server)
2124 .await;
2125 let o = set_opts(false);
2126 let mut lock_path = o.ledger_path.clone().into_os_string();
2127 lock_path.push(".lock");
2128 std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
2129
2130 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2131 assert!(
2132 matches!(outcome.result, DeveloperMetadataResult::Failed { .. }),
2133 "{:?}",
2134 outcome.result
2135 );
2136 }
2137
2138 #[tokio::test]
2141 async fn a_failed_spreadsheet_refetch_after_the_gate_is_reported_as_failed() {
2142 let server = wiremock::MockServer::start().await;
2143 let (drive, sheets) = clients(&server).await;
2144 mount_file(
2145 "sheet-1",
2146 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2147 &["folder-1"],
2148 )
2149 .mount(&server)
2150 .await;
2151 mount_folder("folder-1").mount(&server).await;
2152 wiremock::Mock::given(wiremock::matchers::method("GET"))
2153 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2154 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2155 .mount(&server)
2156 .await;
2157 let outcome =
2158 developer_metadata(&drive, &sheets, &set_opts(false), &[allow_rule("folder-1")]).await;
2159 assert!(
2160 matches!(outcome.result, DeveloperMetadataResult::Failed { .. }),
2161 "{:?}",
2162 outcome.result
2163 );
2164 }
2165
2166 #[tokio::test]
2169 async fn set_at_a_dimension_location_describes_the_row_span() {
2170 let server = wiremock::MockServer::start().await;
2171 let (drive, sheets, rules) = setup(&server).await;
2172 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2173 .mount(&server)
2174 .await;
2175 let mut o = set_opts(true);
2176 o.verb = DeveloperMetadataVerb::Set {
2177 key: "owner".to_string(),
2178 value: "team-a".to_string(),
2179 sheet: Some("Q1".to_string()),
2180 dimension: Some(Dimension::Rows),
2181 start: Some(2),
2182 end: Some(2),
2183 };
2184 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2185 let text = describe(&outcome);
2186 assert!(text.contains("row 2 of sheet 'Q1'"), "{text}");
2187 }
2188
2189 #[tokio::test]
2190 async fn set_at_a_multi_row_dimension_location_is_refused() {
2191 let server = wiremock::MockServer::start().await;
2195 let (drive, sheets, rules) = setup(&server).await;
2196 let mut o = set_opts(true);
2197 o.verb = DeveloperMetadataVerb::Set {
2198 key: "source".to_string(),
2199 value: "import".to_string(),
2200 sheet: Some("Q1".to_string()),
2201 dimension: Some(Dimension::Rows),
2202 start: Some(2),
2203 end: Some(7),
2204 };
2205 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2206 match outcome.result {
2207 DeveloperMetadataResult::RefusedInvalidLocation { detail } => {
2208 assert!(detail.contains("single row or column"), "{detail}");
2209 }
2210 other => panic!("expected RefusedInvalidLocation, got {other:?}"),
2211 }
2212 }
2213
2214 #[tokio::test]
2215 async fn set_with_an_unknown_sheet_in_an_empty_workbook_lists_no_available_sheets() {
2216 let server = wiremock::MockServer::start().await;
2217 let (drive, sheets) = clients(&server).await;
2218 mount_file(
2219 "sheet-1",
2220 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2221 &["folder-1"],
2222 )
2223 .mount(&server)
2224 .await;
2225 mount_folder("folder-1").mount(&server).await;
2226 wiremock::Mock::given(wiremock::matchers::method("GET"))
2227 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2228 .respond_with(
2229 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2230 "spreadsheetId": "sheet-1",
2231 "properties": {"title": "Budget"},
2232 "sheets": [],
2233 })),
2234 )
2235 .mount(&server)
2236 .await;
2237
2238 let mut o = set_opts(false);
2239 o.verb = DeveloperMetadataVerb::Set {
2240 key: "owner".to_string(),
2241 value: "team-a".to_string(),
2242 sheet: Some("Nope".to_string()),
2243 dimension: None,
2244 start: None,
2245 end: None,
2246 };
2247 let outcome = developer_metadata(&drive, &sheets, &o, &[allow_rule("folder-1")]).await;
2248 let text = describe(&outcome);
2249 assert!(text.contains("Available: none"), "{text}");
2250 }
2251
2252 #[test]
2253 fn sheet_title_or_id_falls_back_to_the_raw_id_when_unknown() {
2254 let workbook = test_workbook();
2255 assert_eq!(sheet_title_or_id(&workbook, 999), "id 999");
2256 }
2257
2258 #[test]
2259 fn describe_location_falls_back_for_an_unrecognized_shape() {
2260 let workbook = test_workbook();
2261 let empty_location = DeveloperMetadataLocation::default();
2262 assert_eq!(
2263 describe_location(&workbook, &empty_location),
2264 "an unknown location"
2265 );
2266 }
2267
2268 #[test]
2269 fn log_status_maps_a_would_change_variant_to_would_change() {
2270 let would_create = DeveloperMetadataResult::WouldCreate {
2275 key: "owner".to_string(),
2276 value: "team-a".to_string(),
2277 location: "the whole spreadsheet".to_string(),
2278 };
2279 assert_eq!(would_create.log_status(), "would-change");
2280 }
2281
2282 #[tokio::test]
2285 async fn search_rejects_an_unknown_sheet_when_the_workbook_has_none() {
2286 let server = wiremock::MockServer::start().await;
2287 let (_drive, sheets) = clients(&server).await;
2288 let api = SheetsApi::new(&sheets);
2289 let workbook: Spreadsheet = serde_json::from_value(serde_json::json!({
2290 "spreadsheetId": "sheet-1",
2291 "properties": {"title": "Budget"},
2292 "sheets": [],
2293 }))
2294 .unwrap();
2295 let err = search(
2296 &api,
2297 "sheet-1",
2298 &workbook,
2299 None,
2300 SearchLocationFilter {
2301 sheet: Some("Nope"),
2302 ..Default::default()
2303 },
2304 )
2305 .await
2306 .unwrap_err();
2307 assert!(err.contains("Available: none"), "{err}");
2308 }
2309
2310 #[tokio::test]
2311 async fn search_rejects_an_invalid_location_combination() {
2312 let server = wiremock::MockServer::start().await;
2313 let (_drive, sheets) = clients(&server).await;
2314 let api = SheetsApi::new(&sheets);
2315 let workbook = test_workbook();
2316 let err = search(
2317 &api,
2318 "sheet-1",
2319 &workbook,
2320 None,
2321 SearchLocationFilter {
2322 dimension: Some(Dimension::Rows),
2323 ..Default::default()
2324 },
2325 )
2326 .await
2327 .unwrap_err();
2328 assert!(
2329 err.contains("--dimension/--start/--end need --sheet too"),
2330 "{err}"
2331 );
2332 }
2333
2334 #[test]
2335 fn describe_location_renders_a_single_row_without_a_span() {
2336 let workbook = test_workbook();
2337 let location = DeveloperMetadataLocation::dimension(DimensionRange {
2338 sheet_id: 0,
2339 dimension: Dimension::Columns,
2340 start_index: 2,
2341 end_index: 3,
2342 });
2343 assert_eq!(
2344 describe_location(&workbook, &location),
2345 "column 3 of sheet 'Q1'"
2346 );
2347 }
2348
2349 #[test]
2350 fn describe_location_falls_back_to_a_span_for_a_wider_range() {
2351 let workbook = test_workbook();
2352 let location = DeveloperMetadataLocation::dimension(DimensionRange {
2353 sheet_id: 0,
2354 dimension: Dimension::Rows,
2355 start_index: 1,
2356 end_index: 5,
2357 });
2358 assert_eq!(
2359 describe_location(&workbook, &location),
2360 "row(s) 2-5 of sheet 'Q1'"
2361 );
2362 }
2363
2364 #[test]
2365 fn resolve_location_still_rejects_a_start_below_one() {
2366 let workbook = test_workbook();
2367 let result = resolve_location(
2368 &workbook,
2369 Some("Q1"),
2370 Some(Dimension::Rows),
2371 Some(0),
2372 Some(0),
2373 )
2374 .unwrap_err();
2375 match result {
2376 DeveloperMetadataResult::RefusedInvalidLocation { detail } => {
2377 assert!(detail.contains("--start must be at least 1"), "{detail}");
2378 }
2379 other => panic!("expected RefusedInvalidLocation, got {other:?}"), }
2381 }
2382
2383 #[tokio::test]
2384 async fn search_rejects_a_multi_row_span_before_calling_the_api() {
2385 let server = wiremock::MockServer::start().await;
2389 let (_drive, sheets) = clients(&server).await;
2390 let api = SheetsApi::new(&sheets);
2391 let workbook = test_workbook();
2392 let err = search(
2393 &api,
2394 "sheet-1",
2395 &workbook,
2396 None,
2397 SearchLocationFilter {
2398 sheet: Some("Q1"),
2399 dimension: Some(Dimension::Rows),
2400 start: Some(2),
2401 end: Some(7),
2402 },
2403 )
2404 .await
2405 .unwrap_err();
2406 assert!(err.contains("single row or column"), "{err}");
2407 assert!(server.received_requests().await.unwrap().is_empty());
2408 }
2409}