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::{
51 applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
52};
53use crate::drive::sheets::client::SheetsClient;
54use crate::drive::sheets::grid_range;
55use crate::drive::sheets::target_gate;
56use crate::drive::sheets::types::{
57 BatchUpdateRequestItem, CreateDeveloperMetadataRequest, DataFilter,
58 DeleteDeveloperMetadataRequest, DeveloperMetadata, DeveloperMetadataLocation,
59 DeveloperMetadataLookupFilter, DeveloperMetadataValueUpdate, Dimension, DimensionRange,
60 NewDeveloperMetadata, Spreadsheet, UpdateDeveloperMetadataRequest, DOCUMENT_VISIBILITY,
61};
62use crate::drive::types::SheetTargetRefusal;
63use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
64use crate::request_log::{self, DriveMutationOutcome};
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum DeveloperMetadataVerb {
69 Set {
72 key: String,
74 value: String,
76 sheet: Option<String>,
80 dimension: Option<Dimension>,
82 start: Option<i64>,
84 end: Option<i64>,
86 },
87 Delete {
89 key: String,
91 sheet: Option<String>,
93 dimension: Option<Dimension>,
95 start: Option<i64>,
97 end: Option<i64>,
99 },
100}
101
102impl DeveloperMetadataVerb {
103 const fn log_operation(&self) -> &'static str {
105 match self {
106 Self::Set { .. } => "sheets-set-developer-metadata",
107 Self::Delete { .. } => "sheets-delete-developer-metadata",
108 }
109 }
110
111 const fn label(&self) -> &'static str {
113 match self {
114 Self::Set { .. } => "set-developer-metadata",
115 Self::Delete { .. } => "delete-developer-metadata",
116 }
117 }
118}
119
120#[derive(Debug, Clone)]
122pub struct DeveloperMetadataOptions {
123 pub spreadsheet_id: String,
125 pub verb: DeveloperMetadataVerb,
127 pub dry_run: bool,
129 pub lease_token: Option<String>,
133 pub ledger_path: PathBuf,
135}
136
137#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
140pub struct DeveloperMetadataEntry {
141 pub metadata_id: i64,
143 pub key: String,
145 pub value: String,
147 pub location: String,
150}
151
152impl DeveloperMetadataEntry {
153 fn from_entry(workbook: &Spreadsheet, entry: &DeveloperMetadata) -> Self {
154 Self {
155 metadata_id: entry.metadata_id,
156 key: entry.metadata_key.clone(),
157 value: entry.metadata_value.clone(),
158 location: describe_location(workbook, &entry.location),
159 }
160 }
161}
162
163#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
165#[serde(tag = "status", rename_all = "kebab-case")]
166pub enum DeveloperMetadataResult {
167 WouldCreate {
169 key: String,
171 value: String,
173 location: String,
175 },
176 WouldUpdate {
179 new_value: String,
181 previous: Vec<DeveloperMetadataEntry>,
183 },
184 WouldDelete {
187 entries: Vec<DeveloperMetadataEntry>,
189 },
190 RefusedNotASpreadsheet {
192 mime_type: String,
194 },
195 RefusedShortcut,
197 RefusedNoVisibleParents,
199 RefusedSheetNotFound {
201 title: String,
203 available: Vec<String>,
205 },
206 RefusedInvalidLocation {
209 detail: String,
211 },
212 RefusedNotFound {
215 key: String,
217 },
218 Blocked {
220 decided_by: Option<DecidingRule>,
222 },
223 RefusedNoLease,
226 RefusedLeaseExpired,
229 RefusedLeaseWrongFile,
231 RefusedLeaseStale,
233 Created {
235 key: String,
237 value: String,
239 location: String,
241 },
242 Updated {
244 new_value: String,
246 previous: Vec<DeveloperMetadataEntry>,
248 },
249 Deleted {
251 entries: Vec<DeveloperMetadataEntry>,
253 },
254 AppliedReplyUnreadable {
258 summary: String,
262 applied: Box<Self>,
267 detail: String,
269 },
270 Failed {
272 detail: String,
274 },
275}
276
277impl FromLeaseRefusal for DeveloperMetadataResult {
278 fn from_no_lease() -> Self {
279 Self::RefusedNoLease
280 }
281 fn from_lease_expired() -> Self {
282 Self::RefusedLeaseExpired
283 }
284 fn from_lease_wrong_file() -> Self {
285 Self::RefusedLeaseWrongFile
286 }
287 fn from_lease_stale() -> Self {
288 Self::RefusedLeaseStale
289 }
290 fn from_lease_failed(detail: String) -> Self {
291 Self::Failed { detail }
292 }
293}
294
295impl DeveloperMetadataResult {
296 fn log_status(&self) -> &'static str {
297 match self {
298 Self::WouldCreate { .. } | Self::WouldUpdate { .. } | Self::WouldDelete { .. } => {
299 "would-change"
300 }
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::RefusedInvalidLocation { .. } => "refused-invalid-range",
306 Self::RefusedNotFound { .. } => "refused-not-found",
307 Self::Blocked { .. } => "blocked",
308 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
309 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
310 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
311 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
312 Self::Created { .. } | Self::Updated { .. } | Self::Deleted { .. } => "changed",
313 Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
314 Self::Failed { .. } => "failed",
315 }
316 }
317}
318
319#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
321pub struct DeveloperMetadataOutcome {
322 pub spreadsheet_id: String,
324 #[serde(skip_serializing_if = "Option::is_none")]
326 pub file_name: Option<String>,
327 #[serde(skip_serializing_if = "Option::is_none")]
329 pub resolved_folder_id: Option<String>,
330 #[serde(skip)]
333 pub verb: DeveloperMetadataVerb,
334 pub result: DeveloperMetadataResult,
336}
337
338impl JsonlSerialize for DeveloperMetadataOutcome {
339 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
340 write_scalar_jsonl(self, out)
341 }
342}
343
344fn find_sheet_id(workbook: &Spreadsheet, title: &str) -> Result<i64, DeveloperMetadataResult> {
345 grid_range::find_sheet_id(workbook, title, |title, available| {
346 DeveloperMetadataResult::RefusedSheetNotFound { title, available }
347 })
348}
349
350fn resolve_location(
363 workbook: &Spreadsheet,
364 sheet: Option<&str>,
365 dimension: Option<Dimension>,
366 start: Option<i64>,
367 end: Option<i64>,
368) -> Result<DeveloperMetadataLocation, DeveloperMetadataResult> {
369 match (sheet, dimension, start, end) {
370 (None, None, None, None) => Ok(DeveloperMetadataLocation::spreadsheet()),
371 (Some(sheet), None, None, None) => {
372 let sheet_id = find_sheet_id(workbook, sheet)?;
373 Ok(DeveloperMetadataLocation::sheet(sheet_id))
374 }
375 (Some(sheet), Some(dimension), Some(start), Some(end)) => {
376 let sheet_id = find_sheet_id(workbook, sheet)?;
377 if start < 1 {
378 return Err(DeveloperMetadataResult::RefusedInvalidLocation {
379 detail: format!("--start must be at least 1 (got --start {start})"),
380 });
381 }
382 if end != start {
389 return Err(DeveloperMetadataResult::RefusedInvalidLocation {
390 detail: format!(
391 "developer metadata can only be attached to a single row or \
392 column — Sheets rejects a span of more than one. Pass the \
393 same value for --start and --end (e.g. --start {start} --end \
394 {start} selects that row/column alone); got --start {start} \
395 --end {end}"
396 ),
397 });
398 }
399 Ok(DeveloperMetadataLocation::dimension(DimensionRange {
400 sheet_id,
401 dimension,
402 start_index: start - 1,
403 end_index: end,
404 }))
405 }
406 (None, Some(_) | None, ..) if dimension.is_some() || start.is_some() || end.is_some() => {
407 Err(DeveloperMetadataResult::RefusedInvalidLocation {
408 detail: "--dimension/--start/--end need --sheet too".to_string(),
409 })
410 }
411 _ => Err(DeveloperMetadataResult::RefusedInvalidLocation {
412 detail: "--dimension, --start and --end must be given together (a row/column \
413 location), or none of the three alongside --sheet (a whole-sheet \
414 location), or none of --sheet/--dimension/--start/--end at all (a \
415 whole-spreadsheet location)"
416 .to_string(),
417 }),
418 }
419}
420
421fn location_error_to_string(result: DeveloperMetadataResult) -> String {
425 match result {
426 DeveloperMetadataResult::RefusedSheetNotFound { title, available } => format!(
427 "no sheet titled '{title}'. Available: {}",
428 grid_range::available_sheets(&available)
429 ),
430 DeveloperMetadataResult::RefusedInvalidLocation { detail } => detail,
431 other => unreachable!(
432 "resolve_location only ever returns RefusedSheetNotFound or \
433 RefusedInvalidLocation, got {other:?}"
434 ),
435 }
436}
437
438fn describe_location(workbook: &Spreadsheet, location: &DeveloperMetadataLocation) -> String {
441 if location.spreadsheet == Some(true) {
442 return "the whole spreadsheet".to_string();
443 }
444 if let Some(sheet_id) = location.sheet_id {
445 return format!("sheet {}", sheet_title_or_id(workbook, sheet_id));
446 }
447 if let Some(range) = &location.dimension_range {
448 let sheet = sheet_title_or_id(workbook, range.sheet_id);
449 if range.end_index == range.start_index + 1 {
453 return format!(
454 "{} {} of sheet {sheet}",
455 range.dimension.noun(),
456 range.end_index
457 );
458 }
459 return format!(
460 "{}(s) {}-{} of sheet {}",
461 range.dimension.noun(),
462 range.start_index + 1,
463 range.end_index,
464 sheet
465 );
466 }
467 "an unknown location".to_string()
468}
469
470fn sheet_title_or_id(workbook: &Spreadsheet, sheet_id: i64) -> String {
471 workbook
472 .sheets
473 .iter()
474 .filter_map(|sheet| sheet.properties.as_ref())
475 .find(|props| props.sheet_id == Some(sheet_id))
476 .map_or_else(
477 || format!("id {sheet_id}"),
478 |props| format!("'{}'", props.title),
479 )
480}
481
482fn assert_document_visibility(entries: &[DeveloperMetadata]) -> Result<(), String> {
490 for entry in entries {
491 if entry.visibility != DOCUMENT_VISIBILITY {
492 return Err(format!(
493 "developerMetadata.search returned an entry (id {}) with visibility \
494 '{}', not '{DOCUMENT_VISIBILITY}' — refusing to act on or display it \
495 (issue #1795, ADR-0081 §4)",
496 entry.metadata_id, entry.visibility
497 ));
498 }
499 }
500 Ok(())
501}
502
503async fn find_document_metadata(
507 api: &SheetsApi<'_>,
508 spreadsheet_id: &str,
509 key: Option<&str>,
510 location: Option<&DeveloperMetadataLocation>,
511) -> Result<Vec<DeveloperMetadata>, String> {
512 let filter = build_filter(key, location);
513 let response = api
514 .search_developer_metadata(spreadsheet_id, vec![filter])
515 .await
516 .map_err(|err| format!("{err:#}"))?;
517 let entries: Vec<DeveloperMetadata> = response
518 .matched_developer_metadata
519 .into_iter()
520 .map(|matched| matched.developer_metadata)
521 .collect();
522 assert_document_visibility(&entries)?;
523 Ok(entries)
524}
525
526fn build_filter(key: Option<&str>, location: Option<&DeveloperMetadataLocation>) -> DataFilter {
532 DataFilter {
533 developer_metadata_lookup: DeveloperMetadataLookupFilter {
534 metadata_location: location.cloned(),
535 metadata_key: key.map(str::to_string),
536 visibility: Some(DOCUMENT_VISIBILITY.to_string()),
537 location_matching_strategy: location.map(|_| "EXACT_LOCATION".to_string()),
538 },
539 }
540}
541
542pub async fn developer_metadata(
546 drive: &DriveClient,
547 sheets: &SheetsClient,
548 opts: &DeveloperMetadataOptions,
549 rules: &[FolderPermissionRule],
550) -> DeveloperMetadataOutcome {
551 let started = Instant::now();
552 let outcome = developer_metadata_inner(drive, sheets, opts, rules).await;
553 if !opts.dry_run {
554 record_attempt(&outcome, opts, started.elapsed());
555 }
556 outcome
557}
558
559async fn developer_metadata_inner(
560 drive: &DriveClient,
561 sheets: &SheetsClient,
562 opts: &DeveloperMetadataOptions,
563 rules: &[FolderPermissionRule],
564) -> DeveloperMetadataOutcome {
565 let bare = |result| DeveloperMetadataOutcome {
566 spreadsheet_id: opts.spreadsheet_id.clone(),
567 file_name: None,
568 resolved_folder_id: None,
569 verb: opts.verb.clone(),
570 result,
571 };
572
573 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
574 drive,
575 &opts.spreadsheet_id,
576 DriveOperation::SheetsStructure,
577 rules,
578 )
579 .await
580 {
581 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
582 return bare(DeveloperMetadataResult::Failed { detail })
583 }
584 target_gate::TargetGateOutcome::Refused { target, refusal } => {
585 let result = match refusal {
586 SheetTargetRefusal::Shortcut => DeveloperMetadataResult::RefusedShortcut,
587 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
588 DeveloperMetadataResult::RefusedNotASpreadsheet { mime_type }
589 }
590 SheetTargetRefusal::NoVisibleParents => {
591 DeveloperMetadataResult::RefusedNoVisibleParents
592 }
593 };
594 return DeveloperMetadataOutcome {
595 spreadsheet_id: opts.spreadsheet_id.clone(),
596 file_name: Some(target.name),
597 resolved_folder_id: None,
598 verb: opts.verb.clone(),
599 result,
600 };
601 }
602 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
603 return DeveloperMetadataOutcome {
604 spreadsheet_id: opts.spreadsheet_id.clone(),
605 file_name: Some(target.name),
606 resolved_folder_id: None,
607 verb: opts.verb.clone(),
608 result: DeveloperMetadataResult::Failed { detail },
609 };
610 }
611 target_gate::TargetGateOutcome::Gated {
612 target,
613 decision,
614 resolved_folder_id,
615 requires_lease,
616 } => (target, decision, resolved_folder_id, requires_lease),
617 };
618
619 let gated = |result| DeveloperMetadataOutcome {
620 spreadsheet_id: opts.spreadsheet_id.clone(),
621 file_name: Some(target.name.clone()),
622 resolved_folder_id: resolved_folder_id.clone(),
623 verb: opts.verb.clone(),
624 result,
625 };
626
627 if decision.verdict == write_gate::Verdict::Deny {
628 return gated(DeveloperMetadataResult::Blocked {
629 decided_by: decision.decided_by,
630 });
631 }
632
633 let api = SheetsApi::new(sheets);
634 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
635 Ok(workbook) => workbook,
636 Err(err) => {
637 return gated(DeveloperMetadataResult::Failed {
638 detail: format!("{err:#}"),
639 })
640 }
641 };
642
643 let (key, sheet, dimension, start, end): (
644 &str,
645 Option<&str>,
646 Option<Dimension>,
647 Option<i64>,
648 Option<i64>,
649 ) = match &opts.verb {
650 DeveloperMetadataVerb::Set {
651 key,
652 sheet,
653 dimension,
654 start,
655 end,
656 ..
657 }
658 | DeveloperMetadataVerb::Delete {
659 key,
660 sheet,
661 dimension,
662 start,
663 end,
664 } => (key.as_str(), sheet.as_deref(), *dimension, *start, *end),
665 };
666
667 let location = match resolve_location(&workbook, sheet, dimension, start, end) {
668 Ok(location) => location,
669 Err(result) => return gated(result),
670 };
671
672 let matches = match find_document_metadata(
677 &api,
678 &opts.spreadsheet_id,
679 Some(key),
680 Some(&location),
681 )
682 .await
683 {
684 Ok(matches) => matches,
685 Err(detail) => return gated(DeveloperMetadataResult::Failed { detail }),
686 };
687 let entries: Vec<DeveloperMetadataEntry> = matches
688 .iter()
689 .map(|entry| DeveloperMetadataEntry::from_entry(&workbook, entry))
690 .collect();
691 let filter = build_filter(Some(key), Some(&location));
692
693 let (preview_result, changed_result, request) = match &opts.verb {
698 DeveloperMetadataVerb::Set { value, .. } => {
699 if entries.is_empty() {
700 let location_desc = describe_location(&workbook, &location);
701 (
702 DeveloperMetadataResult::WouldCreate {
703 key: key.to_string(),
704 value: value.clone(),
705 location: location_desc.clone(),
706 },
707 DeveloperMetadataResult::Created {
708 key: key.to_string(),
709 value: value.clone(),
710 location: location_desc,
711 },
712 BatchUpdateRequestItem::CreateDeveloperMetadata(
713 CreateDeveloperMetadataRequest {
714 developer_metadata: NewDeveloperMetadata {
715 metadata_key: key.to_string(),
716 metadata_value: value.clone(),
717 location,
718 visibility: DOCUMENT_VISIBILITY.to_string(),
719 },
720 },
721 ),
722 )
723 } else {
724 (
725 DeveloperMetadataResult::WouldUpdate {
726 new_value: value.clone(),
727 previous: entries.clone(),
728 },
729 DeveloperMetadataResult::Updated {
730 new_value: value.clone(),
731 previous: entries.clone(),
732 },
733 BatchUpdateRequestItem::UpdateDeveloperMetadata(
734 UpdateDeveloperMetadataRequest {
735 data_filters: vec![filter],
736 developer_metadata: DeveloperMetadataValueUpdate {
737 metadata_value: value.clone(),
738 },
739 fields: "metadataValue".to_string(),
740 },
741 ),
742 )
743 }
744 }
745 DeveloperMetadataVerb::Delete { .. } => {
746 if entries.is_empty() {
747 return gated(DeveloperMetadataResult::RefusedNotFound {
748 key: key.to_string(),
749 });
750 }
751 (
752 DeveloperMetadataResult::WouldDelete {
753 entries: entries.clone(),
754 },
755 DeveloperMetadataResult::Deleted {
756 entries: entries.clone(),
757 },
758 BatchUpdateRequestItem::DeleteDeveloperMetadata(DeleteDeveloperMetadataRequest {
759 data_filter: filter,
760 }),
761 )
762 }
763 };
764
765 if opts.dry_run {
766 return gated(preview_result);
767 }
768
769 let files_api = FilesApi::new(drive);
770 let leased = LeasedWrite {
771 log_prefix: "drive sheets developer-metadata",
772 operation: opts.verb.log_operation(),
773 ledger_path: &opts.ledger_path,
774 file_id: &opts.spreadsheet_id,
775 };
776 let lease_grant = match gate_optional_leased_write(
777 leased,
778 &files_api,
779 requires_lease,
780 opts.lease_token.as_deref(),
781 )
782 .await
783 {
784 Ok(grant) => grant,
785 Err(err) => return gated(err.into_result()),
786 };
787
788 let result = match conclude_native_leased_write(
789 leased,
790 &lease_grant,
791 &files_api,
792 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
793 |err| format!("{err:#}"),
794 )
795 .await
796 {
797 Ok(BatchUpdateOutcome::Applied(_response)) => changed_result,
798 Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
801 DeveloperMetadataResult::AppliedReplyUnreadable {
802 summary: changed_summary(&changed_result).unwrap_or_default(),
803 applied: Box::new(changed_result),
804 detail,
805 }
806 }
807 Err(err) => DeveloperMetadataResult::Failed {
808 detail: format!("{err:#}"),
809 },
810 };
811 drop(lease_grant);
812 gated(result)
813}
814
815#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
822pub struct SearchLocationFilter<'a> {
823 pub sheet: Option<&'a str>,
825 pub dimension: Option<Dimension>,
827 pub start: Option<i64>,
829 pub end: Option<i64>,
831}
832
833impl SearchLocationFilter<'_> {
834 fn is_empty(self) -> bool {
835 self.sheet.is_none()
836 && self.dimension.is_none()
837 && self.start.is_none()
838 && self.end.is_none()
839 }
840}
841
842pub async fn search(
849 api: &SheetsApi<'_>,
850 spreadsheet_id: &str,
851 workbook: &Spreadsheet,
852 key: Option<&str>,
853 filter: SearchLocationFilter<'_>,
854) -> Result<Vec<DeveloperMetadataEntry>, String> {
855 let location = if filter.is_empty() {
856 None
857 } else {
858 Some(
859 resolve_location(
860 workbook,
861 filter.sheet,
862 filter.dimension,
863 filter.start,
864 filter.end,
865 )
866 .map_err(location_error_to_string)?,
867 )
868 };
869 let matches = find_document_metadata(api, spreadsheet_id, key, location.as_ref()).await?;
870 Ok(matches
871 .iter()
872 .map(|entry| DeveloperMetadataEntry::from_entry(workbook, entry))
873 .collect())
874}
875
876fn record_attempt(
877 outcome: &DeveloperMetadataOutcome,
878 opts: &DeveloperMetadataOptions,
879 duration: Duration,
880) {
881 request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
882}
883
884fn mutation_record(
887 outcome: &DeveloperMetadataOutcome,
888 opts: &DeveloperMetadataOptions,
889 duration: Duration,
890) -> DriveMutationOutcome {
891 let error = match &outcome.result {
892 DeveloperMetadataResult::Failed { detail }
893 | DeveloperMetadataResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
894 _ => None,
895 };
896 let decided_by = match &outcome.result {
897 DeveloperMetadataResult::Blocked { decided_by } => decided_by.as_ref(),
898 _ => None,
899 };
900 let decided_by = write_gate::decided_by_log_fields(decided_by);
901 let fields_changed = match &outcome.result {
902 DeveloperMetadataResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
903 other => changed_summary(other),
904 };
905
906 DriveMutationOutcome {
907 operation: opts.verb.log_operation(),
908 file_id: outcome.spreadsheet_id.clone(),
909 file_name: outcome.file_name.clone().unwrap_or_default(),
910 status: outcome.result.log_status().to_string(),
911 resolved_folder_id: outcome.resolved_folder_id.clone(),
912 decided_by_folder_id: decided_by.folder_id,
913 decided_by_depth: decided_by.depth,
914 decided_by_file_id: decided_by.file_id,
915 fields_changed,
916 error,
917 duration,
918 ..Default::default()
919 }
920}
921
922fn changed_summary(result: &DeveloperMetadataResult) -> Option<String> {
926 match result {
927 DeveloperMetadataResult::Created { key, value, .. } => {
928 Some(format!("create key={key:?} value={value:?}"))
929 }
930 DeveloperMetadataResult::Updated {
931 new_value,
932 previous,
933 } => Some(format!(
934 "update {} entr{} to value={new_value:?}",
935 previous.len(),
936 plural(previous.len())
937 )),
938 DeveloperMetadataResult::Deleted { entries } => Some(format!(
939 "delete {} entr{}",
940 entries.len(),
941 plural(entries.len())
942 )),
943 _ => None,
944 }
945}
946
947fn plural(n: usize) -> &'static str {
948 if n == 1 {
949 "y"
950 } else {
951 "ies"
952 }
953}
954
955fn describe_entry(entry: &DeveloperMetadataEntry) -> String {
956 format!(
957 " id {}: {:?}={:?} at {}",
958 entry.metadata_id, entry.key, entry.value, entry.location
959 )
960}
961
962fn describe_updated_entry(entry: &DeveloperMetadataEntry, new_value: &str) -> String {
968 format!(
969 " id {}: {:?}={:?} at {}",
970 entry.metadata_id, entry.key, new_value, entry.location
971 )
972}
973
974#[must_use]
976pub fn describe(outcome: &DeveloperMetadataOutcome) -> String {
977 describe_lines(outcome).join("\n")
978}
979
980#[must_use]
983pub fn describe_lines(outcome: &DeveloperMetadataOutcome) -> Vec<String> {
984 let verb = &outcome.verb;
985 let book = outcome.file_name.as_deref().map_or_else(
986 || format!("'{}'", outcome.spreadsheet_id),
987 |n| format!("'{n}'"),
988 );
989 match &outcome.result {
990 DeveloperMetadataResult::WouldCreate {
991 key,
992 value,
993 location,
994 } => vec![format!(
995 "Would create developer metadata {key:?}={value:?} at {location} in {book}"
996 )],
997 DeveloperMetadataResult::WouldUpdate {
998 new_value,
999 previous,
1000 } => {
1001 let mut lines = vec![format!(
1002 "Would update {} existing entr{} to {new_value:?} in {book}:",
1003 previous.len(),
1004 plural(previous.len())
1005 )];
1006 lines.extend(previous.iter().map(describe_entry));
1007 lines
1008 }
1009 DeveloperMetadataResult::WouldDelete { entries } => {
1010 let mut lines = vec![format!(
1011 "Would delete {} entr{} in {book}:",
1012 entries.len(),
1013 plural(entries.len())
1014 )];
1015 lines.extend(entries.iter().map(describe_entry));
1016 lines
1017 }
1018 DeveloperMetadataResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1019 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
1020 `drive sheets {}` only works on spreadsheets",
1021 verb.label()
1022 )],
1023 DeveloperMetadataResult::RefusedShortcut => vec![format!(
1024 "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
1025 verb.label()
1026 )],
1027 DeveloperMetadataResult::RefusedNoVisibleParents => vec![format!(
1028 "Refused: {book} has no parent folder visible to this account, so no folder \
1029 rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
1030 id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
1031 )],
1032 DeveloperMetadataResult::RefusedSheetNotFound { title, available } => {
1033 vec![grid_range::no_sheet_refusal(&book, title, available)]
1034 }
1035 DeveloperMetadataResult::RefusedInvalidLocation { detail } => {
1036 vec![format!("Refused: {detail}")]
1037 }
1038 DeveloperMetadataResult::RefusedNotFound { key } => vec![format!(
1039 "Refused: no developer metadata matching key {key:?} at that location in {book}"
1040 )],
1041 DeveloperMetadataResult::Blocked { decided_by } => vec![match decided_by {
1042 Some(rule) => format!(
1043 "Blocked: {} on {book} refused by rule on {} {}{}",
1044 verb.label(),
1045 rule.kind_label(),
1046 rule.id(),
1047 rule.depth_suffix()
1048 ),
1049 None => format!(
1050 "Blocked: {} on {book} refused by default policy (no matching rule for \
1051 sheets-structure)",
1052 verb.label()
1053 ),
1054 }],
1055 DeveloperMetadataResult::RefusedNoLease => LeaseGateRefusal::NoLease
1056 .describe_line(&outcome.spreadsheet_id, &book)
1057 .into_iter()
1058 .collect(),
1059 DeveloperMetadataResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1060 .describe_line(&outcome.spreadsheet_id, &book)
1061 .into_iter()
1062 .collect(),
1063 DeveloperMetadataResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1064 .describe_line(&outcome.spreadsheet_id, &book)
1065 .into_iter()
1066 .collect(),
1067 DeveloperMetadataResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1068 .describe_line(&outcome.spreadsheet_id, &book)
1069 .into_iter()
1070 .collect(),
1071 DeveloperMetadataResult::Created {
1072 key,
1073 value,
1074 location,
1075 } => vec![format!(
1076 "Created developer metadata {key:?}={value:?} at {location} in {book}"
1077 )],
1078 DeveloperMetadataResult::Updated {
1079 new_value,
1080 previous,
1081 } => {
1082 let mut lines = vec![format!(
1083 "Updated {} entr{} to {new_value:?} in {book}:",
1084 previous.len(),
1085 plural(previous.len())
1086 )];
1087 lines.extend(
1088 previous
1089 .iter()
1090 .map(|entry| describe_updated_entry(entry, new_value)),
1091 );
1092 lines
1093 }
1094 DeveloperMetadataResult::Deleted { entries } => {
1095 let mut lines = vec![format!(
1096 "Deleted {} entr{} in {book}:",
1097 entries.len(),
1098 plural(entries.len())
1099 )];
1100 lines.extend(entries.iter().map(describe_entry));
1101 lines
1102 }
1103 DeveloperMetadataResult::AppliedReplyUnreadable {
1108 summary,
1109 applied,
1110 detail,
1111 } => {
1112 let (location, entry_lines): (String, Vec<String>) = match &**applied {
1113 DeveloperMetadataResult::Created { location, .. } => {
1114 (format!(" at {location}"), Vec::new())
1115 }
1116 DeveloperMetadataResult::Updated {
1117 new_value,
1118 previous,
1119 } => (
1120 String::new(),
1121 previous
1122 .iter()
1123 .map(|entry| describe_updated_entry(entry, new_value))
1124 .collect(),
1125 ),
1126 DeveloperMetadataResult::Deleted { entries } => {
1127 (String::new(), entries.iter().map(describe_entry).collect())
1128 }
1129 _ => (String::new(), Vec::new()),
1130 };
1131 let mut lines = vec![applied_reply_unreadable_line(
1132 &format!("{summary}{location}"),
1133 &book,
1134 RetryHint::NotIdempotent,
1135 detail,
1136 )];
1137 lines.extend(entry_lines);
1138 lines
1139 }
1140 DeveloperMetadataResult::Failed { detail } => vec![format!("Failed: {detail}")],
1141 }
1142}
1143
1144#[cfg(test)]
1145#[allow(clippy::unwrap_used, clippy::expect_used)]
1146mod tests {
1147 use super::*;
1148 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1149 use crate::drive::sheets::client::SHEETS_API_URL;
1150 use crate::drive::test_support::seed_lease;
1151 use crate::test_support::env::MapEnv;
1152 use crate::utils::secret::Secret;
1153 use std::collections::HashSet;
1154
1155 fn test_workbook() -> Spreadsheet {
1156 serde_json::from_value(serde_json::json!({
1157 "spreadsheetId": "sheet-1",
1158 "properties": {"title": "Budget"},
1159 "sheets": [
1160 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1161 ],
1162 }))
1163 .unwrap()
1164 }
1165
1166 #[test]
1167 fn resolve_location_with_no_flags_is_spreadsheet_scoped() {
1168 let workbook = test_workbook();
1169 let location = resolve_location(&workbook, None, None, None, None).unwrap();
1170 assert_eq!(location, DeveloperMetadataLocation::spreadsheet());
1171 }
1172
1173 #[test]
1174 fn resolve_location_with_only_sheet_is_sheet_scoped() {
1175 let workbook = test_workbook();
1176 let location = resolve_location(&workbook, Some("Q1"), None, None, None).unwrap();
1177 assert_eq!(location, DeveloperMetadataLocation::sheet(0));
1178 }
1179
1180 #[test]
1181 fn resolve_location_rejects_an_unknown_sheet() {
1182 let workbook = test_workbook();
1183 let result = resolve_location(&workbook, Some("Nope"), None, None, None).unwrap_err();
1184 assert!(matches!(
1185 result,
1186 DeveloperMetadataResult::RefusedSheetNotFound { title, .. } if title == "Nope"
1187 ));
1188 }
1189
1190 #[test]
1191 fn resolve_location_with_full_dimension_converts_1_based_to_0_based() {
1192 let workbook = test_workbook();
1193 let location = resolve_location(
1194 &workbook,
1195 Some("Q1"),
1196 Some(Dimension::Rows),
1197 Some(2),
1198 Some(2),
1199 )
1200 .unwrap();
1201 assert_eq!(
1202 location,
1203 DeveloperMetadataLocation::dimension(DimensionRange {
1204 sheet_id: 0,
1205 dimension: Dimension::Rows,
1206 start_index: 1,
1207 end_index: 2,
1208 })
1209 );
1210 }
1211
1212 #[test]
1213 fn resolve_location_rejects_end_before_start() {
1214 let workbook = test_workbook();
1215 let result = resolve_location(
1216 &workbook,
1217 Some("Q1"),
1218 Some(Dimension::Rows),
1219 Some(5),
1220 Some(2),
1221 )
1222 .unwrap_err();
1223 assert!(matches!(
1224 result,
1225 DeveloperMetadataResult::RefusedInvalidLocation { .. }
1226 ));
1227 }
1228
1229 #[test]
1230 fn resolve_location_rejects_a_multi_row_span() {
1231 let workbook = test_workbook();
1235 let result = resolve_location(
1236 &workbook,
1237 Some("Q1"),
1238 Some(Dimension::Rows),
1239 Some(2),
1240 Some(7),
1241 )
1242 .unwrap_err();
1243 match result {
1244 DeveloperMetadataResult::RefusedInvalidLocation { detail } => {
1245 assert!(detail.contains("single row or column"), "{detail}");
1246 assert!(detail.contains("--start 2"), "{detail}");
1247 assert!(detail.contains("--end 7"), "{detail}");
1248 }
1249 other => panic!("expected RefusedInvalidLocation, got {other:?}"), }
1251 }
1252
1253 #[test]
1254 fn resolve_location_accepts_a_single_column() {
1255 let workbook = test_workbook();
1256 let location = resolve_location(
1257 &workbook,
1258 Some("Q1"),
1259 Some(Dimension::Columns),
1260 Some(3),
1261 Some(3),
1262 )
1263 .unwrap();
1264 assert_eq!(
1265 location,
1266 DeveloperMetadataLocation::dimension(DimensionRange {
1267 sheet_id: 0,
1268 dimension: Dimension::Columns,
1269 start_index: 2,
1270 end_index: 3,
1271 })
1272 );
1273 }
1274
1275 #[test]
1276 fn resolve_location_rejects_dimension_without_start_and_end() {
1277 let workbook = test_workbook();
1278 let result =
1279 resolve_location(&workbook, Some("Q1"), Some(Dimension::Rows), None, None).unwrap_err();
1280 assert!(matches!(
1281 result,
1282 DeveloperMetadataResult::RefusedInvalidLocation { .. }
1283 ));
1284 }
1285
1286 #[test]
1287 fn resolve_location_rejects_start_end_without_sheet() {
1288 let workbook = test_workbook();
1289 let result =
1290 resolve_location(&workbook, None, Some(Dimension::Rows), Some(1), Some(2)).unwrap_err();
1291 assert!(matches!(
1292 result,
1293 DeveloperMetadataResult::RefusedInvalidLocation { .. }
1294 ));
1295 }
1296
1297 fn document_entry(id: i64, visibility: &str) -> DeveloperMetadata {
1298 DeveloperMetadata {
1299 metadata_id: id,
1300 metadata_key: "owner".to_string(),
1301 metadata_value: "team-a".to_string(),
1302 location: DeveloperMetadataLocation::spreadsheet(),
1303 visibility: visibility.to_string(),
1304 }
1305 }
1306
1307 #[test]
1308 fn assert_document_visibility_accepts_every_document_entry() {
1309 let entries = vec![document_entry(1, "DOCUMENT"), document_entry(2, "DOCUMENT")];
1310 assert!(assert_document_visibility(&entries).is_ok());
1311 }
1312
1313 #[test]
1314 fn assert_document_visibility_rejects_a_project_entry() {
1315 let entries = vec![document_entry(1, "PROJECT")];
1320 let err = assert_document_visibility(&entries).unwrap_err();
1321 assert!(err.contains("PROJECT"), "{err}");
1322 assert!(err.contains('1'), "{err}");
1323 }
1324
1325 fn test_credentials() -> DriveCredentials {
1326 DriveCredentials {
1327 client_id: "client-1".to_string(),
1328 client_secret: Secret::new("secret-1"),
1329 refresh_token: Secret::new("refresh-1"),
1330 scope: DriveGrantedScopes::READONLY,
1331 }
1332 }
1333
1334 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1335 wiremock::Mock::given(wiremock::matchers::method("POST"))
1336 .and(wiremock::matchers::path("/token"))
1337 .respond_with(
1338 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1339 "access_token": "test-token", "expires_in": 3600,
1340 })),
1341 )
1342 .mount(server)
1343 .await;
1344 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1345 crate::drive::client::test_support::replace_session(
1346 &mut drive,
1347 &test_credentials(),
1348 &format!("{}/token", server.uri()),
1349 );
1350 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1351 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1352 (drive, sheets)
1353 }
1354
1355 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1356 let parents: Vec<&str> = parents.to_vec();
1357 wiremock::Mock::given(wiremock::matchers::method("GET"))
1358 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1359 .respond_with(
1360 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1361 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1362 "version": "1",
1363 })),
1364 )
1365 }
1366
1367 fn mount_folder(id: &str) -> wiremock::Mock {
1368 mount_file(id, "application/vnd.google-apps.folder", &[])
1369 }
1370
1371 fn allow_rule(folder: &str) -> FolderPermissionRule {
1372 FolderPermissionRule {
1373 folder_id: Some(folder.to_string()),
1374 file_id: None,
1375 recursive: true,
1376 allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
1377 deny: HashSet::default(),
1378 require_lease: true,
1379 }
1380 }
1381
1382 fn deny_rule() -> Vec<FolderPermissionRule> {
1383 Vec::new()
1384 }
1385
1386 fn mount_workbook() -> wiremock::Mock {
1387 wiremock::Mock::given(wiremock::matchers::method("GET"))
1388 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1389 .respond_with(
1390 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1391 "spreadsheetId": "sheet-1",
1392 "properties": {"title": "Budget"},
1393 "sheets": [
1394 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1395 ],
1396 })),
1397 )
1398 }
1399
1400 fn mount_search(body: serde_json::Value) -> wiremock::Mock {
1401 wiremock::Mock::given(wiremock::matchers::method("POST"))
1402 .and(wiremock::matchers::path(
1403 "/v4/spreadsheets/sheet-1/developerMetadata:search",
1404 ))
1405 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
1406 }
1407
1408 fn mount_batch_update(body: serde_json::Value) -> wiremock::Mock {
1409 wiremock::Mock::given(wiremock::matchers::method("POST"))
1410 .and(wiremock::matchers::path(
1411 "/v4/spreadsheets/sheet-1:batchUpdate",
1412 ))
1413 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
1414 }
1415
1416 fn find_request(requests: &[wiremock::Request], suffix: &str) -> serde_json::Value {
1420 let req = requests
1421 .iter()
1422 .find(|r| r.url.path().ends_with(suffix))
1423 .unwrap_or_else(|| panic!("no request ending in {suffix:?} was sent"));
1424 serde_json::from_slice(&req.body).unwrap()
1425 }
1426
1427 fn set_opts(dry_run: bool) -> DeveloperMetadataOptions {
1428 let ledger_path = tempfile::tempdir()
1429 .unwrap()
1430 .keep()
1431 .join("lease-ledger.jsonl");
1432 let token = seed_lease(&ledger_path, "sheet-1", "1");
1433 DeveloperMetadataOptions {
1434 spreadsheet_id: "sheet-1".to_string(),
1435 verb: DeveloperMetadataVerb::Set {
1436 key: "owner".to_string(),
1437 value: "team-a".to_string(),
1438 sheet: None,
1439 dimension: None,
1440 start: None,
1441 end: None,
1442 },
1443 dry_run,
1444 lease_token: Some(token),
1445 ledger_path,
1446 }
1447 }
1448
1449 fn delete_opts(dry_run: bool) -> DeveloperMetadataOptions {
1450 let mut opts = set_opts(dry_run);
1451 opts.verb = DeveloperMetadataVerb::Delete {
1452 key: "owner".to_string(),
1453 sheet: None,
1454 dimension: None,
1455 start: None,
1456 end: None,
1457 };
1458 opts
1459 }
1460
1461 async fn setup(
1462 server: &wiremock::MockServer,
1463 ) -> (DriveClient, SheetsClient, Vec<FolderPermissionRule>) {
1464 let (drive, sheets) = clients(server).await;
1465 mount_file(
1466 "sheet-1",
1467 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1468 &["folder-1"],
1469 )
1470 .mount(server)
1471 .await;
1472 mount_folder("folder-1").mount(server).await;
1473 mount_workbook().mount(server).await;
1474 (drive, sheets, vec![allow_rule("folder-1")])
1475 }
1476
1477 #[tokio::test]
1478 async fn dry_run_set_with_no_existing_entry_reports_would_create() {
1479 let server = wiremock::MockServer::start().await;
1480 let (drive, sheets, rules) = setup(&server).await;
1481 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1482 .mount(&server)
1483 .await;
1484
1485 let outcome = developer_metadata(&drive, &sheets, &set_opts(true), &rules).await;
1486 match &outcome.result {
1487 DeveloperMetadataResult::WouldCreate { key, value, .. } => {
1488 assert_eq!(key, "owner");
1489 assert_eq!(value, "team-a");
1490 }
1491 other => panic!("expected WouldCreate, got {other:?}"),
1492 }
1493 let text = describe(&outcome);
1496 assert!(text.contains("Would create developer metadata"), "{text}");
1497 }
1498
1499 #[tokio::test]
1500 async fn dry_run_set_with_an_existing_entry_reports_would_update_with_previous() {
1501 let server = wiremock::MockServer::start().await;
1502 let (drive, sheets, rules) = setup(&server).await;
1503 mount_search(serde_json::json!({"matchedDeveloperMetadata": [
1504 {"developerMetadata": {
1505 "metadataId": 42, "metadataKey": "owner", "metadataValue": "team-b",
1506 "location": {"spreadsheet": true}, "visibility": "DOCUMENT",
1507 }},
1508 ]}))
1509 .mount(&server)
1510 .await;
1511
1512 let outcome = developer_metadata(&drive, &sheets, &set_opts(true), &rules).await;
1513 match &outcome.result {
1514 DeveloperMetadataResult::WouldUpdate {
1515 new_value,
1516 previous,
1517 } => {
1518 assert_eq!(new_value, "team-a");
1519 assert_eq!(previous.len(), 1);
1520 assert_eq!(previous[0].value, "team-b");
1521 assert_eq!(previous[0].metadata_id, 42);
1522 }
1523 other => panic!("expected WouldUpdate, got {other:?}"),
1524 }
1525 let text = describe(&outcome);
1526 assert!(
1527 text.contains("Would update 1 existing entry to \"team-a\""),
1528 "{text}"
1529 );
1530 assert!(text.contains("id 42"), "{text}");
1531 }
1532
1533 #[tokio::test]
1534 async fn set_creates_when_no_existing_entry_matches() {
1535 let server = wiremock::MockServer::start().await;
1536 let (drive, sheets, rules) = setup(&server).await;
1537 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1538 .mount(&server)
1539 .await;
1540 mount_batch_update(serde_json::json!({"replies": [{}]}))
1541 .mount(&server)
1542 .await;
1543
1544 let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
1545 match &outcome.result {
1546 DeveloperMetadataResult::Created { key, value, .. } => {
1547 assert_eq!(key, "owner");
1548 assert_eq!(value, "team-a");
1549 }
1550 other => panic!("expected Created, got {other:?}"),
1551 }
1552 let text = describe(&outcome);
1553 assert!(text.contains("Created developer metadata"), "{text}");
1554 }
1555
1556 #[tokio::test]
1557 async fn set_with_an_unreadable_reply_reports_applied_reply_unreadable() {
1558 let server = wiremock::MockServer::start().await;
1559 let (drive, sheets, rules) = setup(&server).await;
1560 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1561 .mount(&server)
1562 .await;
1563 wiremock::Mock::given(wiremock::matchers::method("POST"))
1565 .and(wiremock::matchers::path(
1566 "/v4/spreadsheets/sheet-1:batchUpdate",
1567 ))
1568 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
1569 .expect(1)
1570 .mount(&server)
1571 .await;
1572
1573 let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
1574 assert_eq!(
1575 outcome.result.log_status(),
1576 "applied-reply-unreadable",
1577 "{:?}",
1578 outcome.result
1579 );
1580 assert!(
1581 describe(&outcome).starts_with("Applied, but the reply could not be read"),
1582 "{}",
1583 describe(&outcome)
1584 );
1585 }
1586
1587 #[tokio::test]
1588 async fn set_create_hardcodes_document_visibility_on_every_outgoing_request() {
1589 let server = wiremock::MockServer::start().await;
1596 let (drive, sheets, rules) = setup(&server).await;
1597 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1598 .mount(&server)
1599 .await;
1600 mount_batch_update(serde_json::json!({"replies": [{}]}))
1601 .mount(&server)
1602 .await;
1603
1604 let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
1605 assert!(
1606 matches!(outcome.result, DeveloperMetadataResult::Created { .. }),
1607 "{:?}",
1608 outcome.result
1609 );
1610
1611 let requests = server.received_requests().await.unwrap();
1612 let search_body = find_request(&requests, "developerMetadata:search");
1613 assert_eq!(
1614 search_body["dataFilters"][0]["developerMetadataLookup"]["visibility"],
1615 "DOCUMENT"
1616 );
1617 let batch_body = find_request(&requests, ":batchUpdate");
1618 assert_eq!(
1619 batch_body["requests"][0]["createDeveloperMetadata"]["developerMetadata"]["visibility"],
1620 "DOCUMENT"
1621 );
1622 }
1623
1624 #[tokio::test]
1625 async fn delete_reports_not_found_when_nothing_matches() {
1626 let server = wiremock::MockServer::start().await;
1627 let (drive, sheets, rules) = setup(&server).await;
1628 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1629 .mount(&server)
1630 .await;
1631
1632 let outcome = developer_metadata(&drive, &sheets, &delete_opts(false), &rules).await;
1633 match &outcome.result {
1634 DeveloperMetadataResult::RefusedNotFound { key } => assert_eq!(key, "owner"),
1635 other => panic!("expected RefusedNotFound, got {other:?}"),
1636 }
1637 let text = describe(&outcome);
1641 assert!(
1642 text.contains("no developer metadata matching key \"owner\""),
1643 "{text}"
1644 );
1645 }
1646
1647 #[tokio::test]
1648 async fn delete_previews_every_matched_entry_before_deleting() {
1649 let server = wiremock::MockServer::start().await;
1650 let (drive, sheets, rules) = setup(&server).await;
1651 mount_search(serde_json::json!({"matchedDeveloperMetadata": [
1652 {"developerMetadata": {
1653 "metadataId": 1, "metadataKey": "owner", "metadataValue": "team-a",
1654 "location": {"spreadsheet": true}, "visibility": "DOCUMENT",
1655 }},
1656 {"developerMetadata": {
1657 "metadataId": 2, "metadataKey": "owner", "metadataValue": "team-b",
1658 "location": {"spreadsheet": true}, "visibility": "DOCUMENT",
1659 }},
1660 ]}))
1661 .mount(&server)
1662 .await;
1663
1664 let outcome = developer_metadata(&drive, &sheets, &delete_opts(true), &rules).await;
1665 match &outcome.result {
1666 DeveloperMetadataResult::WouldDelete { entries } => {
1667 assert_eq!(entries.len(), 2);
1668 assert_eq!(entries[0].metadata_id, 1);
1669 assert_eq!(entries[1].metadata_id, 2);
1670 }
1671 other => panic!("expected WouldDelete, got {other:?}"),
1672 }
1673 let text = describe(&outcome);
1675 assert!(text.contains("Would delete 2 entries"), "{text}");
1676 }
1677
1678 #[tokio::test]
1679 async fn delete_hardcodes_document_visibility_on_every_outgoing_request() {
1680 let server = wiremock::MockServer::start().await;
1683 let (drive, sheets, rules) = setup(&server).await;
1684 mount_search(serde_json::json!({"matchedDeveloperMetadata": [
1685 {"developerMetadata": {
1686 "metadataId": 1, "metadataKey": "owner", "metadataValue": "team-a",
1687 "location": {"spreadsheet": true}, "visibility": "DOCUMENT",
1688 }},
1689 ]}))
1690 .mount(&server)
1691 .await;
1692 mount_batch_update(serde_json::json!({"replies": [{}]}))
1693 .mount(&server)
1694 .await;
1695
1696 let outcome = developer_metadata(&drive, &sheets, &delete_opts(false), &rules).await;
1697 assert!(
1698 matches!(outcome.result, DeveloperMetadataResult::Deleted { .. }),
1699 "{:?}",
1700 outcome.result
1701 );
1702
1703 let requests = server.received_requests().await.unwrap();
1704 let search_body = find_request(&requests, "developerMetadata:search");
1705 assert_eq!(
1706 search_body["dataFilters"][0]["developerMetadataLookup"]["visibility"],
1707 "DOCUMENT"
1708 );
1709 let batch_body = find_request(&requests, ":batchUpdate");
1710 assert_eq!(
1711 batch_body["requests"][0]["deleteDeveloperMetadata"]["dataFilter"]
1712 ["developerMetadataLookup"]["visibility"],
1713 "DOCUMENT"
1714 );
1715 let text = describe(&outcome);
1716 assert!(text.contains("Deleted 1 entry"), "{text}");
1717 }
1718
1719 #[tokio::test]
1720 async fn a_denied_gate_blocks_before_any_search_or_batch_update_call() {
1721 let server = wiremock::MockServer::start().await;
1722 let (drive, sheets, _rules) = setup(&server).await;
1723
1724 let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &deny_rule()).await;
1725 assert!(
1726 matches!(
1727 outcome.result,
1728 DeveloperMetadataResult::Blocked { decided_by: None }
1729 ),
1730 "{:?}",
1731 outcome.result
1732 );
1733 let text = describe(&outcome);
1736 assert!(
1737 text.contains("refused by default policy (no matching rule for sheets-structure)"),
1738 "{text}"
1739 );
1740 }
1741
1742 #[tokio::test]
1743 async fn search_returns_every_document_visibility_match() {
1744 let server = wiremock::MockServer::start().await;
1745 let (drive, sheets) = clients(&server).await;
1746 mount_workbook().mount(&server).await;
1747 mount_search(serde_json::json!({"matchedDeveloperMetadata": [
1748 {"developerMetadata": {
1749 "metadataId": 7, "metadataKey": "owner", "metadataValue": "team-a",
1750 "location": {"sheetId": 0}, "visibility": "DOCUMENT",
1751 }},
1752 ]}))
1753 .mount(&server)
1754 .await;
1755
1756 let api = SheetsApi::new(&sheets);
1757 let workbook = api.get_spreadsheet("sheet-1").await.unwrap();
1758 let entries = search(
1759 &api,
1760 "sheet-1",
1761 &workbook,
1762 None,
1763 SearchLocationFilter::default(),
1764 )
1765 .await
1766 .unwrap();
1767 assert_eq!(entries.len(), 1);
1768 assert_eq!(entries[0].location, "sheet 'Q1'");
1769 let _ = drive;
1772 }
1773
1774 #[tokio::test]
1777 async fn non_spreadsheet_is_refused_before_any_gate_or_search_call() {
1778 let server = wiremock::MockServer::start().await;
1779 let (drive, sheets) = clients(&server).await;
1780 mount_file("sheet-1", "application/pdf", &["folder-1"])
1781 .mount(&server)
1782 .await;
1783 let outcome =
1787 developer_metadata(&drive, &sheets, &set_opts(false), &[allow_rule("folder-1")]).await;
1788 assert!(
1789 matches!(
1790 outcome.result,
1791 DeveloperMetadataResult::RefusedNotASpreadsheet { .. }
1792 ),
1793 "{:?}",
1794 outcome.result
1795 );
1796 let text = describe(&outcome);
1797 assert!(text.contains("is not a Google Sheet"), "{text}");
1798 assert!(
1799 text.contains("drive sheets set-developer-metadata"),
1800 "{text}"
1801 );
1802 }
1803
1804 #[tokio::test]
1805 async fn shortcut_is_refused_with_its_own_message_not_the_generic_one() {
1806 let server = wiremock::MockServer::start().await;
1807 let (drive, sheets) = clients(&server).await;
1808 mount_file(
1809 "sheet-1",
1810 "application/vnd.google-apps.shortcut",
1811 &["folder-1"],
1812 )
1813 .mount(&server)
1814 .await;
1815 let outcome = developer_metadata(
1816 &drive,
1817 &sheets,
1818 &delete_opts(false),
1819 &[allow_rule("folder-1")],
1820 )
1821 .await;
1822 assert!(
1823 matches!(outcome.result, DeveloperMetadataResult::RefusedShortcut),
1824 "{:?}",
1825 outcome.result
1826 );
1827 let text = describe(&outcome);
1828 assert!(text.contains("is a shortcut"), "{text}");
1829 assert!(
1830 text.contains("drive sheets delete-developer-metadata"),
1831 "{text}"
1832 );
1833 assert!(!text.contains("is not a Google Sheet"), "{text}");
1834 }
1835
1836 #[tokio::test]
1837 async fn a_sheet_with_no_visible_parents_is_refused_distinctly_from_a_blocked_one() {
1838 let server = wiremock::MockServer::start().await;
1839 let (drive, sheets) = clients(&server).await;
1840 mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
1842 .mount(&server)
1843 .await;
1844 let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &[]).await;
1845 assert!(
1846 matches!(
1847 outcome.result,
1848 DeveloperMetadataResult::RefusedNoVisibleParents
1849 ),
1850 "{:?}",
1851 outcome.result
1852 );
1853 let text = describe(&outcome);
1854 assert!(text.contains("no parent folder visible"), "{text}");
1855 }
1856
1857 #[tokio::test]
1858 async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
1859 let server = wiremock::MockServer::start().await;
1860 let (drive, sheets) = clients(&server).await;
1861 mount_file(
1862 "sheet-1",
1863 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1864 &["folder-1"],
1865 )
1866 .mount(&server)
1867 .await;
1868 wiremock::Mock::given(wiremock::matchers::method("GET"))
1869 .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
1870 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1871 .mount(&server)
1872 .await;
1873 let outcome =
1874 developer_metadata(&drive, &sheets, &set_opts(false), &[allow_rule("folder-1")]).await;
1875 assert!(
1876 matches!(outcome.result, DeveloperMetadataResult::Failed { .. }),
1877 "{:?}",
1878 outcome.result
1879 );
1880 }
1881
1882 #[tokio::test]
1885 async fn a_blocked_by_rule_names_the_deciding_folder_in_the_message() {
1886 let server = wiremock::MockServer::start().await;
1887 let (drive, sheets) = clients(&server).await;
1888 mount_file(
1889 "sheet-1",
1890 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1891 &["folder-1"],
1892 )
1893 .mount(&server)
1894 .await;
1895 mount_folder("folder-1").mount(&server).await;
1896 let deny_by_rule = FolderPermissionRule {
1897 folder_id: Some("folder-1".to_string()),
1898 file_id: None,
1899 recursive: true,
1900 allow: HashSet::default(),
1901 deny: std::iter::once(DriveOperation::SheetsStructure).collect(),
1902 require_lease: true,
1903 };
1904 let outcome =
1905 developer_metadata(&drive, &sheets, &delete_opts(false), &[deny_by_rule]).await;
1906 assert!(
1907 matches!(
1908 outcome.result,
1909 DeveloperMetadataResult::Blocked {
1910 decided_by: Some(_)
1911 }
1912 ),
1913 "{:?}",
1914 outcome.result
1915 );
1916 let text = describe(&outcome);
1917 assert!(
1918 text.contains("refused by rule on folder folder-1"),
1919 "{text}"
1920 );
1921 }
1922
1923 #[tokio::test]
1926 async fn set_with_an_unknown_sheet_is_refused_via_the_full_flow() {
1927 let server = wiremock::MockServer::start().await;
1928 let (drive, sheets, rules) = setup(&server).await;
1929 let mut o = set_opts(false);
1932 o.verb = DeveloperMetadataVerb::Set {
1933 key: "owner".to_string(),
1934 value: "team-a".to_string(),
1935 sheet: Some("Nope".to_string()),
1936 dimension: None,
1937 start: None,
1938 end: None,
1939 };
1940 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
1941 assert!(
1942 matches!(
1943 outcome.result,
1944 DeveloperMetadataResult::RefusedSheetNotFound { .. }
1945 ),
1946 "{:?}",
1947 outcome.result
1948 );
1949 let text = describe(&outcome);
1950 assert!(text.contains("no sheet titled 'Nope'"), "{text}");
1951 }
1952
1953 #[tokio::test]
1954 async fn set_with_a_dimension_but_no_span_is_refused_via_the_full_flow() {
1955 let server = wiremock::MockServer::start().await;
1956 let (drive, sheets, rules) = setup(&server).await;
1957 let mut o = set_opts(false);
1958 o.verb = DeveloperMetadataVerb::Set {
1959 key: "owner".to_string(),
1960 value: "team-a".to_string(),
1961 sheet: Some("Q1".to_string()),
1962 dimension: Some(Dimension::Rows),
1963 start: None,
1964 end: None,
1965 };
1966 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
1967 assert!(
1968 matches!(
1969 outcome.result,
1970 DeveloperMetadataResult::RefusedInvalidLocation { .. }
1971 ),
1972 "{:?}",
1973 outcome.result
1974 );
1975 let text = describe(&outcome);
1976 assert!(text.starts_with("Refused:"), "{text}");
1977 }
1978
1979 #[tokio::test]
1982 async fn a_failed_search_is_reported_as_failed() {
1983 let server = wiremock::MockServer::start().await;
1984 let (drive, sheets, rules) = setup(&server).await;
1985 wiremock::Mock::given(wiremock::matchers::method("POST"))
1986 .and(wiremock::matchers::path(
1987 "/v4/spreadsheets/sheet-1/developerMetadata:search",
1988 ))
1989 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1990 .mount(&server)
1991 .await;
1992 let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
1993 assert!(
1994 matches!(outcome.result, DeveloperMetadataResult::Failed { .. }),
1995 "{:?}",
1996 outcome.result
1997 );
1998 let text = describe(&outcome);
1999 assert!(text.starts_with("Failed:"), "{text}");
2000 }
2001
2002 #[tokio::test]
2003 async fn a_failed_batch_update_is_reported_as_failed() {
2004 let server = wiremock::MockServer::start().await;
2005 let (drive, sheets, rules) = setup(&server).await;
2006 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2007 .mount(&server)
2008 .await;
2009 wiremock::Mock::given(wiremock::matchers::method("POST"))
2010 .and(wiremock::matchers::path(
2011 "/v4/spreadsheets/sheet-1:batchUpdate",
2012 ))
2013 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2014 .mount(&server)
2015 .await;
2016 let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
2017 assert!(
2018 matches!(outcome.result, DeveloperMetadataResult::Failed { .. }),
2019 "{:?}",
2020 outcome.result
2021 );
2022 }
2023
2024 #[tokio::test]
2025 async fn set_updates_every_matching_entry_when_one_exists() {
2026 let server = wiremock::MockServer::start().await;
2027 let (drive, sheets, rules) = setup(&server).await;
2028 mount_search(serde_json::json!({"matchedDeveloperMetadata": [
2029 {"developerMetadata": {
2030 "metadataId": 42, "metadataKey": "owner", "metadataValue": "team-b",
2031 "location": {"spreadsheet": true}, "visibility": "DOCUMENT",
2032 }},
2033 ]}))
2034 .mount(&server)
2035 .await;
2036 mount_batch_update(serde_json::json!({"replies": [{}]}))
2037 .mount(&server)
2038 .await;
2039
2040 let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
2041 match &outcome.result {
2042 DeveloperMetadataResult::Updated {
2043 new_value,
2044 previous,
2045 } => {
2046 assert_eq!(new_value, "team-a");
2047 assert_eq!(previous.len(), 1);
2048 }
2049 other => panic!("expected Updated, got {other:?}"),
2050 }
2051 let text = describe(&outcome);
2052 assert!(text.contains("Updated 1 entry to \"team-a\""), "{text}");
2053 assert!(text.contains(r#"id 42: "owner"="team-a""#), "{text}");
2056 assert!(!text.contains(r#"id 42: "owner"="team-b""#), "{text}");
2057 }
2058
2059 #[tokio::test]
2062 async fn refuses_without_a_lease_when_the_rule_requires_one() {
2063 let server = wiremock::MockServer::start().await;
2064 let (drive, sheets, rules) = setup(&server).await;
2065 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2066 .mount(&server)
2067 .await;
2068 let mut o = set_opts(false);
2071 o.lease_token = None;
2072 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2073 assert_eq!(outcome.result, DeveloperMetadataResult::RefusedNoLease);
2074 let text = describe(&outcome);
2075 assert!(text.contains("lease"), "{text}");
2076 }
2077
2078 #[tokio::test]
2079 async fn refuses_an_unknown_lease_token() {
2080 let server = wiremock::MockServer::start().await;
2081 let (drive, sheets, rules) = setup(&server).await;
2082 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2083 .mount(&server)
2084 .await;
2085 let mut o = set_opts(false);
2086 o.lease_token = Some("bogus-token".to_string());
2087 o.ledger_path = tempfile::tempdir()
2089 .unwrap()
2090 .keep()
2091 .join("lease-ledger.jsonl");
2092 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2093 assert_eq!(outcome.result, DeveloperMetadataResult::RefusedLeaseExpired);
2094 }
2095
2096 #[tokio::test]
2097 async fn refuses_a_lease_bound_to_a_different_file() {
2098 let server = wiremock::MockServer::start().await;
2099 let (drive, sheets, rules) = setup(&server).await;
2100 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2101 .mount(&server)
2102 .await;
2103 let ledger_path = tempfile::tempdir()
2104 .unwrap()
2105 .keep()
2106 .join("lease-ledger.jsonl");
2107 let token = seed_lease(&ledger_path, "other-sheet", "1");
2108 let mut o = set_opts(false);
2109 o.lease_token = Some(token);
2110 o.ledger_path = ledger_path;
2111 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2112 assert_eq!(
2113 outcome.result,
2114 DeveloperMetadataResult::RefusedLeaseWrongFile
2115 );
2116 }
2117
2118 #[tokio::test]
2119 async fn refuses_a_stale_lease() {
2120 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 ledger_path = tempfile::tempdir()
2128 .unwrap()
2129 .keep()
2130 .join("lease-ledger.jsonl");
2131 let token = seed_lease(&ledger_path, "sheet-1", "0");
2132 let mut o = set_opts(false);
2133 o.lease_token = Some(token);
2134 o.ledger_path = ledger_path;
2135 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2136 assert_eq!(outcome.result, DeveloperMetadataResult::RefusedLeaseStale);
2137 }
2138
2139 #[test]
2142 fn write_jsonl_serializes_the_outcome_as_one_json_line() {
2143 let outcome = DeveloperMetadataOutcome {
2144 spreadsheet_id: "sheet-1".to_string(),
2145 file_name: Some("Budget".to_string()),
2146 resolved_folder_id: None,
2147 verb: DeveloperMetadataVerb::Set {
2148 key: "owner".to_string(),
2149 value: "team-a".to_string(),
2150 sheet: None,
2151 dimension: None,
2152 start: None,
2153 end: None,
2154 },
2155 result: DeveloperMetadataResult::Created {
2156 key: "owner".to_string(),
2157 value: "team-a".to_string(),
2158 location: "the whole spreadsheet".to_string(),
2159 },
2160 };
2161 let mut buf = Vec::new();
2162 outcome.write_jsonl(&mut buf).unwrap();
2163 let text = String::from_utf8(buf).unwrap();
2164 assert!(text.contains("\"status\":\"created\""), "{text}");
2165 assert!(text.contains("\"spreadsheet_id\":\"sheet-1\""), "{text}");
2166 }
2167
2168 #[tokio::test]
2171 async fn describe_of_an_unknown_lease_token_names_it_a_lease_refusal() {
2172 let server = wiremock::MockServer::start().await;
2173 let (drive, sheets, rules) = setup(&server).await;
2174 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2175 .mount(&server)
2176 .await;
2177 let mut o = set_opts(false);
2178 o.lease_token = Some("bogus-token".to_string());
2179 o.ledger_path = tempfile::tempdir()
2180 .unwrap()
2181 .keep()
2182 .join("lease-ledger.jsonl");
2183 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2184 let text = describe(&outcome);
2185 assert!(text.contains("lease"), "{text}");
2186 }
2187
2188 #[tokio::test]
2189 async fn describe_of_a_lease_bound_to_a_different_file_names_it_a_lease_refusal() {
2190 let server = wiremock::MockServer::start().await;
2191 let (drive, sheets, rules) = setup(&server).await;
2192 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2193 .mount(&server)
2194 .await;
2195 let ledger_path = tempfile::tempdir()
2196 .unwrap()
2197 .keep()
2198 .join("lease-ledger.jsonl");
2199 let token = seed_lease(&ledger_path, "other-sheet", "1");
2200 let mut o = set_opts(false);
2201 o.lease_token = Some(token);
2202 o.ledger_path = ledger_path;
2203 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2204 let text = describe(&outcome);
2205 assert!(text.contains("lease"), "{text}");
2206 }
2207
2208 #[tokio::test]
2209 async fn describe_of_a_stale_lease_names_it_a_lease_refusal() {
2210 let server = wiremock::MockServer::start().await;
2211 let (drive, sheets, rules) = setup(&server).await;
2212 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2213 .mount(&server)
2214 .await;
2215 let ledger_path = tempfile::tempdir()
2216 .unwrap()
2217 .keep()
2218 .join("lease-ledger.jsonl");
2219 let token = seed_lease(&ledger_path, "sheet-1", "0");
2220 let mut o = set_opts(false);
2221 o.lease_token = Some(token);
2222 o.ledger_path = ledger_path;
2223 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2224 let text = describe(&outcome);
2225 assert!(text.contains("lease"), "{text}");
2226 }
2227
2228 #[tokio::test]
2231 async fn a_lock_acquisition_failure_is_reported_as_failed() {
2232 let server = wiremock::MockServer::start().await;
2238 let (drive, sheets, rules) = setup(&server).await;
2239 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2240 .mount(&server)
2241 .await;
2242 let o = set_opts(false);
2243 let mut lock_path = o.ledger_path.clone().into_os_string();
2244 lock_path.push(".lock");
2245 std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
2246
2247 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2248 assert!(
2249 matches!(outcome.result, DeveloperMetadataResult::Failed { .. }),
2250 "{:?}",
2251 outcome.result
2252 );
2253 }
2254
2255 #[tokio::test]
2258 async fn a_failed_spreadsheet_refetch_after_the_gate_is_reported_as_failed() {
2259 let server = wiremock::MockServer::start().await;
2260 let (drive, sheets) = clients(&server).await;
2261 mount_file(
2262 "sheet-1",
2263 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2264 &["folder-1"],
2265 )
2266 .mount(&server)
2267 .await;
2268 mount_folder("folder-1").mount(&server).await;
2269 wiremock::Mock::given(wiremock::matchers::method("GET"))
2270 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2271 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2272 .mount(&server)
2273 .await;
2274 let outcome =
2275 developer_metadata(&drive, &sheets, &set_opts(false), &[allow_rule("folder-1")]).await;
2276 assert!(
2277 matches!(outcome.result, DeveloperMetadataResult::Failed { .. }),
2278 "{:?}",
2279 outcome.result
2280 );
2281 }
2282
2283 #[tokio::test]
2286 async fn set_at_a_dimension_location_describes_the_row_span() {
2287 let server = wiremock::MockServer::start().await;
2288 let (drive, sheets, rules) = setup(&server).await;
2289 mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2290 .mount(&server)
2291 .await;
2292 let mut o = set_opts(true);
2293 o.verb = DeveloperMetadataVerb::Set {
2294 key: "owner".to_string(),
2295 value: "team-a".to_string(),
2296 sheet: Some("Q1".to_string()),
2297 dimension: Some(Dimension::Rows),
2298 start: Some(2),
2299 end: Some(2),
2300 };
2301 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2302 let text = describe(&outcome);
2303 assert!(text.contains("row 2 of sheet 'Q1'"), "{text}");
2304 }
2305
2306 #[tokio::test]
2307 async fn set_at_a_multi_row_dimension_location_is_refused() {
2308 let server = wiremock::MockServer::start().await;
2312 let (drive, sheets, rules) = setup(&server).await;
2313 let mut o = set_opts(true);
2314 o.verb = DeveloperMetadataVerb::Set {
2315 key: "source".to_string(),
2316 value: "import".to_string(),
2317 sheet: Some("Q1".to_string()),
2318 dimension: Some(Dimension::Rows),
2319 start: Some(2),
2320 end: Some(7),
2321 };
2322 let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2323 match outcome.result {
2324 DeveloperMetadataResult::RefusedInvalidLocation { detail } => {
2325 assert!(detail.contains("single row or column"), "{detail}");
2326 }
2327 other => panic!("expected RefusedInvalidLocation, got {other:?}"),
2328 }
2329 }
2330
2331 #[tokio::test]
2332 async fn set_with_an_unknown_sheet_in_an_empty_workbook_lists_no_available_sheets() {
2333 let server = wiremock::MockServer::start().await;
2334 let (drive, sheets) = clients(&server).await;
2335 mount_file(
2336 "sheet-1",
2337 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2338 &["folder-1"],
2339 )
2340 .mount(&server)
2341 .await;
2342 mount_folder("folder-1").mount(&server).await;
2343 wiremock::Mock::given(wiremock::matchers::method("GET"))
2344 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2345 .respond_with(
2346 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2347 "spreadsheetId": "sheet-1",
2348 "properties": {"title": "Budget"},
2349 "sheets": [],
2350 })),
2351 )
2352 .mount(&server)
2353 .await;
2354
2355 let mut o = set_opts(false);
2356 o.verb = DeveloperMetadataVerb::Set {
2357 key: "owner".to_string(),
2358 value: "team-a".to_string(),
2359 sheet: Some("Nope".to_string()),
2360 dimension: None,
2361 start: None,
2362 end: None,
2363 };
2364 let outcome = developer_metadata(&drive, &sheets, &o, &[allow_rule("folder-1")]).await;
2365 let text = describe(&outcome);
2366 assert!(text.contains("Available: none"), "{text}");
2367 }
2368
2369 #[test]
2370 fn sheet_title_or_id_falls_back_to_the_raw_id_when_unknown() {
2371 let workbook = test_workbook();
2372 assert_eq!(sheet_title_or_id(&workbook, 999), "id 999");
2373 }
2374
2375 #[test]
2376 fn describe_location_falls_back_for_an_unrecognized_shape() {
2377 let workbook = test_workbook();
2378 let empty_location = DeveloperMetadataLocation::default();
2379 assert_eq!(
2380 describe_location(&workbook, &empty_location),
2381 "an unknown location"
2382 );
2383 }
2384
2385 #[test]
2386 fn log_status_maps_a_would_change_variant_to_would_change() {
2387 let would_create = DeveloperMetadataResult::WouldCreate {
2392 key: "owner".to_string(),
2393 value: "team-a".to_string(),
2394 location: "the whole spreadsheet".to_string(),
2395 };
2396 assert_eq!(would_create.log_status(), "would-change");
2397 }
2398
2399 fn created(location: &str) -> DeveloperMetadataResult {
2400 DeveloperMetadataResult::Created {
2401 key: "owner".to_string(),
2402 value: "team-a".to_string(),
2403 location: location.to_string(),
2404 }
2405 }
2406
2407 fn entry_42() -> DeveloperMetadataEntry {
2408 DeveloperMetadataEntry {
2409 metadata_id: 42,
2410 key: "owner".to_string(),
2411 value: "team-b".to_string(),
2412 location: "the whole spreadsheet".to_string(),
2413 }
2414 }
2415
2416 fn updated() -> DeveloperMetadataResult {
2417 DeveloperMetadataResult::Updated {
2418 new_value: "team-a".to_string(),
2419 previous: vec![entry_42()],
2420 }
2421 }
2422
2423 fn deleted() -> DeveloperMetadataResult {
2424 DeveloperMetadataResult::Deleted {
2425 entries: vec![entry_42()],
2426 }
2427 }
2428
2429 fn unreadable(applied: DeveloperMetadataResult) -> DeveloperMetadataResult {
2430 DeveloperMetadataResult::AppliedReplyUnreadable {
2431 summary: changed_summary(&applied).unwrap(),
2432 applied: Box::new(applied),
2433 detail: "bad reply".to_string(),
2434 }
2435 }
2436
2437 #[test]
2438 fn log_status_names_every_result_variant_distinctly() {
2439 let statuses = [
2440 created("").log_status(),
2441 unreadable(created("")).log_status(),
2442 DeveloperMetadataResult::Failed {
2443 detail: String::new(),
2444 }
2445 .log_status(),
2446 ];
2447 assert_eq!(statuses, ["changed", "applied-reply-unreadable", "failed"]);
2448 }
2449
2450 fn describe_of(verb: DeveloperMetadataVerb, result: DeveloperMetadataResult) -> Vec<String> {
2451 describe_lines(&DeveloperMetadataOutcome {
2452 spreadsheet_id: "sheet-1".to_string(),
2453 file_name: Some("Budget".to_string()),
2454 resolved_folder_id: None,
2455 verb,
2456 result,
2457 })
2458 }
2459
2460 #[test]
2464 fn describe_lines_keeps_the_plan_time_detail_for_an_unreadable_reply() {
2465 assert_eq!(
2466 describe_of(set_opts(false).verb, unreadable(created("sheet 'Q1'"))),
2467 vec![
2468 "Applied, but the reply could not be read: create key=\"owner\" \
2469 value=\"team-a\" at sheet 'Q1' in 'Budget' — do not retry; check the \
2470 spreadsheet first (bad reply)"
2471 .to_string()
2472 ]
2473 );
2474 assert_eq!(
2475 describe_of(set_opts(false).verb, unreadable(updated())),
2476 vec![
2477 "Applied, but the reply could not be read: update 1 entry to \
2478 value=\"team-a\" in 'Budget' — do not retry; check the spreadsheet first \
2479 (bad reply)"
2480 .to_string(),
2481 " id 42: \"owner\"=\"team-a\" at the whole spreadsheet".to_string(),
2482 ]
2483 );
2484 assert_eq!(
2485 describe_of(delete_opts(false).verb, unreadable(deleted())),
2486 vec![
2487 "Applied, but the reply could not be read: delete 1 entry in 'Budget' — \
2488 do not retry; check the spreadsheet first (bad reply)"
2489 .to_string(),
2490 " id 42: \"owner\"=\"team-b\" at the whole spreadsheet".to_string(),
2491 ]
2492 );
2493 }
2494
2495 #[test]
2498 fn describe_lines_prints_a_bare_line_when_the_wrapped_result_carries_no_detail() {
2499 let wrapped = DeveloperMetadataResult::AppliedReplyUnreadable {
2500 summary: "create key=\"owner\"".to_string(),
2501 applied: Box::new(DeveloperMetadataResult::Failed {
2502 detail: String::new(),
2503 }),
2504 detail: "bad reply".to_string(),
2505 };
2506 assert_eq!(
2507 describe_of(set_opts(false).verb, wrapped),
2508 vec![
2509 "Applied, but the reply could not be read: create key=\"owner\" in 'Budget' \
2510 — do not retry; check the spreadsheet first (bad reply)"
2511 .to_string()
2512 ]
2513 );
2514 }
2515
2516 #[test]
2517 fn mutation_record_logs_the_same_fields_for_an_unreadable_reply_as_for_changed() {
2518 let opts = set_opts(false);
2519 let record = |result| {
2520 let out = DeveloperMetadataOutcome {
2521 spreadsheet_id: "sheet-1".to_string(),
2522 file_name: Some("Budget".to_string()),
2523 resolved_folder_id: None,
2524 verb: opts.verb.clone(),
2525 result,
2526 };
2527 mutation_record(&out, &opts, Duration::ZERO)
2528 };
2529 let changed = record(updated());
2530 let unreadable = record(unreadable(updated()));
2531 assert_eq!(unreadable.fields_changed, changed.fields_changed);
2532 assert_eq!(
2533 unreadable.fields_changed.as_deref(),
2534 Some("update 1 entry to value=\"team-a\"")
2535 );
2536 assert_eq!(unreadable.status, "applied-reply-unreadable");
2537 assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
2538 }
2539
2540 #[tokio::test]
2543 async fn search_rejects_an_unknown_sheet_when_the_workbook_has_none() {
2544 let server = wiremock::MockServer::start().await;
2545 let (_drive, sheets) = clients(&server).await;
2546 let api = SheetsApi::new(&sheets);
2547 let workbook: Spreadsheet = serde_json::from_value(serde_json::json!({
2548 "spreadsheetId": "sheet-1",
2549 "properties": {"title": "Budget"},
2550 "sheets": [],
2551 }))
2552 .unwrap();
2553 let err = search(
2554 &api,
2555 "sheet-1",
2556 &workbook,
2557 None,
2558 SearchLocationFilter {
2559 sheet: Some("Nope"),
2560 ..Default::default()
2561 },
2562 )
2563 .await
2564 .unwrap_err();
2565 assert!(err.contains("Available: none"), "{err}");
2566 }
2567
2568 #[tokio::test]
2569 async fn search_rejects_an_invalid_location_combination() {
2570 let server = wiremock::MockServer::start().await;
2571 let (_drive, sheets) = clients(&server).await;
2572 let api = SheetsApi::new(&sheets);
2573 let workbook = test_workbook();
2574 let err = search(
2575 &api,
2576 "sheet-1",
2577 &workbook,
2578 None,
2579 SearchLocationFilter {
2580 dimension: Some(Dimension::Rows),
2581 ..Default::default()
2582 },
2583 )
2584 .await
2585 .unwrap_err();
2586 assert!(
2587 err.contains("--dimension/--start/--end need --sheet too"),
2588 "{err}"
2589 );
2590 }
2591
2592 #[test]
2593 fn describe_location_renders_a_single_row_without_a_span() {
2594 let workbook = test_workbook();
2595 let location = DeveloperMetadataLocation::dimension(DimensionRange {
2596 sheet_id: 0,
2597 dimension: Dimension::Columns,
2598 start_index: 2,
2599 end_index: 3,
2600 });
2601 assert_eq!(
2602 describe_location(&workbook, &location),
2603 "column 3 of sheet 'Q1'"
2604 );
2605 }
2606
2607 #[test]
2608 fn describe_location_falls_back_to_a_span_for_a_wider_range() {
2609 let workbook = test_workbook();
2610 let location = DeveloperMetadataLocation::dimension(DimensionRange {
2611 sheet_id: 0,
2612 dimension: Dimension::Rows,
2613 start_index: 1,
2614 end_index: 5,
2615 });
2616 assert_eq!(
2617 describe_location(&workbook, &location),
2618 "row(s) 2-5 of sheet 'Q1'"
2619 );
2620 }
2621
2622 #[test]
2623 fn resolve_location_still_rejects_a_start_below_one() {
2624 let workbook = test_workbook();
2625 let result = resolve_location(
2626 &workbook,
2627 Some("Q1"),
2628 Some(Dimension::Rows),
2629 Some(0),
2630 Some(0),
2631 )
2632 .unwrap_err();
2633 match result {
2634 DeveloperMetadataResult::RefusedInvalidLocation { detail } => {
2635 assert!(detail.contains("--start must be at least 1"), "{detail}");
2636 }
2637 other => panic!("expected RefusedInvalidLocation, got {other:?}"), }
2639 }
2640
2641 #[tokio::test]
2642 async fn search_rejects_a_multi_row_span_before_calling_the_api() {
2643 let server = wiremock::MockServer::start().await;
2647 let (_drive, sheets) = clients(&server).await;
2648 let api = SheetsApi::new(&sheets);
2649 let workbook = test_workbook();
2650 let err = search(
2651 &api,
2652 "sheet-1",
2653 &workbook,
2654 None,
2655 SearchLocationFilter {
2656 sheet: Some("Q1"),
2657 dimension: Some(Dimension::Rows),
2658 start: Some(2),
2659 end: Some(7),
2660 },
2661 )
2662 .await
2663 .unwrap_err();
2664 assert!(err.contains("single row or column"), "{err}");
2665 assert!(server.received_requests().await.unwrap().is_empty());
2666 }
2667}