1use std::path::PathBuf;
31use std::time::{Duration, Instant};
32
33use serde::Serialize;
34
35use super::table::{self, TableEdit, TableError, TablePreview, TableVerb};
36use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
37use crate::drive::client::DriveClient;
38use crate::drive::docs::anchor::{self, AnchorError, EditPreview, ListPreview, Side};
39use crate::drive::docs::api::{is_stale_revision, DocsApi, SuggestionsViewMode};
40use crate::drive::docs::client::DocsClient;
41use crate::drive::docs::write_types::{BulletPreset, DocsRequest};
42use crate::drive::files_api::FilesApi;
43use crate::drive::folder_ancestry;
44use crate::drive::lease::check::{
45 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
46 LeasedWrite,
47};
48use crate::drive::types::{GOOGLE_DOC_MIME_TYPE, GOOGLE_SHORTCUT_MIME_TYPE};
49use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
50use crate::request_log::{self, DriveMutationOutcome};
51
52#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum WritePayload {
62 Table(TableEdit),
64 Replace {
66 search: String,
68 replace: String,
70 match_case: bool,
72 },
73 Append {
75 text: String,
77 },
78 Insert {
80 anchor: String,
82 side: Side,
84 text: String,
86 match_case: bool,
88 selection: anchor::SegmentSelection,
90 },
91 Format {
93 from: String,
95 to: Option<String>,
97 match_case: bool,
99 style: super::style::StylePatch,
101 },
102 NamedRange {
104 scope: super::named_range::Scope,
106 mutation: super::named_range::Mutation,
108 },
109 Delete {
111 from: String,
113 to: Option<String>,
115 match_case: bool,
117 selection: anchor::SegmentSelection,
119 },
120 List {
122 from: String,
124 to: Option<String>,
126 preset: Option<BulletPreset>,
128 match_case: bool,
130 },
131}
132
133impl WritePayload {
134 #[must_use]
136 pub const fn verb(&self) -> WriteVerb {
137 match self {
138 Self::Replace { .. } => WriteVerb::Replace,
139 Self::Append { .. } => WriteVerb::Append,
140 Self::Insert { .. } => WriteVerb::Insert,
141 Self::Delete { .. } => WriteVerb::Delete,
142 Self::List {
143 preset: Some(_), ..
144 } => WriteVerb::CreateBullets,
145 Self::List { preset: None, .. } => WriteVerb::DeleteBullets,
146 Self::Format {
147 style: super::style::StylePatch::Text(_),
148 ..
149 } => WriteVerb::TextStyle,
150 Self::Format { .. } => WriteVerb::ParagraphStyle,
151 Self::Table(edit) => WriteVerb::Table(edit.verb()),
152 Self::NamedRange { mutation, .. } => match mutation {
153 super::named_range::Mutation::Create { .. } => WriteVerb::CreateNamedRange,
154 super::named_range::Mutation::Delete { .. } => WriteVerb::DeleteNamedRange,
155 super::named_range::Mutation::Replace { text, .. } if text.is_empty() => {
156 WriteVerb::DeleteNamedRangeContent
157 }
158 super::named_range::Mutation::Replace { .. } => WriteVerb::ReplaceNamedRangeContent,
159 },
160 }
161 }
162
163 #[must_use]
167 pub const fn gate_operation(&self) -> DriveOperation {
168 match self {
169 Self::Delete { .. } => DriveOperation::DocsDelete,
170 Self::Format { .. } => DriveOperation::DocsFormat,
171 Self::Table(edit) => edit.verb().gate_operation(),
172 Self::NamedRange { mutation, .. } => match mutation {
173 super::named_range::Mutation::Create { .. }
174 | super::named_range::Mutation::Delete { .. } => DriveOperation::DocsStructure,
175 super::named_range::Mutation::Replace { text, .. } if text.is_empty() => {
176 DriveOperation::DocsDelete
177 }
178 super::named_range::Mutation::Replace { .. } => DriveOperation::DocsWrite,
179 },
180 _ => DriveOperation::DocsWrite,
181 }
182 }
183
184 const fn index_addressed(&self) -> bool {
185 matches!(
186 self,
187 Self::Insert { .. }
188 | Self::Delete { .. }
189 | Self::List { .. }
190 | Self::Format { .. }
191 | Self::Table(_)
192 | Self::NamedRange { .. }
193 )
194 }
195
196 fn validate(&self) -> Result<(), String> {
202 if let Self::Insert { selection, .. } | Self::Delete { selection, .. } = self {
203 selection
204 .validate()
205 .map_err(|_| "invalid segment selection".to_owned())?;
206 }
207 match self {
208 Self::Table(edit) => edit.validate(),
209 Self::Replace { search, .. } if search.is_empty() => {
210 Err("--search cannot be empty".to_string())
211 }
212 Self::Append { text } if text.is_empty() => {
213 Err("nothing to append: the text is empty".to_string())
214 }
215 Self::Insert { text, .. } if anchor::insertion_counts(text).0 == 0 => Err(
216 "nothing to insert: no text remains after Docs strips unsupported characters"
217 .to_string(),
218 ),
219 Self::Insert { anchor, .. } if anchor.is_empty() || anchor.contains(['\n', '\r']) => {
220 Err("anchor must be nonempty and within one paragraph".to_owned())
221 }
222 Self::Format { style, .. } if style.fields().is_empty() => {
223 Err("at least one style property is required".to_owned())
224 }
225 Self::Delete { from, to, .. }
226 | Self::List { from, to, .. }
227 | Self::Format { from, to, .. }
228 if from.is_empty()
229 || from.contains(['\n', '\r'])
230 || to
231 .as_ref()
232 .is_some_and(|to| to.is_empty() || to.contains(['\n', '\r'])) =>
233 {
234 Err("anchors must be nonempty and within one paragraph".to_owned())
235 }
236 Self::NamedRange { scope, mutation } => mutation.validate(scope),
237 _ => Ok(()),
238 }
239 }
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum WriteVerb {
245 Table(TableVerb),
247 Replace,
249 Append,
251 Insert,
253 Delete,
255 CreateBullets,
257 DeleteBullets,
259 TextStyle,
261 ParagraphStyle,
263 CreateNamedRange,
265 DeleteNamedRange,
267 ReplaceNamedRangeContent,
269 DeleteNamedRangeContent,
271}
272
273impl WriteVerb {
274 const fn log_operation(self) -> &'static str {
280 match self {
281 Self::Replace => "docs-replace",
282 Self::Append => "docs-append",
283 Self::Insert => "docs-insert",
284 Self::Delete => "docs-delete",
285 Self::CreateBullets => "docs-create-bullets",
286 Self::DeleteBullets => "docs-delete-bullets",
287 Self::TextStyle => "docs-text-style",
288 Self::ParagraphStyle => "docs-paragraph-style",
289 Self::Table(verb) => verb.log_operation(),
290 Self::CreateNamedRange => "docs-create-named-range",
291 Self::DeleteNamedRange => "docs-delete-named-range",
292 Self::ReplaceNamedRangeContent | Self::DeleteNamedRangeContent => {
293 "docs-replace-named-range-content"
294 }
295 }
296 }
297
298 const fn label(self) -> &'static str {
300 match self {
301 Self::Replace => "replace",
302 Self::Append => "append",
303 Self::Insert => "insert",
304 Self::Delete => "delete",
305 Self::CreateBullets => "create-bullets",
306 Self::DeleteBullets => "delete-bullets",
307 Self::TextStyle => "text-style",
308 Self::ParagraphStyle => "paragraph-style",
309 Self::Table(verb) => verb.label(),
310 Self::CreateNamedRange => "create-named-range",
311 Self::DeleteNamedRange => "delete-named-range",
312 Self::ReplaceNamedRangeContent | Self::DeleteNamedRangeContent => {
313 "replace-named-range-content"
314 }
315 }
316 }
317
318 const fn gate_operation(self) -> DriveOperation {
321 match self {
322 Self::Table(verb) => verb.gate_operation(),
323 Self::Delete | Self::DeleteNamedRangeContent => DriveOperation::DocsDelete,
324 Self::TextStyle | Self::ParagraphStyle => DriveOperation::DocsFormat,
325 Self::CreateNamedRange | Self::DeleteNamedRange => DriveOperation::DocsStructure,
326 _ => DriveOperation::DocsWrite,
327 }
328 }
329}
330
331#[derive(Debug, Clone)]
333pub struct WriteOptions {
334 pub document_id: String,
336 pub payload: WritePayload,
338 pub dry_run: bool,
340 pub lease_token: Option<String>,
345 pub ledger_path: PathBuf,
349}
350
351#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
353#[serde(tag = "status", rename_all = "kebab-case")]
354pub enum WriteResult {
355 WouldEditTable {
357 edit: TablePreview,
359 },
360 EditedTable {
362 edit: TablePreview,
364 },
365 RefusedTable {
367 error: TableError,
369 },
370 WouldMutateNamedRange {
372 preview: super::named_range::Preview,
374 },
375 MutatedNamedRange {
377 preview: super::named_range::Preview,
379 },
380 RefusedNamedRange {
382 error: super::named_range::Error,
384 },
385 WouldReplace {
387 occurrences: usize,
389 },
390 WouldAppend {
392 #[serde(skip_serializing_if = "Option::is_none")]
395 document_end_index: Option<i64>,
396 chars: usize,
398 bytes: usize,
400 },
401 WouldInsert {
403 edit: EditPreview,
405 },
406 WouldDelete {
408 edit: EditPreview,
410 },
411 Inserted {
413 edit: EditPreview,
415 },
416 Deleted {
418 edit: EditPreview,
420 },
421 WouldFormatList {
423 edit: ListPreview,
425 preset: Option<BulletPreset>,
427 },
428 ListFormatted {
430 edit: ListPreview,
432 preset: Option<BulletPreset>,
434 },
435 WouldFormat {
437 edit: EditPreview,
439 style: super::style::StylePatch,
441 fields: String,
443 },
444 Formatted {
446 edit: EditPreview,
448 style: super::style::StylePatch,
450 fields: String,
452 },
453 RefusedAnchor {
455 error: AnchorError,
457 },
458 RefusedNotADocument {
460 mime_type: String,
462 },
463 RefusedShortcut,
465 RefusedNoVisibleParents,
468 RefusedNoRevisionId,
472 Blocked {
474 decided_by: Option<DecidingRule>,
476 },
477 RefusedNoLease,
480 RefusedLeaseExpired,
483 RefusedLeaseWrongFile,
485 RefusedLeaseStale,
489 Replaced {
491 occurrences_changed: i64,
499 },
500 Appended {
502 chars: usize,
504 bytes: usize,
506 },
507 StaleRevision {
511 required_revision_id: String,
513 detail: String,
515 },
516 Failed {
518 detail: String,
520 },
521}
522
523impl FromLeaseRefusal for WriteResult {
524 fn from_no_lease() -> Self {
525 Self::RefusedNoLease
526 }
527 fn from_lease_expired() -> Self {
528 Self::RefusedLeaseExpired
529 }
530 fn from_lease_wrong_file() -> Self {
531 Self::RefusedLeaseWrongFile
532 }
533 fn from_lease_stale() -> Self {
534 Self::RefusedLeaseStale
535 }
536 fn from_lease_failed(detail: String) -> Self {
537 Self::Failed { detail }
538 }
539}
540
541impl WriteResult {
542 fn log_status(&self) -> &'static str {
544 match self {
545 Self::WouldFormat { .. } => "would-format",
546 Self::Formatted { .. } => "formatted",
547 Self::WouldMutateNamedRange { .. } => "would-mutate-named-range",
548 Self::MutatedNamedRange { .. } => "mutated-named-range",
549 Self::RefusedNamedRange { .. } => "refused-named-range",
550 Self::WouldReplace { .. } => "would-replace",
551 Self::WouldAppend { .. } => "would-append",
552 Self::WouldEditTable { .. } => "would-edit-table",
553 Self::EditedTable { .. } => "edited-table",
554 Self::RefusedTable { .. } => "refused-table",
555 Self::WouldInsert { .. } => "would-insert",
556 Self::WouldDelete { .. } => "would-delete",
557 Self::Inserted { .. } => "inserted",
558 Self::Deleted { .. } => "deleted",
559 Self::WouldFormatList { .. } => "would-format-list",
560 Self::ListFormatted { .. } => "list-formatted",
561 Self::RefusedAnchor { .. } => "refused-anchor",
562 Self::RefusedNotADocument { .. } => "refused-not-a-document",
563 Self::RefusedShortcut => "refused-shortcut",
564 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
565 Self::RefusedNoRevisionId => "refused-no-revision-id",
566 Self::Blocked { .. } => "blocked",
567 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
568 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
569 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
570 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
571 Self::Replaced { .. } => "replaced",
572 Self::Appended { .. } => "appended",
573 Self::StaleRevision { .. } => "stale-revision",
574 Self::Failed { .. } => "failed",
575 }
576 }
577}
578
579#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
581pub struct WriteOutcome {
582 pub document_id: String,
584 #[serde(skip_serializing_if = "Option::is_none")]
586 pub file_name: Option<String>,
587 #[serde(skip_serializing_if = "Option::is_none")]
589 pub resolved_folder_id: Option<String>,
590 #[serde(skip_serializing_if = "Option::is_none")]
592 pub required_revision_id: Option<String>,
593 pub result: WriteResult,
595}
596
597impl JsonlSerialize for WriteOutcome {
598 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
599 write_scalar_jsonl(self, out)
600 }
601}
602
603#[must_use]
615pub fn count_occurrences(haystack: &str, needle: &str, match_case: bool) -> usize {
616 if needle.is_empty() {
617 return 0;
618 }
619 if match_case {
620 return haystack.matches(needle).count();
621 }
622 haystack
625 .to_lowercase()
626 .matches(&needle.to_lowercase())
627 .count()
628}
629
630pub async fn write(
636 drive: &DriveClient,
637 docs: &DocsClient,
638 opts: &WriteOptions,
639 rules: &[FolderPermissionRule],
640) -> WriteOutcome {
641 let started = Instant::now();
642 let outcome = write_inner(drive, docs, opts, rules).await;
643 if !opts.dry_run {
644 record_attempt(&outcome, opts, started.elapsed());
645 }
646 outcome
647}
648
649async fn write_inner(
650 drive: &DriveClient,
651 docs: &DocsClient,
652 opts: &WriteOptions,
653 rules: &[FolderPermissionRule],
654) -> WriteOutcome {
655 let bare = |result| WriteOutcome {
656 document_id: opts.document_id.clone(),
657 file_name: None,
658 resolved_folder_id: None,
659 required_revision_id: None,
660 result,
661 };
662
663 if let Err(detail) = opts.payload.validate() {
665 return bare(WriteResult::Failed { detail });
666 }
667
668 let files_api = FilesApi::new(drive);
669 let target = match files_api.get_metadata(&opts.document_id).await {
670 Ok(target) => target,
671 Err(err) => {
672 return bare(WriteResult::Failed {
673 detail: err.to_string(),
674 })
675 }
676 };
677
678 let with_target = |result| WriteOutcome {
679 document_id: opts.document_id.clone(),
680 file_name: Some(target.name.clone()),
681 resolved_folder_id: None,
682 required_revision_id: None,
683 result,
684 };
685
686 if target.mime_type == GOOGLE_SHORTCUT_MIME_TYPE {
688 return with_target(WriteResult::RefusedShortcut);
689 }
690 if target.mime_type != GOOGLE_DOC_MIME_TYPE {
691 return with_target(WriteResult::RefusedNotADocument {
692 mime_type: target.mime_type.clone(),
693 });
694 }
695
696 let evaluated = match folder_ancestry::resolve_decision_for_file_target(
700 &files_api,
701 &target,
702 opts.payload.gate_operation(),
703 rules,
704 )
705 .await
706 {
707 Ok(evaluated) => evaluated,
708 Err(err) => {
711 return with_target(WriteResult::Failed {
712 detail: err.to_string(),
713 })
714 }
715 };
716
717 if evaluated.source == folder_ancestry::DecisionSource::NoVisibleParents {
720 return with_target(WriteResult::RefusedNoVisibleParents);
721 }
722
723 let folder_ancestry::FileTargetDecision {
724 decision,
725 resolved_folder_id,
726 requires_lease,
727 ..
728 } = evaluated;
729
730 let gated = |result, revision: Option<String>| WriteOutcome {
731 document_id: opts.document_id.clone(),
732 file_name: Some(target.name.clone()),
733 resolved_folder_id: resolved_folder_id.clone(),
734 required_revision_id: revision,
735 result,
736 };
737
738 if decision.verdict == write_gate::Verdict::Deny {
739 return gated(
740 WriteResult::Blocked {
741 decided_by: decision.decided_by,
742 },
743 None,
744 );
745 }
746
747 let api = DocsApi::new(docs);
749 let document = match api
750 .get_document(
751 &opts.document_id,
752 if opts.payload.index_addressed() {
753 SuggestionsViewMode::Inline
754 } else {
755 SuggestionsViewMode::default()
756 },
757 )
758 .await
759 {
760 Ok(document) => document,
761 Err(err) => {
762 return gated(
763 WriteResult::Failed {
764 detail: err.to_string(),
765 },
766 None,
767 )
768 }
769 };
770
771 let Some(revision_id) = document.revision_id.clone() else {
772 return gated(WriteResult::RefusedNoRevisionId, None);
773 };
774
775 let (request, preview) = match &opts.payload {
780 WritePayload::Table(edit) => match table::resolve(&document, edit) {
781 Ok((request, edit)) => (request, WriteResult::WouldEditTable { edit }),
782 Err(error) => return gated(WriteResult::RefusedTable { error }, Some(revision_id)),
783 },
784 WritePayload::NamedRange { scope, mutation } => {
785 match super::named_range::resolve(&document, scope, mutation) {
786 Ok((request, preview)) => (request, WriteResult::WouldMutateNamedRange { preview }),
787 Err(error) => {
788 return gated(WriteResult::RefusedNamedRange { error }, Some(revision_id))
789 }
790 }
791 }
792 WritePayload::Replace {
793 search,
794 replace,
795 match_case,
796 } => {
797 let corpus = document_text(&document);
800 (
801 DocsRequest::replace_all_text(search, replace, *match_case),
802 WriteResult::WouldReplace {
803 occurrences: count_occurrences(&corpus, search, *match_case),
804 },
805 )
806 }
807 WritePayload::Append { text } => (
808 DocsRequest::insert_text_at_end(text),
809 WriteResult::WouldAppend {
810 document_end_index: body_end_index(&document),
811 chars: text.chars().count(),
812 bytes: text.len(),
813 },
814 ),
815 WritePayload::Insert {
816 anchor,
817 side,
818 text,
819 match_case,
820 selection,
821 } => {
822 match anchor::resolve_insert_in(&document, anchor, *side, text, *match_case, selection)
823 {
824 Ok(edit) => (
825 DocsRequest::insert_text_at(text, &edit),
826 WriteResult::WouldInsert { edit },
827 ),
828 Err(error) => {
829 return gated(WriteResult::RefusedAnchor { error }, Some(revision_id))
830 }
831 }
832 }
833 WritePayload::Format {
834 from,
835 to,
836 match_case,
837 style,
838 } => match anchor::resolve_format(
839 &document,
840 from,
841 to.as_deref(),
842 *match_case,
843 matches!(style, super::style::StylePatch::Paragraph(_)),
844 ) {
845 Ok(edit) => (
846 DocsRequest::format_range(&edit, style),
847 WriteResult::WouldFormat {
848 edit,
849 style: style.clone(),
850 fields: style.fields(),
851 },
852 ),
853 Err(error) => return gated(WriteResult::RefusedAnchor { error }, Some(revision_id)),
854 },
855 WritePayload::Delete {
856 from,
857 to,
858 match_case,
859 selection,
860 } => {
861 match anchor::resolve_delete_in(&document, from, to.as_deref(), *match_case, selection)
862 {
863 Ok(edit) => (
864 DocsRequest::delete_range(&edit),
865 WriteResult::WouldDelete { edit },
866 ),
867 Err(error) => {
868 return gated(WriteResult::RefusedAnchor { error }, Some(revision_id))
869 }
870 }
871 }
872 WritePayload::List {
873 from,
874 to,
875 preset,
876 match_case,
877 } => match anchor::resolve_list(
878 &document,
879 from,
880 to.as_deref(),
881 *match_case,
882 preset.is_some(),
883 ) {
884 Ok(edit) => (
885 DocsRequest::list_bullets(&edit, *preset),
886 WriteResult::WouldFormatList {
887 edit,
888 preset: *preset,
889 },
890 ),
891 Err(error) => return gated(WriteResult::RefusedAnchor { error }, Some(revision_id)),
892 },
893 };
894
895 if opts.dry_run {
896 return gated(preview, Some(revision_id));
897 }
898
899 let leased = LeasedWrite {
911 log_prefix: "drive docs write",
912 operation: opts.payload.verb().log_operation(),
913 ledger_path: &opts.ledger_path,
914 file_id: &opts.document_id,
915 };
916 let lease_grant = match gate_optional_leased_write(
917 leased,
918 &files_api,
919 requires_lease,
920 opts.lease_token.as_deref(),
921 )
922 .await
923 {
924 Ok(grant) => grant,
925 Err(err) => return gated(err.into_result(), Some(revision_id)),
926 };
927
928 let result = match conclude_native_leased_write(
930 leased,
931 &lease_grant,
932 &files_api,
933 api.batch_update(&opts.document_id, request, &revision_id)
934 .await,
935 ToString::to_string,
936 )
937 .await
938 {
939 Ok(response) => match &opts.payload {
940 WritePayload::Table(_) => match preview {
941 WriteResult::WouldEditTable { edit } => WriteResult::EditedTable { edit },
942 _ => WriteResult::Failed {
944 detail: "missing resolved table preview".into(),
945 },
946 },
948 WritePayload::NamedRange { .. } => match preview {
949 WriteResult::WouldMutateNamedRange { mut preview } => {
950 if let Some(id) = response
951 .replies
952 .first()
953 .and_then(|reply| reply.create_named_range.as_ref())
954 .and_then(|reply| reply.named_range_id.clone())
955 {
956 preview.named_range_id = Some(id);
957 }
958 WriteResult::MutatedNamedRange { preview }
959 }
960 _ => WriteResult::Failed {
962 detail: "missing named-range preview".into(),
963 },
964 },
966 WritePayload::Replace { .. } => WriteResult::Replaced {
967 occurrences_changed: response.occurrences_changed_for_replace(),
968 },
969 WritePayload::Append { text } => WriteResult::Appended {
970 chars: text.chars().count(),
971 bytes: text.len(),
972 },
973 WritePayload::Insert { .. }
974 | WritePayload::Delete { .. }
975 | WritePayload::List { .. }
976 | WritePayload::Format { .. } => match preview {
977 WriteResult::WouldFormatList { edit, preset } => {
978 WriteResult::ListFormatted { edit, preset }
979 }
980 WriteResult::WouldFormat {
981 edit,
982 style,
983 fields,
984 } => WriteResult::Formatted {
985 edit,
986 style,
987 fields,
988 },
989 WriteResult::WouldInsert { edit } => WriteResult::Inserted { edit },
990 WriteResult::WouldDelete { edit } => WriteResult::Deleted { edit },
991 _ => WriteResult::Failed {
993 detail: "missing resolved edit preview".to_owned(),
994 },
995 },
997 },
998 Err(err) => {
999 let detail = err.to_string();
1003 if is_stale_revision(&err) {
1004 WriteResult::StaleRevision {
1005 required_revision_id: revision_id.clone(),
1006 detail,
1007 }
1008 } else {
1009 WriteResult::Failed { detail }
1010 }
1011 }
1012 };
1013 drop(lease_grant);
1014 gated(result, Some(revision_id))
1015}
1016
1017fn document_text(document: &crate::drive::docs::types::Document) -> String {
1023 document
1024 .resolved_tabs()
1025 .iter()
1026 .filter_map(|tab| tab.body)
1027 .flat_map(crate::drive::docs::structure::flatten)
1028 .map(|element| element.text)
1029 .collect::<Vec<_>>()
1030 .join("\n")
1031}
1032
1033fn body_end_index(document: &crate::drive::docs::types::Document) -> Option<i64> {
1038 document
1039 .resolved_tabs()
1040 .first()
1041 .and_then(|tab| tab.body)
1042 .and_then(|body| body.content.last())
1043 .map(crate::drive::docs::types::StructuralElement::end_index)
1044}
1045
1046fn record_attempt(outcome: &WriteOutcome, opts: &WriteOptions, duration: Duration) {
1057 let error = match &outcome.result {
1058 WriteResult::Failed { detail } | WriteResult::StaleRevision { detail, .. } => {
1062 Some(detail.clone())
1063 }
1064 _ => None,
1065 };
1066 let decided_by = match &outcome.result {
1067 WriteResult::Blocked { decided_by } => decided_by.as_ref(),
1068 _ => None,
1069 };
1070 let decided_by = write_gate::decided_by_log_fields(decided_by);
1071 let occurrences_changed = match &outcome.result {
1072 WriteResult::Replaced {
1073 occurrences_changed,
1074 } => Some(*occurrences_changed),
1075 _ => None,
1076 };
1077 let inserted_chars = match &outcome.result {
1078 WriteResult::Appended { chars, .. } => Some(*chars as i64),
1079 WriteResult::Inserted { edit } => Some(edit.chars as i64),
1080 WriteResult::MutatedNamedRange { preview } => Some(preview.inserted_chars as i64),
1081 _ => None,
1082 };
1083
1084 request_log::record_drive_mutation(DriveMutationOutcome {
1085 operation: opts.payload.verb().log_operation(),
1086 file_id: outcome.document_id.clone(),
1087 file_name: outcome.file_name.clone().unwrap_or_default(),
1088 status: outcome.result.log_status().to_string(),
1089 resolved_folder_id: outcome.resolved_folder_id.clone(),
1090 decided_by_folder_id: decided_by.folder_id,
1091 decided_by_depth: decided_by.depth,
1092 decided_by_file_id: decided_by.file_id,
1093 occurrences_changed,
1094 inserted_chars,
1095 named_range_id: match &outcome.result {
1096 WriteResult::MutatedNamedRange { preview } => preview.named_range_id.clone(),
1097 _ => None,
1098 },
1099 required_revision_id: outcome.required_revision_id.clone(),
1100 error,
1101 duration,
1102 ..Default::default()
1103 });
1104}
1105
1106#[must_use]
1111pub fn describe(outcome: &WriteOutcome, verb: WriteVerb) -> String {
1112 let name = outcome.file_name.as_deref().unwrap_or(&outcome.document_id);
1113 match &outcome.result {
1114 WriteResult::WouldEditTable { edit } | WriteResult::EditedTable { edit } => {
1115 let action = if matches!(outcome.result, WriteResult::WouldEditTable { .. }) {
1116 "Would apply"
1117 } else {
1118 "Applied"
1119 };
1120 let cell = match (edit.row_index, edit.column_index) {
1121 (Some(row), Some(column)) => format!(", reference cell ({row}, {column})"),
1122 _ => String::new(),
1123 };
1124 let direction = match edit.operation {
1125 TableVerb::InsertRow => {
1126 if edit.insert_after {
1127 ", below"
1128 } else {
1129 ", above"
1130 }
1131 }
1132 TableVerb::InsertColumn => {
1133 if edit.insert_after {
1134 ", right"
1135 } else {
1136 ", left"
1137 }
1138 }
1139 _ => "",
1140 };
1141 let newline = if edit.preceding_newline {
1142 "; adds preceding newline"
1143 } else {
1144 ""
1145 };
1146 format!("{action} {} in '{name}': {}x{} -> {}x{}, UTF-16 index {}, tab {}{cell}{direction}{newline}",
1147 edit.operation.label(), edit.rows_before, edit.columns_before, edit.rows_after, edit.columns_after,
1148 edit.location.index, edit.location.tab_id.as_deref().unwrap_or("first"))
1149 }
1150 WriteResult::RefusedTable { error } => {
1151 format!("Refused: unsafe or unresolved table in '{name}': {error:?}")
1152 }
1153 WriteResult::WouldMutateNamedRange { preview }
1154 | WriteResult::MutatedNamedRange { preview } => {
1155 let action = if matches!(&outcome.result, WriteResult::WouldMutateNamedRange { .. }) {
1156 "Would apply"
1157 } else {
1158 "Applied"
1159 };
1160 format!("{action} {} in '{name}': ID {}, {} span(s), removed {} char(s) / {} byte(s), inserted {} char(s) / {} byte(s); {}", verb.label(), preview.named_range_id.as_deref().unwrap_or("pending"), preview.ranges.len(), preview.removed_chars, preview.removed_bytes, preview.inserted_chars, preview.inserted_bytes, preview.describe_spans())
1161 }
1162 WriteResult::RefusedNamedRange { error } => {
1163 format!("Refused: unsafe or unresolved named range in '{name}': {error:?}")
1164 }
1165 WriteResult::WouldReplace { occurrences } => format!(
1166 "Would replace: {occurrences} occurrence(s) in '{name}' \
1167 (counted from the copy just read)"
1168 ),
1169 WriteResult::WouldAppend {
1170 document_end_index,
1171 chars,
1172 bytes,
1173 } => {
1174 let where_ = document_end_index
1175 .map(|index| format!(" (body ends at index {index})"))
1176 .unwrap_or_default();
1177 format!("Would append: {chars} char(s) / {bytes} byte(s) to '{name}'{where_}")
1178 }
1179 WriteResult::WouldFormat {
1180 edit,
1181 style,
1182 fields,
1183 }
1184 | WriteResult::Formatted {
1185 edit,
1186 style,
1187 fields,
1188 } => {
1189 let action = if matches!(outcome.result, WriteResult::WouldFormat { .. }) {
1190 "Would format"
1191 } else {
1192 "Formatted"
1193 };
1194 format!("{action}: '{}' tab {:?} UTF-16 [{}, {}) / {} paragraph(s) / {} char(s) / {} byte(s); fields={fields}; style={style:?}", name, edit.tab_id, edit.start_index, edit.end_index, edit.paragraphs, edit.chars, edit.bytes)
1195 }
1196 WriteResult::WouldInsert { edit }
1197 | WriteResult::Inserted { edit }
1198 | WriteResult::WouldDelete { edit }
1199 | WriteResult::Deleted { edit } => {
1200 let action = match &outcome.result {
1201 WriteResult::WouldInsert { .. } => "Would insert",
1202 WriteResult::Inserted { .. } => "Inserted",
1203 WriteResult::WouldDelete { .. } => "Would delete",
1204 _ => "Deleted",
1205 };
1206 let segment = edit.segment_id.as_ref().map_or_else(String::new, |id| {
1207 let kind = match edit.segment_kind {
1208 Some(anchor::SegmentKind::Header) => "header",
1209 Some(anchor::SegmentKind::Footer) => "footer",
1210 Some(anchor::SegmentKind::Footnote) => "footnote",
1211 None => "segment",
1212 };
1213 format!(", {kind} {id}")
1214 });
1215 format!("{action}: {} char(s) / {} byte(s) in '{name}' at [{}, {}) UTF-16 code units, tab {}{}, {} paragraph(s)",
1216 edit.chars, edit.bytes, edit.start_index, edit.end_index,
1217 edit.tab_id.as_deref().unwrap_or("first"), segment, edit.paragraphs)
1218 }
1219 WriteResult::WouldFormatList { edit, preset }
1220 | WriteResult::ListFormatted { edit, preset } => {
1221 let action = if matches!(outcome.result, WriteResult::WouldFormatList { .. }) {
1222 "Would format list"
1223 } else {
1224 "Formatted list"
1225 };
1226 let preset = preset.map_or_else(|| "remove bullets".to_owned(), |p| format!("{p:?}"));
1227 format!("{action}: {preset} in '{name}', {} paragraph(s), pre-write [{}, {}) UTF-16 code units, tab {}, {} leading tab(s) removed",
1228 edit.paragraphs, edit.start_index, edit.end_index,
1229 edit.tab_id.as_deref().unwrap_or("first"), edit.leading_tabs_removed)
1230 }
1231 WriteResult::RefusedAnchor { error } => {
1232 format!("Refused: unsafe or unresolved anchor in '{name}': {error:?}")
1233 }
1234 WriteResult::RefusedNotADocument { mime_type } => format!(
1235 "Refused: '{name}' is not a Google Doc (mimeType: {mime_type}); \
1236 `drive docs {}` only works on Google Docs",
1237 verb.label()
1238 ),
1239 WriteResult::RefusedShortcut => format!(
1240 "Refused: '{name}' is a shortcut; `drive docs {}` doesn't follow shortcuts — \
1241 resolve the target document's id and use that instead",
1242 verb.label()
1243 ),
1244 WriteResult::RefusedNoVisibleParents => format!(
1245 "Refused: '{name}' has no parent folder visible to this account, so no folder \
1246 rule can apply to it. This is normal for a Doc shared by link or email. \
1247 Grant it by id instead: add {{\"file_id\": \"<document id>\", \"allow\": \
1248 [\"{}\"]}} to write_permissions.rules. (Adding it to a folder in your \
1249 own Drive and granting that folder `{}` also works.)",
1250 verb.gate_operation(),
1251 verb.gate_operation()
1252 ),
1253 WriteResult::RefusedNoRevisionId => format!(
1254 "Refused: '{name}' returned no revision id, which Google sends only to callers \
1255 with edit access — so this write cannot be leased against a known version. \
1256 Request edit access, or check the account in use."
1257 ),
1258 WriteResult::Blocked { decided_by } => match decided_by {
1259 Some(rule) => format!(
1262 "Blocked: '{name}' — refused by rule on {} {}{}",
1263 rule.kind_label(),
1264 rule.id(),
1265 rule.depth_suffix()
1266 ),
1267 None => format!("Blocked: '{name}' — refused by default policy (no matching rule)"),
1268 },
1269 WriteResult::RefusedNoLease => LeaseGateRefusal::NoLease
1270 .describe_line(&outcome.document_id, &format!("'{name}'"))
1271 .unwrap_or_default(),
1272 WriteResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1273 .describe_line(&outcome.document_id, &format!("'{name}'"))
1274 .unwrap_or_default(),
1275 WriteResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1276 .describe_line(&outcome.document_id, &format!("'{name}'"))
1277 .unwrap_or_default(),
1278 WriteResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1279 .describe_line(&outcome.document_id, &format!("'{name}'"))
1280 .unwrap_or_default(),
1281 WriteResult::Replaced {
1282 occurrences_changed,
1283 } => format!("Replaced: {occurrences_changed} occurrence(s) in '{name}'"),
1284 WriteResult::Appended { chars, bytes } => {
1285 format!("Appended: {chars} char(s) / {bytes} byte(s) to '{name}'")
1286 }
1287 WriteResult::StaleRevision {
1288 required_revision_id,
1289 ..
1290 } => format!(
1291 "Refused: '{name}' changed since it was read (revision lease \
1292 {required_revision_id} no longer current) — nothing was written. \
1293 Re-run to apply against the current version."
1294 ),
1295 WriteResult::Failed { detail } => format!("Failed: '{name}': {detail}"),
1296 }
1297}
1298
1299#[cfg(test)]
1300#[allow(clippy::unwrap_used, clippy::expect_used)]
1301mod tests {
1302 use super::*;
1303 use std::collections::HashSet;
1304
1305 use crate::drive::test_support::seed_lease;
1306
1307 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1308 use crate::drive::docs::client::DOCS_API_URL;
1309 use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
1310 use crate::test_support::env::MapEnv;
1311 use crate::utils::secret::Secret;
1312 use wiremock::matchers::{method, path, query_param};
1313 use wiremock::{Mock, MockServer, ResponseTemplate};
1314
1315 fn test_credentials() -> DriveCredentials {
1316 DriveCredentials {
1317 client_id: "client-1".to_string(),
1318 client_secret: Secret::new("secret-1"),
1319 refresh_token: Secret::new("refresh-1"),
1320 scope: DriveGrantedScopes::READONLY,
1321 }
1322 }
1323
1324 async fn clients(server: &MockServer) -> (DriveClient, DocsClient) {
1331 Mock::given(method("POST"))
1332 .and(path("/token"))
1333 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1334 "access_token": "test-token", "expires_in": 3600,
1335 })))
1336 .mount(server)
1337 .await;
1338 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1339 crate::drive::client::test_support::replace_session(
1340 &mut drive,
1341 &test_credentials(),
1342 &format!("{}/token", server.uri()),
1343 );
1344 let env = MapEnv::new().with(DOCS_API_URL, &server.uri());
1345 let docs = DocsClient::from_drive_client_with(&env, &drive).unwrap();
1346 (drive, docs)
1347 }
1348
1349 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> Mock {
1354 let parents: Vec<&str> = parents.to_vec();
1355 Mock::given(method("GET"))
1356 .and(path(format!("/drive/v3/files/{id}")))
1357 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1358 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1359 "version": "1",
1360 })))
1361 }
1362
1363 fn leased_opts_for(document_id: &str) -> (Option<String>, std::path::PathBuf) {
1366 let ledger_path = tempfile::tempdir()
1367 .unwrap()
1368 .keep()
1369 .join("lease-ledger.jsonl");
1370 let token = seed_lease(&ledger_path, document_id, "1");
1371 (Some(token), ledger_path)
1372 }
1373
1374 fn mount_folder(id: &str) -> Mock {
1375 mount_file(id, "application/vnd.google-apps.folder", &[])
1376 }
1377
1378 fn mount_document(revision: Option<&str>, text: &str) -> Mock {
1379 let mut body = serde_json::json!({
1380 "documentId": "doc-1",
1381 "body": {"content": [{
1382 "startIndex": 1, "endIndex": 2 + text.encode_utf16().count() as i64,
1383 "paragraph": {"elements": [{"startIndex": 1, "endIndex": 2 + text.encode_utf16().count() as i64, "textRun": {"content": format!("{text}\n")}}]},
1384 }]},
1385 });
1386 if let Some(revision) = revision {
1387 body["revisionId"] = serde_json::json!(revision);
1388 }
1389 Mock::given(method("GET"))
1390 .and(path("/v1/documents/doc-1"))
1391 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1392 }
1393
1394 fn mount_batch_update(response: serde_json::Value) -> Mock {
1395 Mock::given(method("POST"))
1396 .and(path("/v1/documents/doc-1:batchUpdate"))
1397 .respond_with(ResponseTemplate::new(200).set_body_json(response))
1398 }
1399
1400 fn allow_rule(folder: &str) -> FolderPermissionRule {
1401 FolderPermissionRule {
1402 folder_id: Some(folder.to_string()),
1403 file_id: None,
1404 recursive: true,
1405 allow: std::iter::once(DriveOperation::DocsWrite).collect(),
1406 deny: HashSet::default(),
1407 require_lease: true,
1408 }
1409 }
1410
1411 fn replace_opts(dry_run: bool) -> WriteOptions {
1420 let (lease_token, ledger_path) = leased_opts_for("doc-1");
1421 WriteOptions {
1422 document_id: "doc-1".to_string(),
1423 payload: WritePayload::Replace {
1424 search: "Q3".to_string(),
1425 replace: "Q4".to_string(),
1426 match_case: true,
1427 },
1428 dry_run,
1429 lease_token,
1430 ledger_path,
1431 }
1432 }
1433
1434 fn append_opts(dry_run: bool) -> WriteOptions {
1435 let (lease_token, ledger_path) = leased_opts_for("doc-1");
1436 WriteOptions {
1437 document_id: "doc-1".to_string(),
1438 payload: WritePayload::Append {
1439 text: "hello".to_string(),
1440 },
1441 dry_run,
1442 lease_token,
1443 ledger_path,
1444 }
1445 }
1446
1447 #[test]
1450 fn count_occurrences_counts_non_overlapping_matches() {
1451 assert_eq!(count_occurrences("Q3 and Q3 and Q3", "Q3", true), 3);
1452 assert_eq!(count_occurrences("aaaa", "aa", true), 2);
1453 }
1454
1455 #[test]
1456 fn count_occurrences_honours_case_sensitivity_both_ways() {
1457 assert_eq!(count_occurrences("it It IT", "it", true), 1);
1458 assert_eq!(count_occurrences("it It IT", "it", false), 3);
1459 }
1460
1461 #[test]
1462 fn count_occurrences_of_an_empty_needle_is_zero() {
1463 assert_eq!(count_occurrences("anything", "", true), 0);
1464 assert_eq!(count_occurrences("anything", "", false), 0);
1465 }
1466
1467 #[test]
1468 fn count_occurrences_handles_multibyte_text() {
1469 assert_eq!(count_occurrences("😀 x 😀", "😀", true), 2);
1470 assert_eq!(count_occurrences("ÉCOLE école", "école", false), 2);
1471 }
1472
1473 #[tokio::test]
1476 async fn a_non_document_is_refused_before_the_gate_or_any_docs_call() {
1477 let server = MockServer::start().await;
1478 let (drive, docs) = clients(&server).await;
1479 mount_file("doc-1", GOOGLE_SHEET_MIME_TYPE, &["folder-1"])
1483 .mount(&server)
1484 .await;
1485
1486 let outcome = write(
1487 &drive,
1488 &docs,
1489 &replace_opts(false),
1490 &[allow_rule("folder-1")],
1491 )
1492 .await;
1493 assert!(matches!(
1494 outcome.result,
1495 WriteResult::RefusedNotADocument { .. }
1496 ));
1497 }
1498
1499 #[tokio::test]
1500 async fn a_shortcut_gets_its_own_refusal_not_the_generic_one() {
1501 let server = MockServer::start().await;
1502 let (drive, docs) = clients(&server).await;
1503 mount_file("doc-1", GOOGLE_SHORTCUT_MIME_TYPE, &["folder-1"])
1504 .mount(&server)
1505 .await;
1506
1507 let outcome = write(
1508 &drive,
1509 &docs,
1510 &replace_opts(false),
1511 &[allow_rule("folder-1")],
1512 )
1513 .await;
1514 assert_eq!(outcome.result, WriteResult::RefusedShortcut);
1515 let text = describe(&outcome, WriteVerb::Replace);
1516 assert!(text.contains("is a shortcut"), "{text}");
1517 assert!(!text.contains("not a Google Doc"), "{text}");
1518 }
1519
1520 #[tokio::test]
1521 async fn an_empty_search_is_refused_before_any_network_call() {
1522 let server = MockServer::start().await;
1523 let (drive, docs) = clients(&server).await;
1524 let opts = WriteOptions {
1526 payload: WritePayload::Replace {
1527 search: String::new(),
1528 replace: "x".to_string(),
1529 match_case: true,
1530 },
1531 ..replace_opts(false)
1532 };
1533 let outcome = write(&drive, &docs, &opts, &[]).await;
1534 match outcome.result {
1535 WriteResult::Failed { detail } => assert!(detail.contains("--search"), "{detail}"),
1536 other => panic!("expected Failed, got {other:?}"),
1537 }
1538 }
1539
1540 #[tokio::test]
1545 async fn a_denied_target_is_blocked_with_zero_docs_calls() {
1546 let server = MockServer::start().await;
1547 let (drive, docs) = clients(&server).await;
1548 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1549 .mount(&server)
1550 .await;
1551 mount_folder("folder-1").mount(&server).await;
1552 let outcome = write(&drive, &docs, &replace_opts(false), &[]).await;
1556 assert!(matches!(outcome.result, WriteResult::Blocked { .. }));
1557 }
1558
1559 #[tokio::test]
1560 async fn a_parentless_document_is_refused_distinctly_not_blocked() {
1561 let server = MockServer::start().await;
1562 let (drive, docs) = clients(&server).await;
1563 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &[])
1564 .mount(&server)
1565 .await;
1566
1567 let outcome = write(&drive, &docs, &replace_opts(false), &[]).await;
1568 assert_eq!(outcome.result, WriteResult::RefusedNoVisibleParents);
1569 let text = describe(&outcome, WriteVerb::Replace);
1570 assert!(text.contains("file_id"), "names the actual fix: {text}");
1571 }
1572
1573 #[tokio::test]
1576 async fn a_file_rule_grants_a_parentless_document() {
1577 let server = MockServer::start().await;
1578 let (drive, docs) = clients(&server).await;
1579 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &[])
1580 .mount(&server)
1581 .await;
1582 mount_document(Some("rev-1"), "Q3 report")
1583 .mount(&server)
1584 .await;
1585
1586 let rules = [FolderPermissionRule {
1587 folder_id: None,
1588 file_id: Some("doc-1".to_string()),
1589 recursive: false,
1590 allow: std::iter::once(DriveOperation::DocsWrite).collect(),
1591 deny: HashSet::default(),
1592 require_lease: true,
1593 }];
1594 let outcome = write(&drive, &docs, &replace_opts(true), &rules).await;
1595 assert!(matches!(outcome.result, WriteResult::WouldReplace { .. }));
1596 }
1597
1598 #[tokio::test]
1599 async fn an_ancestor_fetch_failure_produces_failed_not_allow() {
1600 let server = MockServer::start().await;
1601 let (drive, docs) = clients(&server).await;
1602 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1603 .mount(&server)
1604 .await;
1605 Mock::given(method("GET"))
1606 .and(path("/drive/v3/files/folder-1"))
1607 .respond_with(ResponseTemplate::new(500))
1608 .mount(&server)
1609 .await;
1610
1611 let outcome = write(
1612 &drive,
1613 &docs,
1614 &replace_opts(false),
1615 &[allow_rule("folder-1")],
1616 )
1617 .await;
1618 assert!(
1619 matches!(outcome.result, WriteResult::Failed { .. }),
1620 "a chain that could not be resolved must never become an allow: {:?}",
1621 outcome.result
1622 );
1623 }
1624
1625 #[tokio::test]
1628 async fn the_batch_update_presents_the_revision_from_the_get_it_just_made() {
1629 let server = MockServer::start().await;
1630 let (drive, docs) = clients(&server).await;
1631 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1632 .mount(&server)
1633 .await;
1634 mount_folder("folder-1").mount(&server).await;
1635 mount_document(Some("rev-abc"), "Q3 report")
1636 .mount(&server)
1637 .await;
1638 Mock::given(method("POST"))
1639 .and(path("/v1/documents/doc-1:batchUpdate"))
1640 .and(wiremock::matchers::body_partial_json(serde_json::json!({
1641 "writeControl": {"requiredRevisionId": "rev-abc"},
1642 })))
1643 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1644 "documentId": "doc-1",
1645 "replies": [{"replaceAllText": {"occurrencesChanged": 1}}],
1646 })))
1647 .expect(1)
1648 .mount(&server)
1649 .await;
1650
1651 let outcome = write(
1652 &drive,
1653 &docs,
1654 &replace_opts(false),
1655 &[allow_rule("folder-1")],
1656 )
1657 .await;
1658 assert_eq!(
1659 outcome.result,
1660 WriteResult::Replaced {
1661 occurrences_changed: 1
1662 }
1663 );
1664 assert_eq!(outcome.required_revision_id.as_deref(), Some("rev-abc"));
1665 }
1666
1667 #[tokio::test]
1671 async fn a_document_with_no_revision_id_is_refused_rather_than_written_unleased() {
1672 let server = MockServer::start().await;
1673 let (drive, docs) = clients(&server).await;
1674 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1675 .mount(&server)
1676 .await;
1677 mount_folder("folder-1").mount(&server).await;
1678 mount_document(None, "Q3 report").mount(&server).await;
1679
1680 let outcome = write(
1681 &drive,
1682 &docs,
1683 &replace_opts(false),
1684 &[allow_rule("folder-1")],
1685 )
1686 .await;
1687 assert_eq!(outcome.result, WriteResult::RefusedNoRevisionId);
1688 let text = describe(&outcome, WriteVerb::Replace);
1689 assert!(text.contains("edit access"), "{text}");
1690 }
1691
1692 #[tokio::test]
1694 async fn a_stale_revision_400_is_its_own_outcome_not_a_generic_failure() {
1695 let server = MockServer::start().await;
1696 let (drive, docs) = clients(&server).await;
1697 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1698 .mount(&server)
1699 .await;
1700 mount_folder("folder-1").mount(&server).await;
1701 mount_document(Some("rev-old"), "Q3 report")
1702 .mount(&server)
1703 .await;
1704 Mock::given(method("POST"))
1705 .and(path("/v1/documents/doc-1:batchUpdate"))
1706 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
1707 "error": {
1708 "code": 400,
1709 "message": "The required revision ID 'rev-old' does not match the latest revision.",
1710 "status": "INVALID_ARGUMENT",
1711 },
1712 })))
1713 .mount(&server)
1714 .await;
1715
1716 let outcome = write(
1717 &drive,
1718 &docs,
1719 &replace_opts(false),
1720 &[allow_rule("folder-1")],
1721 )
1722 .await;
1723 match &outcome.result {
1724 WriteResult::StaleRevision {
1725 required_revision_id,
1726 ..
1727 } => assert_eq!(required_revision_id, "rev-old"),
1728 other => panic!("expected StaleRevision, got {other:?}"),
1729 }
1730 let text = describe(&outcome, WriteVerb::Replace);
1731 assert!(text.contains("nothing was written"), "{text}");
1732 assert!(text.contains("Re-run"), "{text}");
1733 }
1734
1735 #[tokio::test]
1739 async fn an_unrelated_400_stays_a_generic_failure() {
1740 let server = MockServer::start().await;
1741 let (drive, docs) = clients(&server).await;
1742 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1743 .mount(&server)
1744 .await;
1745 mount_folder("folder-1").mount(&server).await;
1746 mount_document(Some("rev-1"), "Q3 report")
1747 .mount(&server)
1748 .await;
1749 Mock::given(method("POST"))
1750 .and(path("/v1/documents/doc-1:batchUpdate"))
1751 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
1752 "error": {"code": 400, "message": "Invalid requests[0]", "status": "INVALID_ARGUMENT"},
1753 })))
1754 .mount(&server)
1755 .await;
1756
1757 let outcome = write(
1758 &drive,
1759 &docs,
1760 &replace_opts(false),
1761 &[allow_rule("folder-1")],
1762 )
1763 .await;
1764 assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1765 }
1766
1767 #[tokio::test]
1771 async fn dry_run_never_calls_batch_update() {
1772 let server = MockServer::start().await;
1773 let (drive, docs) = clients(&server).await;
1774 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1775 .mount(&server)
1776 .await;
1777 mount_folder("folder-1").mount(&server).await;
1778 mount_document(Some("rev-1"), "Q3 report")
1779 .mount(&server)
1780 .await;
1781 let outcome = write(
1784 &drive,
1785 &docs,
1786 &replace_opts(true),
1787 &[allow_rule("folder-1")],
1788 )
1789 .await;
1790 assert!(matches!(outcome.result, WriteResult::WouldReplace { .. }));
1791 }
1792
1793 #[tokio::test]
1796 async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
1797 let server = MockServer::start().await;
1798 let (drive, docs) = clients(&server).await;
1799 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1800 .expect(2)
1801 .mount(&server)
1802 .await;
1803 mount_folder("folder-1").expect(2).mount(&server).await;
1804
1805 let dry = write(&drive, &docs, &replace_opts(true), &[]).await;
1806 let real = write(&drive, &docs, &replace_opts(false), &[]).await;
1807 assert_eq!(dry.result, real.result);
1808 assert!(matches!(dry.result, WriteResult::Blocked { .. }));
1809 }
1810
1811 #[tokio::test]
1812 async fn dry_run_replace_reports_the_occurrence_count_from_the_snapshot() {
1813 let server = MockServer::start().await;
1814 let (drive, docs) = clients(&server).await;
1815 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1816 .mount(&server)
1817 .await;
1818 mount_folder("folder-1").mount(&server).await;
1819 mount_document(Some("rev-1"), "Q3 then Q3 then Q3")
1820 .mount(&server)
1821 .await;
1822
1823 let outcome = write(
1824 &drive,
1825 &docs,
1826 &replace_opts(true),
1827 &[allow_rule("folder-1")],
1828 )
1829 .await;
1830 assert_eq!(outcome.result, WriteResult::WouldReplace { occurrences: 3 });
1831 let text = describe(&outcome, WriteVerb::Replace);
1832 assert!(text.contains("3 occurrence(s)"), "{text}");
1833 }
1834
1835 #[tokio::test]
1836 async fn dry_run_append_reports_the_end_index_and_the_insert_size() {
1837 let server = MockServer::start().await;
1838 let (drive, docs) = clients(&server).await;
1839 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1840 .mount(&server)
1841 .await;
1842 mount_folder("folder-1").mount(&server).await;
1843 mount_document(Some("rev-1"), "body").mount(&server).await;
1844
1845 let outcome = write(&drive, &docs, &append_opts(true), &[allow_rule("folder-1")]).await;
1846 match outcome.result {
1847 WriteResult::WouldAppend {
1848 document_end_index,
1849 chars,
1850 bytes,
1851 } => {
1852 assert_eq!(chars, 5);
1853 assert_eq!(bytes, 5);
1854 assert!(document_end_index.is_some());
1855 }
1856 other => panic!("expected WouldAppend, got {other:?}"),
1857 }
1858 }
1859
1860 #[tokio::test]
1865 async fn a_zero_occurrence_replace_still_sends_the_batch_update() {
1866 let server = MockServer::start().await;
1867 let (drive, docs) = clients(&server).await;
1868 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1869 .mount(&server)
1870 .await;
1871 mount_folder("folder-1").mount(&server).await;
1872 mount_document(Some("rev-1"), "nothing matching here")
1873 .mount(&server)
1874 .await;
1875 mount_batch_update(serde_json::json!({
1876 "documentId": "doc-1",
1877 "replies": [{"replaceAllText": {"occurrencesChanged": 0}}],
1878 }))
1879 .expect(1)
1880 .mount(&server)
1881 .await;
1882
1883 let outcome = write(
1884 &drive,
1885 &docs,
1886 &replace_opts(false),
1887 &[allow_rule("folder-1")],
1888 )
1889 .await;
1890 assert_eq!(
1891 outcome.result,
1892 WriteResult::Replaced {
1893 occurrences_changed: 0
1894 }
1895 );
1896 }
1897
1898 #[tokio::test]
1900 async fn an_append_reports_its_own_size_since_the_api_reports_no_count() {
1901 let server = MockServer::start().await;
1902 let (drive, docs) = clients(&server).await;
1903 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1904 .mount(&server)
1905 .await;
1906 mount_folder("folder-1").mount(&server).await;
1907 mount_document(Some("rev-1"), "body").mount(&server).await;
1908 mount_batch_update(serde_json::json!({"documentId": "doc-1", "replies": [{}]}))
1909 .mount(&server)
1910 .await;
1911
1912 let outcome = write(
1913 &drive,
1914 &docs,
1915 &append_opts(false),
1916 &[allow_rule("folder-1")],
1917 )
1918 .await;
1919 assert_eq!(outcome.result, WriteResult::Appended { chars: 5, bytes: 5 });
1920 }
1921
1922 #[tokio::test]
1925 async fn refuses_without_a_lease_when_the_rule_requires_one() {
1926 let server = MockServer::start().await;
1927 let (drive, docs) = clients(&server).await;
1928 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1929 .mount(&server)
1930 .await;
1931 mount_folder("folder-1").mount(&server).await;
1932 mount_document(Some("rev-1"), "Q3 report")
1933 .mount(&server)
1934 .await;
1935 let opts = WriteOptions {
1939 lease_token: None,
1940 ledger_path: std::path::PathBuf::new(),
1941 ..replace_opts(false)
1942 };
1943 let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
1944 assert!(matches!(outcome.result, WriteResult::RefusedNoLease));
1945 assert_eq!(outcome.result.log_status(), "refused-no-lease");
1946 }
1947
1948 #[tokio::test]
1949 async fn reports_a_lock_acquisition_failure_as_failed() {
1950 let server = MockServer::start().await;
1954 let (drive, docs) = clients(&server).await;
1955 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1956 .mount(&server)
1957 .await;
1958 mount_folder("folder-1").mount(&server).await;
1959 mount_document(Some("rev-1"), "Q3 report")
1960 .mount(&server)
1961 .await;
1962
1963 let opts = replace_opts(false);
1964 let mut lock_path = opts.ledger_path.clone().into_os_string();
1970 lock_path.push(".lock");
1971 std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
1972
1973 let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
1974 assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1975 }
1976
1977 #[tokio::test]
1978 async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
1979 let server = MockServer::start().await;
1984 let (drive, docs) = clients(&server).await;
1985 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1986 .up_to_n_times(1)
1987 .with_priority(1)
1988 .mount(&server)
1989 .await;
1990 Mock::given(method("GET"))
1991 .and(path("/drive/v3/files/doc-1"))
1992 .respond_with(ResponseTemplate::new(500))
1993 .with_priority(2)
1994 .mount(&server)
1995 .await;
1996 mount_folder("folder-1").mount(&server).await;
1997 mount_document(Some("rev-1"), "Q3 report")
1998 .mount(&server)
1999 .await;
2000 Mock::given(method("POST"))
2001 .and(path("/v1/documents/doc-1:batchUpdate"))
2002 .respond_with(ResponseTemplate::new(200))
2003 .expect(0)
2004 .mount(&server)
2005 .await;
2006
2007 let outcome = write(
2008 &drive,
2009 &docs,
2010 &replace_opts(false),
2011 &[allow_rule("folder-1")],
2012 )
2013 .await;
2014 assert!(matches!(outcome.result, WriteResult::Failed { .. }));
2015 }
2016
2017 #[tokio::test]
2018 async fn refuses_an_unknown_lease_token() {
2019 let server = MockServer::start().await;
2020 let (drive, docs) = clients(&server).await;
2021 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
2022 .mount(&server)
2023 .await;
2024 mount_folder("folder-1").mount(&server).await;
2025 mount_document(Some("rev-1"), "Q3 report")
2026 .mount(&server)
2027 .await;
2028 let ledger_path = tempfile::tempdir()
2029 .unwrap()
2030 .keep()
2031 .join("lease-ledger.jsonl");
2032 let opts = WriteOptions {
2035 lease_token: Some("bogus-token".to_string()),
2036 ledger_path,
2037 ..replace_opts(false)
2038 };
2039 let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
2040 assert!(matches!(outcome.result, WriteResult::RefusedLeaseExpired));
2041 }
2042
2043 #[tokio::test]
2044 async fn refuses_a_lease_bound_to_a_different_file() {
2045 let server = MockServer::start().await;
2046 let (drive, docs) = clients(&server).await;
2047 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
2048 .mount(&server)
2049 .await;
2050 mount_folder("folder-1").mount(&server).await;
2051 mount_document(Some("rev-1"), "Q3 report")
2052 .mount(&server)
2053 .await;
2054 let ledger_path = tempfile::tempdir()
2055 .unwrap()
2056 .keep()
2057 .join("lease-ledger.jsonl");
2058 let token = seed_lease(&ledger_path, "some-other-doc", "1");
2060
2061 let opts = WriteOptions {
2062 lease_token: Some(token),
2063 ledger_path,
2064 ..replace_opts(false)
2065 };
2066 let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
2067 assert!(matches!(outcome.result, WriteResult::RefusedLeaseWrongFile));
2068 }
2069
2070 #[tokio::test]
2071 async fn refuses_a_stale_lease_when_the_file_has_moved() {
2072 let server = MockServer::start().await;
2073 let (drive, docs) = clients(&server).await;
2074 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
2077 .mount(&server)
2078 .await;
2079 mount_folder("folder-1").mount(&server).await;
2080 mount_document(Some("rev-1"), "Q3 report")
2081 .mount(&server)
2082 .await;
2083 let ledger_path = tempfile::tempdir()
2084 .unwrap()
2085 .keep()
2086 .join("lease-ledger.jsonl");
2087 let token = seed_lease(&ledger_path, "doc-1", "0");
2088
2089 let opts = WriteOptions {
2090 lease_token: Some(token),
2091 ledger_path,
2092 ..replace_opts(false)
2093 };
2094 let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
2095 assert!(matches!(outcome.result, WriteResult::RefusedLeaseStale));
2096 }
2097
2098 #[tokio::test]
2099 async fn require_lease_false_writes_without_a_lease() {
2100 let server = MockServer::start().await;
2101 let (drive, docs) = clients(&server).await;
2102 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
2103 .mount(&server)
2104 .await;
2105 mount_folder("folder-1").mount(&server).await;
2106 mount_document(Some("rev-1"), "Q3 report")
2107 .mount(&server)
2108 .await;
2109 mount_batch_update(serde_json::json!({
2110 "documentId": "doc-1",
2111 "replies": [{"replaceAllText": {"occurrencesChanged": 1}}],
2112 }))
2113 .mount(&server)
2114 .await;
2115 let rule = FolderPermissionRule {
2116 folder_id: Some("folder-1".to_string()),
2117 file_id: None,
2118 recursive: true,
2119 allow: std::iter::once(DriveOperation::DocsWrite).collect(),
2120 deny: HashSet::default(),
2121 require_lease: false,
2122 };
2123
2124 let opts = WriteOptions {
2126 lease_token: None,
2127 ledger_path: std::path::PathBuf::from("/nonexistent/lease-ledger.jsonl"),
2128 ..replace_opts(false)
2129 };
2130 let outcome = write(&drive, &docs, &opts, &[rule]).await;
2131 assert!(matches!(outcome.result, WriteResult::Replaced { .. }));
2132 }
2133
2134 #[tokio::test]
2135 async fn require_lease_false_still_refuses_a_stale_lease_if_one_is_presented() {
2136 let server = MockServer::start().await;
2140 let (drive, docs) = clients(&server).await;
2141 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
2144 .mount(&server)
2145 .await;
2146 mount_folder("folder-1").mount(&server).await;
2147 mount_document(Some("rev-1"), "Q3 report")
2148 .mount(&server)
2149 .await;
2150 let rule = FolderPermissionRule {
2153 folder_id: Some("folder-1".to_string()),
2154 file_id: None,
2155 recursive: true,
2156 allow: std::iter::once(DriveOperation::DocsWrite).collect(),
2157 deny: HashSet::default(),
2158 require_lease: false,
2159 };
2160 let ledger_path = tempfile::tempdir()
2161 .unwrap()
2162 .keep()
2163 .join("lease-ledger.jsonl");
2164 let token = seed_lease(&ledger_path, "doc-1", "0");
2165
2166 let opts = WriteOptions {
2167 lease_token: Some(token),
2168 ledger_path,
2169 ..replace_opts(false)
2170 };
2171 let outcome = write(&drive, &docs, &opts, &[rule]).await;
2172 assert!(matches!(outcome.result, WriteResult::RefusedLeaseStale));
2173 }
2174
2175 #[test]
2176 fn describe_renders_every_lease_refusal_with_the_lease_acquire_hint() {
2177 let outcome_with = |result: WriteResult| WriteOutcome {
2178 document_id: "doc-1".to_string(),
2179 file_name: Some("Budget".to_string()),
2180 resolved_folder_id: None,
2181 required_revision_id: None,
2182 result,
2183 };
2184
2185 let text = describe(
2186 &outcome_with(WriteResult::RefusedNoLease),
2187 WriteVerb::Replace,
2188 );
2189 assert!(text.contains("requires a Drive write lease"), "{text}");
2190 assert!(text.contains("drive lease acquire doc-1"), "{text}");
2191
2192 let text = describe(
2193 &outcome_with(WriteResult::RefusedLeaseExpired),
2194 WriteVerb::Replace,
2195 );
2196 assert!(text.contains("expired, released, or unknown"), "{text}");
2197 assert!(text.contains("drive lease acquire doc-1"), "{text}");
2198
2199 let text = describe(
2200 &outcome_with(WriteResult::RefusedLeaseWrongFile),
2201 WriteVerb::Replace,
2202 );
2203 assert!(text.contains("acquired for a different file"), "{text}");
2204 assert!(text.contains("drive lease acquire doc-1"), "{text}");
2205
2206 let text = describe(
2207 &outcome_with(WriteResult::RefusedLeaseStale),
2208 WriteVerb::Replace,
2209 );
2210 assert!(
2211 text.contains("changed since the lease was acquired"),
2212 "{text}"
2213 );
2214 assert!(text.contains("drive lease acquire doc-1"), "{text}");
2215 }
2216
2217 #[tokio::test]
2218 async fn a_dry_run_never_needs_a_lease() {
2219 let server = MockServer::start().await;
2220 let (drive, docs) = clients(&server).await;
2221 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
2222 .mount(&server)
2223 .await;
2224 mount_folder("folder-1").mount(&server).await;
2225 mount_document(Some("rev-1"), "Q3 report")
2226 .mount(&server)
2227 .await;
2228 let opts = WriteOptions {
2232 lease_token: None,
2233 ledger_path: std::path::PathBuf::new(),
2234 ..replace_opts(true)
2235 };
2236 let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
2237 assert!(matches!(outcome.result, WriteResult::WouldReplace { .. }));
2238 }
2239
2240 #[tokio::test]
2243 async fn a_leased_docs_write_concludes_its_audit_pair_with_allowed() {
2244 let server = MockServer::start().await;
2245 let (drive, docs) = clients(&server).await;
2246 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
2247 .mount(&server)
2248 .await;
2249 mount_folder("folder-1").mount(&server).await;
2250 mount_document(Some("rev-abc"), "Q3 report")
2251 .mount(&server)
2252 .await;
2253 Mock::given(method("POST"))
2254 .and(path("/v1/documents/doc-1:batchUpdate"))
2255 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2256 "documentId": "doc-1",
2257 "replies": [{"replaceAllText": {"occurrencesChanged": 1}}],
2258 })))
2259 .mount(&server)
2260 .await;
2261 let dir = tempfile::tempdir().unwrap();
2262 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
2263
2264 let outcome = write(
2265 &drive,
2266 &docs,
2267 &replace_opts(false),
2268 &[allow_rule("folder-1")],
2269 )
2270 .await;
2271 assert!(matches!(outcome.result, WriteResult::Replaced { .. }));
2272
2273 let records = audit.records();
2274 assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
2275 assert_eq!(records[0].command, ["drive", "docs-replace"]);
2279 }
2280
2281 #[tokio::test]
2282 async fn a_leased_docs_write_that_fails_concludes_its_audit_pair_with_the_error() {
2283 let server = MockServer::start().await;
2284 let (drive, docs) = clients(&server).await;
2285 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
2286 .mount(&server)
2287 .await;
2288 mount_folder("folder-1").mount(&server).await;
2289 mount_document(Some("rev-abc"), "Q3 report")
2290 .mount(&server)
2291 .await;
2292 Mock::given(method("POST"))
2293 .and(path("/v1/documents/doc-1:batchUpdate"))
2294 .respond_with(ResponseTemplate::new(500))
2295 .mount(&server)
2296 .await;
2297 let dir = tempfile::tempdir().unwrap();
2298 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
2299
2300 let outcome = write(
2301 &drive,
2302 &docs,
2303 &replace_opts(false),
2304 &[allow_rule("folder-1")],
2305 )
2306 .await;
2307 let WriteResult::Failed { detail } = &outcome.result else {
2308 panic!("expected Failed, got {:?}", outcome.result);
2309 };
2310
2311 let records = audit.records();
2312 assert_eq!(audit.verdicts(), ["pending", "failed"], "{records:?}");
2313 assert_eq!(records[1].error.as_deref(), Some(detail.as_str()));
2314 }
2315 fn anchored_payloads() -> Vec<WritePayload> {
2316 vec![
2317 WritePayload::Insert {
2318 anchor: "Q3".into(),
2319 side: Side::After,
2320 text: "😀".into(),
2321 match_case: true,
2322 selection: anchor::SegmentSelection::default(),
2323 },
2324 WritePayload::Delete {
2325 from: "Q3".into(),
2326 to: None,
2327 match_case: true,
2328 selection: anchor::SegmentSelection::default(),
2329 },
2330 ]
2331 }
2332
2333 fn segment_payloads() -> Vec<WritePayload> {
2334 let mut out = Vec::new();
2335 for id in ["header", "footer", "footnote"] {
2336 for mut payload in anchored_payloads() {
2337 match &mut payload {
2338 WritePayload::Insert { selection, .. }
2339 | WritePayload::Delete { selection, .. } => {
2340 selection.segment_id = Some(id.into());
2341 selection.tab_id = Some("child".into());
2342 }
2343 _ => unreachable!(), }
2345 out.push(payload);
2346 }
2347 }
2348 out
2349 }
2350
2351 async fn mount_segments(server: &MockServer) {
2352 Mock::given(method("GET"))
2353 .and(path("/v1/documents/doc-1"))
2354 .respond_with(ResponseTemplate::new(200).set_body_json(segment_document()))
2355 .with_priority(1)
2356 .mount(server)
2357 .await;
2358 }
2359
2360 fn segment_document() -> serde_json::Value {
2361 let content = serde_json::json!([{"endIndex": 13, "paragraph": {"elements": [{"endIndex": 13, "textRun": {"content": "😀 Q3 report\n"}}]}}]);
2362 serde_json::json!({"revisionId": "rev-anchor", "body": {}, "tabs": [{
2363 "tabProperties": {"tabId": "parent"}, "childTabs": [{
2364 "tabProperties": {"tabId": "child"}, "documentTab": {
2365 "headers": {"header": {"content": content}},
2366 "footers": {"footer": {"content": content}},
2367 "footnotes": {"footnote": {"content": content}}
2368 }
2369 }]
2370 }]})
2371 }
2372
2373 #[tokio::test]
2374 async fn missing_segments_refuse_without_mutation() {
2375 let server = MockServer::start().await;
2376 let (drive, docs) = anchored_setup(&server).await;
2377 let payload = segment_payloads().remove(0);
2378 let rule = rule_for(&payload);
2379 let mut opts = replace_opts(false);
2380 opts.payload = payload;
2381 assert_eq!(
2382 write(&drive, &docs, &opts, &[rule]).await.result,
2383 WriteResult::RefusedAnchor {
2384 error: AnchorError::SegmentNotFound
2385 }
2386 );
2387 assert!(!server
2388 .received_requests()
2389 .await
2390 .unwrap()
2391 .iter()
2392 .any(|r| r.url.path().ends_with(":batchUpdate")));
2393 }
2394
2395 #[tokio::test]
2396 async fn selected_segment_previews_and_writes_share_identity_indices_and_revision() {
2397 for payload in segment_payloads() {
2398 let server = MockServer::start().await;
2399 let (drive, docs) = anchored_setup(&server).await;
2400 mount_segments(&server).await;
2401 let mut opts = replace_opts(true);
2402 opts.payload = payload.clone();
2403 let rule = rule_for(&payload);
2404 let edit = match write(&drive, &docs, &opts, std::slice::from_ref(&rule))
2405 .await
2406 .result
2407 {
2408 WriteResult::WouldInsert { edit } | WriteResult::WouldDelete { edit } => edit,
2409 other => panic!("{other:?}"),
2410 };
2411 assert_eq!(edit.tab_id.as_deref(), Some("child"));
2412 assert!(edit.segment_kind.is_some());
2413 mount_batch_update(serde_json::json!({"replies": [{}]}))
2414 .expect(1)
2415 .mount(&server)
2416 .await;
2417 opts.dry_run = false;
2418 let applied = match write(&drive, &docs, &opts, &[rule]).await.result {
2419 WriteResult::Inserted { edit } | WriteResult::Deleted { edit } => edit,
2420 other => panic!("{other:?}"),
2421 };
2422 assert_eq!(edit, applied);
2423 let requests = server.received_requests().await.unwrap();
2424 let batch = requests
2425 .iter()
2426 .find(|r| r.url.path().ends_with(":batchUpdate"))
2427 .unwrap();
2428 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
2429 assert_eq!(
2430 body["writeControl"],
2431 serde_json::json!({"requiredRevisionId": "rev-anchor"})
2432 );
2433 assert_eq!(body["requests"].as_array().unwrap().len(), 1);
2434 let location = if payload.verb() == WriteVerb::Insert {
2435 assert_eq!((edit.start_index, edit.end_index), (5, 5));
2436 &body["requests"][0]["insertText"]["location"]
2437 } else {
2438 assert_eq!((edit.start_index, edit.end_index), (3, 5));
2439 &body["requests"][0]["deleteContentRange"]["range"]
2440 };
2441 assert_eq!(location["segmentId"], edit.segment_id.unwrap());
2442 assert_eq!(location["tabId"], "child");
2443 }
2444 }
2445
2446 fn list_payloads() -> Vec<WritePayload> {
2447 [Some(BulletPreset::BulletDiscCircleSquare), None]
2448 .into_iter()
2449 .map(|preset| WritePayload::List {
2450 from: "Q3".into(),
2451 to: None,
2452 preset,
2453 match_case: true,
2454 })
2455 .collect()
2456 }
2457
2458 #[tokio::test]
2459 async fn list_edits_preview_and_apply_the_same_snapshot_in_one_inline_leased_request() {
2460 for payload in list_payloads() {
2461 assert_eq!(payload.gate_operation(), DriveOperation::DocsWrite);
2462 let server = MockServer::start().await;
2463 let (drive, docs) = clients(&server).await;
2464 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
2465 .mount(&server)
2466 .await;
2467 mount_folder("folder-1").mount(&server).await;
2468 mount_document(Some("rev-list"), "\t\t😀 Q3 report")
2469 .mount(&server)
2470 .await;
2471 let mut opts = replace_opts(true);
2472 opts.payload = payload.clone();
2473 let rule = rule_for(&payload);
2474 let outcome = write(&drive, &docs, &opts, std::slice::from_ref(&rule)).await;
2475 let (expected, preset) = match outcome.result {
2476 WriteResult::WouldFormatList { edit, preset } => (edit, preset),
2477 other => panic!("{other:?}"),
2478 };
2479 assert_eq!(
2480 (
2481 expected.start_index,
2482 expected.end_index,
2483 expected.paragraphs
2484 ),
2485 (1, 16, 1)
2486 );
2487 assert_eq!(
2488 expected.leading_tabs_removed,
2489 if preset.is_some() { 2 } else { 0 }
2490 );
2491 mount_batch_update(serde_json::json!({"replies": [{}]}))
2492 .expect(1)
2493 .mount(&server)
2494 .await;
2495 opts.dry_run = false;
2496 let outcome = write(&drive, &docs, &opts, &[rule]).await;
2497 assert_eq!(
2498 outcome.result,
2499 WriteResult::ListFormatted {
2500 edit: expected,
2501 preset
2502 }
2503 );
2504 let requests = server.received_requests().await.unwrap();
2505 let reads: Vec<_> = requests
2506 .iter()
2507 .filter(|r| r.url.path() == "/v1/documents/doc-1")
2508 .collect();
2509 assert_eq!(reads.len(), 2);
2510 assert!(reads.iter().all(|r| r
2511 .url
2512 .query_pairs()
2513 .any(|(k, v)| k == "suggestionsViewMode" && v == "SUGGESTIONS_INLINE")));
2514 let batch = requests
2515 .iter()
2516 .find(|r| r.url.path().ends_with(":batchUpdate"))
2517 .unwrap();
2518 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
2519 let range = serde_json::json!({"startIndex": 1, "endIndex": 16});
2520 let request = if preset.is_some() {
2521 serde_json::json!({"createParagraphBullets": {"range": range, "bulletPreset": "BULLET_DISC_CIRCLE_SQUARE"}})
2522 } else {
2523 serde_json::json!({"deleteParagraphBullets": {"range": range}})
2524 };
2525 assert_eq!(
2526 body,
2527 serde_json::json!({"requests": [request], "writeControl": {"requiredRevisionId": "rev-list"}})
2528 );
2529 let serialized = serde_json::to_string(&outcome).unwrap();
2530 assert!(!serialized.contains("Q3") && !serialized.contains("report"));
2531 }
2532 }
2533
2534 async fn anchored_setup(server: &MockServer) -> (DriveClient, DocsClient) {
2535 let clients = clients(server).await;
2536 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
2537 .mount(server)
2538 .await;
2539 mount_folder("folder-1").mount(server).await;
2540 mount_document(Some("rev-anchor"), "😀 Q3 report")
2541 .mount(server)
2542 .await;
2543
2544 clients
2545 }
2546
2547 fn rule_for(payload: &WritePayload) -> FolderPermissionRule {
2548 let mut rule = allow_rule("folder-1");
2549 rule.allow = std::iter::once(payload.gate_operation()).collect();
2550 rule
2551 }
2552
2553 #[test]
2554 fn every_docs_verb_maps_to_its_gate_operation() {
2555 for payload in [
2556 replace_opts(true).payload,
2557 append_opts(true).payload,
2558 anchored_payloads().remove(0),
2559 ] {
2560 assert_eq!(payload.gate_operation(), DriveOperation::DocsWrite);
2561 }
2562 assert_eq!(
2563 anchored_payloads().remove(1).gate_operation(),
2564 DriveOperation::DocsDelete
2565 );
2566 }
2567
2568 #[tokio::test]
2569 async fn anchored_edits_read_inline_and_send_one_request_from_the_leased_snapshot() {
2570 for payload in anchored_payloads() {
2571 let server = MockServer::start().await;
2572 let (drive, docs) = anchored_setup(&server).await;
2573 let rule = rule_for(&payload);
2574 let mut opts = replace_opts(true);
2575 opts.payload = payload;
2576 let preview = write(&drive, &docs, &opts, std::slice::from_ref(&rule)).await;
2577 let expected = match &preview.result {
2578 WriteResult::WouldInsert { edit } | WriteResult::WouldDelete { edit } => {
2579 edit.clone()
2580 }
2581 other => panic!("{other:?}"),
2582 };
2583 assert_eq!(
2584 expected.start_index,
2585 if opts.payload.verb() == WriteVerb::Insert {
2586 6
2587 } else {
2588 4
2589 }
2590 );
2591 mount_batch_update(serde_json::json!({"replies": [{}]}))
2592 .expect(1)
2593 .mount(&server)
2594 .await;
2595 opts.dry_run = false;
2596 let result = write(&drive, &docs, &opts, &[rule]).await;
2597 let actual = match result.result {
2598 WriteResult::Inserted { edit } | WriteResult::Deleted { edit } => edit,
2599 other => panic!("{other:?}"),
2600 };
2601 assert_eq!(actual, expected);
2602 let requests = server.received_requests().await.unwrap();
2603 let reads: Vec<_> = requests
2604 .iter()
2605 .filter(|r| r.url.path() == "/v1/documents/doc-1")
2606 .collect();
2607 assert_eq!(reads.len(), 2);
2608 for read in reads {
2609 assert!(read
2610 .url
2611 .query_pairs()
2612 .any(|(k, v)| k == "suggestionsViewMode" && v == "SUGGESTIONS_INLINE"));
2613 }
2614 let batch = requests
2615 .iter()
2616 .find(|r| r.url.path().ends_with(":batchUpdate"))
2617 .unwrap();
2618 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
2619 assert_eq!(
2620 body["writeControl"],
2621 serde_json::json!({"requiredRevisionId": "rev-anchor"})
2622 );
2623 assert_eq!(body["requests"].as_array().unwrap().len(), 1);
2624 if opts.payload.verb() == WriteVerb::Insert {
2625 assert_eq!(
2626 body["requests"][0]["insertText"],
2627 serde_json::json!({"text": "😀", "location": {"index": 6}})
2628 );
2629 } else {
2630 assert_eq!(
2631 body["requests"][0]["deleteContentRange"]["range"],
2632 serde_json::json!({"startIndex": 4, "endIndex": 6})
2633 );
2634 }
2635 }
2636 }
2637
2638 #[tokio::test]
2639 async fn a_docs_write_grant_does_not_allow_delete_and_delete_does_not_allow_other_verbs() {
2640 let mut payloads = anchored_payloads();
2641 payloads.extend(segment_payloads());
2642 payloads.extend(list_payloads());
2643 payloads.push(replace_opts(false).payload);
2644 payloads.push(append_opts(false).payload);
2645 for payload in payloads {
2646 let server = MockServer::start().await;
2647 let (drive, docs) = clients(&server).await;
2648 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
2649 .mount(&server)
2650 .await;
2651 mount_folder("folder-1").mount(&server).await;
2652 let mut rule = allow_rule("folder-1");
2653 if payload.verb() != WriteVerb::Delete {
2654 rule.allow = std::iter::once(DriveOperation::DocsDelete).collect();
2655 }
2656 let mut opts = replace_opts(false);
2657 opts.payload = payload;
2658 assert!(matches!(
2659 write(&drive, &docs, &opts, &[rule]).await.result,
2660 WriteResult::Blocked { .. }
2661 ));
2662 assert!(!server
2663 .received_requests()
2664 .await
2665 .unwrap()
2666 .iter()
2667 .any(|r| r.url.path().starts_with("/v1/documents")));
2668 }
2669 }
2670
2671 #[tokio::test]
2672 async fn unresolved_anchors_and_missing_revisions_never_mutate() {
2673 for payload in anchored_payloads()
2674 .into_iter()
2675 .chain(list_payloads())
2676 .chain(formatting_payloads())
2677 {
2678 for (text, revision, expected) in [
2679 ("nothing", Some("r"), "missing"),
2680 ("Q3 Q3", Some("r"), "ambiguous"),
2681 ("Q3", None, "revision"),
2682 ] {
2683 let server = MockServer::start().await;
2684 let (drive, docs) = clients(&server).await;
2685 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
2686 .mount(&server)
2687 .await;
2688 mount_folder("folder-1").mount(&server).await;
2689 mount_document(revision, text).mount(&server).await;
2690 let mut opts = replace_opts(false);
2691 opts.payload = payload.clone();
2692 let outcome = write(&drive, &docs, &opts, &[rule_for(&payload)]).await;
2693 match expected {
2694 "missing" => assert!(matches!(
2695 outcome.result,
2696 WriteResult::RefusedAnchor {
2697 error: AnchorError::NotFound
2698 }
2699 )),
2700 "ambiguous" => assert!(matches!(
2701 outcome.result,
2702 WriteResult::RefusedAnchor {
2703 error: AnchorError::Ambiguous { count: 2 }
2704 }
2705 )),
2706 _ => assert_eq!(outcome.result, WriteResult::RefusedNoRevisionId),
2707 }
2708 assert!(!server
2709 .received_requests()
2710 .await
2711 .unwrap()
2712 .iter()
2713 .any(|r| r.url.path().ends_with(":batchUpdate")));
2714 }
2715 }
2716 }
2717
2718 #[tokio::test]
2719 async fn anchored_edits_preserve_every_lease_refusal() {
2720 for payload in anchored_payloads()
2721 .into_iter()
2722 .chain(segment_payloads())
2723 .chain(list_payloads())
2724 .chain(formatting_payloads())
2725 {
2726 for expected in [
2727 WriteResult::RefusedNoLease,
2728 WriteResult::RefusedLeaseExpired,
2729 WriteResult::RefusedLeaseWrongFile,
2730 WriteResult::RefusedLeaseStale,
2731 ] {
2732 let server = MockServer::start().await;
2733 let (drive, docs) = anchored_setup(&server).await;
2734 if matches!(&payload, WritePayload::Insert { selection, .. } | WritePayload::Delete { selection, .. } if selection.segment_id.is_some())
2735 {
2736 mount_segments(&server).await;
2737 }
2738 let mut opts = replace_opts(false);
2739 opts.payload = payload.clone();
2740 opts.lease_token = match expected {
2741 WriteResult::RefusedNoLease => None,
2742 WriteResult::RefusedLeaseExpired => Some("unknown".into()),
2743 WriteResult::RefusedLeaseWrongFile => {
2744 Some(seed_lease(&opts.ledger_path, "different-doc", "1"))
2745 }
2746 _ => Some(seed_lease(&opts.ledger_path, "doc-1", "0")),
2747 };
2748 assert_eq!(
2749 write(&drive, &docs, &opts, &[rule_for(&payload)])
2750 .await
2751 .result,
2752 expected
2753 );
2754 assert!(!server
2755 .received_requests()
2756 .await
2757 .unwrap()
2758 .iter()
2759 .any(|r| r.url.path().ends_with(":batchUpdate")));
2760 }
2761 }
2762 }
2763
2764 #[tokio::test]
2765 async fn anchored_edits_refuse_stale_docs_revisions() {
2766 for payload in anchored_payloads()
2767 .into_iter()
2768 .chain(segment_payloads())
2769 .chain(list_payloads())
2770 .chain(formatting_payloads())
2771 {
2772 let server = MockServer::start().await;
2773 let (drive, docs) = anchored_setup(&server).await;
2774 if matches!(&payload, WritePayload::Insert { selection, .. } | WritePayload::Delete { selection, .. } if selection.segment_id.is_some())
2775 {
2776 mount_segments(&server).await;
2777 }
2778 Mock::given(method("POST")).and(path("/v1/documents/doc-1:batchUpdate"))
2779 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({"error": {"code": 400, "message": "The required revision ID 'rev-anchor' does not match the latest revision."}})))
2780 .expect(1).mount(&server).await;
2781 let mut opts = replace_opts(false);
2782 opts.payload = payload.clone();
2783 assert!(matches!(
2784 write(&drive, &docs, &opts, &[rule_for(&payload)])
2785 .await
2786 .result,
2787 WriteResult::StaleRevision { .. }
2788 ));
2789 }
2790 }
2791 #[tokio::test]
2792 async fn anchored_edits_send_the_child_tab_and_cross_paragraph_range() {
2793 for payload in [
2794 WritePayload::Insert {
2795 anchor: "needle".into(),
2796 side: Side::After,
2797 text: "text".into(),
2798 match_case: true,
2799 selection: anchor::SegmentSelection::default(),
2800 },
2801 WritePayload::Delete {
2802 from: "first".into(),
2803 to: Some("needle".into()),
2804 match_case: true,
2805 selection: anchor::SegmentSelection::default(),
2806 },
2807 ] {
2808 let server = MockServer::start().await;
2809 let (drive, docs) = clients(&server).await;
2810 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
2811 .mount(&server)
2812 .await;
2813 mount_folder("folder-1").mount(&server).await;
2814 Mock::given(method("GET")).and(path("/v1/documents/doc-1"))
2815 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2816 "revisionId": "tab-revision", "tabs": [{
2817 "tabProperties": {"tabId": "parent"}, "childTabs": [{
2818 "tabProperties": {"tabId": "child"}, "documentTab": {"body": {"content": [
2819 {"startIndex": 1, "endIndex": 7, "paragraph": {"elements": [{"startIndex": 1, "endIndex": 7, "textRun": {"content": "first\n"}}]}},
2820 {"startIndex": 7, "endIndex": 17, "paragraph": {"elements": [{"startIndex": 7, "endIndex": 17, "textRun": {"content": "😀 needle\n"}}]}}
2821 ]}}
2822 }]
2823 }]
2824 }))).mount(&server).await;
2825 mount_batch_update(serde_json::json!({"replies": [{}]}))
2826 .expect(1)
2827 .mount(&server)
2828 .await;
2829 let mut opts = replace_opts(false);
2830 opts.payload = payload.clone();
2831 let outcome = write(&drive, &docs, &opts, &[rule_for(&payload)]).await;
2832 let edit = match outcome.result {
2833 WriteResult::Inserted { edit } | WriteResult::Deleted { edit } => edit,
2834 other => panic!("{other:?}"),
2835 };
2836 assert_eq!(edit.tab_id.as_deref(), Some("child"));
2837 let requests = server.received_requests().await.unwrap();
2838 let batch = requests
2839 .iter()
2840 .find(|r| r.url.path().ends_with(":batchUpdate"))
2841 .unwrap();
2842 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
2843 if payload.verb() == WriteVerb::Insert {
2844 assert_eq!(
2845 body["requests"][0]["insertText"]["location"],
2846 serde_json::json!({"index": 16, "tabId": "child"})
2847 );
2848 } else {
2849 assert_eq!((edit.chars, edit.bytes, edit.paragraphs), (14, 17, 2));
2850 assert_eq!(
2851 body["requests"][0]["deleteContentRange"]["range"],
2852 serde_json::json!({"startIndex": 1, "endIndex": 16, "tabId": "child"})
2853 );
2854 }
2855 }
2856 }
2857
2858 fn insert_payload(anchor: &str, text: &str) -> WritePayload {
2861 WritePayload::Insert {
2862 anchor: anchor.into(),
2863 side: Side::After,
2864 text: text.into(),
2865 match_case: true,
2866 selection: anchor::SegmentSelection::default(),
2867 }
2868 }
2869
2870 fn delete_payload(from: &str, to: Option<&str>) -> WritePayload {
2871 WritePayload::Delete {
2872 from: from.into(),
2873 to: to.map(Into::into),
2874 match_case: true,
2875 selection: anchor::SegmentSelection::default(),
2876 }
2877 }
2878
2879 #[test]
2880 fn validate_refuses_anchored_payloads_that_cannot_address_an_edit() {
2881 let nothing_left = insert_payload("a", "\0\r\u{e000}").validate().unwrap_err();
2882 assert!(nothing_left.contains("nothing to insert"), "{nothing_left}");
2883
2884 for anchor in ["", "two\nlines", "carriage\rreturn"] {
2885 let err = insert_payload(anchor, "x").validate().unwrap_err();
2886 assert!(err.contains("anchor must be nonempty"), "{anchor:?}: {err}");
2887 }
2888 for (from, to) in [
2889 ("", None),
2890 ("two\nlines", None),
2891 ("a", Some("")),
2892 ("a", Some("two\nlines")),
2893 ("a", Some("carriage\rreturn")),
2894 ] {
2895 let err = delete_payload(from, to).validate().unwrap_err();
2896 assert!(
2897 err.contains("anchors must be nonempty"),
2898 "{from:?}..{to:?}: {err}"
2899 );
2900 }
2901
2902 assert!(insert_payload("a", "x").validate().is_ok());
2903 assert!(delete_payload("a", None).validate().is_ok());
2904 assert!(delete_payload("a", Some("b")).validate().is_ok());
2905 }
2906
2907 #[tokio::test]
2910 async fn an_unaddressable_anchored_payload_fails_before_any_network_call() {
2911 let mut invalid_segment = insert_payload("a", "x");
2912 if let WritePayload::Insert { selection, .. } = &mut invalid_segment {
2913 selection.segment_id = Some(String::new());
2914 }
2915 let mut invalid_tab = delete_payload("a", None);
2916 if let WritePayload::Delete { selection, .. } = &mut invalid_tab {
2917 selection.tab_id = Some("child".into());
2918 }
2919 for payload in [
2920 invalid_segment,
2921 invalid_tab,
2922 insert_payload("", "x"),
2923 insert_payload("a", "\0"),
2924 delete_payload("", None),
2925 delete_payload("a", Some("two\nlines")),
2926 WritePayload::List {
2927 from: String::new(),
2928 to: None,
2929 preset: Some(BulletPreset::BulletCheckbox),
2930 match_case: true,
2931 },
2932 WritePayload::List {
2933 from: "a".into(),
2934 to: Some("two\nlines".into()),
2935 preset: None,
2936 match_case: true,
2937 },
2938 ] {
2939 let server = MockServer::start().await;
2940 let (drive, docs) = clients(&server).await;
2941 let mut opts = replace_opts(false);
2942 opts.payload = payload;
2943 let outcome = write(&drive, &docs, &opts, &[]).await;
2944 assert!(
2945 matches!(outcome.result, WriteResult::Failed { .. }),
2946 "{:?}",
2947 outcome.result
2948 );
2949 assert!(server
2950 .received_requests()
2951 .await
2952 .unwrap()
2953 .iter()
2954 .all(|r| r.url.path() == "/token"));
2955 }
2956 }
2957
2958 fn preview_edit() -> EditPreview {
2959 EditPreview {
2960 segment_id: None,
2961 segment_kind: None,
2962 start_index: 4,
2963 end_index: 6,
2964 tab_id: None,
2965 paragraphs: 1,
2966 chars: 1,
2967 bytes: 4,
2968 }
2969 }
2970
2971 fn outcome_with(result: WriteResult) -> WriteOutcome {
2972 WriteOutcome {
2973 document_id: "doc-1".to_string(),
2974 file_name: Some("Budget".to_string()),
2975 resolved_folder_id: None,
2976 required_revision_id: None,
2977 result,
2978 }
2979 }
2980
2981 fn named_range_preview(id: Option<&str>) -> super::super::named_range::Preview {
2982 super::super::named_range::Preview {
2983 effect: super::super::named_range::Effect::ReplaceContent,
2984 named_range_id: id.map(str::to_owned),
2985 ranges: vec![crate::drive::docs::types::Range {
2986 segment_id: None,
2987 tab_id: Some("t".into()),
2988 start_index: Some(1),
2989 end_index: Some(3),
2990 }],
2991 removed_chars: 2,
2992 removed_bytes: 5,
2993 inserted_chars: 1,
2994 inserted_bytes: 4,
2995 }
2996 }
2997
2998 #[test]
2999 fn named_range_results_are_named_for_the_log() {
3000 for (result, status) in [
3001 (
3002 WriteResult::WouldMutateNamedRange {
3003 preview: named_range_preview(None),
3004 },
3005 "would-mutate-named-range",
3006 ),
3007 (
3008 WriteResult::MutatedNamedRange {
3009 preview: named_range_preview(None),
3010 },
3011 "mutated-named-range",
3012 ),
3013 (
3014 WriteResult::RefusedNamedRange {
3015 error: super::super::named_range::Error::UnsafeRange,
3016 },
3017 "refused-named-range",
3018 ),
3019 ] {
3020 assert_eq!(result.log_status(), status);
3021 }
3022 }
3023
3024 #[test]
3027 fn named_range_descriptions_report_the_action_id_counts_and_spans() {
3028 let would = describe(
3029 &outcome_with(WriteResult::WouldMutateNamedRange {
3030 preview: named_range_preview(None),
3031 }),
3032 WriteVerb::CreateNamedRange,
3033 );
3034 assert_eq!(
3035 would,
3036 "Would apply create-named-range in 'Budget': ID pending, 1 span(s), removed 2 char(s) / 5 byte(s), inserted 1 char(s) / 4 byte(s); [1, 3) UTF-16, tab t, segment body"
3037 );
3038 let applied = describe(
3039 &outcome_with(WriteResult::MutatedNamedRange {
3040 preview: named_range_preview(Some("nr")),
3041 }),
3042 WriteVerb::ReplaceNamedRangeContent,
3043 );
3044 assert!(
3045 applied.starts_with("Applied replace-named-range-content in 'Budget': ID nr,"),
3046 "{applied}"
3047 );
3048 let refused = describe(
3049 &outcome_with(WriteResult::RefusedNamedRange {
3050 error: super::super::named_range::Error::ScopeMismatch,
3051 }),
3052 WriteVerb::DeleteNamedRange,
3053 );
3054 assert_eq!(
3055 refused,
3056 "Refused: unsafe or unresolved named range in 'Budget': ScopeMismatch"
3057 );
3058 }
3059
3060 #[test]
3063 fn named_range_verbs_are_labelled_with_their_cli_spelling() {
3064 for (verb, label) in [
3065 (WriteVerb::CreateNamedRange, "create-named-range"),
3066 (WriteVerb::DeleteNamedRange, "delete-named-range"),
3067 (
3068 WriteVerb::ReplaceNamedRangeContent,
3069 "replace-named-range-content",
3070 ),
3071 (
3072 WriteVerb::DeleteNamedRangeContent,
3073 "replace-named-range-content",
3074 ),
3075 ] {
3076 assert_eq!(verb.label(), label);
3077 }
3078 }
3079
3080 #[test]
3081 fn describe_reports_selected_segment_identity_without_debug_types() {
3082 let mut edit = preview_edit();
3083 edit.segment_id = Some("h1".into());
3084 edit.segment_kind = Some(anchor::SegmentKind::Header);
3085 let rendered = describe(
3086 &outcome_with(WriteResult::WouldInsert { edit }),
3087 WriteVerb::Insert,
3088 );
3089 assert!(rendered.contains(", header h1,"), "{rendered}");
3090 assert!(!rendered.contains("Some("), "{rendered}");
3091 }
3092
3093 #[test]
3094 fn describe_labels_each_segment_kind() {
3095 for (kind, label) in [
3096 (Some(anchor::SegmentKind::Footer), ", footer s1,"),
3097 (Some(anchor::SegmentKind::Footnote), ", footnote s1,"),
3098 (None, ", segment s1,"),
3099 ] {
3100 let mut edit = preview_edit();
3101 edit.segment_id = Some("s1".into());
3102 edit.segment_kind = kind;
3103 let rendered = describe(
3104 &outcome_with(WriteResult::WouldDelete { edit }),
3105 WriteVerb::Delete,
3106 );
3107 assert!(rendered.contains(label), "{rendered}");
3108 }
3109 }
3110
3111 #[test]
3112 fn describe_renders_each_anchored_outcome_with_its_utf16_range() {
3113 for (result, verb, expected) in [
3114 (
3115 WriteResult::WouldInsert {
3116 edit: preview_edit(),
3117 },
3118 WriteVerb::Insert,
3119 "Would insert",
3120 ),
3121 (
3122 WriteResult::Inserted {
3123 edit: preview_edit(),
3124 },
3125 WriteVerb::Insert,
3126 "Inserted",
3127 ),
3128 (
3129 WriteResult::WouldDelete {
3130 edit: preview_edit(),
3131 },
3132 WriteVerb::Delete,
3133 "Would delete",
3134 ),
3135 (
3136 WriteResult::Deleted {
3137 edit: preview_edit(),
3138 },
3139 WriteVerb::Delete,
3140 "Deleted",
3141 ),
3142 ] {
3143 let text = describe(&outcome_with(result), verb);
3144 assert_eq!(
3145 text,
3146 format!(
3147 "{expected}: 1 char(s) / 4 byte(s) in 'Budget' at [4, 6) UTF-16 code units, tab first, 1 paragraph(s)"
3148 )
3149 );
3150 }
3151
3152 let mut edit = preview_edit();
3153 edit.tab_id = Some("t.1".to_string());
3154 let text = describe(
3155 &outcome_with(WriteResult::Deleted { edit }),
3156 WriteVerb::Delete,
3157 );
3158 assert!(text.contains("tab t.1,"), "{text}");
3159 }
3160
3161 #[test]
3162 fn list_descriptions_expose_pre_write_indices_and_tab_removal_without_prose() {
3163 let edit = ListPreview {
3164 start_index: 1,
3165 end_index: 20,
3166 tab_id: Some("child".into()),
3167 paragraphs: 2,
3168 leading_tabs_removed: 3,
3169 };
3170 let preview = WriteResult::WouldFormatList {
3171 edit: edit.clone(),
3172 preset: Some(BulletPreset::BulletCheckbox),
3173 };
3174 let applied = WriteResult::ListFormatted {
3175 edit: ListPreview {
3176 leading_tabs_removed: 0,
3177 ..edit
3178 },
3179 preset: None,
3180 };
3181 assert_eq!(preview.log_status(), "would-format-list");
3182 assert_eq!(applied.log_status(), "list-formatted");
3183 for (result, verb, action) in [
3184 (preview, WriteVerb::CreateBullets, "Would format list"),
3185 (applied, WriteVerb::DeleteBullets, "Formatted list"),
3186 ] {
3187 let text = describe(&outcome_with(result), verb);
3188 assert!(text.starts_with(action), "{text}");
3189 assert!(
3190 text.contains("pre-write [1, 20)")
3191 && text.contains("tab child")
3192 && text.contains(if verb == WriteVerb::CreateBullets {
3193 "3 leading tab(s) removed"
3194 } else {
3195 "0 leading tab(s) removed"
3196 }),
3197 "{text}"
3198 );
3199 }
3200 }
3201
3202 #[test]
3203 fn describe_names_the_unresolved_anchor_without_quoting_document_text() {
3204 let text = describe(
3205 &outcome_with(WriteResult::RefusedAnchor {
3206 error: AnchorError::Ambiguous { count: 2 },
3207 }),
3208 WriteVerb::Insert,
3209 );
3210 assert!(text.starts_with("Refused: unsafe or unresolved anchor in 'Budget'"));
3211 assert!(text.contains("Ambiguous"), "{text}");
3212 }
3213
3214 #[test]
3215 fn refusals_name_the_verb_and_the_grant_that_would_allow_it() {
3216 let not_a_doc = WriteResult::RefusedNotADocument {
3217 mime_type: GOOGLE_SHEET_MIME_TYPE.to_string(),
3218 };
3219 for (verb, label) in [
3220 (WriteVerb::Replace, "replace"),
3221 (WriteVerb::Append, "append"),
3222 (WriteVerb::Insert, "insert"),
3223 (WriteVerb::Delete, "delete"),
3224 (WriteVerb::CreateBullets, "create-bullets"),
3225 (WriteVerb::DeleteBullets, "delete-bullets"),
3226 (WriteVerb::TextStyle, "text-style"),
3227 (WriteVerb::ParagraphStyle, "paragraph-style"),
3228 ] {
3229 let text = describe(&outcome_with(not_a_doc.clone()), verb);
3230 assert!(text.contains(&format!("`drive docs {label}`")), "{text}");
3231 }
3232
3233 for (verb, grant) in [
3234 (WriteVerb::Insert, "docs-write"),
3235 (WriteVerb::Delete, "docs-delete"),
3236 (WriteVerb::CreateBullets, "docs-write"),
3237 (WriteVerb::DeleteBullets, "docs-write"),
3238 (WriteVerb::TextStyle, "docs-format"),
3239 (WriteVerb::ParagraphStyle, "docs-format"),
3240 (WriteVerb::CreateNamedRange, "docs-structure"),
3241 (WriteVerb::DeleteNamedRange, "docs-structure"),
3242 (WriteVerb::ReplaceNamedRangeContent, "docs-write"),
3243 (WriteVerb::DeleteNamedRangeContent, "docs-delete"),
3244 ] {
3245 let text = describe(&outcome_with(WriteResult::RefusedNoVisibleParents), verb);
3246 assert!(text.contains(&format!("[\"{grant}\"]")), "{text}");
3247 assert!(
3248 text.contains(&format!("granting that folder `{grant}`")),
3249 "{text}"
3250 );
3251 }
3252 }
3253
3254 #[test]
3255 fn describe_names_the_rule_that_blocked_a_write_or_the_default_policy() {
3256 let blocked = |decided_by| {
3257 describe(
3258 &outcome_with(WriteResult::Blocked { decided_by }),
3259 WriteVerb::Delete,
3260 )
3261 };
3262 assert_eq!(
3263 blocked(Some(DecidingRule::Folder {
3264 folder_id: "folder-1".to_string(),
3265 depth: 2,
3266 })),
3267 "Blocked: 'Budget' — refused by rule on folder folder-1 (depth 2)"
3268 );
3269 assert_eq!(
3270 blocked(Some(DecidingRule::File {
3271 file_id: "doc-1".to_string(),
3272 })),
3273 "Blocked: 'Budget' — refused by rule on file doc-1"
3274 );
3275 assert_eq!(
3276 blocked(None),
3277 "Blocked: 'Budget' — refused by default policy (no matching rule)"
3278 );
3279 }
3280
3281 #[test]
3282 fn dry_run_statuses_are_named_for_the_log_even_though_only_writes_record() {
3283 for (result, status) in [
3284 (
3285 WriteResult::WouldReplace { occurrences: 1 },
3286 "would-replace",
3287 ),
3288 (
3289 WriteResult::WouldInsert {
3290 edit: preview_edit(),
3291 },
3292 "would-insert",
3293 ),
3294 (
3295 WriteResult::WouldDelete {
3296 edit: preview_edit(),
3297 },
3298 "would-delete",
3299 ),
3300 (
3301 WriteResult::Inserted {
3302 edit: preview_edit(),
3303 },
3304 "inserted",
3305 ),
3306 (
3307 WriteResult::Deleted {
3308 edit: preview_edit(),
3309 },
3310 "deleted",
3311 ),
3312 (
3313 WriteResult::WouldFormat {
3314 edit: preview_edit(),
3315 style: formatting_style(),
3316 fields: "bold".to_string(),
3317 },
3318 "would-format",
3319 ),
3320 (
3321 WriteResult::Formatted {
3322 edit: preview_edit(),
3323 style: formatting_style(),
3324 fields: "bold".to_string(),
3325 },
3326 "formatted",
3327 ),
3328 ] {
3329 assert_eq!(result.log_status(), status);
3330 }
3331 }
3332
3333 fn formatting_style() -> super::super::style::StylePatch {
3334 super::super::style::StylePatch::Text(super::super::style::TextStyle {
3335 bold: Some(true),
3336 ..super::super::style::TextStyle::default()
3337 })
3338 }
3339
3340 #[test]
3344 fn describe_renders_a_formatting_outcome_with_its_range_mask_and_style() {
3345 for (result, expected) in [
3346 (
3347 WriteResult::WouldFormat {
3348 edit: preview_edit(),
3349 style: formatting_style(),
3350 fields: "bold".to_string(),
3351 },
3352 "Would format",
3353 ),
3354 (
3355 WriteResult::Formatted {
3356 edit: preview_edit(),
3357 style: formatting_style(),
3358 fields: "bold".to_string(),
3359 },
3360 "Formatted",
3361 ),
3362 ] {
3363 let text = describe(&outcome_with(result), WriteVerb::TextStyle);
3364 assert!(
3365 text.starts_with(&format!(
3366 "{expected}: 'Budget' tab None UTF-16 [4, 6) / 1 paragraph(s) / 1 char(s) / 4 byte(s); fields=bold; style="
3367 )),
3368 "{text}"
3369 );
3370 assert!(text.contains("bold: Some(true)"), "{text}");
3371 }
3372
3373 let mut edit = preview_edit();
3374 edit.tab_id = Some("t.1".to_string());
3375 let text = describe(
3376 &outcome_with(WriteResult::Formatted {
3377 edit,
3378 style: formatting_style(),
3379 fields: "bold".to_string(),
3380 }),
3381 WriteVerb::TextStyle,
3382 );
3383 assert!(text.contains("tab Some(\"t.1\")"), "{text}");
3384 }
3385
3386 fn formatting_payloads() -> Vec<WritePayload> {
3387 use super::super::style::*;
3388 [
3389 StylePatch::Text(TextStyle {
3390 bold: Some(false),
3391 italic: Some(true),
3392 ..TextStyle::default()
3393 }),
3394 StylePatch::Paragraph(ParagraphStyle {
3395 alignment: Some(Alignment::Center),
3396 named_style_type: Some(NamedStyle::Heading1),
3397 }),
3398 ]
3399 .into_iter()
3400 .map(|style| WritePayload::Format {
3401 from: "Q3".into(),
3402 to: None,
3403 match_case: true,
3404 style,
3405 })
3406 .collect()
3407 }
3408
3409 #[tokio::test]
3410 async fn formatting_previews_and_writes_share_exact_wire_effects() {
3411 for payload in formatting_payloads() {
3412 let server = MockServer::start().await;
3413 let (drive, docs) = anchored_setup(&server).await;
3414 let rule = rule_for(&payload);
3415 let mut opts = replace_opts(true);
3416 opts.payload = payload;
3417 let preview = write(&drive, &docs, &opts, std::slice::from_ref(&rule)).await;
3418 let (edit, style, fields) = match preview.result {
3419 WriteResult::WouldFormat {
3420 edit,
3421 style,
3422 fields,
3423 } => (edit, style, fields),
3424 other => panic!("{other:?}"),
3425 };
3426 assert!(!server
3427 .received_requests()
3428 .await
3429 .unwrap()
3430 .iter()
3431 .any(|r| r.url.path().ends_with(":batchUpdate")));
3432 mount_batch_update(serde_json::json!({"replies": [{}]}))
3433 .expect(1)
3434 .mount(&server)
3435 .await;
3436 opts.dry_run = false;
3437 let actual = write(&drive, &docs, &opts, &[rule]).await;
3438 assert_eq!(
3439 actual.result,
3440 WriteResult::Formatted {
3441 edit: edit.clone(),
3442 style: style.clone(),
3443 fields: fields.clone()
3444 }
3445 );
3446 let requests = server.received_requests().await.unwrap();
3447 for read in requests
3448 .iter()
3449 .filter(|r| r.url.path() == "/v1/documents/doc-1")
3450 {
3451 assert!(read
3452 .url
3453 .query_pairs()
3454 .any(|(k, v)| k == "suggestionsViewMode" && v == "SUGGESTIONS_INLINE"));
3455 }
3456 let batch = requests
3457 .iter()
3458 .find(|r| r.url.path().ends_with(":batchUpdate"))
3459 .unwrap();
3460 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
3461 assert_eq!(
3462 body["writeControl"],
3463 serde_json::json!({"requiredRevisionId": "rev-anchor"})
3464 );
3465 assert_eq!(body["requests"].as_array().unwrap().len(), 1);
3466 let (verb, properties, expected_style, expected_range) = match style {
3467 super::super::style::StylePatch::Text(_) => (
3468 "updateTextStyle",
3469 "textStyle",
3470 serde_json::json!({"bold": false, "italic": true}),
3471 (4, 6),
3472 ),
3473 super::super::style::StylePatch::Paragraph(_) => (
3474 "updateParagraphStyle",
3475 "paragraphStyle",
3476 serde_json::json!({"alignment": "CENTER", "namedStyleType": "HEADING_1"}),
3477 (1, 14),
3478 ),
3479 };
3480 assert_eq!((edit.start_index, edit.end_index), expected_range);
3481 assert_eq!(
3482 body["requests"][0][verb],
3483 serde_json::json!({"range": {"startIndex": edit.start_index, "endIndex": edit.end_index}, properties: expected_style, "fields": fields})
3484 );
3485 }
3486 }
3487
3488 #[tokio::test]
3489 async fn formatting_is_blocked_before_content_reads_by_other_grants() {
3490 for payload in formatting_payloads() {
3491 for grant in [
3492 DriveOperation::DocsWrite,
3493 DriveOperation::DocsDelete,
3494 DriveOperation::Edit,
3495 DriveOperation::SheetsWrite,
3496 DriveOperation::SlidesWrite,
3497 ] {
3498 let server = MockServer::start().await;
3499 let (drive, docs) = clients(&server).await;
3500 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
3501 .mount(&server)
3502 .await;
3503 mount_folder("folder-1").mount(&server).await;
3504 let mut rule = rule_for(&payload);
3505 rule.allow = std::iter::once(grant).collect();
3506 let mut opts = replace_opts(true);
3507 opts.payload = payload.clone();
3508 assert!(matches!(
3509 write(&drive, &docs, &opts, &[rule]).await.result,
3510 WriteResult::Blocked { .. }
3511 ));
3512 assert!(!server
3513 .received_requests()
3514 .await
3515 .unwrap()
3516 .iter()
3517 .any(|r| r.url.path().starts_with("/v1/documents")));
3518 }
3519 }
3520 }
3521
3522 #[test]
3523 fn empty_style_and_invalid_anchors_fail_before_network() {
3524 let mut payload = formatting_payloads().remove(0);
3525 if let WritePayload::Format { style, .. } = &mut payload {
3526 *style =
3527 super::super::style::StylePatch::Text(super::super::style::TextStyle::default());
3528 }
3529 assert!(payload.validate().is_err());
3530 for anchor in ["", "a\nb"] {
3531 let mut payload = formatting_payloads().remove(0);
3532 if let WritePayload::Format { from, .. } = &mut payload {
3533 *from = anchor.into();
3534 }
3535 assert!(payload.validate().is_err());
3536 }
3537 }
3538
3539 fn table_payloads() -> Vec<WritePayload> {
3540 use super::super::table::{TableEdit, TableVerb};
3541 let mut payloads = vec![WritePayload::Table(TableEdit::Insert {
3542 anchor: "Intro".into(),
3543 side: Side::After,
3544 rows: 2,
3545 columns: 3,
3546 match_case: true,
3547 })];
3548 for verb in [
3549 TableVerb::InsertRow,
3550 TableVerb::InsertColumn,
3551 TableVerb::DeleteRow,
3552 TableVerb::DeleteColumn,
3553 ] {
3554 payloads.push(WritePayload::Table(TableEdit::Dimension {
3555 verb,
3556 cell: "Alpha".into(),
3557 after: true,
3558 match_case: true,
3559 }));
3560 }
3561 payloads
3562 }
3563
3564 async fn table_setup(server: &MockServer) -> (DriveClient, DocsClient) {
3565 let clients = clients(server).await;
3566 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
3567 .mount(server)
3568 .await;
3569 mount_folder("folder-1").mount(server).await;
3570 Mock::given(method("GET"))
3571 .and(path("/v1/documents/doc-1"))
3572 .respond_with(ResponseTemplate::new(200).set_body_json(super::super::table::fixture()))
3573 .mount(server)
3574 .await;
3575 clients
3576 }
3577
3578 #[tokio::test]
3579 async fn table_gate_isolation_blocks_before_content_fetch_in_preview_and_real_runs() {
3580 for payload in table_payloads() {
3581 for dry_run in [false, true] {
3582 for grant in [
3583 DriveOperation::Edit,
3584 DriveOperation::DocsWrite,
3585 DriveOperation::DocsDelete,
3586 DriveOperation::SheetsStructure,
3587 DriveOperation::SheetsDelete,
3588 if payload.gate_operation() == DriveOperation::DocsStructure {
3589 DriveOperation::DocsTableDelete
3590 } else {
3591 DriveOperation::DocsStructure
3592 },
3593 ] {
3594 let server = MockServer::start().await;
3595 let (drive, docs) = clients(&server).await;
3596 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
3597 .mount(&server)
3598 .await;
3599 mount_folder("folder-1").mount(&server).await;
3600 let mut rule = rule_for(&payload);
3601 rule.allow = std::iter::once(grant).collect();
3602 let mut opts = replace_opts(dry_run);
3603 opts.payload = payload.clone();
3604 assert!(matches!(
3605 write(&drive, &docs, &opts, &[rule]).await.result,
3606 WriteResult::Blocked { .. }
3607 ));
3608 assert!(server
3609 .received_requests()
3610 .await
3611 .unwrap()
3612 .iter()
3613 .all(|r| !r.url.path().starts_with("/v1/documents")));
3614 }
3615 }
3616 }
3617 }
3618
3619 #[tokio::test]
3620 async fn table_preview_and_leased_write_share_effects_and_one_revision_pinned_request() {
3621 for payload in table_payloads() {
3622 let server = MockServer::start().await;
3623 let (drive, docs) = table_setup(&server).await;
3624 let mut opts = replace_opts(true);
3625 opts.payload = payload;
3626 let rule = rule_for(&opts.payload);
3627 opts.lease_token = None;
3628 let preview = write(&drive, &docs, &opts, std::slice::from_ref(&rule)).await;
3629 let WriteResult::WouldEditTable { edit: expected } = preview.result else {
3630 panic!("{preview:?}");
3631 };
3632 assert!(server
3633 .received_requests()
3634 .await
3635 .unwrap()
3636 .iter()
3637 .all(|r| !r.url.path().ends_with(":batchUpdate")));
3638 mount_batch_update(serde_json::json!({"replies":[{}]}))
3639 .expect(1)
3640 .mount(&server)
3641 .await;
3642 let (token, path) = leased_opts_for("doc-1");
3643 opts.lease_token = token;
3644 opts.ledger_path = path;
3645 opts.dry_run = false;
3646 let applied = write(&drive, &docs, &opts, &[rule]).await;
3647 assert_eq!(
3648 applied.result,
3649 WriteResult::EditedTable {
3650 edit: expected.clone()
3651 }
3652 );
3653 let requests = server.received_requests().await.unwrap();
3654 for read in requests
3655 .iter()
3656 .filter(|r| r.url.path() == "/v1/documents/doc-1")
3657 {
3658 assert!(read
3659 .url
3660 .query_pairs()
3661 .any(|(k, v)| k == "suggestionsViewMode" && v == "SUGGESTIONS_INLINE"));
3662 }
3663 let batch = requests
3664 .iter()
3665 .find(|r| r.url.path().ends_with(":batchUpdate"))
3666 .unwrap();
3667 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
3668 let WritePayload::Table(edit) = &opts.payload else {
3669 unreachable!()
3670 };
3671 let doc = serde_json::from_value(super::super::table::fixture()).unwrap();
3672 let (request, _) = super::super::table::resolve(&doc, edit).unwrap();
3673 assert_eq!(
3674 body,
3675 serde_json::to_value(super::super::write_types::BatchUpdateDocumentRequest::new(
3676 request,
3677 "rev-table"
3678 ))
3679 .unwrap()
3680 );
3681 }
3682 }
3683
3684 #[tokio::test]
3685 async fn table_writes_refuse_missing_and_stale_backup_leases_and_surface_stale_docs_revisions()
3686 {
3687 for payload in table_payloads() {
3688 for stage in 0..3 {
3689 let server = MockServer::start().await;
3690 let (drive, docs) = table_setup(&server).await;
3691 let mut opts = replace_opts(false);
3692 opts.payload = payload.clone();
3693 match stage {
3694 0 => opts.lease_token = None,
3695 1 => opts.lease_token = Some(seed_lease(&opts.ledger_path, "doc-1", "0")),
3696 _ => {
3697 Mock::given(method("POST")).and(path("/v1/documents/doc-1:batchUpdate"))
3698 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({"error":{"code":400,"message":"The required revision ID does not match the latest revision.","status":"INVALID_ARGUMENT"}})))
3699 .expect(1).mount(&server).await;
3700 }
3701 }
3702 let result = write(&drive, &docs, &opts, &[rule_for(&payload)])
3703 .await
3704 .result;
3705 match stage {
3706 0 => assert!(matches!(result, WriteResult::RefusedNoLease)),
3707 1 => assert!(matches!(result, WriteResult::RefusedLeaseStale)),
3708 _ => assert!(matches!(result, WriteResult::StaleRevision { .. })),
3709 }
3710 if stage < 2 {
3711 assert!(server
3712 .received_requests()
3713 .await
3714 .unwrap()
3715 .iter()
3716 .all(|r| !r.url.path().ends_with(":batchUpdate")));
3717 }
3718 }
3719 }
3720 }
3721
3722 fn table_preview() -> TablePreview {
3723 TablePreview {
3724 operation: TableVerb::InsertRow,
3725 location: super::super::write_types::Location {
3726 index: 10,
3727 tab_id: Some("child".into()),
3728 segment_id: None,
3729 },
3730 row_index: Some(0),
3731 column_index: Some(1),
3732 rows_before: 2,
3733 columns_before: 2,
3734 rows_after: 3,
3735 columns_after: 2,
3736 insert_after: true,
3737 preceding_newline: false,
3738 }
3739 }
3740
3741 #[test]
3745 fn describe_renders_each_table_effect_with_its_direction_and_reference_cell() {
3746 let text = |applied: bool, edit: TablePreview| {
3747 let verb = WriteVerb::Table(edit.operation);
3748 let result = if applied {
3749 WriteResult::EditedTable { edit }
3750 } else {
3751 WriteResult::WouldEditTable { edit }
3752 };
3753 describe(&outcome_with(result), verb)
3754 };
3755 let with = |operation, insert_after, rows_after, columns_after| TablePreview {
3756 operation,
3757 rows_after,
3758 columns_after,
3759 insert_after,
3760 ..table_preview()
3761 };
3762 for (applied, edit, expected) in [
3763 (
3764 false,
3765 with(TableVerb::InsertRow, true, 3, 2),
3766 "Would apply insert-table-row in 'Budget': 2x2 -> 3x2, UTF-16 index 10, \
3767 tab child, reference cell (0, 1), below",
3768 ),
3769 (
3770 true,
3771 with(TableVerb::InsertRow, false, 3, 2),
3772 "Applied insert-table-row in 'Budget': 2x2 -> 3x2, UTF-16 index 10, \
3773 tab child, reference cell (0, 1), above",
3774 ),
3775 (
3776 false,
3777 with(TableVerb::InsertColumn, true, 2, 3),
3778 "Would apply insert-table-column in 'Budget': 2x2 -> 2x3, UTF-16 index 10, \
3779 tab child, reference cell (0, 1), right",
3780 ),
3781 (
3782 true,
3783 with(TableVerb::InsertColumn, false, 2, 3),
3784 "Applied insert-table-column in 'Budget': 2x2 -> 2x3, UTF-16 index 10, \
3785 tab child, reference cell (0, 1), left",
3786 ),
3787 (
3788 true,
3789 with(TableVerb::DeleteRow, false, 1, 2),
3790 "Applied delete-table-row in 'Budget': 2x2 -> 1x2, UTF-16 index 10, \
3791 tab child, reference cell (0, 1)",
3792 ),
3793 (
3794 false,
3795 with(TableVerb::DeleteColumn, false, 2, 1),
3796 "Would apply delete-table-column in 'Budget': 2x2 -> 2x1, UTF-16 index 10, \
3797 tab child, reference cell (0, 1)",
3798 ),
3799 (
3800 false,
3801 TablePreview {
3802 operation: TableVerb::InsertTable,
3803 location: super::super::write_types::Location {
3804 index: 4,
3805 tab_id: None,
3806 segment_id: None,
3807 },
3808 row_index: None,
3809 column_index: None,
3810 rows_before: 0,
3811 columns_before: 0,
3812 rows_after: 2,
3813 columns_after: 3,
3814 insert_after: false,
3815 preceding_newline: true,
3816 },
3817 "Would apply insert-table in 'Budget': 0x0 -> 2x3, UTF-16 index 4, \
3818 tab first; adds preceding newline",
3819 ),
3820 ] {
3821 assert_eq!(text(applied, edit), expected);
3822 }
3823 }
3824
3825 #[test]
3826 fn describe_names_the_refused_table_reason_without_quoting_document_text() {
3827 assert_eq!(
3828 describe(
3829 &outcome_with(WriteResult::RefusedTable {
3830 error: TableError::LastDimension,
3831 }),
3832 WriteVerb::Table(TableVerb::DeleteRow),
3833 ),
3834 "Refused: unsafe or unresolved table in 'Budget': LastDimension"
3835 );
3836 }
3837
3838 #[test]
3841 fn table_statuses_are_named_for_the_log_and_match_the_wire_tag() {
3842 for (result, status) in [
3843 (
3844 WriteResult::WouldEditTable {
3845 edit: table_preview(),
3846 },
3847 "would-edit-table",
3848 ),
3849 (
3850 WriteResult::EditedTable {
3851 edit: table_preview(),
3852 },
3853 "edited-table",
3854 ),
3855 (
3856 WriteResult::RefusedTable {
3857 error: TableError::UnsafeBoundary,
3858 },
3859 "refused-table",
3860 ),
3861 ] {
3862 assert_eq!(result.log_status(), status);
3863 assert_eq!(serde_json::to_value(&result).unwrap()["status"], status);
3864 }
3865 }
3866
3867 #[test]
3871 fn a_table_verb_agrees_with_its_payload_on_label_log_name_and_permission() {
3872 for table_verb in [
3873 TableVerb::InsertTable,
3874 TableVerb::InsertRow,
3875 TableVerb::InsertColumn,
3876 TableVerb::DeleteRow,
3877 TableVerb::DeleteColumn,
3878 ] {
3879 let verb = WriteVerb::Table(table_verb);
3880 assert_eq!(verb.label(), table_verb.label());
3881 assert_eq!(verb.log_operation(), table_verb.log_operation());
3882 assert_eq!(verb.gate_operation(), table_verb.gate_operation());
3883 }
3884 for payload in table_payloads().into_iter().chain(formatting_payloads()) {
3885 assert_eq!(payload.verb().gate_operation(), payload.gate_operation());
3886 }
3887 }
3888
3889 #[test]
3890 fn every_verb_is_gated_by_the_permission_its_payload_is() {
3891 for (verb, gate) in [
3892 (WriteVerb::Replace, DriveOperation::DocsWrite),
3893 (WriteVerb::Append, DriveOperation::DocsWrite),
3894 (WriteVerb::Insert, DriveOperation::DocsWrite),
3895 (WriteVerb::Delete, DriveOperation::DocsDelete),
3896 (WriteVerb::TextStyle, DriveOperation::DocsFormat),
3897 (WriteVerb::ParagraphStyle, DriveOperation::DocsFormat),
3898 (WriteVerb::CreateBullets, DriveOperation::DocsWrite),
3899 (WriteVerb::DeleteBullets, DriveOperation::DocsWrite),
3900 (
3901 WriteVerb::Table(TableVerb::InsertRow),
3902 DriveOperation::DocsStructure,
3903 ),
3904 (
3905 WriteVerb::Table(TableVerb::DeleteRow),
3906 DriveOperation::DocsTableDelete,
3907 ),
3908 ] {
3909 assert_eq!(verb.gate_operation(), gate, "{verb:?}");
3910 }
3911 }
3912
3913 #[test]
3917 fn describe_names_the_permission_each_verb_needs_when_no_parent_is_visible() {
3918 for (verb, operation) in [
3919 (WriteVerb::Replace, "docs-write"),
3920 (WriteVerb::Delete, "docs-delete"),
3921 (WriteVerb::TextStyle, "docs-format"),
3922 (WriteVerb::ParagraphStyle, "docs-format"),
3923 (WriteVerb::Table(TableVerb::InsertTable), "docs-structure"),
3924 (WriteVerb::Table(TableVerb::InsertColumn), "docs-structure"),
3925 (
3926 WriteVerb::Table(TableVerb::DeleteColumn),
3927 "docs-table-delete",
3928 ),
3929 ] {
3930 let text = describe(&outcome_with(WriteResult::RefusedNoVisibleParents), verb);
3931 assert!(text.contains(&format!("[\"{operation}\"]")), "{text}");
3932 assert!(
3933 text.contains(&format!("granting that folder `{operation}`")),
3934 "{text}"
3935 );
3936 }
3937 }
3938
3939 #[tokio::test]
3943 async fn a_table_edit_that_does_not_resolve_is_refused_before_any_mutation() {
3944 for dry_run in [true, false] {
3945 let server = MockServer::start().await;
3946 let (drive, docs) = table_setup(&server).await;
3947 let payload = WritePayload::Table(TableEdit::Dimension {
3948 verb: TableVerb::DeleteRow,
3949 cell: "missing".into(),
3950 after: false,
3951 match_case: true,
3952 });
3953 let rule = rule_for(&payload);
3954 let mut opts = replace_opts(dry_run);
3955 opts.payload = payload;
3956 let outcome = write(&drive, &docs, &opts, &[rule]).await;
3957 assert_eq!(
3958 outcome.result,
3959 WriteResult::RefusedTable {
3960 error: TableError::Anchor {
3961 error: AnchorError::NotFound
3962 }
3963 },
3964 "dry_run {dry_run}"
3965 );
3966 assert_eq!(outcome.required_revision_id.as_deref(), Some("rev-table"));
3967 assert!(server
3968 .received_requests()
3969 .await
3970 .unwrap()
3971 .iter()
3972 .all(|r| !r.url.path().ends_with(":batchUpdate")));
3973 }
3974 }
3975
3976 fn named_payloads() -> Vec<WritePayload> {
3977 use super::super::named_range::{Mutation, Scope};
3978 [
3979 Mutation::Create {
3980 name: "label".into(),
3981 start_index: 1,
3982 end_index: 3,
3983 },
3984 Mutation::Delete { id: "nr".into() },
3985 Mutation::Replace {
3986 id: "nr".into(),
3987 text: "new".into(),
3988 },
3989 Mutation::Replace {
3990 id: "nr".into(),
3991 text: String::new(),
3992 },
3993 ]
3994 .into_iter()
3995 .map(|mutation| WritePayload::NamedRange {
3996 scope: Scope {
3997 tab_id: Some("t".into()),
3998 segment_id: None,
3999 },
4000 mutation,
4001 })
4002 .collect()
4003 }
4004
4005 async fn named_setup(server: &MockServer, revision: Option<&str>) -> (DriveClient, DocsClient) {
4006 let pair = clients(server).await;
4007 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
4008 .mount(server)
4009 .await;
4010 mount_folder("folder-1").mount(server).await;
4011 Mock::given(method("GET")).and(path("/v1/documents/doc-1"))
4012 .and(query_param("suggestionsViewMode", "SUGGESTIONS_INLINE"))
4013 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4014 "revisionId": revision, "tabs": [{"tabProperties": {"tabId": "t"}, "documentTab": {
4015 "body": {"content": [{"startIndex": 1, "endIndex": 10, "paragraph": {"elements": [
4016 {"startIndex": 1, "endIndex": 10, "textRun": {"content": "😀 label\n"}}
4017 ]}}]},
4018 "namedRanges": {"label": {"namedRanges": [{"namedRangeId": "nr", "ranges": [{"startIndex": 1, "endIndex": 3}]}]}}
4019 }}]
4020 }))).mount(server).await;
4021 pair
4022 }
4023
4024 #[tokio::test]
4025 async fn named_range_preview_and_apply_share_scope_counts_revision_and_created_id() {
4026 for payload in named_payloads() {
4027 let server = MockServer::start().await;
4028 let (drive, docs) = named_setup(&server, Some("r-named")).await;
4029 let mut opts = replace_opts(true);
4030 opts.payload = payload;
4031 opts.lease_token = None;
4032 let rule = rule_for(&opts.payload);
4033 let expected = match write(&drive, &docs, &opts, std::slice::from_ref(&rule))
4034 .await
4035 .result
4036 {
4037 WriteResult::WouldMutateNamedRange { preview } => preview,
4038 other => panic!("{other:?}"),
4039 };
4040 assert!(!server
4041 .received_requests()
4042 .await
4043 .unwrap()
4044 .iter()
4045 .any(|r| r.url.path().ends_with(":batchUpdate")));
4046 let create = opts.payload.verb() == WriteVerb::CreateNamedRange;
4047 mount_batch_update(if create { serde_json::json!({"replies": [{"createNamedRange": {"namedRangeId": "created-id"}}]}) }
4048 else { serde_json::json!({"replies": [{}]}) }).expect(1).mount(&server).await;
4049 opts.dry_run = false;
4050 opts.lease_token = Some(seed_lease(&opts.ledger_path, "doc-1", "1"));
4051 let actual = match write(&drive, &docs, &opts, &[rule]).await.result {
4052 WriteResult::MutatedNamedRange { preview } => preview,
4053 other => panic!("{other:?}"),
4054 };
4055 let mut expected = expected;
4056 if create {
4057 expected.named_range_id = Some("created-id".into());
4058 }
4059 assert_eq!(actual, expected);
4060 let requests = server.received_requests().await.unwrap();
4061 let batch = requests
4062 .iter()
4063 .find(|r| r.url.path().ends_with(":batchUpdate"))
4064 .unwrap();
4065 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
4066 assert_eq!(
4067 body["writeControl"],
4068 serde_json::json!({"requiredRevisionId": "r-named"})
4069 );
4070 assert_eq!(body["requests"].as_array().unwrap().len(), 1);
4071 if !create {
4072 let operation = if opts.payload.verb() == WriteVerb::DeleteNamedRange {
4073 "deleteNamedRange"
4074 } else {
4075 "replaceNamedRangeContent"
4076 };
4077 assert_eq!(
4078 body["requests"][0][operation]["tabsCriteria"],
4079 serde_json::json!({"tabIds": ["t"]})
4080 );
4081 assert_eq!(body["requests"][0][operation]["namedRangeId"], "nr");
4082 }
4083 }
4084 }
4085
4086 #[tokio::test]
4087 async fn named_range_gates_are_isolated_before_any_docs_read() {
4088 for payload in named_payloads() {
4089 for grant in [
4090 DriveOperation::DocsWrite,
4091 DriveOperation::DocsDelete,
4092 DriveOperation::DocsStructure,
4093 DriveOperation::Edit,
4094 DriveOperation::SheetsStructure,
4095 ] {
4096 if grant == payload.gate_operation() {
4097 continue;
4098 }
4099 let server = MockServer::start().await;
4100 let (drive, docs) = clients(&server).await;
4101 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
4102 .mount(&server)
4103 .await;
4104 mount_folder("folder-1").mount(&server).await;
4105 let mut rule = allow_rule("folder-1");
4106 rule.allow = std::iter::once(grant).collect();
4107 let mut opts = replace_opts(false);
4108 opts.payload = payload.clone();
4109 assert!(matches!(
4110 write(&drive, &docs, &opts, &[rule]).await.result,
4111 WriteResult::Blocked { .. }
4112 ));
4113 assert!(!server
4114 .received_requests()
4115 .await
4116 .unwrap()
4117 .iter()
4118 .any(|r| r.url.path().starts_with("/v1/documents")));
4119 }
4120 }
4121 }
4122
4123 #[tokio::test]
4124 async fn named_range_mutations_preserve_all_lease_refusals_and_stale_revision() {
4125 for payload in named_payloads() {
4126 for expected in [
4127 WriteResult::RefusedNoLease,
4128 WriteResult::RefusedLeaseExpired,
4129 WriteResult::RefusedLeaseWrongFile,
4130 WriteResult::RefusedLeaseStale,
4131 ] {
4132 let server = MockServer::start().await;
4133 let (drive, docs) = named_setup(&server, Some("r-named")).await;
4134 let mut opts = replace_opts(false);
4135 opts.payload = payload.clone();
4136 opts.lease_token = match expected {
4137 WriteResult::RefusedNoLease => None,
4138 WriteResult::RefusedLeaseExpired => Some("unknown".into()),
4139 WriteResult::RefusedLeaseWrongFile => {
4140 Some(seed_lease(&opts.ledger_path, "other", "1"))
4141 }
4142 _ => Some(seed_lease(&opts.ledger_path, "doc-1", "0")),
4143 };
4144 assert_eq!(
4145 write(&drive, &docs, &opts, &[rule_for(&payload)])
4146 .await
4147 .result,
4148 expected
4149 );
4150 assert!(!server
4151 .received_requests()
4152 .await
4153 .unwrap()
4154 .iter()
4155 .any(|r| r.url.path().ends_with(":batchUpdate")));
4156 }
4157 let server = MockServer::start().await;
4158 let (drive, docs) = named_setup(&server, Some("r-named")).await;
4159 Mock::given(method("POST")).and(path("/v1/documents/doc-1:batchUpdate"))
4160 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({"error": {"code": 400, "message": "The required revision ID 'r-named' does not match the latest revision."}})))
4161 .expect(1).mount(&server).await;
4162 let mut opts = replace_opts(false);
4163 opts.payload = payload.clone();
4164 assert!(matches!(
4165 write(&drive, &docs, &opts, &[rule_for(&payload)])
4166 .await
4167 .result,
4168 WriteResult::StaleRevision { .. }
4169 ));
4170 }
4171 }
4172
4173 #[tokio::test]
4174 async fn named_range_missing_revision_and_unsafe_scope_never_mutate() {
4175 for payload in named_payloads() {
4176 for revision in [None, Some("r-named")] {
4177 let server = MockServer::start().await;
4178 let (drive, docs) = named_setup(&server, revision).await;
4179 let mut opts = replace_opts(false);
4180 opts.payload = payload.clone();
4181 if revision.is_some() {
4182 if let WritePayload::NamedRange { scope, .. } = &mut opts.payload {
4183 scope.segment_id = Some("missing".into());
4184 }
4185 }
4186 let result = write(&drive, &docs, &opts, &[rule_for(&payload)])
4187 .await
4188 .result;
4189 if revision.is_none() {
4190 assert_eq!(result, WriteResult::RefusedNoRevisionId);
4191 } else {
4192 assert!(matches!(result, WriteResult::RefusedNamedRange { .. }));
4193 }
4194 assert!(!server
4195 .received_requests()
4196 .await
4197 .unwrap()
4198 .iter()
4199 .any(|r| r.url.path().ends_with(":batchUpdate")));
4200 }
4201 }
4202 }
4203 #[tokio::test]
4204 async fn named_range_ids_and_spans_are_available_in_structured_reads() {
4205 let server = MockServer::start().await;
4206 let (_, docs) = named_setup(&server, Some("r-named")).await;
4207 let outcome = super::super::read::read(
4208 &DocsApi::new(&docs),
4209 &super::super::read::ReadOptions {
4210 document_id: "doc-1".into(),
4211 tab: Some("t".into()),
4212 suggestions: SuggestionsViewMode::Inline,
4213 },
4214 )
4215 .await
4216 .unwrap();
4217 let named = &outcome.tabs[0].named_ranges["label"].named_ranges[0];
4218 assert_eq!(named.named_range_id.as_deref(), Some("nr"));
4219 assert_eq!(named.ranges[0].end_index, Some(3));
4220 assert!(serde_json::to_value(outcome).unwrap()["tabs"][0]
4221 .get("named_ranges")
4222 .is_some());
4223 }
4224}