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 crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
36use crate::drive::client::DriveClient;
37use crate::drive::docs::api::{is_stale_revision, DocsApi, SuggestionsViewMode};
38use crate::drive::docs::client::DocsClient;
39use crate::drive::docs::write_types::DocsRequest;
40use crate::drive::files_api::FilesApi;
41use crate::drive::folder_ancestry;
42use crate::drive::lease::check::{
43    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
44    LeasedWrite,
45};
46use crate::drive::types::{GOOGLE_DOC_MIME_TYPE, GOOGLE_SHORTCUT_MIME_TYPE};
47use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
48use crate::request_log::{self, DriveMutationOutcome};
49
50/// What to mutate.
51///
52/// An enum carrying its own data rather than a verb discriminant plus a bag
53/// of `Option`s, so "a replace with no search text" is unrepresentable. This
54/// is the one place this module improves on `sheets/write.rs`'s template
55/// rather than copying it: that module's `WriteVerb::Clear` sits beside a
56/// `values` field which must be empty, and `describe` has to special-case
57/// the pairing.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum WritePayload {
60    /// Replace every occurrence of `search` with `replace`.
61    Replace {
62        /// The literal substring to find. Never a regex.
63        search: String,
64        /// What to put in its place.
65        replace: String,
66        /// Whether matching is case-sensitive.
67        match_case: bool,
68    },
69    /// Append `text` to the end of the document body.
70    Append {
71        /// The text to insert.
72        text: String,
73    },
74}
75
76impl WritePayload {
77    /// Which verb this payload is.
78    #[must_use]
79    pub const fn verb(&self) -> WriteVerb {
80        match self {
81            Self::Replace { .. } => WriteVerb::Replace,
82            Self::Append { .. } => WriteVerb::Append,
83        }
84    }
85
86    /// Rejects a payload that cannot produce a valid request, before any
87    /// network call.
88    ///
89    /// The API rejects an empty `containsText.text`, and an empty append is
90    /// a no-op worth naming rather than a round-trip worth spending.
91    fn validate(&self) -> Result<(), String> {
92        match self {
93            Self::Replace { search, .. } if search.is_empty() => {
94                Err("--search cannot be empty".to_string())
95            }
96            Self::Append { text } if text.is_empty() => {
97                Err("nothing to append: the text is empty".to_string())
98            }
99            _ => Ok(()),
100        }
101    }
102
103    /// The single `DocsRequest` this payload sends.
104    fn to_request(&self) -> DocsRequest {
105        match self {
106            Self::Replace {
107                search,
108                replace,
109                match_case,
110            } => DocsRequest::replace_all_text(search, replace, *match_case),
111            Self::Append { text } => DocsRequest::insert_text_at_end(text),
112        }
113    }
114}
115
116/// Which text mutation to perform.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum WriteVerb {
119    /// Replace every occurrence of some text.
120    Replace,
121    /// Append text to the end of the document.
122    Append,
123}
124
125impl WriteVerb {
126    /// The `operation` this verb records in the request log.
127    ///
128    /// `build_drive_mutation_record` shapes `command` as `["drive",
129    /// <operation>]`, so these read as `drive docs-replace` in the log even
130    /// though the CLI spells it `drive docs replace`.
131    const fn log_operation(self) -> &'static str {
132        match self {
133            Self::Replace => "docs-replace",
134            Self::Append => "docs-append",
135        }
136    }
137
138    /// The CLI spelling, for messages.
139    const fn label(self) -> &'static str {
140        match self {
141            Self::Replace => "replace",
142            Self::Append => "append",
143        }
144    }
145}
146
147/// Per-call options.
148#[derive(Debug, Clone)]
149pub struct WriteOptions {
150    /// The document to mutate.
151    pub document_id: String,
152    /// What to do to it.
153    pub payload: WritePayload,
154    /// Classify and preview, but send no mutation.
155    pub dry_run: bool,
156    /// The lease token presented via `--lease`. Checked only when the
157    /// deciding rule requires one
158    /// ([`write_gate::decided_rule_requires_lease`], ADR-0080 §1/§9);
159    /// `None` is only ever valid when it does not.
160    pub lease_token: Option<String>,
161    /// Path to the lease ledger the token is checked against. Production
162    /// callers pass `crate::drive::lease::ledger::ledger_path`'s own
163    /// result; tests pass a path under a `tempdir`.
164    pub ledger_path: PathBuf,
165}
166
167/// What happened (or, under `--dry-run`, would happen).
168#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
169#[serde(tag = "status", rename_all = "kebab-case")]
170pub enum WriteResult {
171    /// A dry run of a replace: how many occurrences the *snapshot* held.
172    WouldReplace {
173        /// Counted client-side. Display-only — see [`count_occurrences`].
174        occurrences: usize,
175    },
176    /// A dry run of an append.
177    WouldAppend {
178        /// The body's last `endIndex`, purely informational — nothing
179        /// computes from it (ADR-0076 §5).
180        #[serde(skip_serializing_if = "Option::is_none")]
181        document_end_index: Option<i64>,
182        /// Unicode scalar values being inserted.
183        chars: usize,
184        /// UTF-8 bytes being inserted.
185        bytes: usize,
186    },
187    /// The target is not a Google Doc.
188    RefusedNotADocument {
189        /// Its actual mime type.
190        mime_type: String,
191    },
192    /// The target is a shortcut, which is never followed.
193    RefusedShortcut,
194    /// The target has no parent folder this account can see, so no
195    /// folder rule can apply and no `file_id` rule named it either.
196    RefusedNoVisibleParents,
197    /// `documents.get` returned no `revisionId`, which Google populates only
198    /// for callers with edit access — so no lease can be taken, and there is
199    /// no unleased path (ADR-0076 §3).
200    RefusedNoRevisionId,
201    /// The write-permission gate refused it.
202    Blocked {
203        /// The rule that decided, when one did.
204        decided_by: Option<DecidingRule>,
205    },
206    /// No `--lease` was presented, and the deciding rule requires one
207    /// (ADR-0080 §9).
208    RefusedNoLease,
209    /// The presented lease has expired, or was never a token this ledger
210    /// knows about.
211    RefusedLeaseExpired,
212    /// The presented lease is bound to a different file id.
213    RefusedLeaseWrongFile,
214    /// The file has moved since the lease's recorded `version` — the
215    /// staleness check (ADR-0080 §6). Distinct from [`Self::StaleRevision`],
216    /// which is ADR-0076's own, unrelated Docs revision check.
217    RefusedLeaseStale,
218    /// A replace landed.
219    Replaced {
220        /// What the *server* reported changing.
221        ///
222        /// Not `Option`: a replace always has an answer, and "nothing
223        /// matched" is a count rather than an absence of one. Docs omits
224        /// `occurrencesChanged` entirely when it is zero (proto3), so the
225        /// zero is resolved at the boundary — see
226        /// [`crate::drive::docs::write_types::BatchUpdateDocumentResponse::occurrences_changed_for_replace`].
227        occurrences_changed: i64,
228    },
229    /// An append landed.
230    Appended {
231        /// Unicode scalar values inserted.
232        chars: usize,
233        /// UTF-8 bytes inserted.
234        bytes: usize,
235    },
236    /// The document changed between the read that computed this mutation
237    /// and the `batchUpdate` that would have applied it. Nothing was
238    /// written — a one-request batch is atomic.
239    StaleRevision {
240        /// The lease presented, for the log and for support.
241        required_revision_id: String,
242        /// The server's own message, unaltered.
243        detail: String,
244    },
245    /// Anything else.
246    Failed {
247        /// The error, verbatim.
248        detail: String,
249    },
250}
251
252impl FromLeaseRefusal for WriteResult {
253    fn from_no_lease() -> Self {
254        Self::RefusedNoLease
255    }
256    fn from_lease_expired() -> Self {
257        Self::RefusedLeaseExpired
258    }
259    fn from_lease_wrong_file() -> Self {
260        Self::RefusedLeaseWrongFile
261    }
262    fn from_lease_stale() -> Self {
263        Self::RefusedLeaseStale
264    }
265    fn from_lease_failed(detail: String) -> Self {
266        Self::Failed { detail }
267    }
268}
269
270impl WriteResult {
271    /// The kebab-case status for the request log.
272    fn log_status(&self) -> &'static str {
273        match self {
274            Self::WouldReplace { .. } => "would-replace",
275            Self::WouldAppend { .. } => "would-append",
276            Self::RefusedNotADocument { .. } => "refused-not-a-document",
277            Self::RefusedShortcut => "refused-shortcut",
278            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
279            Self::RefusedNoRevisionId => "refused-no-revision-id",
280            Self::Blocked { .. } => "blocked",
281            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
282            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
283            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
284            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
285            Self::Replaced { .. } => "replaced",
286            Self::Appended { .. } => "appended",
287            Self::StaleRevision { .. } => "stale-revision",
288            Self::Failed { .. } => "failed",
289        }
290    }
291}
292
293/// The full outcome of one attempt.
294#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
295pub struct WriteOutcome {
296    /// The document acted on.
297    pub document_id: String,
298    /// Its name at the time of the attempt.
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub file_name: Option<String>,
301    /// The folder the gate evaluated against, when a chain was walked.
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub resolved_folder_id: Option<String>,
304    /// The lease presented, once one was minted.
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub required_revision_id: Option<String>,
307    /// What happened.
308    pub result: WriteResult,
309}
310
311impl JsonlSerialize for WriteOutcome {
312    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
313        write_scalar_jsonl(self, out)
314    }
315}
316
317/// Counts non-overlapping occurrences of `needle` in `haystack`.
318///
319/// **Display-only.** ADR-0076 §7 makes this an invariant rather than a
320/// convention: the count never gates the mutation, and a count of zero still
321/// sends the `batchUpdate`. It is an estimate over the body text this
322/// command read, while the server matches over its own view — a match may
323/// span a `textRun` boundary, or sit in a segment (a header, a footer, a
324/// footnote) that `document_text` does not include in this count (issue
325/// #1799 made those segments fetchable; folding them into this estimate is
326/// separate follow-up work). Acting on the count would let `omni-dev` report
327/// "nothing to do" for a document that does have matches.
328#[must_use]
329pub fn count_occurrences(haystack: &str, needle: &str, match_case: bool) -> usize {
330    if needle.is_empty() {
331        return 0;
332    }
333    if match_case {
334        return haystack.matches(needle).count();
335    }
336    // Lowercasing can change byte length (e.g. 'İ'), so this counts over
337    // *both* sides lowercased rather than indexing back into the original.
338    haystack
339        .to_lowercase()
340        .matches(&needle.to_lowercase())
341        .count()
342}
343
344/// Runs one text mutation, logging every attempt that isn't a dry run.
345///
346/// Never returns `Err`: every failure is a [`WriteResult`] variant, so the
347/// caller renders one shape and the log records one shape. Exit code stays 0
348/// regardless, matching ADR-0076 §13.
349pub async fn write(
350    drive: &DriveClient,
351    docs: &DocsClient,
352    opts: &WriteOptions,
353    rules: &[FolderPermissionRule],
354) -> WriteOutcome {
355    let started = Instant::now();
356    let outcome = write_inner(drive, docs, opts, rules).await;
357    if !opts.dry_run {
358        record_attempt(&outcome, opts, started.elapsed());
359    }
360    outcome
361}
362
363async fn write_inner(
364    drive: &DriveClient,
365    docs: &DocsClient,
366    opts: &WriteOptions,
367    rules: &[FolderPermissionRule],
368) -> WriteOutcome {
369    let bare = |result| WriteOutcome {
370        document_id: opts.document_id.clone(),
371        file_name: None,
372        resolved_folder_id: None,
373        required_revision_id: None,
374        result,
375    };
376
377    // ── Pure validation, before any request ────────────────────────────
378    if let Err(detail) = opts.payload.validate() {
379        return bare(WriteResult::Failed { detail });
380    }
381
382    let files_api = FilesApi::new(drive);
383    let target = match files_api.get_metadata(&opts.document_id).await {
384        Ok(target) => target,
385        Err(err) => {
386            return bare(WriteResult::Failed {
387                detail: err.to_string(),
388            })
389        }
390    };
391
392    let with_target = |result| WriteOutcome {
393        document_id: opts.document_id.clone(),
394        file_name: Some(target.name.clone()),
395        resolved_folder_id: None,
396        required_revision_id: None,
397        result,
398    };
399
400    // ── Refusals that precede the gate ─────────────────────────────────
401    if target.mime_type == GOOGLE_SHORTCUT_MIME_TYPE {
402        return with_target(WriteResult::RefusedShortcut);
403    }
404    if target.mime_type != GOOGLE_DOC_MIME_TYPE {
405        return with_target(WriteResult::RefusedNotADocument {
406            mime_type: target.mime_type.clone(),
407        });
408    }
409
410    // ── The gate, before any Docs call ─────────────────────────────────
411    // A `file_id` rule is consulted before the parents are looked at, so a
412    // Doc with no visible parent can still be granted (issue #1612).
413    let evaluated = match folder_ancestry::resolve_decision_for_file_target(
414        &files_api,
415        &target,
416        DriveOperation::DocsWrite,
417        rules,
418    )
419    .await
420    {
421        Ok(evaluated) => evaluated,
422        // A chain that could not be resolved is a refusal, never a silent
423        // allow — ADR-0071 §3's highest-priority invariant.
424        Err(err) => {
425            return with_target(WriteResult::Failed {
426                detail: err.to_string(),
427            })
428        }
429    };
430
431    // Only *after* the file-rule lookup has come up empty is "no visible
432    // parents" the real story.
433    if evaluated.source == folder_ancestry::DecisionSource::NoVisibleParents {
434        return with_target(WriteResult::RefusedNoVisibleParents);
435    }
436
437    let folder_ancestry::FileTargetDecision {
438        decision,
439        resolved_folder_id,
440        requires_lease,
441        ..
442    } = evaluated;
443
444    let gated = |result, revision: Option<String>| WriteOutcome {
445        document_id: opts.document_id.clone(),
446        file_name: Some(target.name.clone()),
447        resolved_folder_id: resolved_folder_id.clone(),
448        required_revision_id: revision,
449        result,
450    };
451
452    if decision.verdict == write_gate::Verdict::Deny {
453        return gated(
454            WriteResult::Blocked {
455                decided_by: decision.decided_by,
456            },
457            None,
458        );
459    }
460
461    // ── The read that mints the lease and computes the preview ─────────
462    let api = DocsApi::new(docs);
463    let document = match api
464        .get_document(&opts.document_id, SuggestionsViewMode::default())
465        .await
466    {
467        Ok(document) => document,
468        Err(err) => {
469            return gated(
470                WriteResult::Failed {
471                    detail: err.to_string(),
472                },
473                None,
474            )
475        }
476    };
477
478    let Some(revision_id) = document.revision_id.clone() else {
479        return gated(WriteResult::RefusedNoRevisionId, None);
480    };
481
482    // Computed from *this* snapshot, before the dry-run branch, so a dry run
483    // and a real run cannot disagree about what they saw (ADR-0076 §7).
484    let preview = match &opts.payload {
485        WritePayload::Replace {
486            search, match_case, ..
487        } => {
488            // Across every tab, because a `replaceAllText` with no
489            // `tabsCriteria` spans every tab (ADR-0076 §11).
490            let corpus = document_text(&document);
491            WriteResult::WouldReplace {
492                occurrences: count_occurrences(&corpus, search, *match_case),
493            }
494        }
495        WritePayload::Append { text } => WriteResult::WouldAppend {
496            document_end_index: body_end_index(&document),
497            chars: text.chars().count(),
498            bytes: text.len(),
499        },
500    };
501
502    if opts.dry_run {
503        return gated(preview, Some(revision_id));
504    }
505
506    // The lease check (ADR-0080 §9) sits here: after the permission gate
507    // and the `--dry-run` branch, before the mutating call — see
508    // `content_edit.rs::edit_inner`'s doc comment for the full reasoning,
509    // shared verbatim by every leased engine. This is a *separate*,
510    // unrelated staleness check from ADR-0076's own `revisionId` lease
511    // above: that one guards the Docs `batchUpdate` itself against a
512    // concurrent edit, while this one guards the *lease ledger*'s recorded
513    // Drive `version` (ADR-0080 §6). A fresh `files.get` immediately before
514    // `batchUpdate`, not a reuse of the metadata fetched before the
515    // (potentially slow) ancestor-chain walk and `documents.get` above.
516    //
517    // `opts.payload.to_request()` — the build-before-gate invariant's
518    // "build" step for this engine — is called below only *after* this
519    // gate succeeds (see the mutating call itself), which trivially
520    // satisfies the invariant: there is no pre-gate build to fail and
521    // orphan a `pending` record (#1688/#1742).
522    let leased = LeasedWrite {
523        log_prefix: "drive docs write",
524        operation: opts.payload.verb().log_operation(),
525        ledger_path: &opts.ledger_path,
526        file_id: &opts.document_id,
527    };
528    let lease_grant = match gate_optional_leased_write(
529        leased,
530        &files_api,
531        requires_lease,
532        opts.lease_token.as_deref(),
533    )
534    .await
535    {
536        Ok(grant) => grant,
537        Err(err) => return gated(err.into_result(), Some(revision_id)),
538    };
539
540    // ── The mutation ───────────────────────────────────────────────────
541    let result = match conclude_native_leased_write(
542        leased,
543        &lease_grant,
544        &files_api,
545        api.batch_update(&opts.document_id, opts.payload.to_request(), &revision_id)
546            .await,
547        ToString::to_string,
548    )
549    .await
550    {
551        Ok(response) => match &opts.payload {
552            WritePayload::Replace { .. } => WriteResult::Replaced {
553                occurrences_changed: response.occurrences_changed_for_replace(),
554            },
555            WritePayload::Append { text } => WriteResult::Appended {
556                chars: text.chars().count(),
557                bytes: text.len(),
558            },
559        },
560        Err(err) => {
561            // Either way the mutation did not happen — a `412` on
562            // `writeControl.requiredRevisionId` included — so the intent
563            // record gets a `failed` outcome carrying the reason.
564            let detail = err.to_string();
565            if is_stale_revision(&err) {
566                WriteResult::StaleRevision {
567                    required_revision_id: revision_id.clone(),
568                    detail,
569                }
570            } else {
571                WriteResult::Failed { detail }
572            }
573        }
574    };
575    drop(lease_grant);
576    gated(result, Some(revision_id))
577}
578
579/// Every tab's text, concatenated — the corpus the occurrence preview counts
580/// over.
581///
582/// Spans every tab because the mutation does (ADR-0076 §11); counting only
583/// the first would make the preview a lower bound on a tabbed document.
584fn document_text(document: &crate::drive::docs::types::Document) -> String {
585    document
586        .resolved_tabs()
587        .iter()
588        .filter_map(|tab| tab.body)
589        .flat_map(crate::drive::docs::structure::flatten)
590        .map(|element| element.text)
591        .collect::<Vec<_>>()
592        .join("\n")
593}
594
595/// The first tab's body end index, for the append preview.
596///
597/// Informational only — nothing computes an insertion point from it, because
598/// `endOfSegmentLocation` lets the server do that (ADR-0076 §5).
599fn body_end_index(document: &crate::drive::docs::types::Document) -> Option<i64> {
600    document
601        .resolved_tabs()
602        .first()
603        .and_then(|tab| tab.body)
604        .and_then(|body| body.content.last())
605        .map(crate::drive::docs::types::StructuralElement::end_index)
606}
607
608/// Emits the `kind: "drivemutation"` record.
609///
610/// Inside the engine, never the CLI layer, so a future MCP caller cannot
611/// bypass it — and so a `Blocked` outcome, which makes zero API calls, still
612/// leaves a trace. Same reasoning as `sheets/write.rs::record_attempt`.
613///
614/// **The searched, replacement and appended text are never recorded.** They
615/// are user prose, and often the most sensitive thing in the invocation —
616/// sharper than the Sheets case, where an A1 range is metadata rather than
617/// content. Only counts and the opaque revision id go in.
618fn record_attempt(outcome: &WriteOutcome, opts: &WriteOptions, duration: Duration) {
619    let error = match &outcome.result {
620        // A lost lease is recorded as an error alongside its own
621        // `stale-revision` status: the status says what happened, the
622        // detail carries the server's own message for support.
623        WriteResult::Failed { detail } | WriteResult::StaleRevision { detail, .. } => {
624            Some(detail.clone())
625        }
626        _ => None,
627    };
628    let decided_by = match &outcome.result {
629        WriteResult::Blocked { decided_by } => decided_by.as_ref(),
630        _ => None,
631    };
632    let decided_by = write_gate::decided_by_log_fields(decided_by);
633    let occurrences_changed = match &outcome.result {
634        WriteResult::Replaced {
635            occurrences_changed,
636        } => Some(*occurrences_changed),
637        _ => None,
638    };
639    let inserted_chars = match &outcome.result {
640        WriteResult::Appended { chars, .. } => Some(*chars as i64),
641        _ => None,
642    };
643
644    request_log::record_drive_mutation(DriveMutationOutcome {
645        operation: opts.payload.verb().log_operation(),
646        file_id: outcome.document_id.clone(),
647        file_name: outcome.file_name.clone().unwrap_or_default(),
648        status: outcome.result.log_status().to_string(),
649        resolved_folder_id: outcome.resolved_folder_id.clone(),
650        decided_by_folder_id: decided_by.folder_id,
651        decided_by_depth: decided_by.depth,
652        decided_by_file_id: decided_by.file_id,
653        occurrences_changed,
654        inserted_chars,
655        required_revision_id: outcome.required_revision_id.clone(),
656        error,
657        duration,
658        ..Default::default()
659    });
660}
661
662/// Renders an outcome as a single human-readable line.
663///
664/// Lives here rather than in the CLI layer so the CLI and a future MCP
665/// caller describe an outcome identically.
666#[must_use]
667pub fn describe(outcome: &WriteOutcome, verb: WriteVerb) -> String {
668    let name = outcome.file_name.as_deref().unwrap_or(&outcome.document_id);
669    match &outcome.result {
670        WriteResult::WouldReplace { occurrences } => format!(
671            "Would replace: {occurrences} occurrence(s) in '{name}' \
672             (counted from the copy just read)"
673        ),
674        WriteResult::WouldAppend {
675            document_end_index,
676            chars,
677            bytes,
678        } => {
679            let where_ = document_end_index
680                .map(|index| format!(" (body ends at index {index})"))
681                .unwrap_or_default();
682            format!("Would append: {chars} char(s) / {bytes} byte(s) to '{name}'{where_}")
683        }
684        WriteResult::RefusedNotADocument { mime_type } => format!(
685            "Refused: '{name}' is not a Google Doc (mimeType: {mime_type}); \
686             `drive docs {}` only works on Google Docs",
687            verb.label()
688        ),
689        WriteResult::RefusedShortcut => format!(
690            "Refused: '{name}' is a shortcut; `drive docs {}` doesn't follow shortcuts — \
691             resolve the target document's id and use that instead",
692            verb.label()
693        ),
694        WriteResult::RefusedNoVisibleParents => format!(
695            "Refused: '{name}' has no parent folder visible to this account, so no folder \
696             rule can apply to it. This is normal for a Doc shared by link or email. \
697             Grant it by id instead: add {{\"file_id\": \"<document id>\", \"allow\": \
698             [\"docs-write\"]}} to write_permissions.rules. (Adding it to a folder in your \
699             own Drive and granting that folder `docs-write` also works.)"
700        ),
701        WriteResult::RefusedNoRevisionId => format!(
702            "Refused: '{name}' returned no revision id, which Google sends only to callers \
703             with edit access — so this write cannot be leased against a known version. \
704             Request edit access, or check the account in use."
705        ),
706        WriteResult::Blocked { decided_by } => match decided_by {
707            // The same one-line shape the other five `describe`/report
708            // sites use, so the wording cannot drift between them.
709            Some(rule) => format!(
710                "Blocked: '{name}' — refused by rule on {} {}{}",
711                rule.kind_label(),
712                rule.id(),
713                rule.depth_suffix()
714            ),
715            None => format!("Blocked: '{name}' — refused by default policy (no matching rule)"),
716        },
717        WriteResult::RefusedNoLease => LeaseGateRefusal::NoLease
718            .describe_line(&outcome.document_id, &format!("'{name}'"))
719            .unwrap_or_default(),
720        WriteResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
721            .describe_line(&outcome.document_id, &format!("'{name}'"))
722            .unwrap_or_default(),
723        WriteResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
724            .describe_line(&outcome.document_id, &format!("'{name}'"))
725            .unwrap_or_default(),
726        WriteResult::RefusedLeaseStale => LeaseGateRefusal::Stale
727            .describe_line(&outcome.document_id, &format!("'{name}'"))
728            .unwrap_or_default(),
729        WriteResult::Replaced {
730            occurrences_changed,
731        } => format!("Replaced: {occurrences_changed} occurrence(s) in '{name}'"),
732        WriteResult::Appended { chars, bytes } => {
733            format!("Appended: {chars} char(s) / {bytes} byte(s) to '{name}'")
734        }
735        WriteResult::StaleRevision {
736            required_revision_id,
737            ..
738        } => format!(
739            "Refused: '{name}' changed since it was read (revision lease \
740             {required_revision_id} no longer current) — nothing was written. \
741             Re-run to apply against the current version."
742        ),
743        WriteResult::Failed { detail } => format!("Failed: '{name}': {detail}"),
744    }
745}
746
747#[cfg(test)]
748#[allow(clippy::unwrap_used, clippy::expect_used)]
749mod tests {
750    use super::*;
751    use std::collections::HashSet;
752
753    use crate::drive::test_support::seed_lease;
754
755    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
756    use crate::drive::docs::client::DOCS_API_URL;
757    use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
758    use crate::test_support::env::MapEnv;
759    use crate::utils::secret::Secret;
760    use wiremock::matchers::{method, path};
761    use wiremock::{Mock, MockServer, ResponseTemplate};
762
763    fn test_credentials() -> DriveCredentials {
764        DriveCredentials {
765            client_id: "client-1".to_string(),
766            client_secret: Secret::new("secret-1"),
767            refresh_token: Secret::new("refresh-1"),
768            scope: DriveGrantedScopes::READONLY,
769        }
770    }
771
772    /// Both hosts point at one wiremock server.
773    ///
774    /// `replace_session` swaps the Drive client's whole transport, so it
775    /// must run **before** the derive — deriving first would leave the Docs
776    /// client holding the original session, pointed at the real
777    /// `oauth2.googleapis.com`, and make a live network call from a test.
778    async fn clients(server: &MockServer) -> (DriveClient, DocsClient) {
779        Mock::given(method("POST"))
780            .and(path("/token"))
781            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
782                "access_token": "test-token", "expires_in": 3600,
783            })))
784            .mount(server)
785            .await;
786        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
787        crate::drive::client::test_support::replace_session(
788            &mut drive,
789            &test_credentials(),
790            &format!("{}/token", server.uri()),
791        );
792        let env = MapEnv::new().with(DOCS_API_URL, &server.uri());
793        let docs = DocsClient::from_drive_client_with(&env, &drive).unwrap();
794        (drive, docs)
795    }
796
797    /// `version: "1"` throughout — matches [`replace_opts`]'s/
798    /// [`append_opts`]'s default seeded lease, so any test reaching the
799    /// mutating call has a live, non-stale lease by construction (ADR-0080
800    /// §9).
801    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> Mock {
802        let parents: Vec<&str> = parents.to_vec();
803        Mock::given(method("GET"))
804            .and(path(format!("/drive/v3/files/{id}")))
805            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
806                "id": id, "name": id, "mimeType": mime_type, "parents": parents,
807                "version": "1",
808            })))
809    }
810
811    /// A fresh, isolated ledger path holding a live lease for `document_id`
812    /// at version `"1"` (matching [`mount_file`]'s default).
813    fn leased_opts_for(document_id: &str) -> (Option<String>, std::path::PathBuf) {
814        let ledger_path = tempfile::tempdir()
815            .unwrap()
816            .keep()
817            .join("lease-ledger.jsonl");
818        let token = seed_lease(&ledger_path, document_id, "1");
819        (Some(token), ledger_path)
820    }
821
822    fn mount_folder(id: &str) -> Mock {
823        mount_file(id, "application/vnd.google-apps.folder", &[])
824    }
825
826    fn mount_document(revision: Option<&str>, text: &str) -> Mock {
827        let mut body = serde_json::json!({
828            "documentId": "doc-1",
829            "body": {"content": [{
830                "startIndex": 1, "endIndex": 1 + text.encode_utf16().count() as i64,
831                "paragraph": {"elements": [{"textRun": {"content": format!("{text}\n")}}]},
832            }]},
833        });
834        if let Some(revision) = revision {
835            body["revisionId"] = serde_json::json!(revision);
836        }
837        Mock::given(method("GET"))
838            .and(path("/v1/documents/doc-1"))
839            .respond_with(ResponseTemplate::new(200).set_body_json(body))
840    }
841
842    fn mount_batch_update(response: serde_json::Value) -> Mock {
843        Mock::given(method("POST"))
844            .and(path("/v1/documents/doc-1:batchUpdate"))
845            .respond_with(ResponseTemplate::new(200).set_body_json(response))
846    }
847
848    fn allow_rule(folder: &str) -> FolderPermissionRule {
849        FolderPermissionRule {
850            folder_id: Some(folder.to_string()),
851            file_id: None,
852            recursive: true,
853            allow: std::iter::once(DriveOperation::DocsWrite).collect(),
854            deny: HashSet::default(),
855            require_lease: true,
856        }
857    }
858
859    /// Seeds a fresh, isolated ledger with a live lease for `"doc-1"` at
860    /// version `"1"` (matching [`mount_file`]'s default) and returns options
861    /// carrying it. Every existing test built before the lease (ADR-0080
862    /// §9) reaches its mutating call this way by construction — see
863    /// `sheets/structure.rs::opts_for`'s doc comment for why this doesn't
864    /// need touching each test individually. A test exercising the lease
865    /// *refusal* paths builds `WriteOptions` directly instead (see the "the
866    /// Drive write lease" test section below).
867    fn replace_opts(dry_run: bool) -> WriteOptions {
868        let (lease_token, ledger_path) = leased_opts_for("doc-1");
869        WriteOptions {
870            document_id: "doc-1".to_string(),
871            payload: WritePayload::Replace {
872                search: "Q3".to_string(),
873                replace: "Q4".to_string(),
874                match_case: true,
875            },
876            dry_run,
877            lease_token,
878            ledger_path,
879        }
880    }
881
882    fn append_opts(dry_run: bool) -> WriteOptions {
883        let (lease_token, ledger_path) = leased_opts_for("doc-1");
884        WriteOptions {
885            document_id: "doc-1".to_string(),
886            payload: WritePayload::Append {
887                text: "hello".to_string(),
888            },
889            dry_run,
890            lease_token,
891            ledger_path,
892        }
893    }
894
895    // ── count_occurrences (pure) ───────────────────────────────────────
896
897    #[test]
898    fn count_occurrences_counts_non_overlapping_matches() {
899        assert_eq!(count_occurrences("Q3 and Q3 and Q3", "Q3", true), 3);
900        assert_eq!(count_occurrences("aaaa", "aa", true), 2);
901    }
902
903    #[test]
904    fn count_occurrences_honours_case_sensitivity_both_ways() {
905        assert_eq!(count_occurrences("it It IT", "it", true), 1);
906        assert_eq!(count_occurrences("it It IT", "it", false), 3);
907    }
908
909    #[test]
910    fn count_occurrences_of_an_empty_needle_is_zero() {
911        assert_eq!(count_occurrences("anything", "", true), 0);
912        assert_eq!(count_occurrences("anything", "", false), 0);
913    }
914
915    #[test]
916    fn count_occurrences_handles_multibyte_text() {
917        assert_eq!(count_occurrences("😀 x 😀", "😀", true), 2);
918        assert_eq!(count_occurrences("ÉCOLE école", "école", false), 2);
919    }
920
921    // ── Refusals that precede the gate and every network call ──────────
922
923    #[tokio::test]
924    async fn a_non_document_is_refused_before_the_gate_or_any_docs_call() {
925        let server = MockServer::start().await;
926        let (drive, docs) = clients(&server).await;
927        // A spreadsheet with a parent, and permissive rules — yet neither
928        // the parent lookup nor any Docs call is mounted, so the refusal
929        // must happen before both.
930        mount_file("doc-1", GOOGLE_SHEET_MIME_TYPE, &["folder-1"])
931            .mount(&server)
932            .await;
933
934        let outcome = write(
935            &drive,
936            &docs,
937            &replace_opts(false),
938            &[allow_rule("folder-1")],
939        )
940        .await;
941        assert!(matches!(
942            outcome.result,
943            WriteResult::RefusedNotADocument { .. }
944        ));
945    }
946
947    #[tokio::test]
948    async fn a_shortcut_gets_its_own_refusal_not_the_generic_one() {
949        let server = MockServer::start().await;
950        let (drive, docs) = clients(&server).await;
951        mount_file("doc-1", GOOGLE_SHORTCUT_MIME_TYPE, &["folder-1"])
952            .mount(&server)
953            .await;
954
955        let outcome = write(
956            &drive,
957            &docs,
958            &replace_opts(false),
959            &[allow_rule("folder-1")],
960        )
961        .await;
962        assert_eq!(outcome.result, WriteResult::RefusedShortcut);
963        let text = describe(&outcome, WriteVerb::Replace);
964        assert!(text.contains("is a shortcut"), "{text}");
965        assert!(!text.contains("not a Google Doc"), "{text}");
966    }
967
968    #[tokio::test]
969    async fn an_empty_search_is_refused_before_any_network_call() {
970        let server = MockServer::start().await;
971        let (drive, docs) = clients(&server).await;
972        // No mocks at all: nothing may be requested.
973        let opts = WriteOptions {
974            payload: WritePayload::Replace {
975                search: String::new(),
976                replace: "x".to_string(),
977                match_case: true,
978            },
979            ..replace_opts(false)
980        };
981        let outcome = write(&drive, &docs, &opts, &[]).await;
982        match outcome.result {
983            WriteResult::Failed { detail } => assert!(detail.contains("--search"), "{detail}"),
984            other => panic!("expected Failed, got {other:?}"),
985        }
986    }
987
988    // ── The gate ───────────────────────────────────────────────────────
989
990    /// ADR-0076 §3: a blocked write must make **zero** Docs API calls, so
991    /// the document body never reaches a caller the gate is refusing.
992    #[tokio::test]
993    async fn a_denied_target_is_blocked_with_zero_docs_calls() {
994        let server = MockServer::start().await;
995        let (drive, docs) = clients(&server).await;
996        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
997            .mount(&server)
998            .await;
999        mount_folder("folder-1").mount(&server).await;
1000        // No `documents.get` and no batchUpdate mounted: reaching either
1001        // fails the test.
1002
1003        let outcome = write(&drive, &docs, &replace_opts(false), &[]).await;
1004        assert!(matches!(outcome.result, WriteResult::Blocked { .. }));
1005    }
1006
1007    #[tokio::test]
1008    async fn a_parentless_document_is_refused_distinctly_not_blocked() {
1009        let server = MockServer::start().await;
1010        let (drive, docs) = clients(&server).await;
1011        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &[])
1012            .mount(&server)
1013            .await;
1014
1015        let outcome = write(&drive, &docs, &replace_opts(false), &[]).await;
1016        assert_eq!(outcome.result, WriteResult::RefusedNoVisibleParents);
1017        let text = describe(&outcome, WriteVerb::Replace);
1018        assert!(text.contains("file_id"), "names the actual fix: {text}");
1019    }
1020
1021    /// A `file_id` rule reaches a link-shared Doc that no folder rule could
1022    /// (issue #1612), so the parentless refusal must not pre-empt it.
1023    #[tokio::test]
1024    async fn a_file_rule_grants_a_parentless_document() {
1025        let server = MockServer::start().await;
1026        let (drive, docs) = clients(&server).await;
1027        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &[])
1028            .mount(&server)
1029            .await;
1030        mount_document(Some("rev-1"), "Q3 report")
1031            .mount(&server)
1032            .await;
1033
1034        let rules = [FolderPermissionRule {
1035            folder_id: None,
1036            file_id: Some("doc-1".to_string()),
1037            recursive: false,
1038            allow: std::iter::once(DriveOperation::DocsWrite).collect(),
1039            deny: HashSet::default(),
1040            require_lease: true,
1041        }];
1042        let outcome = write(&drive, &docs, &replace_opts(true), &rules).await;
1043        assert!(matches!(outcome.result, WriteResult::WouldReplace { .. }));
1044    }
1045
1046    #[tokio::test]
1047    async fn an_ancestor_fetch_failure_produces_failed_not_allow() {
1048        let server = MockServer::start().await;
1049        let (drive, docs) = clients(&server).await;
1050        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1051            .mount(&server)
1052            .await;
1053        Mock::given(method("GET"))
1054            .and(path("/drive/v3/files/folder-1"))
1055            .respond_with(ResponseTemplate::new(500))
1056            .mount(&server)
1057            .await;
1058
1059        let outcome = write(
1060            &drive,
1061            &docs,
1062            &replace_opts(false),
1063            &[allow_rule("folder-1")],
1064        )
1065        .await;
1066        assert!(
1067            matches!(outcome.result, WriteResult::Failed { .. }),
1068            "a chain that could not be resolved must never become an allow: {:?}",
1069            outcome.result
1070        );
1071    }
1072
1073    // ── The lease ──────────────────────────────────────────────────────
1074
1075    #[tokio::test]
1076    async fn the_batch_update_presents_the_revision_from_the_get_it_just_made() {
1077        let server = MockServer::start().await;
1078        let (drive, docs) = clients(&server).await;
1079        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1080            .mount(&server)
1081            .await;
1082        mount_folder("folder-1").mount(&server).await;
1083        mount_document(Some("rev-abc"), "Q3 report")
1084            .mount(&server)
1085            .await;
1086        Mock::given(method("POST"))
1087            .and(path("/v1/documents/doc-1:batchUpdate"))
1088            .and(wiremock::matchers::body_partial_json(serde_json::json!({
1089                "writeControl": {"requiredRevisionId": "rev-abc"},
1090            })))
1091            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1092                "documentId": "doc-1",
1093                "replies": [{"replaceAllText": {"occurrencesChanged": 1}}],
1094            })))
1095            .expect(1)
1096            .mount(&server)
1097            .await;
1098
1099        let outcome = write(
1100            &drive,
1101            &docs,
1102            &replace_opts(false),
1103            &[allow_rule("folder-1")],
1104        )
1105        .await;
1106        assert_eq!(
1107            outcome.result,
1108            WriteResult::Replaced {
1109                occurrences_changed: 1
1110            }
1111        );
1112        assert_eq!(outcome.required_revision_id.as_deref(), Some("rev-abc"));
1113    }
1114
1115    /// ADR-0076 §3: rather than fall back to an unleased write, a document
1116    /// Google withheld the revision id for is refused — and no batchUpdate
1117    /// is mounted, so reaching one fails the test.
1118    #[tokio::test]
1119    async fn a_document_with_no_revision_id_is_refused_rather_than_written_unleased() {
1120        let server = MockServer::start().await;
1121        let (drive, docs) = clients(&server).await;
1122        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1123            .mount(&server)
1124            .await;
1125        mount_folder("folder-1").mount(&server).await;
1126        mount_document(None, "Q3 report").mount(&server).await;
1127
1128        let outcome = write(
1129            &drive,
1130            &docs,
1131            &replace_opts(false),
1132            &[allow_rule("folder-1")],
1133        )
1134        .await;
1135        assert_eq!(outcome.result, WriteResult::RefusedNoRevisionId);
1136        let text = describe(&outcome, WriteVerb::Replace);
1137        assert!(text.contains("edit access"), "{text}");
1138    }
1139
1140    /// The exact 400 confirmed live (ADR-0076 §6).
1141    #[tokio::test]
1142    async fn a_stale_revision_400_is_its_own_outcome_not_a_generic_failure() {
1143        let server = MockServer::start().await;
1144        let (drive, docs) = clients(&server).await;
1145        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1146            .mount(&server)
1147            .await;
1148        mount_folder("folder-1").mount(&server).await;
1149        mount_document(Some("rev-old"), "Q3 report")
1150            .mount(&server)
1151            .await;
1152        Mock::given(method("POST"))
1153            .and(path("/v1/documents/doc-1:batchUpdate"))
1154            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
1155                "error": {
1156                    "code": 400,
1157                    "message": "The required revision ID 'rev-old' does not match the latest revision.",
1158                    "status": "INVALID_ARGUMENT",
1159                },
1160            })))
1161            .mount(&server)
1162            .await;
1163
1164        let outcome = write(
1165            &drive,
1166            &docs,
1167            &replace_opts(false),
1168            &[allow_rule("folder-1")],
1169        )
1170        .await;
1171        match &outcome.result {
1172            WriteResult::StaleRevision {
1173                required_revision_id,
1174                ..
1175            } => assert_eq!(required_revision_id, "rev-old"),
1176            other => panic!("expected StaleRevision, got {other:?}"),
1177        }
1178        let text = describe(&outcome, WriteVerb::Replace);
1179        assert!(text.contains("nothing was written"), "{text}");
1180        assert!(text.contains("Re-run"), "{text}");
1181    }
1182
1183    /// `INVALID_ARGUMENT` is also what a malformed request carries, so an
1184    /// unrelated 400 must stay a generic failure — telling the user to
1185    /// re-run it would be advice that can never work.
1186    #[tokio::test]
1187    async fn an_unrelated_400_stays_a_generic_failure() {
1188        let server = MockServer::start().await;
1189        let (drive, docs) = clients(&server).await;
1190        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1191            .mount(&server)
1192            .await;
1193        mount_folder("folder-1").mount(&server).await;
1194        mount_document(Some("rev-1"), "Q3 report")
1195            .mount(&server)
1196            .await;
1197        Mock::given(method("POST"))
1198            .and(path("/v1/documents/doc-1:batchUpdate"))
1199            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
1200                "error": {"code": 400, "message": "Invalid requests[0]", "status": "INVALID_ARGUMENT"},
1201            })))
1202            .mount(&server)
1203            .await;
1204
1205        let outcome = write(
1206            &drive,
1207            &docs,
1208            &replace_opts(false),
1209            &[allow_rule("folder-1")],
1210        )
1211        .await;
1212        assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1213    }
1214
1215    // ── --dry-run ──────────────────────────────────────────────────────
1216
1217    /// Mirrors `content_edit.rs::dry_run_never_calls_edit_endpoint`.
1218    #[tokio::test]
1219    async fn dry_run_never_calls_batch_update() {
1220        let server = MockServer::start().await;
1221        let (drive, docs) = clients(&server).await;
1222        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1223            .mount(&server)
1224            .await;
1225        mount_folder("folder-1").mount(&server).await;
1226        mount_document(Some("rev-1"), "Q3 report")
1227            .mount(&server)
1228            .await;
1229        // No batchUpdate mock: reaching it fails the test.
1230
1231        let outcome = write(
1232            &drive,
1233            &docs,
1234            &replace_opts(true),
1235            &[allow_rule("folder-1")],
1236        )
1237        .await;
1238        assert!(matches!(outcome.result, WriteResult::WouldReplace { .. }));
1239    }
1240
1241    /// Mirrors
1242    /// `content_edit.rs::dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run`.
1243    #[tokio::test]
1244    async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
1245        let server = MockServer::start().await;
1246        let (drive, docs) = clients(&server).await;
1247        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1248            .expect(2)
1249            .mount(&server)
1250            .await;
1251        mount_folder("folder-1").expect(2).mount(&server).await;
1252
1253        let dry = write(&drive, &docs, &replace_opts(true), &[]).await;
1254        let real = write(&drive, &docs, &replace_opts(false), &[]).await;
1255        assert_eq!(dry.result, real.result);
1256        assert!(matches!(dry.result, WriteResult::Blocked { .. }));
1257    }
1258
1259    #[tokio::test]
1260    async fn dry_run_replace_reports_the_occurrence_count_from_the_snapshot() {
1261        let server = MockServer::start().await;
1262        let (drive, docs) = clients(&server).await;
1263        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1264            .mount(&server)
1265            .await;
1266        mount_folder("folder-1").mount(&server).await;
1267        mount_document(Some("rev-1"), "Q3 then Q3 then Q3")
1268            .mount(&server)
1269            .await;
1270
1271        let outcome = write(
1272            &drive,
1273            &docs,
1274            &replace_opts(true),
1275            &[allow_rule("folder-1")],
1276        )
1277        .await;
1278        assert_eq!(outcome.result, WriteResult::WouldReplace { occurrences: 3 });
1279        let text = describe(&outcome, WriteVerb::Replace);
1280        assert!(text.contains("3 occurrence(s)"), "{text}");
1281    }
1282
1283    #[tokio::test]
1284    async fn dry_run_append_reports_the_end_index_and_the_insert_size() {
1285        let server = MockServer::start().await;
1286        let (drive, docs) = clients(&server).await;
1287        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1288            .mount(&server)
1289            .await;
1290        mount_folder("folder-1").mount(&server).await;
1291        mount_document(Some("rev-1"), "body").mount(&server).await;
1292
1293        let outcome = write(&drive, &docs, &append_opts(true), &[allow_rule("folder-1")]).await;
1294        match outcome.result {
1295            WriteResult::WouldAppend {
1296                document_end_index,
1297                chars,
1298                bytes,
1299            } => {
1300                assert_eq!(chars, 5);
1301                assert_eq!(bytes, 5);
1302                assert!(document_end_index.is_some());
1303            }
1304            other => panic!("expected WouldAppend, got {other:?}"),
1305        }
1306    }
1307
1308    /// ADR-0076 §7: the client-side count is display-only and **never**
1309    /// gates the mutation, so a zero count still sends the batch. Reporting
1310    /// "nothing to do" from an estimate would be wrong whenever the server
1311    /// can see a match this crate cannot.
1312    #[tokio::test]
1313    async fn a_zero_occurrence_replace_still_sends_the_batch_update() {
1314        let server = MockServer::start().await;
1315        let (drive, docs) = clients(&server).await;
1316        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1317            .mount(&server)
1318            .await;
1319        mount_folder("folder-1").mount(&server).await;
1320        mount_document(Some("rev-1"), "nothing matching here")
1321            .mount(&server)
1322            .await;
1323        mount_batch_update(serde_json::json!({
1324            "documentId": "doc-1",
1325            "replies": [{"replaceAllText": {"occurrencesChanged": 0}}],
1326        }))
1327        .expect(1)
1328        .mount(&server)
1329        .await;
1330
1331        let outcome = write(
1332            &drive,
1333            &docs,
1334            &replace_opts(false),
1335            &[allow_rule("folder-1")],
1336        )
1337        .await;
1338        assert_eq!(
1339            outcome.result,
1340            WriteResult::Replaced {
1341                occurrences_changed: 0
1342            }
1343        );
1344    }
1345
1346    /// An `insertText` replies with an empty object, which is normal.
1347    #[tokio::test]
1348    async fn an_append_reports_its_own_size_since_the_api_reports_no_count() {
1349        let server = MockServer::start().await;
1350        let (drive, docs) = clients(&server).await;
1351        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1352            .mount(&server)
1353            .await;
1354        mount_folder("folder-1").mount(&server).await;
1355        mount_document(Some("rev-1"), "body").mount(&server).await;
1356        mount_batch_update(serde_json::json!({"documentId": "doc-1", "replies": [{}]}))
1357            .mount(&server)
1358            .await;
1359
1360        let outcome = write(
1361            &drive,
1362            &docs,
1363            &append_opts(false),
1364            &[allow_rule("folder-1")],
1365        )
1366        .await;
1367        assert_eq!(outcome.result, WriteResult::Appended { chars: 5, bytes: 5 });
1368    }
1369
1370    // ── the Drive write lease (ADR-0080 §9) ────────────────────────────
1371
1372    #[tokio::test]
1373    async fn refuses_without_a_lease_when_the_rule_requires_one() {
1374        let server = MockServer::start().await;
1375        let (drive, docs) = clients(&server).await;
1376        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1377            .mount(&server)
1378            .await;
1379        mount_folder("folder-1").mount(&server).await;
1380        mount_document(Some("rev-1"), "Q3 report")
1381            .mount(&server)
1382            .await;
1383        // No batchUpdate mock mounted — a refusal must make zero mutating
1384        // calls.
1385
1386        let opts = WriteOptions {
1387            lease_token: None,
1388            ledger_path: std::path::PathBuf::new(),
1389            ..replace_opts(false)
1390        };
1391        let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
1392        assert!(matches!(outcome.result, WriteResult::RefusedNoLease));
1393        assert_eq!(outcome.result.log_status(), "refused-no-lease");
1394    }
1395
1396    #[tokio::test]
1397    async fn reports_a_lock_acquisition_failure_as_failed() {
1398        // A pre-existing lock file simulates another `drive lease`
1399        // operation genuinely in progress — reported as an operational
1400        // failure, not folded into `RefusedLeaseExpired`.
1401        let server = MockServer::start().await;
1402        let (drive, docs) = clients(&server).await;
1403        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1404            .mount(&server)
1405            .await;
1406        mount_folder("folder-1").mount(&server).await;
1407        mount_document(Some("rev-1"), "Q3 report")
1408            .mount(&server)
1409            .await;
1410
1411        let opts = replace_opts(false);
1412        // Under `flock` (issue #1687), a busy lock now waits rather than
1413        // hard-failing (`check_and_lock_lease` -> `acquire_waiting`), so a
1414        // held `LedgerLock` no longer reproduces an immediate failure here.
1415        // A directory at the lock path does: opening it for write fails
1416        // outright with an I/O error, which is never retried.
1417        let mut lock_path = opts.ledger_path.clone().into_os_string();
1418        lock_path.push(".lock");
1419        std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
1420
1421        let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
1422        assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1423    }
1424
1425    #[tokio::test]
1426    async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
1427        // The gate's own resolve step succeeds off the first `files.get`,
1428        // but the fresh re-fetch feeding the staleness check (ADR-0080 §6)
1429        // fails — the write must report `Failed` and never reach
1430        // `batchUpdate`.
1431        let server = MockServer::start().await;
1432        let (drive, docs) = clients(&server).await;
1433        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1434            .up_to_n_times(1)
1435            .with_priority(1)
1436            .mount(&server)
1437            .await;
1438        Mock::given(method("GET"))
1439            .and(path("/drive/v3/files/doc-1"))
1440            .respond_with(ResponseTemplate::new(500))
1441            .with_priority(2)
1442            .mount(&server)
1443            .await;
1444        mount_folder("folder-1").mount(&server).await;
1445        mount_document(Some("rev-1"), "Q3 report")
1446            .mount(&server)
1447            .await;
1448        Mock::given(method("POST"))
1449            .and(path("/v1/documents/doc-1:batchUpdate"))
1450            .respond_with(ResponseTemplate::new(200))
1451            .expect(0)
1452            .mount(&server)
1453            .await;
1454
1455        let outcome = write(
1456            &drive,
1457            &docs,
1458            &replace_opts(false),
1459            &[allow_rule("folder-1")],
1460        )
1461        .await;
1462        assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1463    }
1464
1465    #[tokio::test]
1466    async fn refuses_an_unknown_lease_token() {
1467        let server = MockServer::start().await;
1468        let (drive, docs) = clients(&server).await;
1469        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1470            .mount(&server)
1471            .await;
1472        mount_folder("folder-1").mount(&server).await;
1473        mount_document(Some("rev-1"), "Q3 report")
1474            .mount(&server)
1475            .await;
1476        let ledger_path = tempfile::tempdir()
1477            .unwrap()
1478            .keep()
1479            .join("lease-ledger.jsonl");
1480        // Never seeded — the ledger exists nowhere near this token.
1481
1482        let opts = WriteOptions {
1483            lease_token: Some("bogus-token".to_string()),
1484            ledger_path,
1485            ..replace_opts(false)
1486        };
1487        let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
1488        assert!(matches!(outcome.result, WriteResult::RefusedLeaseExpired));
1489    }
1490
1491    #[tokio::test]
1492    async fn refuses_a_lease_bound_to_a_different_file() {
1493        let server = MockServer::start().await;
1494        let (drive, docs) = clients(&server).await;
1495        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1496            .mount(&server)
1497            .await;
1498        mount_folder("folder-1").mount(&server).await;
1499        mount_document(Some("rev-1"), "Q3 report")
1500            .mount(&server)
1501            .await;
1502        let ledger_path = tempfile::tempdir()
1503            .unwrap()
1504            .keep()
1505            .join("lease-ledger.jsonl");
1506        // Seeded for a *different* document id.
1507        let token = seed_lease(&ledger_path, "some-other-doc", "1");
1508
1509        let opts = WriteOptions {
1510            lease_token: Some(token),
1511            ledger_path,
1512            ..replace_opts(false)
1513        };
1514        let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
1515        assert!(matches!(outcome.result, WriteResult::RefusedLeaseWrongFile));
1516    }
1517
1518    #[tokio::test]
1519    async fn refuses_a_stale_lease_when_the_file_has_moved() {
1520        let server = MockServer::start().await;
1521        let (drive, docs) = clients(&server).await;
1522        // `mount_file` always returns version "1"; the lease below was
1523        // acquired against version "0" — a foreign edit landed since.
1524        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1525            .mount(&server)
1526            .await;
1527        mount_folder("folder-1").mount(&server).await;
1528        mount_document(Some("rev-1"), "Q3 report")
1529            .mount(&server)
1530            .await;
1531        let ledger_path = tempfile::tempdir()
1532            .unwrap()
1533            .keep()
1534            .join("lease-ledger.jsonl");
1535        let token = seed_lease(&ledger_path, "doc-1", "0");
1536
1537        let opts = WriteOptions {
1538            lease_token: Some(token),
1539            ledger_path,
1540            ..replace_opts(false)
1541        };
1542        let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
1543        assert!(matches!(outcome.result, WriteResult::RefusedLeaseStale));
1544    }
1545
1546    #[tokio::test]
1547    async fn require_lease_false_writes_without_a_lease() {
1548        let server = MockServer::start().await;
1549        let (drive, docs) = clients(&server).await;
1550        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1551            .mount(&server)
1552            .await;
1553        mount_folder("folder-1").mount(&server).await;
1554        mount_document(Some("rev-1"), "Q3 report")
1555            .mount(&server)
1556            .await;
1557        mount_batch_update(serde_json::json!({
1558            "documentId": "doc-1",
1559            "replies": [{"replaceAllText": {"occurrencesChanged": 1}}],
1560        }))
1561        .mount(&server)
1562        .await;
1563        let rule = FolderPermissionRule {
1564            folder_id: Some("folder-1".to_string()),
1565            file_id: None,
1566            recursive: true,
1567            allow: std::iter::once(DriveOperation::DocsWrite).collect(),
1568            deny: HashSet::default(),
1569            require_lease: false,
1570        };
1571
1572        // No lease token presented at all, and no ledger exists.
1573        let opts = WriteOptions {
1574            lease_token: None,
1575            ledger_path: std::path::PathBuf::from("/nonexistent/lease-ledger.jsonl"),
1576            ..replace_opts(false)
1577        };
1578        let outcome = write(&drive, &docs, &opts, &[rule]).await;
1579        assert!(matches!(outcome.result, WriteResult::Replaced { .. }));
1580    }
1581
1582    #[tokio::test]
1583    async fn require_lease_false_still_refuses_a_stale_lease_if_one_is_presented() {
1584        // ADR-0080 §13: `require_lease: false` relaxes the *requirement*,
1585        // not the *meaning* — a token volunteered anyway is checked exactly
1586        // like a required one, including staleness.
1587        let server = MockServer::start().await;
1588        let (drive, docs) = clients(&server).await;
1589        // `mount_file` always returns version "1"; the lease below was
1590        // acquired against version "0" — a foreign edit landed since.
1591        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1592            .mount(&server)
1593            .await;
1594        mount_folder("folder-1").mount(&server).await;
1595        mount_document(Some("rev-1"), "Q3 report")
1596            .mount(&server)
1597            .await;
1598        // No batchUpdate mock mounted — a refusal must make zero mutating
1599        // calls.
1600        let rule = FolderPermissionRule {
1601            folder_id: Some("folder-1".to_string()),
1602            file_id: None,
1603            recursive: true,
1604            allow: std::iter::once(DriveOperation::DocsWrite).collect(),
1605            deny: HashSet::default(),
1606            require_lease: false,
1607        };
1608        let ledger_path = tempfile::tempdir()
1609            .unwrap()
1610            .keep()
1611            .join("lease-ledger.jsonl");
1612        let token = seed_lease(&ledger_path, "doc-1", "0");
1613
1614        let opts = WriteOptions {
1615            lease_token: Some(token),
1616            ledger_path,
1617            ..replace_opts(false)
1618        };
1619        let outcome = write(&drive, &docs, &opts, &[rule]).await;
1620        assert!(matches!(outcome.result, WriteResult::RefusedLeaseStale));
1621    }
1622
1623    #[test]
1624    fn describe_renders_every_lease_refusal_with_the_lease_acquire_hint() {
1625        let outcome_with = |result: WriteResult| WriteOutcome {
1626            document_id: "doc-1".to_string(),
1627            file_name: Some("Budget".to_string()),
1628            resolved_folder_id: None,
1629            required_revision_id: None,
1630            result,
1631        };
1632
1633        let text = describe(
1634            &outcome_with(WriteResult::RefusedNoLease),
1635            WriteVerb::Replace,
1636        );
1637        assert!(text.contains("requires a Drive write lease"), "{text}");
1638        assert!(text.contains("drive lease acquire doc-1"), "{text}");
1639
1640        let text = describe(
1641            &outcome_with(WriteResult::RefusedLeaseExpired),
1642            WriteVerb::Replace,
1643        );
1644        assert!(text.contains("expired, released, or unknown"), "{text}");
1645        assert!(text.contains("drive lease acquire doc-1"), "{text}");
1646
1647        let text = describe(
1648            &outcome_with(WriteResult::RefusedLeaseWrongFile),
1649            WriteVerb::Replace,
1650        );
1651        assert!(text.contains("acquired for a different file"), "{text}");
1652        assert!(text.contains("drive lease acquire doc-1"), "{text}");
1653
1654        let text = describe(
1655            &outcome_with(WriteResult::RefusedLeaseStale),
1656            WriteVerb::Replace,
1657        );
1658        assert!(
1659            text.contains("changed since the lease was acquired"),
1660            "{text}"
1661        );
1662        assert!(text.contains("drive lease acquire doc-1"), "{text}");
1663    }
1664
1665    #[tokio::test]
1666    async fn a_dry_run_never_needs_a_lease() {
1667        let server = MockServer::start().await;
1668        let (drive, docs) = clients(&server).await;
1669        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1670            .mount(&server)
1671            .await;
1672        mount_folder("folder-1").mount(&server).await;
1673        mount_document(Some("rev-1"), "Q3 report")
1674            .mount(&server)
1675            .await;
1676        // No batchUpdate mock, no lease token, no ledger — a dry run must
1677        // not need any of them.
1678
1679        let opts = WriteOptions {
1680            lease_token: None,
1681            ledger_path: std::path::PathBuf::new(),
1682            ..replace_opts(true)
1683        };
1684        let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
1685        assert!(matches!(outcome.result, WriteResult::WouldReplace { .. }));
1686    }
1687
1688    // ── the write's own audit trail (ADR-0080 §11) ─────────────────────
1689
1690    #[tokio::test]
1691    async fn a_leased_docs_write_concludes_its_audit_pair_with_allowed() {
1692        let server = MockServer::start().await;
1693        let (drive, docs) = clients(&server).await;
1694        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1695            .mount(&server)
1696            .await;
1697        mount_folder("folder-1").mount(&server).await;
1698        mount_document(Some("rev-abc"), "Q3 report")
1699            .mount(&server)
1700            .await;
1701        Mock::given(method("POST"))
1702            .and(path("/v1/documents/doc-1:batchUpdate"))
1703            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1704                "documentId": "doc-1",
1705                "replies": [{"replaceAllText": {"occurrencesChanged": 1}}],
1706            })))
1707            .mount(&server)
1708            .await;
1709        let dir = tempfile::tempdir().unwrap();
1710        let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1711
1712        let outcome = write(
1713            &drive,
1714            &docs,
1715            &replace_opts(false),
1716            &[allow_rule("folder-1")],
1717        )
1718        .await;
1719        assert!(matches!(outcome.result, WriteResult::Replaced { .. }));
1720
1721        let records = audit.records();
1722        assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
1723        // The verb, not the engine — the same `["drive", <log_operation>]`
1724        // this write's `drivemutation` record carries, so an auditor can
1725        // see which verb ran without joining back to `log.jsonl`.
1726        assert_eq!(records[0].command, ["drive", "docs-replace"]);
1727    }
1728
1729    #[tokio::test]
1730    async fn a_leased_docs_write_that_fails_concludes_its_audit_pair_with_the_error() {
1731        let server = MockServer::start().await;
1732        let (drive, docs) = clients(&server).await;
1733        mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
1734            .mount(&server)
1735            .await;
1736        mount_folder("folder-1").mount(&server).await;
1737        mount_document(Some("rev-abc"), "Q3 report")
1738            .mount(&server)
1739            .await;
1740        Mock::given(method("POST"))
1741            .and(path("/v1/documents/doc-1:batchUpdate"))
1742            .respond_with(ResponseTemplate::new(500))
1743            .mount(&server)
1744            .await;
1745        let dir = tempfile::tempdir().unwrap();
1746        let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1747
1748        let outcome = write(
1749            &drive,
1750            &docs,
1751            &replace_opts(false),
1752            &[allow_rule("folder-1")],
1753        )
1754        .await;
1755        let WriteResult::Failed { detail } = &outcome.result else {
1756            panic!("expected Failed, got {:?}", outcome.result);
1757        };
1758
1759        let records = audit.records();
1760        assert_eq!(audit.verdicts(), ["pending", "failed"], "{records:?}");
1761        assert_eq!(records[1].error.as_deref(), Some(detail.as_str()));
1762    }
1763}