1use std::path::PathBuf;
31use std::time::{Duration, Instant};
32
33use serde::Serialize;
34
35use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
36use crate::drive::client::DriveClient;
37use crate::drive::docs::api::{is_stale_revision, DocsApi, SuggestionsViewMode};
38use crate::drive::docs::client::DocsClient;
39use crate::drive::docs::write_types::DocsRequest;
40use crate::drive::files_api::FilesApi;
41use crate::drive::folder_ancestry;
42use crate::drive::lease::check::{
43 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
44 LeasedWrite,
45};
46use crate::drive::types::{GOOGLE_DOC_MIME_TYPE, GOOGLE_SHORTCUT_MIME_TYPE};
47use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
48use crate::request_log::{self, DriveMutationOutcome};
49
50#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum WritePayload {
60 Replace {
62 search: String,
64 replace: String,
66 match_case: bool,
68 },
69 Append {
71 text: String,
73 },
74}
75
76impl WritePayload {
77 #[must_use]
79 pub const fn verb(&self) -> WriteVerb {
80 match self {
81 Self::Replace { .. } => WriteVerb::Replace,
82 Self::Append { .. } => WriteVerb::Append,
83 }
84 }
85
86 fn validate(&self) -> Result<(), String> {
92 match self {
93 Self::Replace { search, .. } if search.is_empty() => {
94 Err("--search cannot be empty".to_string())
95 }
96 Self::Append { text } if text.is_empty() => {
97 Err("nothing to append: the text is empty".to_string())
98 }
99 _ => Ok(()),
100 }
101 }
102
103 fn to_request(&self) -> DocsRequest {
105 match self {
106 Self::Replace {
107 search,
108 replace,
109 match_case,
110 } => DocsRequest::replace_all_text(search, replace, *match_case),
111 Self::Append { text } => DocsRequest::insert_text_at_end(text),
112 }
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum WriteVerb {
119 Replace,
121 Append,
123}
124
125impl WriteVerb {
126 const fn log_operation(self) -> &'static str {
132 match self {
133 Self::Replace => "docs-replace",
134 Self::Append => "docs-append",
135 }
136 }
137
138 const fn label(self) -> &'static str {
140 match self {
141 Self::Replace => "replace",
142 Self::Append => "append",
143 }
144 }
145}
146
147#[derive(Debug, Clone)]
149pub struct WriteOptions {
150 pub document_id: String,
152 pub payload: WritePayload,
154 pub dry_run: bool,
156 pub lease_token: Option<String>,
161 pub ledger_path: PathBuf,
165}
166
167#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
169#[serde(tag = "status", rename_all = "kebab-case")]
170pub enum WriteResult {
171 WouldReplace {
173 occurrences: usize,
175 },
176 WouldAppend {
178 #[serde(skip_serializing_if = "Option::is_none")]
181 document_end_index: Option<i64>,
182 chars: usize,
184 bytes: usize,
186 },
187 RefusedNotADocument {
189 mime_type: String,
191 },
192 RefusedShortcut,
194 RefusedNoVisibleParents,
197 RefusedNoRevisionId,
201 Blocked {
203 decided_by: Option<DecidingRule>,
205 },
206 RefusedNoLease,
209 RefusedLeaseExpired,
212 RefusedLeaseWrongFile,
214 RefusedLeaseStale,
218 Replaced {
220 occurrences_changed: i64,
228 },
229 Appended {
231 chars: usize,
233 bytes: usize,
235 },
236 StaleRevision {
240 required_revision_id: String,
242 detail: String,
244 },
245 Failed {
247 detail: String,
249 },
250}
251
252impl FromLeaseRefusal for WriteResult {
253 fn from_no_lease() -> Self {
254 Self::RefusedNoLease
255 }
256 fn from_lease_expired() -> Self {
257 Self::RefusedLeaseExpired
258 }
259 fn from_lease_wrong_file() -> Self {
260 Self::RefusedLeaseWrongFile
261 }
262 fn from_lease_stale() -> Self {
263 Self::RefusedLeaseStale
264 }
265 fn from_lease_failed(detail: String) -> Self {
266 Self::Failed { detail }
267 }
268}
269
270impl WriteResult {
271 fn log_status(&self) -> &'static str {
273 match self {
274 Self::WouldReplace { .. } => "would-replace",
275 Self::WouldAppend { .. } => "would-append",
276 Self::RefusedNotADocument { .. } => "refused-not-a-document",
277 Self::RefusedShortcut => "refused-shortcut",
278 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
279 Self::RefusedNoRevisionId => "refused-no-revision-id",
280 Self::Blocked { .. } => "blocked",
281 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
282 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
283 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
284 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
285 Self::Replaced { .. } => "replaced",
286 Self::Appended { .. } => "appended",
287 Self::StaleRevision { .. } => "stale-revision",
288 Self::Failed { .. } => "failed",
289 }
290 }
291}
292
293#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
295pub struct WriteOutcome {
296 pub document_id: String,
298 #[serde(skip_serializing_if = "Option::is_none")]
300 pub file_name: Option<String>,
301 #[serde(skip_serializing_if = "Option::is_none")]
303 pub resolved_folder_id: Option<String>,
304 #[serde(skip_serializing_if = "Option::is_none")]
306 pub required_revision_id: Option<String>,
307 pub result: WriteResult,
309}
310
311impl JsonlSerialize for WriteOutcome {
312 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
313 write_scalar_jsonl(self, out)
314 }
315}
316
317#[must_use]
329pub fn count_occurrences(haystack: &str, needle: &str, match_case: bool) -> usize {
330 if needle.is_empty() {
331 return 0;
332 }
333 if match_case {
334 return haystack.matches(needle).count();
335 }
336 haystack
339 .to_lowercase()
340 .matches(&needle.to_lowercase())
341 .count()
342}
343
344pub async fn write(
350 drive: &DriveClient,
351 docs: &DocsClient,
352 opts: &WriteOptions,
353 rules: &[FolderPermissionRule],
354) -> WriteOutcome {
355 let started = Instant::now();
356 let outcome = write_inner(drive, docs, opts, rules).await;
357 if !opts.dry_run {
358 record_attempt(&outcome, opts, started.elapsed());
359 }
360 outcome
361}
362
363async fn write_inner(
364 drive: &DriveClient,
365 docs: &DocsClient,
366 opts: &WriteOptions,
367 rules: &[FolderPermissionRule],
368) -> WriteOutcome {
369 let bare = |result| WriteOutcome {
370 document_id: opts.document_id.clone(),
371 file_name: None,
372 resolved_folder_id: None,
373 required_revision_id: None,
374 result,
375 };
376
377 if let Err(detail) = opts.payload.validate() {
379 return bare(WriteResult::Failed { detail });
380 }
381
382 let files_api = FilesApi::new(drive);
383 let target = match files_api.get_metadata(&opts.document_id).await {
384 Ok(target) => target,
385 Err(err) => {
386 return bare(WriteResult::Failed {
387 detail: err.to_string(),
388 })
389 }
390 };
391
392 let with_target = |result| WriteOutcome {
393 document_id: opts.document_id.clone(),
394 file_name: Some(target.name.clone()),
395 resolved_folder_id: None,
396 required_revision_id: None,
397 result,
398 };
399
400 if target.mime_type == GOOGLE_SHORTCUT_MIME_TYPE {
402 return with_target(WriteResult::RefusedShortcut);
403 }
404 if target.mime_type != GOOGLE_DOC_MIME_TYPE {
405 return with_target(WriteResult::RefusedNotADocument {
406 mime_type: target.mime_type.clone(),
407 });
408 }
409
410 let evaluated = match folder_ancestry::resolve_decision_for_file_target(
414 &files_api,
415 &target,
416 DriveOperation::DocsWrite,
417 rules,
418 )
419 .await
420 {
421 Ok(evaluated) => evaluated,
422 Err(err) => {
425 return with_target(WriteResult::Failed {
426 detail: err.to_string(),
427 })
428 }
429 };
430
431 if evaluated.source == folder_ancestry::DecisionSource::NoVisibleParents {
434 return with_target(WriteResult::RefusedNoVisibleParents);
435 }
436
437 let folder_ancestry::FileTargetDecision {
438 decision,
439 resolved_folder_id,
440 requires_lease,
441 ..
442 } = evaluated;
443
444 let gated = |result, revision: Option<String>| WriteOutcome {
445 document_id: opts.document_id.clone(),
446 file_name: Some(target.name.clone()),
447 resolved_folder_id: resolved_folder_id.clone(),
448 required_revision_id: revision,
449 result,
450 };
451
452 if decision.verdict == write_gate::Verdict::Deny {
453 return gated(
454 WriteResult::Blocked {
455 decided_by: decision.decided_by,
456 },
457 None,
458 );
459 }
460
461 let api = DocsApi::new(docs);
463 let document = match api
464 .get_document(&opts.document_id, SuggestionsViewMode::default())
465 .await
466 {
467 Ok(document) => document,
468 Err(err) => {
469 return gated(
470 WriteResult::Failed {
471 detail: err.to_string(),
472 },
473 None,
474 )
475 }
476 };
477
478 let Some(revision_id) = document.revision_id.clone() else {
479 return gated(WriteResult::RefusedNoRevisionId, None);
480 };
481
482 let preview = match &opts.payload {
485 WritePayload::Replace {
486 search, match_case, ..
487 } => {
488 let corpus = document_text(&document);
491 WriteResult::WouldReplace {
492 occurrences: count_occurrences(&corpus, search, *match_case),
493 }
494 }
495 WritePayload::Append { text } => WriteResult::WouldAppend {
496 document_end_index: body_end_index(&document),
497 chars: text.chars().count(),
498 bytes: text.len(),
499 },
500 };
501
502 if opts.dry_run {
503 return gated(preview, Some(revision_id));
504 }
505
506 let leased = LeasedWrite {
523 log_prefix: "drive docs write",
524 operation: opts.payload.verb().log_operation(),
525 ledger_path: &opts.ledger_path,
526 file_id: &opts.document_id,
527 };
528 let lease_grant = match gate_optional_leased_write(
529 leased,
530 &files_api,
531 requires_lease,
532 opts.lease_token.as_deref(),
533 )
534 .await
535 {
536 Ok(grant) => grant,
537 Err(err) => return gated(err.into_result(), Some(revision_id)),
538 };
539
540 let result = match conclude_native_leased_write(
542 leased,
543 &lease_grant,
544 &files_api,
545 api.batch_update(&opts.document_id, opts.payload.to_request(), &revision_id)
546 .await,
547 ToString::to_string,
548 )
549 .await
550 {
551 Ok(response) => match &opts.payload {
552 WritePayload::Replace { .. } => WriteResult::Replaced {
553 occurrences_changed: response.occurrences_changed_for_replace(),
554 },
555 WritePayload::Append { text } => WriteResult::Appended {
556 chars: text.chars().count(),
557 bytes: text.len(),
558 },
559 },
560 Err(err) => {
561 let detail = err.to_string();
565 if is_stale_revision(&err) {
566 WriteResult::StaleRevision {
567 required_revision_id: revision_id.clone(),
568 detail,
569 }
570 } else {
571 WriteResult::Failed { detail }
572 }
573 }
574 };
575 drop(lease_grant);
576 gated(result, Some(revision_id))
577}
578
579fn document_text(document: &crate::drive::docs::types::Document) -> String {
585 document
586 .resolved_tabs()
587 .iter()
588 .filter_map(|tab| tab.body)
589 .flat_map(crate::drive::docs::structure::flatten)
590 .map(|element| element.text)
591 .collect::<Vec<_>>()
592 .join("\n")
593}
594
595fn body_end_index(document: &crate::drive::docs::types::Document) -> Option<i64> {
600 document
601 .resolved_tabs()
602 .first()
603 .and_then(|tab| tab.body)
604 .and_then(|body| body.content.last())
605 .map(crate::drive::docs::types::StructuralElement::end_index)
606}
607
608fn record_attempt(outcome: &WriteOutcome, opts: &WriteOptions, duration: Duration) {
619 let error = match &outcome.result {
620 WriteResult::Failed { detail } | WriteResult::StaleRevision { detail, .. } => {
624 Some(detail.clone())
625 }
626 _ => None,
627 };
628 let decided_by = match &outcome.result {
629 WriteResult::Blocked { decided_by } => decided_by.as_ref(),
630 _ => None,
631 };
632 let decided_by = write_gate::decided_by_log_fields(decided_by);
633 let occurrences_changed = match &outcome.result {
634 WriteResult::Replaced {
635 occurrences_changed,
636 } => Some(*occurrences_changed),
637 _ => None,
638 };
639 let inserted_chars = match &outcome.result {
640 WriteResult::Appended { chars, .. } => Some(*chars as i64),
641 _ => None,
642 };
643
644 request_log::record_drive_mutation(DriveMutationOutcome {
645 operation: opts.payload.verb().log_operation(),
646 file_id: outcome.document_id.clone(),
647 file_name: outcome.file_name.clone().unwrap_or_default(),
648 status: outcome.result.log_status().to_string(),
649 resolved_folder_id: outcome.resolved_folder_id.clone(),
650 decided_by_folder_id: decided_by.folder_id,
651 decided_by_depth: decided_by.depth,
652 decided_by_file_id: decided_by.file_id,
653 occurrences_changed,
654 inserted_chars,
655 required_revision_id: outcome.required_revision_id.clone(),
656 error,
657 duration,
658 ..Default::default()
659 });
660}
661
662#[must_use]
667pub fn describe(outcome: &WriteOutcome, verb: WriteVerb) -> String {
668 let name = outcome.file_name.as_deref().unwrap_or(&outcome.document_id);
669 match &outcome.result {
670 WriteResult::WouldReplace { occurrences } => format!(
671 "Would replace: {occurrences} occurrence(s) in '{name}' \
672 (counted from the copy just read)"
673 ),
674 WriteResult::WouldAppend {
675 document_end_index,
676 chars,
677 bytes,
678 } => {
679 let where_ = document_end_index
680 .map(|index| format!(" (body ends at index {index})"))
681 .unwrap_or_default();
682 format!("Would append: {chars} char(s) / {bytes} byte(s) to '{name}'{where_}")
683 }
684 WriteResult::RefusedNotADocument { mime_type } => format!(
685 "Refused: '{name}' is not a Google Doc (mimeType: {mime_type}); \
686 `drive docs {}` only works on Google Docs",
687 verb.label()
688 ),
689 WriteResult::RefusedShortcut => format!(
690 "Refused: '{name}' is a shortcut; `drive docs {}` doesn't follow shortcuts — \
691 resolve the target document's id and use that instead",
692 verb.label()
693 ),
694 WriteResult::RefusedNoVisibleParents => format!(
695 "Refused: '{name}' has no parent folder visible to this account, so no folder \
696 rule can apply to it. This is normal for a Doc shared by link or email. \
697 Grant it by id instead: add {{\"file_id\": \"<document id>\", \"allow\": \
698 [\"docs-write\"]}} to write_permissions.rules. (Adding it to a folder in your \
699 own Drive and granting that folder `docs-write` also works.)"
700 ),
701 WriteResult::RefusedNoRevisionId => format!(
702 "Refused: '{name}' returned no revision id, which Google sends only to callers \
703 with edit access — so this write cannot be leased against a known version. \
704 Request edit access, or check the account in use."
705 ),
706 WriteResult::Blocked { decided_by } => match decided_by {
707 Some(rule) => format!(
710 "Blocked: '{name}' — refused by rule on {} {}{}",
711 rule.kind_label(),
712 rule.id(),
713 rule.depth_suffix()
714 ),
715 None => format!("Blocked: '{name}' — refused by default policy (no matching rule)"),
716 },
717 WriteResult::RefusedNoLease => LeaseGateRefusal::NoLease
718 .describe_line(&outcome.document_id, &format!("'{name}'"))
719 .unwrap_or_default(),
720 WriteResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
721 .describe_line(&outcome.document_id, &format!("'{name}'"))
722 .unwrap_or_default(),
723 WriteResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
724 .describe_line(&outcome.document_id, &format!("'{name}'"))
725 .unwrap_or_default(),
726 WriteResult::RefusedLeaseStale => LeaseGateRefusal::Stale
727 .describe_line(&outcome.document_id, &format!("'{name}'"))
728 .unwrap_or_default(),
729 WriteResult::Replaced {
730 occurrences_changed,
731 } => format!("Replaced: {occurrences_changed} occurrence(s) in '{name}'"),
732 WriteResult::Appended { chars, bytes } => {
733 format!("Appended: {chars} char(s) / {bytes} byte(s) to '{name}'")
734 }
735 WriteResult::StaleRevision {
736 required_revision_id,
737 ..
738 } => format!(
739 "Refused: '{name}' changed since it was read (revision lease \
740 {required_revision_id} no longer current) — nothing was written. \
741 Re-run to apply against the current version."
742 ),
743 WriteResult::Failed { detail } => format!("Failed: '{name}': {detail}"),
744 }
745}
746
747#[cfg(test)]
748#[allow(clippy::unwrap_used, clippy::expect_used)]
749mod tests {
750 use super::*;
751 use std::collections::HashSet;
752
753 use crate::drive::test_support::seed_lease;
754
755 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
756 use crate::drive::docs::client::DOCS_API_URL;
757 use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
758 use crate::test_support::env::MapEnv;
759 use crate::utils::secret::Secret;
760 use wiremock::matchers::{method, path};
761 use wiremock::{Mock, MockServer, ResponseTemplate};
762
763 fn test_credentials() -> DriveCredentials {
764 DriveCredentials {
765 client_id: "client-1".to_string(),
766 client_secret: Secret::new("secret-1"),
767 refresh_token: Secret::new("refresh-1"),
768 scope: DriveGrantedScopes::READONLY,
769 }
770 }
771
772 async fn clients(server: &MockServer) -> (DriveClient, DocsClient) {
779 Mock::given(method("POST"))
780 .and(path("/token"))
781 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
782 "access_token": "test-token", "expires_in": 3600,
783 })))
784 .mount(server)
785 .await;
786 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
787 crate::drive::client::test_support::replace_session(
788 &mut drive,
789 &test_credentials(),
790 &format!("{}/token", server.uri()),
791 );
792 let env = MapEnv::new().with(DOCS_API_URL, &server.uri());
793 let docs = DocsClient::from_drive_client_with(&env, &drive).unwrap();
794 (drive, docs)
795 }
796
797 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> Mock {
802 let parents: Vec<&str> = parents.to_vec();
803 Mock::given(method("GET"))
804 .and(path(format!("/drive/v3/files/{id}")))
805 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
806 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
807 "version": "1",
808 })))
809 }
810
811 fn leased_opts_for(document_id: &str) -> (Option<String>, std::path::PathBuf) {
814 let ledger_path = tempfile::tempdir()
815 .unwrap()
816 .keep()
817 .join("lease-ledger.jsonl");
818 let token = seed_lease(&ledger_path, document_id, "1");
819 (Some(token), ledger_path)
820 }
821
822 fn mount_folder(id: &str) -> Mock {
823 mount_file(id, "application/vnd.google-apps.folder", &[])
824 }
825
826 fn mount_document(revision: Option<&str>, text: &str) -> Mock {
827 let mut body = serde_json::json!({
828 "documentId": "doc-1",
829 "body": {"content": [{
830 "startIndex": 1, "endIndex": 1 + text.encode_utf16().count() as i64,
831 "paragraph": {"elements": [{"textRun": {"content": format!("{text}\n")}}]},
832 }]},
833 });
834 if let Some(revision) = revision {
835 body["revisionId"] = serde_json::json!(revision);
836 }
837 Mock::given(method("GET"))
838 .and(path("/v1/documents/doc-1"))
839 .respond_with(ResponseTemplate::new(200).set_body_json(body))
840 }
841
842 fn mount_batch_update(response: serde_json::Value) -> Mock {
843 Mock::given(method("POST"))
844 .and(path("/v1/documents/doc-1:batchUpdate"))
845 .respond_with(ResponseTemplate::new(200).set_body_json(response))
846 }
847
848 fn allow_rule(folder: &str) -> FolderPermissionRule {
849 FolderPermissionRule {
850 folder_id: Some(folder.to_string()),
851 file_id: None,
852 recursive: true,
853 allow: std::iter::once(DriveOperation::DocsWrite).collect(),
854 deny: HashSet::default(),
855 require_lease: true,
856 }
857 }
858
859 fn replace_opts(dry_run: bool) -> WriteOptions {
868 let (lease_token, ledger_path) = leased_opts_for("doc-1");
869 WriteOptions {
870 document_id: "doc-1".to_string(),
871 payload: WritePayload::Replace {
872 search: "Q3".to_string(),
873 replace: "Q4".to_string(),
874 match_case: true,
875 },
876 dry_run,
877 lease_token,
878 ledger_path,
879 }
880 }
881
882 fn append_opts(dry_run: bool) -> WriteOptions {
883 let (lease_token, ledger_path) = leased_opts_for("doc-1");
884 WriteOptions {
885 document_id: "doc-1".to_string(),
886 payload: WritePayload::Append {
887 text: "hello".to_string(),
888 },
889 dry_run,
890 lease_token,
891 ledger_path,
892 }
893 }
894
895 #[test]
898 fn count_occurrences_counts_non_overlapping_matches() {
899 assert_eq!(count_occurrences("Q3 and Q3 and Q3", "Q3", true), 3);
900 assert_eq!(count_occurrences("aaaa", "aa", true), 2);
901 }
902
903 #[test]
904 fn count_occurrences_honours_case_sensitivity_both_ways() {
905 assert_eq!(count_occurrences("it It IT", "it", true), 1);
906 assert_eq!(count_occurrences("it It IT", "it", false), 3);
907 }
908
909 #[test]
910 fn count_occurrences_of_an_empty_needle_is_zero() {
911 assert_eq!(count_occurrences("anything", "", true), 0);
912 assert_eq!(count_occurrences("anything", "", false), 0);
913 }
914
915 #[test]
916 fn count_occurrences_handles_multibyte_text() {
917 assert_eq!(count_occurrences("😀 x 😀", "😀", true), 2);
918 assert_eq!(count_occurrences("ÉCOLE école", "école", false), 2);
919 }
920
921 #[tokio::test]
924 async fn a_non_document_is_refused_before_the_gate_or_any_docs_call() {
925 let server = MockServer::start().await;
926 let (drive, docs) = clients(&server).await;
927 mount_file("doc-1", GOOGLE_SHEET_MIME_TYPE, &["folder-1"])
931 .mount(&server)
932 .await;
933
934 let outcome = write(
935 &drive,
936 &docs,
937 &replace_opts(false),
938 &[allow_rule("folder-1")],
939 )
940 .await;
941 assert!(matches!(
942 outcome.result,
943 WriteResult::RefusedNotADocument { .. }
944 ));
945 }
946
947 #[tokio::test]
948 async fn a_shortcut_gets_its_own_refusal_not_the_generic_one() {
949 let server = MockServer::start().await;
950 let (drive, docs) = clients(&server).await;
951 mount_file("doc-1", GOOGLE_SHORTCUT_MIME_TYPE, &["folder-1"])
952 .mount(&server)
953 .await;
954
955 let outcome = write(
956 &drive,
957 &docs,
958 &replace_opts(false),
959 &[allow_rule("folder-1")],
960 )
961 .await;
962 assert_eq!(outcome.result, WriteResult::RefusedShortcut);
963 let text = describe(&outcome, WriteVerb::Replace);
964 assert!(text.contains("is a shortcut"), "{text}");
965 assert!(!text.contains("not a Google Doc"), "{text}");
966 }
967
968 #[tokio::test]
969 async fn an_empty_search_is_refused_before_any_network_call() {
970 let server = MockServer::start().await;
971 let (drive, docs) = clients(&server).await;
972 let opts = WriteOptions {
974 payload: WritePayload::Replace {
975 search: String::new(),
976 replace: "x".to_string(),
977 match_case: true,
978 },
979 ..replace_opts(false)
980 };
981 let outcome = write(&drive, &docs, &opts, &[]).await;
982 match outcome.result {
983 WriteResult::Failed { detail } => assert!(detail.contains("--search"), "{detail}"),
984 other => panic!("expected Failed, got {other:?}"),
985 }
986 }
987
988 #[tokio::test]
993 async fn a_denied_target_is_blocked_with_zero_docs_calls() {
994 let server = MockServer::start().await;
995 let (drive, docs) = clients(&server).await;
996 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
997 .mount(&server)
998 .await;
999 mount_folder("folder-1").mount(&server).await;
1000 let outcome = write(&drive, &docs, &replace_opts(false), &[]).await;
1004 assert!(matches!(outcome.result, WriteResult::Blocked { .. }));
1005 }
1006
1007 #[tokio::test]
1008 async fn a_parentless_document_is_refused_distinctly_not_blocked() {
1009 let server = MockServer::start().await;
1010 let (drive, docs) = clients(&server).await;
1011 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &[])
1012 .mount(&server)
1013 .await;
1014
1015 let outcome = write(&drive, &docs, &replace_opts(false), &[]).await;
1016 assert_eq!(outcome.result, WriteResult::RefusedNoVisibleParents);
1017 let text = describe(&outcome, WriteVerb::Replace);
1018 assert!(text.contains("file_id"), "names the actual fix: {text}");
1019 }
1020
1021 #[tokio::test]
1024 async fn a_file_rule_grants_a_parentless_document() {
1025 let server = MockServer::start().await;
1026 let (drive, docs) = clients(&server).await;
1027 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &[])
1028 .mount(&server)
1029 .await;
1030 mount_document(Some("rev-1"), "Q3 report")
1031 .mount(&server)
1032 .await;
1033
1034 let rules = [FolderPermissionRule {
1035 folder_id: None,
1036 file_id: Some("doc-1".to_string()),
1037 recursive: false,
1038 allow: std::iter::once(DriveOperation::DocsWrite).collect(),
1039 deny: HashSet::default(),
1040 require_lease: true,
1041 }];
1042 let outcome = write(&drive, &docs, &replace_opts(true), &rules).await;
1043 assert!(matches!(outcome.result, WriteResult::WouldReplace { .. }));
1044 }
1045
1046 #[tokio::test]
1047 async fn an_ancestor_fetch_failure_produces_failed_not_allow() {
1048 let server = MockServer::start().await;
1049 let (drive, docs) = clients(&server).await;
1050 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1051 .mount(&server)
1052 .await;
1053 Mock::given(method("GET"))
1054 .and(path("/drive/v3/files/folder-1"))
1055 .respond_with(ResponseTemplate::new(500))
1056 .mount(&server)
1057 .await;
1058
1059 let outcome = write(
1060 &drive,
1061 &docs,
1062 &replace_opts(false),
1063 &[allow_rule("folder-1")],
1064 )
1065 .await;
1066 assert!(
1067 matches!(outcome.result, WriteResult::Failed { .. }),
1068 "a chain that could not be resolved must never become an allow: {:?}",
1069 outcome.result
1070 );
1071 }
1072
1073 #[tokio::test]
1076 async fn the_batch_update_presents_the_revision_from_the_get_it_just_made() {
1077 let server = MockServer::start().await;
1078 let (drive, docs) = clients(&server).await;
1079 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1080 .mount(&server)
1081 .await;
1082 mount_folder("folder-1").mount(&server).await;
1083 mount_document(Some("rev-abc"), "Q3 report")
1084 .mount(&server)
1085 .await;
1086 Mock::given(method("POST"))
1087 .and(path("/v1/documents/doc-1:batchUpdate"))
1088 .and(wiremock::matchers::body_partial_json(serde_json::json!({
1089 "writeControl": {"requiredRevisionId": "rev-abc"},
1090 })))
1091 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1092 "documentId": "doc-1",
1093 "replies": [{"replaceAllText": {"occurrencesChanged": 1}}],
1094 })))
1095 .expect(1)
1096 .mount(&server)
1097 .await;
1098
1099 let outcome = write(
1100 &drive,
1101 &docs,
1102 &replace_opts(false),
1103 &[allow_rule("folder-1")],
1104 )
1105 .await;
1106 assert_eq!(
1107 outcome.result,
1108 WriteResult::Replaced {
1109 occurrences_changed: 1
1110 }
1111 );
1112 assert_eq!(outcome.required_revision_id.as_deref(), Some("rev-abc"));
1113 }
1114
1115 #[tokio::test]
1119 async fn a_document_with_no_revision_id_is_refused_rather_than_written_unleased() {
1120 let server = MockServer::start().await;
1121 let (drive, docs) = clients(&server).await;
1122 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1123 .mount(&server)
1124 .await;
1125 mount_folder("folder-1").mount(&server).await;
1126 mount_document(None, "Q3 report").mount(&server).await;
1127
1128 let outcome = write(
1129 &drive,
1130 &docs,
1131 &replace_opts(false),
1132 &[allow_rule("folder-1")],
1133 )
1134 .await;
1135 assert_eq!(outcome.result, WriteResult::RefusedNoRevisionId);
1136 let text = describe(&outcome, WriteVerb::Replace);
1137 assert!(text.contains("edit access"), "{text}");
1138 }
1139
1140 #[tokio::test]
1142 async fn a_stale_revision_400_is_its_own_outcome_not_a_generic_failure() {
1143 let server = MockServer::start().await;
1144 let (drive, docs) = clients(&server).await;
1145 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1146 .mount(&server)
1147 .await;
1148 mount_folder("folder-1").mount(&server).await;
1149 mount_document(Some("rev-old"), "Q3 report")
1150 .mount(&server)
1151 .await;
1152 Mock::given(method("POST"))
1153 .and(path("/v1/documents/doc-1:batchUpdate"))
1154 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
1155 "error": {
1156 "code": 400,
1157 "message": "The required revision ID 'rev-old' does not match the latest revision.",
1158 "status": "INVALID_ARGUMENT",
1159 },
1160 })))
1161 .mount(&server)
1162 .await;
1163
1164 let outcome = write(
1165 &drive,
1166 &docs,
1167 &replace_opts(false),
1168 &[allow_rule("folder-1")],
1169 )
1170 .await;
1171 match &outcome.result {
1172 WriteResult::StaleRevision {
1173 required_revision_id,
1174 ..
1175 } => assert_eq!(required_revision_id, "rev-old"),
1176 other => panic!("expected StaleRevision, got {other:?}"),
1177 }
1178 let text = describe(&outcome, WriteVerb::Replace);
1179 assert!(text.contains("nothing was written"), "{text}");
1180 assert!(text.contains("Re-run"), "{text}");
1181 }
1182
1183 #[tokio::test]
1187 async fn an_unrelated_400_stays_a_generic_failure() {
1188 let server = MockServer::start().await;
1189 let (drive, docs) = clients(&server).await;
1190 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1191 .mount(&server)
1192 .await;
1193 mount_folder("folder-1").mount(&server).await;
1194 mount_document(Some("rev-1"), "Q3 report")
1195 .mount(&server)
1196 .await;
1197 Mock::given(method("POST"))
1198 .and(path("/v1/documents/doc-1:batchUpdate"))
1199 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
1200 "error": {"code": 400, "message": "Invalid requests[0]", "status": "INVALID_ARGUMENT"},
1201 })))
1202 .mount(&server)
1203 .await;
1204
1205 let outcome = write(
1206 &drive,
1207 &docs,
1208 &replace_opts(false),
1209 &[allow_rule("folder-1")],
1210 )
1211 .await;
1212 assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1213 }
1214
1215 #[tokio::test]
1219 async fn dry_run_never_calls_batch_update() {
1220 let server = MockServer::start().await;
1221 let (drive, docs) = clients(&server).await;
1222 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1223 .mount(&server)
1224 .await;
1225 mount_folder("folder-1").mount(&server).await;
1226 mount_document(Some("rev-1"), "Q3 report")
1227 .mount(&server)
1228 .await;
1229 let outcome = write(
1232 &drive,
1233 &docs,
1234 &replace_opts(true),
1235 &[allow_rule("folder-1")],
1236 )
1237 .await;
1238 assert!(matches!(outcome.result, WriteResult::WouldReplace { .. }));
1239 }
1240
1241 #[tokio::test]
1244 async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
1245 let server = MockServer::start().await;
1246 let (drive, docs) = clients(&server).await;
1247 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1248 .expect(2)
1249 .mount(&server)
1250 .await;
1251 mount_folder("folder-1").expect(2).mount(&server).await;
1252
1253 let dry = write(&drive, &docs, &replace_opts(true), &[]).await;
1254 let real = write(&drive, &docs, &replace_opts(false), &[]).await;
1255 assert_eq!(dry.result, real.result);
1256 assert!(matches!(dry.result, WriteResult::Blocked { .. }));
1257 }
1258
1259 #[tokio::test]
1260 async fn dry_run_replace_reports_the_occurrence_count_from_the_snapshot() {
1261 let server = MockServer::start().await;
1262 let (drive, docs) = clients(&server).await;
1263 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1264 .mount(&server)
1265 .await;
1266 mount_folder("folder-1").mount(&server).await;
1267 mount_document(Some("rev-1"), "Q3 then Q3 then Q3")
1268 .mount(&server)
1269 .await;
1270
1271 let outcome = write(
1272 &drive,
1273 &docs,
1274 &replace_opts(true),
1275 &[allow_rule("folder-1")],
1276 )
1277 .await;
1278 assert_eq!(outcome.result, WriteResult::WouldReplace { occurrences: 3 });
1279 let text = describe(&outcome, WriteVerb::Replace);
1280 assert!(text.contains("3 occurrence(s)"), "{text}");
1281 }
1282
1283 #[tokio::test]
1284 async fn dry_run_append_reports_the_end_index_and_the_insert_size() {
1285 let server = MockServer::start().await;
1286 let (drive, docs) = clients(&server).await;
1287 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1288 .mount(&server)
1289 .await;
1290 mount_folder("folder-1").mount(&server).await;
1291 mount_document(Some("rev-1"), "body").mount(&server).await;
1292
1293 let outcome = write(&drive, &docs, &append_opts(true), &[allow_rule("folder-1")]).await;
1294 match outcome.result {
1295 WriteResult::WouldAppend {
1296 document_end_index,
1297 chars,
1298 bytes,
1299 } => {
1300 assert_eq!(chars, 5);
1301 assert_eq!(bytes, 5);
1302 assert!(document_end_index.is_some());
1303 }
1304 other => panic!("expected WouldAppend, got {other:?}"),
1305 }
1306 }
1307
1308 #[tokio::test]
1313 async fn a_zero_occurrence_replace_still_sends_the_batch_update() {
1314 let server = MockServer::start().await;
1315 let (drive, docs) = clients(&server).await;
1316 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1317 .mount(&server)
1318 .await;
1319 mount_folder("folder-1").mount(&server).await;
1320 mount_document(Some("rev-1"), "nothing matching here")
1321 .mount(&server)
1322 .await;
1323 mount_batch_update(serde_json::json!({
1324 "documentId": "doc-1",
1325 "replies": [{"replaceAllText": {"occurrencesChanged": 0}}],
1326 }))
1327 .expect(1)
1328 .mount(&server)
1329 .await;
1330
1331 let outcome = write(
1332 &drive,
1333 &docs,
1334 &replace_opts(false),
1335 &[allow_rule("folder-1")],
1336 )
1337 .await;
1338 assert_eq!(
1339 outcome.result,
1340 WriteResult::Replaced {
1341 occurrences_changed: 0
1342 }
1343 );
1344 }
1345
1346 #[tokio::test]
1348 async fn an_append_reports_its_own_size_since_the_api_reports_no_count() {
1349 let server = MockServer::start().await;
1350 let (drive, docs) = clients(&server).await;
1351 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1352 .mount(&server)
1353 .await;
1354 mount_folder("folder-1").mount(&server).await;
1355 mount_document(Some("rev-1"), "body").mount(&server).await;
1356 mount_batch_update(serde_json::json!({"documentId": "doc-1", "replies": [{}]}))
1357 .mount(&server)
1358 .await;
1359
1360 let outcome = write(
1361 &drive,
1362 &docs,
1363 &append_opts(false),
1364 &[allow_rule("folder-1")],
1365 )
1366 .await;
1367 assert_eq!(outcome.result, WriteResult::Appended { chars: 5, bytes: 5 });
1368 }
1369
1370 #[tokio::test]
1373 async fn refuses_without_a_lease_when_the_rule_requires_one() {
1374 let server = MockServer::start().await;
1375 let (drive, docs) = clients(&server).await;
1376 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1377 .mount(&server)
1378 .await;
1379 mount_folder("folder-1").mount(&server).await;
1380 mount_document(Some("rev-1"), "Q3 report")
1381 .mount(&server)
1382 .await;
1383 let opts = WriteOptions {
1387 lease_token: None,
1388 ledger_path: std::path::PathBuf::new(),
1389 ..replace_opts(false)
1390 };
1391 let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
1392 assert!(matches!(outcome.result, WriteResult::RefusedNoLease));
1393 assert_eq!(outcome.result.log_status(), "refused-no-lease");
1394 }
1395
1396 #[tokio::test]
1397 async fn reports_a_lock_acquisition_failure_as_failed() {
1398 let server = MockServer::start().await;
1402 let (drive, docs) = clients(&server).await;
1403 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1404 .mount(&server)
1405 .await;
1406 mount_folder("folder-1").mount(&server).await;
1407 mount_document(Some("rev-1"), "Q3 report")
1408 .mount(&server)
1409 .await;
1410
1411 let opts = replace_opts(false);
1412 let mut lock_path = opts.ledger_path.clone().into_os_string();
1418 lock_path.push(".lock");
1419 std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
1420
1421 let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
1422 assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1423 }
1424
1425 #[tokio::test]
1426 async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
1427 let server = MockServer::start().await;
1432 let (drive, docs) = clients(&server).await;
1433 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1434 .up_to_n_times(1)
1435 .with_priority(1)
1436 .mount(&server)
1437 .await;
1438 Mock::given(method("GET"))
1439 .and(path("/drive/v3/files/doc-1"))
1440 .respond_with(ResponseTemplate::new(500))
1441 .with_priority(2)
1442 .mount(&server)
1443 .await;
1444 mount_folder("folder-1").mount(&server).await;
1445 mount_document(Some("rev-1"), "Q3 report")
1446 .mount(&server)
1447 .await;
1448 Mock::given(method("POST"))
1449 .and(path("/v1/documents/doc-1:batchUpdate"))
1450 .respond_with(ResponseTemplate::new(200))
1451 .expect(0)
1452 .mount(&server)
1453 .await;
1454
1455 let outcome = write(
1456 &drive,
1457 &docs,
1458 &replace_opts(false),
1459 &[allow_rule("folder-1")],
1460 )
1461 .await;
1462 assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1463 }
1464
1465 #[tokio::test]
1466 async fn refuses_an_unknown_lease_token() {
1467 let server = MockServer::start().await;
1468 let (drive, docs) = clients(&server).await;
1469 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1470 .mount(&server)
1471 .await;
1472 mount_folder("folder-1").mount(&server).await;
1473 mount_document(Some("rev-1"), "Q3 report")
1474 .mount(&server)
1475 .await;
1476 let ledger_path = tempfile::tempdir()
1477 .unwrap()
1478 .keep()
1479 .join("lease-ledger.jsonl");
1480 let opts = WriteOptions {
1483 lease_token: Some("bogus-token".to_string()),
1484 ledger_path,
1485 ..replace_opts(false)
1486 };
1487 let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
1488 assert!(matches!(outcome.result, WriteResult::RefusedLeaseExpired));
1489 }
1490
1491 #[tokio::test]
1492 async fn refuses_a_lease_bound_to_a_different_file() {
1493 let server = MockServer::start().await;
1494 let (drive, docs) = clients(&server).await;
1495 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1496 .mount(&server)
1497 .await;
1498 mount_folder("folder-1").mount(&server).await;
1499 mount_document(Some("rev-1"), "Q3 report")
1500 .mount(&server)
1501 .await;
1502 let ledger_path = tempfile::tempdir()
1503 .unwrap()
1504 .keep()
1505 .join("lease-ledger.jsonl");
1506 let token = seed_lease(&ledger_path, "some-other-doc", "1");
1508
1509 let opts = WriteOptions {
1510 lease_token: Some(token),
1511 ledger_path,
1512 ..replace_opts(false)
1513 };
1514 let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
1515 assert!(matches!(outcome.result, WriteResult::RefusedLeaseWrongFile));
1516 }
1517
1518 #[tokio::test]
1519 async fn refuses_a_stale_lease_when_the_file_has_moved() {
1520 let server = MockServer::start().await;
1521 let (drive, docs) = clients(&server).await;
1522 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1525 .mount(&server)
1526 .await;
1527 mount_folder("folder-1").mount(&server).await;
1528 mount_document(Some("rev-1"), "Q3 report")
1529 .mount(&server)
1530 .await;
1531 let ledger_path = tempfile::tempdir()
1532 .unwrap()
1533 .keep()
1534 .join("lease-ledger.jsonl");
1535 let token = seed_lease(&ledger_path, "doc-1", "0");
1536
1537 let opts = WriteOptions {
1538 lease_token: Some(token),
1539 ledger_path,
1540 ..replace_opts(false)
1541 };
1542 let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
1543 assert!(matches!(outcome.result, WriteResult::RefusedLeaseStale));
1544 }
1545
1546 #[tokio::test]
1547 async fn require_lease_false_writes_without_a_lease() {
1548 let server = MockServer::start().await;
1549 let (drive, docs) = clients(&server).await;
1550 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1551 .mount(&server)
1552 .await;
1553 mount_folder("folder-1").mount(&server).await;
1554 mount_document(Some("rev-1"), "Q3 report")
1555 .mount(&server)
1556 .await;
1557 mount_batch_update(serde_json::json!({
1558 "documentId": "doc-1",
1559 "replies": [{"replaceAllText": {"occurrencesChanged": 1}}],
1560 }))
1561 .mount(&server)
1562 .await;
1563 let rule = FolderPermissionRule {
1564 folder_id: Some("folder-1".to_string()),
1565 file_id: None,
1566 recursive: true,
1567 allow: std::iter::once(DriveOperation::DocsWrite).collect(),
1568 deny: HashSet::default(),
1569 require_lease: false,
1570 };
1571
1572 let opts = WriteOptions {
1574 lease_token: None,
1575 ledger_path: std::path::PathBuf::from("/nonexistent/lease-ledger.jsonl"),
1576 ..replace_opts(false)
1577 };
1578 let outcome = write(&drive, &docs, &opts, &[rule]).await;
1579 assert!(matches!(outcome.result, WriteResult::Replaced { .. }));
1580 }
1581
1582 #[tokio::test]
1583 async fn require_lease_false_still_refuses_a_stale_lease_if_one_is_presented() {
1584 let server = MockServer::start().await;
1588 let (drive, docs) = clients(&server).await;
1589 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1592 .mount(&server)
1593 .await;
1594 mount_folder("folder-1").mount(&server).await;
1595 mount_document(Some("rev-1"), "Q3 report")
1596 .mount(&server)
1597 .await;
1598 let rule = FolderPermissionRule {
1601 folder_id: Some("folder-1".to_string()),
1602 file_id: None,
1603 recursive: true,
1604 allow: std::iter::once(DriveOperation::DocsWrite).collect(),
1605 deny: HashSet::default(),
1606 require_lease: false,
1607 };
1608 let ledger_path = tempfile::tempdir()
1609 .unwrap()
1610 .keep()
1611 .join("lease-ledger.jsonl");
1612 let token = seed_lease(&ledger_path, "doc-1", "0");
1613
1614 let opts = WriteOptions {
1615 lease_token: Some(token),
1616 ledger_path,
1617 ..replace_opts(false)
1618 };
1619 let outcome = write(&drive, &docs, &opts, &[rule]).await;
1620 assert!(matches!(outcome.result, WriteResult::RefusedLeaseStale));
1621 }
1622
1623 #[test]
1624 fn describe_renders_every_lease_refusal_with_the_lease_acquire_hint() {
1625 let outcome_with = |result: WriteResult| WriteOutcome {
1626 document_id: "doc-1".to_string(),
1627 file_name: Some("Budget".to_string()),
1628 resolved_folder_id: None,
1629 required_revision_id: None,
1630 result,
1631 };
1632
1633 let text = describe(
1634 &outcome_with(WriteResult::RefusedNoLease),
1635 WriteVerb::Replace,
1636 );
1637 assert!(text.contains("requires a Drive write lease"), "{text}");
1638 assert!(text.contains("drive lease acquire doc-1"), "{text}");
1639
1640 let text = describe(
1641 &outcome_with(WriteResult::RefusedLeaseExpired),
1642 WriteVerb::Replace,
1643 );
1644 assert!(text.contains("expired, released, or unknown"), "{text}");
1645 assert!(text.contains("drive lease acquire doc-1"), "{text}");
1646
1647 let text = describe(
1648 &outcome_with(WriteResult::RefusedLeaseWrongFile),
1649 WriteVerb::Replace,
1650 );
1651 assert!(text.contains("acquired for a different file"), "{text}");
1652 assert!(text.contains("drive lease acquire doc-1"), "{text}");
1653
1654 let text = describe(
1655 &outcome_with(WriteResult::RefusedLeaseStale),
1656 WriteVerb::Replace,
1657 );
1658 assert!(
1659 text.contains("changed since the lease was acquired"),
1660 "{text}"
1661 );
1662 assert!(text.contains("drive lease acquire doc-1"), "{text}");
1663 }
1664
1665 #[tokio::test]
1666 async fn a_dry_run_never_needs_a_lease() {
1667 let server = MockServer::start().await;
1668 let (drive, docs) = clients(&server).await;
1669 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1670 .mount(&server)
1671 .await;
1672 mount_folder("folder-1").mount(&server).await;
1673 mount_document(Some("rev-1"), "Q3 report")
1674 .mount(&server)
1675 .await;
1676 let opts = WriteOptions {
1680 lease_token: None,
1681 ledger_path: std::path::PathBuf::new(),
1682 ..replace_opts(true)
1683 };
1684 let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
1685 assert!(matches!(outcome.result, WriteResult::WouldReplace { .. }));
1686 }
1687
1688 #[tokio::test]
1691 async fn a_leased_docs_write_concludes_its_audit_pair_with_allowed() {
1692 let server = MockServer::start().await;
1693 let (drive, docs) = clients(&server).await;
1694 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1695 .mount(&server)
1696 .await;
1697 mount_folder("folder-1").mount(&server).await;
1698 mount_document(Some("rev-abc"), "Q3 report")
1699 .mount(&server)
1700 .await;
1701 Mock::given(method("POST"))
1702 .and(path("/v1/documents/doc-1:batchUpdate"))
1703 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1704 "documentId": "doc-1",
1705 "replies": [{"replaceAllText": {"occurrencesChanged": 1}}],
1706 })))
1707 .mount(&server)
1708 .await;
1709 let dir = tempfile::tempdir().unwrap();
1710 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1711
1712 let outcome = write(
1713 &drive,
1714 &docs,
1715 &replace_opts(false),
1716 &[allow_rule("folder-1")],
1717 )
1718 .await;
1719 assert!(matches!(outcome.result, WriteResult::Replaced { .. }));
1720
1721 let records = audit.records();
1722 assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
1723 assert_eq!(records[0].command, ["drive", "docs-replace"]);
1727 }
1728
1729 #[tokio::test]
1730 async fn a_leased_docs_write_that_fails_concludes_its_audit_pair_with_the_error() {
1731 let server = MockServer::start().await;
1732 let (drive, docs) = clients(&server).await;
1733 mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1734 .mount(&server)
1735 .await;
1736 mount_folder("folder-1").mount(&server).await;
1737 mount_document(Some("rev-abc"), "Q3 report")
1738 .mount(&server)
1739 .await;
1740 Mock::given(method("POST"))
1741 .and(path("/v1/documents/doc-1:batchUpdate"))
1742 .respond_with(ResponseTemplate::new(500))
1743 .mount(&server)
1744 .await;
1745 let dir = tempfile::tempdir().unwrap();
1746 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1747
1748 let outcome = write(
1749 &drive,
1750 &docs,
1751 &replace_opts(false),
1752 &[allow_rule("folder-1")],
1753 )
1754 .await;
1755 let WriteResult::Failed { detail } = &outcome.result else {
1756 panic!("expected Failed, got {:?}", outcome.result);
1757 };
1758
1759 let records = audit.records();
1760 assert_eq!(audit.verdicts(), ["pending", "failed"], "{records:?}");
1761 assert_eq!(records[1].error.as_deref(), Some(detail.as_str()));
1762 }
1763}