Skip to main content

omni_dev/drive/docs/
write.rs

1//! `drive docs replace`/`append` engines — text mutation gated by the
2//! ADR-0071 write-permission rules and leased against a document revision
3//! (issue #1615, [ADR-0076](../../../docs/adrs/adr-0076.md)).
4//!
5//! Single-target, so this follows `sheets/write.rs`'s linear shape — a
6//! public wrapper that logs, and an `_inner` that classifies then mutates —
7//! rather than `file_move.rs`'s Plan/Execute split. `--dry-run` and a real
8//! run therefore share the same gate classification *by construction* (same
9//! function, same early return), and Docs adds one strengthening: the
10//! preview values are computed from the snapshot **before** the branch and
11//! are the same values on both paths, so the two cannot disagree about what
12//! they saw.
13//!
14//! Two refusals happen **before** the gate, because they are not policy
15//! decisions — the operation is simply nonsensical for that target: anything
16//! that is not a Google Doc, and a shortcut (even one pointing at a Doc,
17//! since we don't follow shortcuts). This is the mirror image of
18//! `content_edit.rs`'s Google-native refusal.
19//!
20//! The ordering of the rest is deliberate and is ADR-0076 §3 and §8:
21//!
22//! 1. The **gate runs before `documents.get`**. The naive ordering — read
23//!    first, so the preview is nicer — would hand the document body to a
24//!    caller the gate is about to refuse.
25//! 2. A missing `revisionId` is refused rather than written unleased. Google
26//!    populates it only for callers with edit access, so its absence means
27//!    the write would fail anyway; refusing keeps "there is no unleased
28//!    path" true without exception.
29
30use 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/// What to mutate.
53///
54/// An enum carrying its own data rather than a verb discriminant plus a bag
55/// of `Option`s, so "a replace with no search text" is unrepresentable. This
56/// is the one place this module improves on `sheets/write.rs`'s template
57/// rather than copying it: that module's `WriteVerb::Clear` sits beside a
58/// `values` field which must be empty, and `describe` has to special-case
59/// the pairing.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum WritePayload {
62    /// Typed table structural mutation.
63    Table(TableEdit),
64    /// Replace every occurrence of `search` with `replace`.
65    Replace {
66        /// The literal substring to find. Never a regex.
67        search: String,
68        /// What to put in its place.
69        replace: String,
70        /// Whether matching is case-sensitive.
71        match_case: bool,
72    },
73    /// Append `text` to the end of the document body.
74    Append {
75        /// The text to insert.
76        text: String,
77    },
78    /// Insert next to a unique anchor in tab bodies or an existing segment.
79    Insert {
80        /// Literal text to locate in the snapshot.
81        anchor: String,
82        /// Which edge of the anchor to use.
83        side: Side,
84        /// Text to insert.
85        text: String,
86        /// Case-sensitive by default.
87        match_case: bool,
88        /// Existing non-body segment selector; default addresses tab bodies.
89        selection: anchor::SegmentSelection,
90    },
91    /// Apply a bounded style patch to a unique anchored range.
92    Format {
93        /// First anchor or complete match.
94        from: String,
95        /// Inclusive final anchor.
96        to: Option<String>,
97        /// Literal matching case sensitivity.
98        match_case: bool,
99        /// Explicit style values.
100        style: super::style::StylePatch,
101    },
102    /// A scoped named-range metadata or content mutation.
103    NamedRange {
104        /// Explicit tab and segment address.
105        scope: super::named_range::Scope,
106        /// Typed operation and its data.
107        mutation: super::named_range::Mutation,
108    },
109    /// Delete a unique match or an inclusive pair of anchors.
110    Delete {
111        /// First anchor (or the entire single match).
112        from: String,
113        /// Last anchor, when deleting an inclusive range.
114        to: Option<String>,
115        /// Case-sensitive by default.
116        match_case: bool,
117        /// Existing non-body segment selector; default addresses tab bodies.
118        selection: anchor::SegmentSelection,
119    },
120    /// Apply or remove list formatting on whole anchor-selected paragraphs.
121    List {
122        /// First anchor or single-paragraph match.
123        from: String,
124        /// Last anchor, inclusive of its containing paragraph.
125        to: Option<String>,
126        /// Creation uses a concrete preset; None removes bullets.
127        preset: Option<BulletPreset>,
128        /// Case-sensitive by default.
129        match_case: bool,
130    },
131}
132
133impl WritePayload {
134    /// Which verb this payload is.
135    #[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    /// Permission vocabulary is independent of the request's wire operation.
164    /// List creation/removal uses DocsWrite: removal preserves prose, while
165    /// creation's leading-tab removal is an explicit part of formatting consent.
166    #[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    /// Rejects a payload that cannot produce a valid request, before any
197    /// network call.
198    ///
199    /// The API rejects an empty `containsText.text`, and an empty append is
200    /// a no-op worth naming rather than a round-trip worth spending.
201    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/// Which text mutation to perform.
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum WriteVerb {
245    /// Table structural edit.
246    Table(TableVerb),
247    /// Replace every occurrence of some text.
248    Replace,
249    /// Append text to the end of the document.
250    Append,
251    /// Anchor-addressed insertion.
252    Insert,
253    /// Anchor-addressed deletion.
254    Delete,
255    /// Create paragraph bullets or numbering.
256    CreateBullets,
257    /// Remove paragraph bullets or numbering.
258    DeleteBullets,
259    /// Character formatting.
260    TextStyle,
261    /// Paragraph formatting.
262    ParagraphStyle,
263    /// Add named-range metadata.
264    CreateNamedRange,
265    /// Remove named-range metadata.
266    DeleteNamedRange,
267    /// Replace a named range's content.
268    ReplaceNamedRangeContent,
269    /// The same wire verb with explicit empty content, under docs-delete.
270    DeleteNamedRangeContent,
271}
272
273impl WriteVerb {
274    /// The `operation` this verb records in the request log.
275    ///
276    /// `build_drive_mutation_record` shapes `command` as `["drive",
277    /// <operation>]`, so these read as `drive docs-replace` in the log even
278    /// though the CLI spells it `drive docs replace`.
279    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    /// The CLI spelling, for messages.
299    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    /// The permission this verb is gated by; the same mapping as
319    /// [`WritePayload::gate_operation`], for messages that only hold a verb.
320    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/// Per-call options.
332#[derive(Debug, Clone)]
333pub struct WriteOptions {
334    /// The document to mutate.
335    pub document_id: String,
336    /// What to do to it.
337    pub payload: WritePayload,
338    /// Classify and preview, but send no mutation.
339    pub dry_run: bool,
340    /// The lease token presented via `--lease`. Checked only when the
341    /// deciding rule requires one
342    /// ([`write_gate::decided_rule_requires_lease`], ADR-0080 §1/§9);
343    /// `None` is only ever valid when it does not.
344    pub lease_token: Option<String>,
345    /// Path to the lease ledger the token is checked against. Production
346    /// callers pass `crate::drive::lease::ledger::ledger_path`'s own
347    /// result; tests pass a path under a `tempdir`.
348    pub ledger_path: PathBuf,
349}
350
351/// What happened (or, under `--dry-run`, would happen).
352#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
353#[serde(tag = "status", rename_all = "kebab-case")]
354pub enum WriteResult {
355    /// Snapshot-resolved table preview.
356    WouldEditTable {
357        /// The exact structural effect.
358        edit: TablePreview,
359    },
360    /// A table mutation applied.
361    EditedTable {
362        /// Same effect metadata as the preview.
363        edit: TablePreview,
364    },
365    /// Table addressing failed closed.
366    RefusedTable {
367        /// Typed refusal, without user prose.
368        error: TableError,
369    },
370    /// Preview of one named-range mutation. Counts contain no prose.
371    WouldMutateNamedRange {
372        /// Complete resolved effect.
373        preview: super::named_range::Preview,
374    },
375    /// One named-range request succeeded.
376    MutatedNamedRange {
377        /// Complete effect, including the server-assigned create ID when returned.
378        preview: super::named_range::Preview,
379    },
380    /// Scope or named-range content cannot safely identify the requested effect.
381    RefusedNamedRange {
382        /// Typed refusal without document text or range names.
383        error: super::named_range::Error,
384    },
385    /// A dry run of a replace: how many occurrences the *snapshot* held.
386    WouldReplace {
387        /// Counted client-side. Display-only — see [`count_occurrences`].
388        occurrences: usize,
389    },
390    /// A dry run of an append.
391    WouldAppend {
392        /// The body's last `endIndex`, purely informational — nothing
393        /// computes from it (ADR-0076 §5).
394        #[serde(skip_serializing_if = "Option::is_none")]
395        document_end_index: Option<i64>,
396        /// Unicode scalar values being inserted.
397        chars: usize,
398        /// UTF-8 bytes being inserted.
399        bytes: usize,
400    },
401    /// Dry-run metadata for insertion.
402    WouldInsert {
403        /// Resolved effect, in UTF-16 units.
404        edit: EditPreview,
405    },
406    /// Dry-run metadata for deletion.
407    WouldDelete {
408        /// Resolved effect, in UTF-16 units.
409        edit: EditPreview,
410    },
411    /// An insertion landed at the resolved position.
412    Inserted {
413        /// The same metadata as the preview.
414        edit: EditPreview,
415    },
416    /// A deletion landed over the resolved range.
417    Deleted {
418        /// The same metadata as the preview.
419        edit: EditPreview,
420    },
421    /// Metadata-only preview of whole-paragraph list formatting.
422    WouldFormatList {
423        /// Range before any leading tabs are removed.
424        edit: ListPreview,
425        /// Some for creation, None for removal.
426        preset: Option<BulletPreset>,
427    },
428    /// List formatting applied; indices still describe the pre-write snapshot.
429    ListFormatted {
430        /// The same metadata as the preview.
431        edit: ListPreview,
432        /// Some for creation, None for removal.
433        preset: Option<BulletPreset>,
434    },
435    /// Exact requested formatting effect before mutation.
436    WouldFormat {
437        /// Affected range and character/paragraph counts (no text mutation).
438        edit: EditPreview,
439        /// Explicit style properties.
440        style: super::style::StylePatch,
441        /// Derived property mask.
442        fields: String,
443    },
444    /// The same requested formatting effect after a successful mutation.
445    Formatted {
446        /// Affected range and counts.
447        edit: EditPreview,
448        /// Explicit style properties.
449        style: super::style::StylePatch,
450        /// Derived property mask.
451        fields: String,
452    },
453    /// An anchor could not safely identify a unique effect.
454    RefusedAnchor {
455        /// Typed reason containing no anchor text.
456        error: AnchorError,
457    },
458    /// The target is not a Google Doc.
459    RefusedNotADocument {
460        /// Its actual mime type.
461        mime_type: String,
462    },
463    /// The target is a shortcut, which is never followed.
464    RefusedShortcut,
465    /// The target has no parent folder this account can see, so no
466    /// folder rule can apply and no `file_id` rule named it either.
467    RefusedNoVisibleParents,
468    /// `documents.get` returned no `revisionId`, which Google populates only
469    /// for callers with edit access — so no lease can be taken, and there is
470    /// no unleased path (ADR-0076 §3).
471    RefusedNoRevisionId,
472    /// The write-permission gate refused it.
473    Blocked {
474        /// The rule that decided, when one did.
475        decided_by: Option<DecidingRule>,
476    },
477    /// No `--lease` was presented, and the deciding rule requires one
478    /// (ADR-0080 §9).
479    RefusedNoLease,
480    /// The presented lease has expired, or was never a token this ledger
481    /// knows about.
482    RefusedLeaseExpired,
483    /// The presented lease is bound to a different file id.
484    RefusedLeaseWrongFile,
485    /// The file has moved since the lease's recorded `version` — the
486    /// staleness check (ADR-0080 §6). Distinct from [`Self::StaleRevision`],
487    /// which is ADR-0076's own, unrelated Docs revision check.
488    RefusedLeaseStale,
489    /// A replace landed.
490    Replaced {
491        /// What the *server* reported changing.
492        ///
493        /// Not `Option`: a replace always has an answer, and "nothing
494        /// matched" is a count rather than an absence of one. Docs omits
495        /// `occurrencesChanged` entirely when it is zero (proto3), so the
496        /// zero is resolved at the boundary — see
497        /// [`crate::drive::docs::write_types::BatchUpdateDocumentResponse::occurrences_changed_for_replace`].
498        occurrences_changed: i64,
499    },
500    /// An append landed.
501    Appended {
502        /// Unicode scalar values inserted.
503        chars: usize,
504        /// UTF-8 bytes inserted.
505        bytes: usize,
506    },
507    /// The document changed between the read that computed this mutation
508    /// and the `batchUpdate` that would have applied it. Nothing was
509    /// written — a one-request batch is atomic.
510    StaleRevision {
511        /// The lease presented, for the log and for support.
512        required_revision_id: String,
513        /// The server's own message, unaltered.
514        detail: String,
515    },
516    /// Anything else.
517    Failed {
518        /// The error, verbatim.
519        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    /// The kebab-case status for the request log.
543    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/// The full outcome of one attempt.
580#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
581pub struct WriteOutcome {
582    /// The document acted on.
583    pub document_id: String,
584    /// Its name at the time of the attempt.
585    #[serde(skip_serializing_if = "Option::is_none")]
586    pub file_name: Option<String>,
587    /// The folder the gate evaluated against, when a chain was walked.
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub resolved_folder_id: Option<String>,
590    /// The lease presented, once one was minted.
591    #[serde(skip_serializing_if = "Option::is_none")]
592    pub required_revision_id: Option<String>,
593    /// What happened.
594    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/// Counts non-overlapping occurrences of `needle` in `haystack`.
604///
605/// **Display-only.** ADR-0076 §7 makes this an invariant rather than a
606/// convention: the count never gates the mutation, and a count of zero still
607/// sends the `batchUpdate`. It is an estimate over the body text this
608/// command read, while the server matches over its own view — a match may
609/// span a `textRun` boundary, or sit in a segment (a header, a footer, a
610/// footnote) that `document_text` does not include in this count (issue
611/// #1799 made those segments fetchable; folding them into this estimate is
612/// separate follow-up work). Acting on the count would let `omni-dev` report
613/// "nothing to do" for a document that does have matches.
614#[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    // Lowercasing can change byte length (e.g. 'İ'), so this counts over
623    // *both* sides lowercased rather than indexing back into the original.
624    haystack
625        .to_lowercase()
626        .matches(&needle.to_lowercase())
627        .count()
628}
629
630/// Runs one text mutation, logging every attempt that isn't a dry run.
631///
632/// Never returns `Err`: every failure is a [`WriteResult`] variant, so the
633/// caller renders one shape and the log records one shape. Exit code stays 0
634/// regardless, matching ADR-0076 §13.
635pub 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    // ── Pure validation, before any request ────────────────────────────
664    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    // ── Refusals that precede the gate ─────────────────────────────────
687    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    // ── The gate, before any Docs call ─────────────────────────────────
697    // A `file_id` rule is consulted before the parents are looked at, so a
698    // Doc with no visible parent can still be granted (issue #1612).
699    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        // A chain that could not be resolved is a refusal, never a silent
709        // allow — ADR-0071 §3's highest-priority invariant.
710        Err(err) => {
711            return with_target(WriteResult::Failed {
712                detail: err.to_string(),
713            })
714        }
715    };
716
717    // Only *after* the file-rule lookup has come up empty is "no visible
718    // parents" the real story.
719    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    // ── The read that mints the lease and computes the preview ─────────
748    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    // Request and preview are built together from *this* snapshot, before the
776    // dry-run branch, so a dry run and a real run cannot disagree about what
777    // they saw (ADR-0076 §7). Building the request here, before the ledger
778    // gate, also means no fallible work can orphan a pending intent.
779    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            // Across every tab, because a `replaceAllText` with no
798            // `tabsCriteria` spans every tab (ADR-0076 §11).
799            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    // The lease check (ADR-0080 §9) sits here: after the permission gate
900    // and the `--dry-run` branch, before the mutating call — see
901    // `content_edit.rs::edit_inner`'s doc comment for the full reasoning,
902    // shared verbatim by every leased engine. This is a *separate*,
903    // unrelated staleness check from ADR-0076's own `revisionId` lease
904    // above: that one guards the Docs `batchUpdate` itself against a
905    // concurrent edit, while this one guards the *lease ledger*'s recorded
906    // Drive `version` (ADR-0080 §6). A fresh `files.get` immediately before
907    // `batchUpdate`, not a reuse of the metadata fetched before the
908    // (potentially slow) ancestor-chain walk and `documents.get` above.
909    //
910    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    // ── The mutation ───────────────────────────────────────────────────
929    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                // omni-dev: coverage ignore reason="`preview` is built by the payload match above, so a Table payload always carries a WouldEditTable preview; this arm exists solely for exhaustiveness over the shared WriteResult enum"
943                _ => WriteResult::Failed {
944                    detail: "missing resolved table preview".into(),
945                },
946                // omni-dev: coverage end
947            },
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                // omni-dev: coverage ignore reason="`preview` is built by the NamedRange payload match above, so a NamedRange payload always carries WouldMutateNamedRange; this arm exists solely for exhaustiveness over the shared WriteResult enum"
961                _ => WriteResult::Failed {
962                    detail: "missing named-range preview".into(),
963                },
964                // omni-dev: coverage end
965            },
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                // omni-dev: coverage ignore reason="`preview` is built by the payload match above, so an Insert/Delete/List payload always carries its matching edit preview; this arm exists solely for exhaustiveness over the shared WriteResult enum"
992                _ => WriteResult::Failed {
993                    detail: "missing resolved edit preview".to_owned(),
994                },
995                // omni-dev: coverage end
996            },
997        },
998        Err(err) => {
999            // Either way the mutation did not happen — a `412` on
1000            // `writeControl.requiredRevisionId` included — so the intent
1001            // record gets a `failed` outcome carrying the reason.
1002            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
1017/// Every tab's text, concatenated — the corpus the occurrence preview counts
1018/// over.
1019///
1020/// Spans every tab because the mutation does (ADR-0076 §11); counting only
1021/// the first would make the preview a lower bound on a tabbed document.
1022fn 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
1033/// The first tab's body end index, for the append preview.
1034///
1035/// Informational only — nothing computes an insertion point from it, because
1036/// `endOfSegmentLocation` lets the server do that (ADR-0076 §5).
1037fn 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
1046/// Emits the `kind: "drivemutation"` record.
1047///
1048/// Inside the engine, never the CLI layer, so a future MCP caller cannot
1049/// bypass it — and so a `Blocked` outcome, which makes zero API calls, still
1050/// leaves a trace. Same reasoning as `sheets/write.rs::record_attempt`.
1051///
1052/// **The searched, replacement and appended text are never recorded.** They
1053/// are user prose, and often the most sensitive thing in the invocation —
1054/// sharper than the Sheets case, where an A1 range is metadata rather than
1055/// content. Only counts and the opaque revision id go in.
1056fn record_attempt(outcome: &WriteOutcome, opts: &WriteOptions, duration: Duration) {
1057    let error = match &outcome.result {
1058        // A lost lease is recorded as an error alongside its own
1059        // `stale-revision` status: the status says what happened, the
1060        // detail carries the server's own message for support.
1061        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/// Renders an outcome as a single human-readable line.
1107///
1108/// Lives here rather than in the CLI layer so the CLI and a future MCP
1109/// caller describe an outcome identically.
1110#[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            // The same one-line shape the other five `describe`/report
1260            // sites use, so the wording cannot drift between them.
1261            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    /// Both hosts point at one wiremock server.
1325    ///
1326    /// `replace_session` swaps the Drive client's whole transport, so it
1327    /// must run **before** the derive — deriving first would leave the Docs
1328    /// client holding the original session, pointed at the real
1329    /// `oauth2.googleapis.com`, and make a live network call from a test.
1330    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    /// `version: "1"` throughout — matches [`replace_opts`]'s/
1350    /// [`append_opts`]'s default seeded lease, so any test reaching the
1351    /// mutating call has a live, non-stale lease by construction (ADR-0080
1352    /// §9).
1353    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    /// A fresh, isolated ledger path holding a live lease for `document_id`
1364    /// at version `"1"` (matching [`mount_file`]'s default).
1365    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    /// Seeds a fresh, isolated ledger with a live lease for `"doc-1"` at
1412    /// version `"1"` (matching [`mount_file`]'s default) and returns options
1413    /// carrying it. Every existing test built before the lease (ADR-0080
1414    /// §9) reaches its mutating call this way by construction — see
1415    /// `sheets/structure.rs::opts_for`'s doc comment for why this doesn't
1416    /// need touching each test individually. A test exercising the lease
1417    /// *refusal* paths builds `WriteOptions` directly instead (see the "the
1418    /// Drive write lease" test section below).
1419    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    // ── count_occurrences (pure) ───────────────────────────────────────
1448
1449    #[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    // ── Refusals that precede the gate and every network call ──────────
1474
1475    #[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        // A spreadsheet with a parent, and permissive rules — yet neither
1480        // the parent lookup nor any Docs call is mounted, so the refusal
1481        // must happen before both.
1482        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        // No mocks at all: nothing may be requested.
1525        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    // ── The gate ───────────────────────────────────────────────────────
1541
1542    /// ADR-0076 §3: a blocked write must make **zero** Docs API calls, so
1543    /// the document body never reaches a caller the gate is refusing.
1544    #[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        // No `documents.get` and no batchUpdate mounted: reaching either
1553        // fails the test.
1554
1555        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    /// A `file_id` rule reaches a link-shared Doc that no folder rule could
1574    /// (issue #1612), so the parentless refusal must not pre-empt it.
1575    #[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    // ── The lease ──────────────────────────────────────────────────────
1626
1627    #[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    /// ADR-0076 §3: rather than fall back to an unleased write, a document
1668    /// Google withheld the revision id for is refused — and no batchUpdate
1669    /// is mounted, so reaching one fails the test.
1670    #[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    /// The exact 400 confirmed live (ADR-0076 §6).
1693    #[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    /// `INVALID_ARGUMENT` is also what a malformed request carries, so an
1736    /// unrelated 400 must stay a generic failure — telling the user to
1737    /// re-run it would be advice that can never work.
1738    #[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    // ── --dry-run ──────────────────────────────────────────────────────
1768
1769    /// Mirrors `content_edit.rs::dry_run_never_calls_edit_endpoint`.
1770    #[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        // No batchUpdate mock: reaching it fails the test.
1782
1783        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    /// Mirrors
1794    /// `content_edit.rs::dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run`.
1795    #[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    /// ADR-0076 §7: the client-side count is display-only and **never**
1861    /// gates the mutation, so a zero count still sends the batch. Reporting
1862    /// "nothing to do" from an estimate would be wrong whenever the server
1863    /// can see a match this crate cannot.
1864    #[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    /// An `insertText` replies with an empty object, which is normal.
1899    #[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    // ── the Drive write lease (ADR-0080 §9) ────────────────────────────
1923
1924    #[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        // No batchUpdate mock mounted — a refusal must make zero mutating
1936        // calls.
1937
1938        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        // A pre-existing lock file simulates another `drive lease`
1951        // operation genuinely in progress — reported as an operational
1952        // failure, not folded into `RefusedLeaseExpired`.
1953        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        // Under `flock` (issue #1687), a busy lock now waits rather than
1965        // hard-failing (`check_and_lock_lease` -> `acquire_waiting`), so a
1966        // held `LedgerLock` no longer reproduces an immediate failure here.
1967        // A directory at the lock path does: opening it for write fails
1968        // outright with an I/O error, which is never retried.
1969        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        // The gate's own resolve step succeeds off the first `files.get`,
1980        // but the fresh re-fetch feeding the staleness check (ADR-0080 §6)
1981        // fails — the write must report `Failed` and never reach
1982        // `batchUpdate`.
1983        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        // Never seeded — the ledger exists nowhere near this token.
2033
2034        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        // Seeded for a *different* document id.
2059        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` always returns version "1"; the lease below was
2075        // acquired against version "0" — a foreign edit landed since.
2076        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        // No lease token presented at all, and no ledger exists.
2125        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        // ADR-0080 §13: `require_lease: false` relaxes the *requirement*,
2137        // not the *meaning* — a token volunteered anyway is checked exactly
2138        // like a required one, including staleness.
2139        let server = MockServer::start().await;
2140        let (drive, docs) = clients(&server).await;
2141        // `mount_file` always returns version "1"; the lease below was
2142        // acquired against version "0" — a foreign edit landed since.
2143        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        // No batchUpdate mock mounted — a refusal must make zero mutating
2151        // calls.
2152        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        // No batchUpdate mock, no lease token, no ledger — a dry run must
2229        // not need any of them.
2230
2231        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    // ── the write's own audit trail (ADR-0080 §11) ─────────────────────
2241
2242    #[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        // The verb, not the engine — the same `["drive", <log_operation>]`
2276        // this write's `drivemutation` record carries, so an auditor can
2277        // see which verb ran without joining back to `log.jsonl`.
2278        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!(), // omni-dev: coverage ignore-line reason="anchored_payloads yields only Insert and Delete"
2344                }
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    // ── Anchored payload validation, rendering and logging ─────────────
2859
2860    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    /// An unaddressable payload is refused before any request, so a typo in an
2908    /// anchor never costs a round-trip or touches the lease.
2909    #[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    /// A preview says "Would apply" and, before a create has been answered,
3025    /// has no ID yet; the applied result names the ID and the exact effect.
3026    #[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    /// The two content verbs share one CLI spelling and the metadata verbs
3061    /// each have their own.
3062    #[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    /// A preview and the write it predicts differ only in the leading verb;
3341    /// the range, counts, property mask and explicit style are reported
3342    /// identically so the two can be diffed by eye.
3343    #[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    /// The same one-line summary serves the preview and the applied write,
3742    /// naming the direction only where one exists and the reference cell only
3743    /// when the edit has one.
3744    #[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    /// The log status is the wire `status` tag, so a log reader and an API
3839    /// consumer see the same word for the same outcome.
3840    #[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    /// A verb on its own (as `describe` holds it) names the same CLI spelling,
3868    /// log operation and permission as the table verb it wraps, and as the
3869    /// payload that produced it.
3870    #[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    /// The grant hint names the permission the verb is really gated by, in
3914    /// both places it appears, so a table edit is not told to grant
3915    /// `docs-write`.
3916    #[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    /// An anchor that does not resolve is a typed refusal, decided before the
3940    /// lease or the mutation, so a preview and a real run agree and neither
3941    /// sends anything.
3942    #[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}