Skip to main content

omni_dev/drive/sheets/
write.rs

1//! `drive sheets write`/`append`/`clear` engines — cell mutation gated by
2//! the ADR-0071 folder write-permission rules (issue #1589,
3//! [ADR-0073](../../../docs/adrs/adr-0073.md)).
4//!
5//! Single-target, so this follows `rename.rs`/`content_edit.rs`'s linear
6//! shape — a public wrapper that logs, and an `_inner` that classifies then
7//! mutates — rather than `file_move.rs`'s Plan/Execute split, which exists
8//! to amortize a shared-destination fetch across a batch these verbs don't
9//! have. `--dry-run` and a real run therefore share the same gate
10//! classification *by construction* (same function, same early return), not
11//! by convention.
12//!
13//! Two refusals happen **before** the gate, because they are not policy
14//! decisions — the operation is simply nonsensical for that target:
15//! anything that is not a spreadsheet, and a shortcut (even one pointing at
16//! a spreadsheet, since we don't follow shortcuts). This is the mirror image
17//! of `content_edit.rs`'s Google-native refusal.
18//!
19//! A third refusal is policy-adjacent but distinct: a target whose `parents`
20//! are not visible. See [`WriteResult::RefusedNoVisibleParents`].
21
22use std::path::PathBuf;
23use std::time::{Duration, Instant};
24
25use serde::Serialize;
26
27use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
28use crate::drive::client::DriveClient;
29use crate::drive::files_api::FilesApi;
30use crate::drive::lease::check::{
31    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
32    LeasedWrite,
33};
34use crate::drive::sheets::a1;
35use crate::drive::sheets::api::{SheetsApi, ValueInputOption};
36use crate::drive::sheets::client::SheetsClient;
37use crate::drive::sheets::grid_range;
38use crate::drive::sheets::target_gate;
39use crate::drive::sheets::types::UpdateValuesResponse;
40use crate::drive::types::SheetTargetRefusal;
41use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
42use crate::request_log::{self, DriveMutationOutcome};
43
44/// Which cell mutation to perform.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum WriteVerb {
47    /// Overwrite the range's values.
48    Write,
49    /// Append rows after the last row of the range's table.
50    Append,
51    /// Clear the range's values, leaving formatting intact.
52    Clear,
53}
54
55impl WriteVerb {
56    /// The `operation` this verb records in the request log.
57    ///
58    /// `build_drive_mutation_record` shapes `command` as `["drive",
59    /// <operation>]`, so these read as `drive sheets-write` in the log even
60    /// though the CLI spells them `drive sheets write`.
61    const fn log_operation(self) -> &'static str {
62        match self {
63            Self::Write => "sheets-write",
64            Self::Append => "sheets-append",
65            Self::Clear => "sheets-clear",
66        }
67    }
68
69    /// Human-readable present-tense verb for CLI output.
70    const fn label(self) -> &'static str {
71        match self {
72            Self::Write => "write",
73            Self::Append => "append",
74            Self::Clear => "clear",
75        }
76    }
77}
78
79/// Per-call options.
80#[derive(Debug, Clone)]
81pub struct WriteOptions {
82    /// Spreadsheet id.
83    pub spreadsheet_id: String,
84    /// Which mutation to perform.
85    pub verb: WriteVerb,
86    /// An explicit A1 range, which may carry its own `Sheet!` prefix.
87    pub range: Option<String>,
88    /// A sheet title, supplying a prefix for a bare `range`.
89    pub sheet: Option<String>,
90    /// Row-major values to write. Empty for [`WriteVerb::Clear`].
91    pub values: Vec<Vec<String>>,
92    /// How the API should interpret the values.
93    pub input: ValueInputOption,
94    /// Classify only; never call a mutating endpoint.
95    pub dry_run: bool,
96    /// The lease token presented via `--lease`. Checked only when the
97    /// deciding rule requires one
98    /// ([`write_gate::decided_rule_requires_lease`], ADR-0080 §1/§9);
99    /// `None` is only ever valid when it does not.
100    pub lease_token: Option<String>,
101    /// Path to the lease ledger the token is checked against. Production
102    /// callers pass `crate::drive::lease::ledger::ledger_path`'s own
103    /// result; tests pass a path under a `tempdir`.
104    pub ledger_path: PathBuf,
105}
106
107/// What happened (or, under `--dry-run`, would happen).
108#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
109#[serde(tag = "status", rename_all = "kebab-case")]
110pub enum WriteResult {
111    /// `--dry-run` of a `write`/`append`, and the gate would allow it.
112    WouldWrite {
113        /// Rows of input parsed, so a transposed or ragged input is visible
114        /// before it lands.
115        rows: usize,
116        /// Widest row, likewise.
117        columns: usize,
118    },
119    /// `--dry-run` of a `clear`, and the gate would allow it. Its own
120    /// variant rather than a `WouldWrite` of `0 x 0`: a clear takes no
121    /// values, so those dimensions would always be zero (#1941).
122    WouldClear,
123    /// The target is not a Google Sheet. Checked client-side before the
124    /// gate: writing cells into a PDF isn't disallowed, it's meaningless.
125    RefusedNotASpreadsheet {
126        /// The target's actual MIME type.
127        mime_type: String,
128    },
129    /// The target is a shortcut. Given its own variant rather than falling
130    /// into `RefusedNotASpreadsheet`, because "this is not a spreadsheet" is
131    /// a confusing thing to say about a shortcut *to* a spreadsheet — the
132    /// same distinction `drive read --content` already draws.
133    RefusedShortcut,
134    /// The target has no parents this account can see, so the gate has no
135    /// ancestor chain to evaluate — **and** no `file_id` rule named it
136    /// either, so nothing granted it.
137    ///
138    /// Distinct from `Blocked { decided_by: None }`, which means "a chain
139    /// was resolved and no rule matched". Conflating them would send an
140    /// operator off to fix folder rules when no folder rule they could
141    /// write would help. This is the common shape for a Sheet shared by
142    /// link or email; since issue #1612 the fix is a `file_id` rule, and
143    /// this variant is reached only once that lookup has come up empty.
144    RefusedNoVisibleParents,
145    /// The named sheet (from `--sheet`, or the range's own `Sheet!` prefix)
146    /// does not exist in this workbook. Checked client-side, like
147    /// find-replace/sort-range/auto-fill/paste and friends, rather than
148    /// letting the API's opaque "Unable to parse range" stand in for it
149    /// (#1941).
150    RefusedSheetNotFound {
151        /// The title that was not found.
152        title: String,
153        /// The titles that do exist.
154        available: Vec<String>,
155    },
156    /// `write`/`append`'s `--values` resolved to zero rows, so there is
157    /// nothing to write. `clear` takes no values and is unaffected.
158    RefusedEmptyValues,
159    /// The folder write-permission gate refused it.
160    Blocked {
161        /// The rule that decided the refusal, if any (`None` means the bare
162        /// default policy — every write defaults deny).
163        decided_by: Option<DecidingRule>,
164    },
165    /// No `--lease` was presented, and the deciding rule requires one
166    /// (ADR-0080 §9).
167    RefusedNoLease,
168    /// The presented lease has expired, or was never a token this ledger
169    /// knows about.
170    RefusedLeaseExpired,
171    /// The presented lease is bound to a different file id.
172    RefusedLeaseWrongFile,
173    /// The file has moved since the lease's recorded `version` — the
174    /// staleness check (ADR-0080 §6).
175    RefusedLeaseStale,
176    /// A `write` succeeded.
177    ///
178    /// Every count is optional because the API may omit it.
179    Written {
180        /// The server-normalised range actually written.
181        #[serde(skip_serializing_if = "Option::is_none")]
182        updated_range: Option<String>,
183        /// Rows the API reported changing.
184        #[serde(skip_serializing_if = "Option::is_none")]
185        updated_rows: Option<i64>,
186        /// Columns the API reported changing.
187        #[serde(skip_serializing_if = "Option::is_none")]
188        updated_columns: Option<i64>,
189        /// Cells the API reported changing.
190        #[serde(skip_serializing_if = "Option::is_none")]
191        updated_cells: Option<i64>,
192    },
193    /// An `append` succeeded. The same counts as [`Self::Written`], under
194    /// its own status so a script can tell an append from an overwrite
195    /// (#1941).
196    Appended {
197        /// The server-normalised range the rows landed in.
198        #[serde(skip_serializing_if = "Option::is_none")]
199        updated_range: Option<String>,
200        /// Rows the API reported appending.
201        #[serde(skip_serializing_if = "Option::is_none")]
202        updated_rows: Option<i64>,
203        /// Columns the API reported appending.
204        #[serde(skip_serializing_if = "Option::is_none")]
205        updated_columns: Option<i64>,
206        /// Cells the API reported appending.
207        #[serde(skip_serializing_if = "Option::is_none")]
208        updated_cells: Option<i64>,
209    },
210    /// A `clear` succeeded. The API reports no counts for a clear, only
211    /// the range.
212    Cleared {
213        /// The server-normalised range actually cleared.
214        #[serde(skip_serializing_if = "Option::is_none")]
215        updated_range: Option<String>,
216    },
217    /// An API or validation error.
218    Failed {
219        /// A human-readable summary of what failed.
220        detail: String,
221    },
222}
223
224impl FromLeaseRefusal for WriteResult {
225    fn from_no_lease() -> Self {
226        Self::RefusedNoLease
227    }
228    fn from_lease_expired() -> Self {
229        Self::RefusedLeaseExpired
230    }
231    fn from_lease_wrong_file() -> Self {
232        Self::RefusedLeaseWrongFile
233    }
234    fn from_lease_stale() -> Self {
235        Self::RefusedLeaseStale
236    }
237    fn from_lease_failed(detail: String) -> Self {
238        Self::Failed { detail }
239    }
240}
241
242impl WriteResult {
243    /// The `status` string the request log records.
244    ///
245    /// Hand-written rather than derived from the `#[serde(tag)]` shape,
246    /// matching `MoveResult`/`EditResult`'s precedent of keeping the log's
247    /// vocabulary decoupled from the wire format.
248    fn log_status(&self) -> &'static str {
249        match self {
250            Self::WouldWrite { .. } => "would-write",
251            Self::WouldClear => "would-clear",
252            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
253            Self::RefusedShortcut => "refused-shortcut",
254            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
255            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
256            Self::RefusedEmptyValues => "refused-empty-values",
257            Self::Blocked { .. } => "blocked",
258            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
259            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
260            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
261            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
262            Self::Written { .. } => "written",
263            Self::Appended { .. } => "appended",
264            Self::Cleared { .. } => "cleared",
265            Self::Failed { .. } => "failed",
266        }
267    }
268}
269
270/// The full outcome of one attempt.
271#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
272pub struct WriteOutcome {
273    /// The spreadsheet acted on.
274    pub spreadsheet_id: String,
275    /// Its name, when the metadata fetch got that far.
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub file_name: Option<String>,
278    /// The composed A1 range, when composition succeeded.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub range: Option<String>,
281    /// The folder the gate evaluated against, when exactly one parent
282    /// resolved it.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub resolved_folder_id: Option<String>,
285    /// Which mutation was attempted.
286    ///
287    /// Not serialised: the caller already knows which verb it invoked, and
288    /// the JSON shape is a stable output contract. It is carried so
289    /// [`describe`] can render an outcome from the outcome alone, rather
290    /// than taking a verb a caller could mismatch against it.
291    #[serde(skip)]
292    pub verb: WriteVerb,
293    /// What happened.
294    pub result: WriteResult,
295}
296
297impl JsonlSerialize for WriteOutcome {
298    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
299        write_scalar_jsonl(self, out)
300    }
301}
302
303/// Runs one cell mutation, logging every attempt that isn't a dry run.
304///
305/// Never returns `Err`: every failure is a [`WriteResult`] variant, so the
306/// caller renders one shape and the log records one shape. Exit code stays 0
307/// regardless, matching ADR-0070 §10 / ADR-0071 §12.
308pub async fn write(
309    drive: &DriveClient,
310    sheets: &SheetsClient,
311    opts: &WriteOptions,
312    rules: &[FolderPermissionRule],
313) -> WriteOutcome {
314    let started = Instant::now();
315    let outcome = write_inner(drive, sheets, opts, rules).await;
316    // The single logging site, and the only reason a dry run leaves no
317    // record — `write_inner` never logs.
318    if !opts.dry_run {
319        record_attempt(&outcome, opts, started.elapsed());
320    }
321    outcome
322}
323
324async fn write_inner(
325    drive: &DriveClient,
326    sheets: &SheetsClient,
327    opts: &WriteOptions,
328    rules: &[FolderPermissionRule],
329) -> WriteOutcome {
330    let bare = |result| WriteOutcome {
331        spreadsheet_id: opts.spreadsheet_id.clone(),
332        file_name: None,
333        range: None,
334        resolved_folder_id: None,
335        verb: opts.verb,
336        result,
337    };
338
339    // `clear` takes no values; `write`/`append` with zero parsed rows have
340    // nothing to send, and the API silently reports success on the request
341    // it *is* sent (`Wrote to ...`) even though nothing changed — refuse
342    // locally instead, matching `paste-data`'s empty-`--data` refusal
343    // (#1941).
344    if matches!(opts.verb, WriteVerb::Write | WriteVerb::Append) && opts.values.is_empty() {
345        return bare(WriteResult::RefusedEmptyValues);
346    }
347
348    // Compose the range first: it is pure, and a conflicting
349    // --sheet/--range pair should fail without spending a request.
350    let range = match a1::compose(opts.sheet.as_deref(), opts.range.as_deref()) {
351        Ok(range) => range,
352        Err(err) => {
353            return bare(WriteResult::Failed {
354                detail: err.to_string(),
355            })
356        }
357    };
358
359    // ── Target resolution, pre-gate refusals, and the gate itself ──────
360    // Shared with `structure.rs` via `target_gate::resolve`, so this whole
361    // shape — the metadata fetch, the shortcut/non-spreadsheet checks, and
362    // the file-id-then-ancestor-chain gate lookup — can't quietly drift
363    // between the two engines.
364    let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
365        drive,
366        &opts.spreadsheet_id,
367        DriveOperation::SheetsWrite,
368        rules,
369    )
370    .await
371    {
372        target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
373            return bare(WriteResult::Failed { detail })
374        }
375        target_gate::TargetGateOutcome::Refused { target, refusal } => {
376            let result = match refusal {
377                SheetTargetRefusal::Shortcut => WriteResult::RefusedShortcut,
378                SheetTargetRefusal::NotASpreadsheet { mime_type } => {
379                    WriteResult::RefusedNotASpreadsheet { mime_type }
380                }
381                SheetTargetRefusal::NoVisibleParents => WriteResult::RefusedNoVisibleParents,
382            };
383            return WriteOutcome {
384                spreadsheet_id: opts.spreadsheet_id.clone(),
385                file_name: Some(target.name),
386                range: Some(range),
387                resolved_folder_id: None,
388                verb: opts.verb,
389                result,
390            };
391        }
392        // A chain that could not be resolved is a refusal, never a
393        // silent allow — ADR-0071 §3's highest-priority invariant.
394        target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
395            return WriteOutcome {
396                spreadsheet_id: opts.spreadsheet_id.clone(),
397                file_name: Some(target.name),
398                range: Some(range),
399                resolved_folder_id: None,
400                verb: opts.verb,
401                result: WriteResult::Failed { detail },
402            };
403        }
404        target_gate::TargetGateOutcome::Gated {
405            target,
406            decision,
407            resolved_folder_id,
408            requires_lease,
409        } => (target, decision, resolved_folder_id, requires_lease),
410    };
411
412    let gated = |result| WriteOutcome {
413        spreadsheet_id: opts.spreadsheet_id.clone(),
414        file_name: Some(target.name.clone()),
415        range: Some(range.clone()),
416        resolved_folder_id: resolved_folder_id.clone(),
417        verb: opts.verb,
418        result,
419    };
420
421    if decision.verdict == write_gate::Verdict::Deny {
422        return gated(WriteResult::Blocked {
423            decided_by: decision.decided_by,
424        });
425    }
426
427    // The named sheet may not exist — `--sheet`, when given, is
428    // authoritative (`compose` already refused a `--range` naming a
429    // *different* sheet); otherwise fall back to `range`'s own prefix, if
430    // it has one. A bare, unprefixed range names no sheet to check here and
431    // is left to the API, which defaults it to the first sheet. Checked
432    // identically in `--dry-run` and a real run, like find-replace/sort's
433    // own metadata fetch, rather than letting a doomed write/append/clear
434    // pass its dry run and then fail live with an opaque "Unable to parse
435    // range" (#1941).
436    if let Some(title) = opts.sheet.clone().or_else(|| a1::sheet_title_of(&range)) {
437        let api = SheetsApi::new(sheets);
438        let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
439            Ok(workbook) => workbook,
440            Err(err) => {
441                return gated(WriteResult::Failed {
442                    detail: format!("{err:#}"),
443                })
444            }
445        };
446        if let Err(result) = grid_range::find_sheet_id(&workbook, &title, |title, available| {
447            WriteResult::RefusedSheetNotFound { title, available }
448        }) {
449            return gated(result);
450        }
451    }
452
453    if opts.dry_run {
454        return gated(match opts.verb {
455            WriteVerb::Clear => WriteResult::WouldClear,
456            WriteVerb::Write | WriteVerb::Append => WriteResult::WouldWrite {
457                rows: opts.values.len(),
458                columns: opts.values.iter().map(Vec::len).max().unwrap_or(0),
459            },
460        });
461    }
462
463    // The lease check (ADR-0080 §9) sits here: after the permission gate
464    // and the `--dry-run` branch, before the mutating call — see
465    // `content_edit.rs::edit_inner`'s doc comment for the full reasoning,
466    // shared verbatim by every leased engine. Sheets has no revision field
467    // of any kind (§6), so the staleness check is a fresh `files.get`
468    // immediately before the values call, not a reuse of the metadata
469    // `target_gate::resolve` fetched before the (potentially slow)
470    // ancestor-chain walk above.
471    //
472    // No request-building step sits between the `--dry-run` branch and the
473    // gate below either — `opts.values`/`opts.input` are already-validated
474    // caller-supplied data, passed straight to `values_update`/
475    // `values_append`/`values_clear` once the gate succeeds — so the
476    // build-before-gate invariant is satisfied with nothing to build. A
477    // future fallible step added here must stay ahead of the gate, the same
478    // way (#1688/#1742).
479    let files_api = FilesApi::new(drive);
480    let leased = LeasedWrite {
481        log_prefix: "drive sheets write",
482        operation: opts.verb.log_operation(),
483        ledger_path: &opts.ledger_path,
484        file_id: &opts.spreadsheet_id,
485    };
486    let lease_grant = match gate_optional_leased_write(
487        leased,
488        &files_api,
489        requires_lease,
490        opts.lease_token.as_deref(),
491    )
492    .await
493    {
494        Ok(grant) => grant,
495        Err(err) => return gated(err.into_result()),
496    };
497
498    // ── The mutation ───────────────────────────────────────────────────
499    let api = SheetsApi::new(sheets);
500    let result = match opts.verb {
501        WriteVerb::Write => api
502            .values_update(&opts.spreadsheet_id, &range, &opts.values, opts.input)
503            .await
504            .map(into_written),
505        WriteVerb::Append => api
506            .values_append(&opts.spreadsheet_id, &range, &opts.values, opts.input)
507            .await
508            .map(|response| into_appended(response.updates.unwrap_or_default())),
509        WriteVerb::Clear => api
510            .values_clear(&opts.spreadsheet_id, &range)
511            .await
512            .map(|response| WriteResult::Cleared {
513                updated_range: response.cleared_range,
514            }),
515    };
516
517    let result =
518        match conclude_native_leased_write(leased, &lease_grant, &files_api, result, |err| {
519            format!("{err:#}")
520        })
521        .await
522        {
523            Ok(written) => written,
524            Err(err) => WriteResult::Failed {
525                detail: format!("{err:#}"),
526            },
527        };
528    drop(lease_grant);
529
530    gated(result)
531}
532
533/// Carries **every** count the API reported through to the outcome, not just
534/// the cell count: `updated_rows`/`updated_columns` are what let a
535/// transposed write be spotted in the request log after the fact, and
536/// `docs/log.md` documents them as recorded.
537fn into_written(response: UpdateValuesResponse) -> WriteResult {
538    WriteResult::Written {
539        updated_range: response.updated_range,
540        updated_rows: response.updated_rows,
541        updated_columns: response.updated_columns,
542        updated_cells: response.updated_cells,
543    }
544}
545
546/// [`into_written`] for an `append`, with the same counts.
547fn into_appended(response: UpdateValuesResponse) -> WriteResult {
548    WriteResult::Appended {
549        updated_range: response.updated_range,
550        updated_rows: response.updated_rows,
551        updated_columns: response.updated_columns,
552        updated_cells: response.updated_cells,
553    }
554}
555
556/// Emits the `kind: "drivemutation"` record.
557///
558/// Inside the engine, never the CLI layer, so a future MCP caller cannot
559/// bypass it — and so a `Blocked` outcome, which makes zero API calls, still
560/// leaves a trace. Same reasoning as `content_edit.rs::record_attempt`.
561fn record_attempt(outcome: &WriteOutcome, opts: &WriteOptions, duration: Duration) {
562    let error = match &outcome.result {
563        WriteResult::Failed { detail } => Some(detail.clone()),
564        _ => None,
565    };
566    let decided_by = match &outcome.result {
567        WriteResult::Blocked { decided_by } => decided_by.as_ref(),
568        _ => None,
569    };
570    let decided_by = write_gate::decided_by_log_fields(decided_by);
571    let (updated_range, updated_rows, updated_columns, updated_cells) = match &outcome.result {
572        WriteResult::Written {
573            updated_range,
574            updated_rows,
575            updated_columns,
576            updated_cells,
577        }
578        | WriteResult::Appended {
579            updated_range,
580            updated_rows,
581            updated_columns,
582            updated_cells,
583        } => (
584            updated_range.clone(),
585            *updated_rows,
586            *updated_columns,
587            *updated_cells,
588        ),
589        WriteResult::Cleared { updated_range } => (updated_range.clone(), None, None, None),
590        _ => (None, None, None, None),
591    };
592
593    request_log::record_drive_mutation(DriveMutationOutcome {
594        operation: opts.verb.log_operation(),
595        file_id: outcome.spreadsheet_id.clone(),
596        file_name: outcome.file_name.clone().unwrap_or_default(),
597        status: outcome.result.log_status().to_string(),
598        resolved_folder_id: outcome.resolved_folder_id.clone(),
599        decided_by_folder_id: decided_by.folder_id,
600        decided_by_depth: decided_by.depth,
601        decided_by_file_id: decided_by.file_id,
602        range: outcome.range.clone(),
603        updated_range,
604        updated_rows,
605        updated_columns,
606        updated_cells,
607        error,
608        duration,
609        ..Default::default()
610    });
611}
612
613/// Renders an outcome as a single human-readable line.
614///
615/// Lives here rather than in the CLI layer so the CLI and a future MCP
616/// caller describe an outcome identically.
617#[must_use]
618pub fn describe(outcome: &WriteOutcome) -> String {
619    let verb = outcome.verb;
620    let name = outcome
621        .file_name
622        .as_deref()
623        .unwrap_or(&outcome.spreadsheet_id);
624    let range = outcome.range.as_deref().unwrap_or("(unresolved range)");
625    match &outcome.result {
626        WriteResult::WouldClear => format!("Would clear: {range} of '{name}'"),
627        WriteResult::WouldWrite { rows, columns } => format!(
628            "Would {}: {rows} row(s) x {columns} column(s) into {range} of '{name}'",
629            verb.label()
630        ),
631        WriteResult::RefusedNotASpreadsheet { mime_type } => format!(
632            "Refused: '{name}' is not a Google Sheet (mimeType: {mime_type}); \
633             `drive sheets {}` only works on spreadsheets",
634            verb.label()
635        ),
636        WriteResult::RefusedShortcut => format!(
637            "Refused: '{name}' is a shortcut; `drive sheets {}` doesn't follow shortcuts — \
638             resolve the target spreadsheet's id and use that instead",
639            verb.label()
640        ),
641        WriteResult::RefusedNoVisibleParents => format!(
642            "Refused: '{name}' has no parent folder visible to this account, so no folder \
643             rule can apply to it. This is normal for a Sheet shared by link or email. \
644             Grant it by id instead: add {{\"file_id\": \"<spreadsheet id>\", \"allow\": \
645             [\"sheets-write\"]}} to write_permissions.rules. (Adding it to a folder in your \
646             own Drive and granting that folder `sheets-write` also works.)"
647        ),
648        WriteResult::RefusedSheetNotFound { title, available } => {
649            grid_range::no_sheet_refusal(&format!("'{name}'"), title, available)
650        }
651        WriteResult::RefusedEmptyValues => format!(
652            "Refused: `drive sheets {}` was given no values to write (--values is empty)",
653            verb.label()
654        ),
655        WriteResult::Blocked { decided_by } => match decided_by {
656            Some(rule) => format!(
657                "Blocked: {range} of '{name}' — refused by rule on {} {}{}",
658                rule.kind_label(),
659                rule.id(),
660                rule.depth_suffix()
661            ),
662            None => format!(
663                "Blocked: {range} of '{name}' — refused by default policy (no matching rule)"
664            ),
665        },
666        WriteResult::RefusedNoLease => LeaseGateRefusal::NoLease
667            .describe_line(&outcome.spreadsheet_id, &format!("'{name}'"))
668            .unwrap_or_default(),
669        WriteResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
670            .describe_line(&outcome.spreadsheet_id, &format!("'{name}'"))
671            .unwrap_or_default(),
672        WriteResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
673            .describe_line(&outcome.spreadsheet_id, &format!("'{name}'"))
674            .unwrap_or_default(),
675        WriteResult::RefusedLeaseStale => LeaseGateRefusal::Stale
676            .describe_line(&outcome.spreadsheet_id, &format!("'{name}'"))
677            .unwrap_or_default(),
678        // Each success is its own variant, never inferred from an absent
679        // cell count: the API is allowed to omit the counts (see
680        // `UpdateValuesResponse`), and reading "a clear" into that would
681        // report a destructive outcome for a write that was nothing of the
682        // sort.
683        WriteResult::Cleared { updated_range } => format!(
684            "Cleared {} of '{name}'",
685            updated_range.as_deref().unwrap_or(range)
686        ),
687        WriteResult::Appended {
688            updated_range,
689            updated_cells,
690            ..
691        } => {
692            let where_ = updated_range.as_deref().unwrap_or(range);
693            match updated_cells {
694                Some(cells) => format!("Appended {cells} cell(s) to {where_} of '{name}'"),
695                None => format!("Appended to {where_} of '{name}'"),
696            }
697        }
698        WriteResult::Written {
699            updated_range,
700            updated_cells,
701            ..
702        } => {
703            let where_ = updated_range.as_deref().unwrap_or(range);
704            match updated_cells {
705                Some(cells) => format!("Wrote {cells} cell(s) to {where_} of '{name}'"),
706                None => format!("Wrote to {where_} of '{name}'"),
707            }
708        }
709        WriteResult::Failed { detail } => {
710            format!("Failed: {range} of '{name}': {detail}")
711        }
712    }
713}
714
715#[cfg(test)]
716#[allow(clippy::unwrap_used, clippy::expect_used)]
717mod tests {
718    use super::*;
719    use crate::drive::test_support::seed_lease;
720
721    /// Every `describe` arm renders to exactly one line, for every verb.
722    ///
723    /// Load-bearing rather than cosmetic. `describe` interpolates a
724    /// Drive-supplied file name, a server-supplied range and the deciding
725    /// rule's id, and its CLI caller sanitizes the **whole rendered line**
726    /// rather than each interpolation — which it can only do because no arm
727    /// emits a newline of its own. `sanitize_for_terminal` strips control
728    /// characters, so a multi-line arm added later would be silently
729    /// flattened into one run-on line rather than failing anything.
730    ///
731    /// The exhaustive `match` in `every_write_result` is the other half: a
732    /// new `WriteResult` variant will not compile until it is listed there,
733    /// so it cannot reach the terminal without passing through this check.
734    #[test]
735    fn every_describe_arm_renders_a_single_line() {
736        for verb in [WriteVerb::Write, WriteVerb::Append, WriteVerb::Clear] {
737            for result in every_write_result() {
738                let outcome = WriteOutcome {
739                    spreadsheet_id: "sheet-1".to_string(),
740                    file_name: Some("Quarterly Plan".to_string()),
741                    range: Some("Sheet1!A1:B2".to_string()),
742                    resolved_folder_id: None,
743                    verb,
744                    result,
745                };
746                let rendered = describe(&outcome);
747                assert_eq!(
748                    rendered.lines().count(),
749                    1,
750                    "describe emitted {} lines for {:?}/{verb:?}: {rendered:?}",
751                    rendered.lines().count(),
752                    outcome.result
753                );
754                assert!(
755                    !rendered.chars().any(char::is_control),
756                    "describe emitted a control character for {:?}/{verb:?}: {rendered:?}",
757                    outcome.result
758                );
759            }
760        }
761    }
762
763    /// One of every `WriteResult` variant.
764    ///
765    /// The `match` is exhaustive and wildcard-free on purpose: adding a
766    /// variant breaks this build, which is what forces the new arm through
767    /// `every_describe_arm_renders_a_single_line`.
768    fn every_write_result() -> Vec<WriteResult> {
769        let all = vec![
770            WriteResult::WouldWrite {
771                rows: 2,
772                columns: 3,
773            },
774            WriteResult::WouldClear,
775            WriteResult::RefusedNotASpreadsheet {
776                mime_type: "application/pdf".to_string(),
777            },
778            WriteResult::RefusedShortcut,
779            WriteResult::RefusedNoVisibleParents,
780            WriteResult::RefusedSheetNotFound {
781                title: "Nope".to_string(),
782                available: vec!["Sheet1".to_string()],
783            },
784            WriteResult::RefusedEmptyValues,
785            WriteResult::Blocked { decided_by: None },
786            WriteResult::Blocked {
787                decided_by: Some(DecidingRule::Folder {
788                    folder_id: "folder-1".to_string(),
789                    depth: 2,
790                }),
791            },
792            WriteResult::Blocked {
793                decided_by: Some(DecidingRule::File {
794                    file_id: "sheet-1".to_string(),
795                }),
796            },
797            WriteResult::RefusedNoLease,
798            WriteResult::RefusedLeaseExpired,
799            WriteResult::RefusedLeaseWrongFile,
800            WriteResult::RefusedLeaseStale,
801            WriteResult::Written {
802                updated_range: Some("Sheet1!A1:B2".to_string()),
803                updated_rows: Some(2),
804                updated_columns: Some(2),
805                updated_cells: Some(4),
806            },
807            WriteResult::Written {
808                updated_range: None,
809                updated_rows: None,
810                updated_columns: None,
811                updated_cells: None,
812            },
813            WriteResult::Appended {
814                updated_range: Some("Sheet1!A3:B3".to_string()),
815                updated_rows: Some(1),
816                updated_columns: Some(2),
817                updated_cells: Some(2),
818            },
819            WriteResult::Cleared {
820                updated_range: Some("Sheet1!A1:B2".to_string()),
821            },
822            WriteResult::Failed {
823                detail: "the API said no".to_string(),
824            },
825        ];
826        for result in &all {
827            match result {
828                WriteResult::WouldWrite { .. }
829                | WriteResult::WouldClear
830                | WriteResult::RefusedNotASpreadsheet { .. }
831                | WriteResult::RefusedShortcut
832                | WriteResult::RefusedNoVisibleParents
833                | WriteResult::RefusedSheetNotFound { .. }
834                | WriteResult::RefusedEmptyValues
835                | WriteResult::Blocked { .. }
836                | WriteResult::RefusedNoLease
837                | WriteResult::RefusedLeaseExpired
838                | WriteResult::RefusedLeaseWrongFile
839                | WriteResult::RefusedLeaseStale
840                | WriteResult::Written { .. }
841                | WriteResult::Appended { .. }
842                | WriteResult::Cleared { .. }
843                | WriteResult::Failed { .. } => (),
844            }
845        }
846        all
847    }
848
849    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
850    use crate::drive::sheets::client::SHEETS_API_URL;
851    use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
852    use crate::drive::write_gate::Verdict;
853    use crate::test_support::env::MapEnv;
854    use crate::utils::secret::Secret;
855    use std::collections::HashSet;
856
857    fn test_credentials() -> DriveCredentials {
858        DriveCredentials {
859            client_id: "client-1".to_string(),
860            client_secret: Secret::new("secret-1"),
861            refresh_token: Secret::new("refresh-1"),
862            scope: DriveGrantedScopes::READONLY,
863        }
864    }
865
866    /// Both clients against one wiremock server, sharing an OAuth session.
867    ///
868    /// `replace_session` must run before the derive: it swaps the Drive
869    /// client's whole transport, so deriving first would leave the Sheets
870    /// client pointed at the real `oauth2.googleapis.com`.
871    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
872        wiremock::Mock::given(wiremock::matchers::method("POST"))
873            .and(wiremock::matchers::path("/token"))
874            .respond_with(
875                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
876                    "access_token": "test-token", "expires_in": 3600,
877                })),
878            )
879            .mount(server)
880            .await;
881        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
882        crate::drive::client::test_support::replace_session(
883            &mut drive,
884            &test_credentials(),
885            &format!("{}/token", server.uri()),
886        );
887        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
888        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
889        (drive, sheets)
890    }
891
892    /// `version: "1"` throughout — matches [`opts`]'s default seeded
893    /// lease, so any test reaching the mutating call has a live, non-stale
894    /// lease by construction (ADR-0080 §9).
895    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
896        let parents: Vec<&str> = parents.to_vec();
897        wiremock::Mock::given(wiremock::matchers::method("GET"))
898            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
899            .respond_with(
900                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
901                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
902                    "version": "1",
903                })),
904            )
905    }
906
907    fn mount_folder(id: &str) -> wiremock::Mock {
908        mount_file(id, "application/vnd.google-apps.folder", &[])
909    }
910
911    fn allow_rule(folder: &str) -> FolderPermissionRule {
912        FolderPermissionRule {
913            folder_id: Some(folder.to_string()),
914            file_id: None,
915            recursive: true,
916            allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
917            deny: HashSet::default(),
918            require_lease: true,
919        }
920    }
921
922    /// Seeds a fresh, isolated ledger with a live lease for `"sheet-1"` at
923    /// version `"1"` (matching [`mount_file`]'s default) and returns
924    /// options carrying it. Every existing test built before the lease
925    /// (ADR-0080 §9) reaches its mutating call this way by construction;
926    /// see `structure.rs::opts`'s doc comment (same rationale, same
927    /// leaked-tempdir mechanism) for why this doesn't need touching each
928    /// test individually.
929    fn opts(verb: WriteVerb, dry_run: bool) -> WriteOptions {
930        let ledger_path = tempfile::tempdir()
931            .unwrap()
932            .keep()
933            .join("lease-ledger.jsonl");
934        let token = seed_lease(&ledger_path, "sheet-1", "1");
935        WriteOptions {
936            spreadsheet_id: "sheet-1".to_string(),
937            verb,
938            range: Some("A1:B2".to_string()),
939            sheet: None,
940            values: vec![vec!["a".to_string(), "b".to_string()]],
941            input: ValueInputOption::UserEntered,
942            dry_run,
943            lease_token: Some(token),
944            ledger_path,
945        }
946    }
947
948    // ── refusals that must precede the gate and the network ────────────
949
950    #[tokio::test]
951    async fn non_spreadsheet_is_refused_before_any_gate_or_sheets_call() {
952        let server = wiremock::MockServer::start().await;
953        let (drive, sheets) = clients(&server).await;
954        mount_file("sheet-1", "application/pdf", &["parent-1"])
955            .mount(&server)
956            .await;
957        // Deliberately no mock for parent-1 (the gate never runs) and none
958        // for any Sheets endpoint — proves the refusal short-circuits both,
959        // even though the rule set below would otherwise permit the write.
960        // Run as an `append` so the "names the verb the user typed" assertion
961        // below is about a verb this outcome was actually produced by.
962        let outcome = write(
963            &drive,
964            &sheets,
965            &opts(WriteVerb::Append, false),
966            &[allow_rule("parent-1")],
967        )
968        .await;
969        assert!(matches!(
970            outcome.result,
971            WriteResult::RefusedNotASpreadsheet { .. }
972        ));
973        let text = describe(&outcome);
974        assert!(text.contains("is not a Google Sheet"), "{text}");
975        assert!(text.contains("drive sheets append"), "{text}");
976    }
977
978    #[tokio::test]
979    async fn shortcut_is_refused_with_its_own_message_not_the_generic_one() {
980        let server = wiremock::MockServer::start().await;
981        let (drive, sheets) = clients(&server).await;
982        mount_file(
983            "sheet-1",
984            "application/vnd.google-apps.shortcut",
985            &["parent-1"],
986        )
987        .mount(&server)
988        .await;
989        let outcome = write(
990            &drive,
991            &sheets,
992            &opts(WriteVerb::Write, false),
993            &[allow_rule("parent-1")],
994        )
995        .await;
996        assert!(matches!(outcome.result, WriteResult::RefusedShortcut));
997        let text = describe(&outcome);
998        assert!(text.contains("is a shortcut"), "{text}");
999        assert!(!text.contains("is not a Google Sheet"), "{text}");
1000    }
1001
1002    #[tokio::test]
1003    async fn a_sheet_with_no_visible_parents_is_refused_distinctly_from_a_blocked_one() {
1004        let server = wiremock::MockServer::start().await;
1005        let (drive, sheets) = clients(&server).await;
1006        // The shape a Sheet shared by link comes back as.
1007        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
1008            .mount(&server)
1009            .await;
1010        let outcome = write(&drive, &sheets, &opts(WriteVerb::Write, false), &[]).await;
1011        assert!(matches!(
1012            outcome.result,
1013            WriteResult::RefusedNoVisibleParents
1014        ));
1015        // The message must not send the operator off to fix rules that
1016        // could never apply — it names the one rule shape that works.
1017        let text = describe(&outcome);
1018        assert!(text.contains("no parent folder visible"), "{text}");
1019        assert!(text.contains("file_id"), "{text}");
1020        assert!(text.contains("Adding it to a folder"), "{text}");
1021    }
1022
1023    // ── the gate ───────────────────────────────────────────────────────
1024
1025    #[tokio::test]
1026    async fn denied_target_makes_zero_sheets_calls() {
1027        let server = wiremock::MockServer::start().await;
1028        let (drive, sheets) = clients(&server).await;
1029        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1030            .mount(&server)
1031            .await;
1032        mount_folder("parent-1").mount(&server).await;
1033        // No Sheets mock: any call would 404 and surface as Failed.
1034        let outcome = write(&drive, &sheets, &opts(WriteVerb::Write, false), &[]).await;
1035        assert!(
1036            matches!(outcome.result, WriteResult::Blocked { decided_by: None }),
1037            "{:?}",
1038            outcome.result
1039        );
1040        let text = describe(&outcome);
1041        assert!(
1042            text.contains("refused by default policy (no matching rule)"),
1043            "{text}"
1044        );
1045    }
1046
1047    #[tokio::test]
1048    async fn a_blocked_by_rule_names_the_deciding_folder_in_the_message() {
1049        let server = wiremock::MockServer::start().await;
1050        let (drive, sheets) = clients(&server).await;
1051        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1052            .mount(&server)
1053            .await;
1054        mount_folder("parent-1").mount(&server).await;
1055        let deny_rule = FolderPermissionRule {
1056            folder_id: Some("parent-1".to_string()),
1057            file_id: None,
1058            recursive: true,
1059            allow: HashSet::default(),
1060            deny: std::iter::once(DriveOperation::SheetsWrite).collect(),
1061            require_lease: true,
1062        };
1063        let outcome = write(
1064            &drive,
1065            &sheets,
1066            &opts(WriteVerb::Write, false),
1067            &[deny_rule],
1068        )
1069        .await;
1070        let text = describe(&outcome);
1071        assert!(
1072            text.contains("refused by rule on folder parent-1"),
1073            "{text}"
1074        );
1075    }
1076
1077    #[tokio::test]
1078    async fn an_edit_rule_alone_does_not_permit_a_cell_write() {
1079        // The consequence of ADR-0073 §3, asserted end-to-end: an existing
1080        // `allow: ["edit"]` rule must not silently grant cell writes.
1081        let server = wiremock::MockServer::start().await;
1082        let (drive, sheets) = clients(&server).await;
1083        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1084            .mount(&server)
1085            .await;
1086        mount_folder("parent-1").mount(&server).await;
1087        let edit_only = FolderPermissionRule {
1088            folder_id: Some("parent-1".to_string()),
1089            file_id: None,
1090            recursive: true,
1091            allow: std::iter::once(DriveOperation::Edit).collect(),
1092            deny: HashSet::default(),
1093            require_lease: true,
1094        };
1095        let outcome = write(
1096            &drive,
1097            &sheets,
1098            &opts(WriteVerb::Write, false),
1099            &[edit_only],
1100        )
1101        .await;
1102        assert!(matches!(outcome.result, WriteResult::Blocked { .. }));
1103    }
1104
1105    #[tokio::test]
1106    async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
1107        // ADR-0071 §3's highest-priority invariant, inherited here: an
1108        // unresolvable chain must never read as "no rule applies".
1109        let server = wiremock::MockServer::start().await;
1110        let (drive, sheets) = clients(&server).await;
1111        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1112            .mount(&server)
1113            .await;
1114        wiremock::Mock::given(wiremock::matchers::method("GET"))
1115            .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
1116            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1117            .mount(&server)
1118            .await;
1119        let outcome = write(
1120            &drive,
1121            &sheets,
1122            &opts(WriteVerb::Write, false),
1123            &[allow_rule("parent-1")],
1124        )
1125        .await;
1126        assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1127    }
1128
1129    // ── dry run ────────────────────────────────────────────────────────
1130
1131    #[tokio::test]
1132    async fn dry_run_reports_dimensions_and_calls_no_values_endpoint() {
1133        let server = wiremock::MockServer::start().await;
1134        let (drive, sheets) = clients(&server).await;
1135        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1136            .mount(&server)
1137            .await;
1138        mount_folder("parent-1").mount(&server).await;
1139        // No Sheets mock mounted.
1140        let mut o = opts(WriteVerb::Write, true);
1141        o.values = vec![
1142            vec!["a".to_string(), "b".to_string(), "c".to_string()],
1143            vec!["d".to_string()],
1144        ];
1145        let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1146        assert_eq!(
1147            outcome.result,
1148            WriteResult::WouldWrite {
1149                rows: 2,
1150                columns: 3
1151            }
1152        );
1153        let text = describe(&outcome);
1154        assert!(text.contains("2 row(s) x 3 column(s)"), "{text}");
1155    }
1156
1157    #[tokio::test]
1158    async fn write_refuses_an_unknown_sheet_identically_in_dry_run_and_a_real_run() {
1159        // `write --sheet Nope --dry-run` used to say `Would write ...` and
1160        // only fail live with `HTTP 400: Unable to parse range` (#1941).
1161        // find-replace/sort-range/auto-fill/paste all refuse this locally
1162        // in both modes; write/append/clear now do too.
1163        for dry_run in [true, false] {
1164            let server = wiremock::MockServer::start().await;
1165            let (drive, sheets) = clients(&server).await;
1166            mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1167                .mount(&server)
1168                .await;
1169            mount_folder("parent-1").mount(&server).await;
1170            wiremock::Mock::given(wiremock::matchers::method("GET"))
1171                .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1172                .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
1173                    serde_json::json!({
1174                        "spreadsheetId": "sheet-1",
1175                        "sheets": [{"properties": {"sheetId": 0, "title": "Sheet1"}}]
1176                    }),
1177                ))
1178                .mount(&server)
1179                .await;
1180            // No mock for the values endpoint: a call to it would fail the
1181            // test with a connection/404 error, distinct from the expected
1182            // refusal.
1183            let mut o = opts(WriteVerb::Write, dry_run);
1184            o.sheet = Some("Nope".to_string());
1185            o.range = None;
1186            let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1187            assert_eq!(
1188                outcome.result,
1189                WriteResult::RefusedSheetNotFound {
1190                    title: "Nope".to_string(),
1191                    available: vec!["Sheet1".to_string()],
1192                },
1193                "dry_run={dry_run}"
1194            );
1195            let text = describe(&outcome);
1196            assert!(text.contains("no sheet titled 'Nope'"), "{text}");
1197            assert!(text.contains("Available: 'Sheet1'"), "{text}");
1198        }
1199    }
1200
1201    #[tokio::test]
1202    async fn sheet_title_check_metadata_fetch_failure_produces_failed() {
1203        // The `--sheet`-existence check (#1941) does its own
1204        // `get_spreadsheet` call; a failure there must surface as `Failed`,
1205        // not panic or read as success.
1206        let server = wiremock::MockServer::start().await;
1207        let (drive, sheets) = clients(&server).await;
1208        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1209            .mount(&server)
1210            .await;
1211        mount_folder("parent-1").mount(&server).await;
1212        wiremock::Mock::given(wiremock::matchers::method("GET"))
1213            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1214            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1215            .mount(&server)
1216            .await;
1217        let mut o = opts(WriteVerb::Write, false);
1218        o.sheet = Some("Sheet1".to_string());
1219        let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1220        assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1221    }
1222
1223    #[tokio::test]
1224    async fn write_and_append_refuse_empty_values_before_any_request() {
1225        let server = wiremock::MockServer::start().await;
1226        let (drive, sheets) = clients(&server).await;
1227        // No mocks at all: the refusal must fire before even the metadata
1228        // fetch.
1229        for verb in [WriteVerb::Write, WriteVerb::Append] {
1230            let mut o = opts(verb, true);
1231            o.values = vec![];
1232            let outcome = write(&drive, &sheets, &o, &[]).await;
1233            assert_eq!(outcome.result, WriteResult::RefusedEmptyValues, "{verb:?}");
1234            let text = describe(&outcome);
1235            assert!(text.contains("no values to write"), "{text}");
1236        }
1237    }
1238
1239    #[tokio::test]
1240    async fn clear_is_unaffected_by_empty_values() {
1241        let server = wiremock::MockServer::start().await;
1242        let (drive, sheets) = clients(&server).await;
1243        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1244            .mount(&server)
1245            .await;
1246        mount_folder("parent-1").mount(&server).await;
1247        let mut o = opts(WriteVerb::Clear, true);
1248        o.values = vec![];
1249        let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1250        assert_eq!(outcome.result, WriteResult::WouldClear);
1251    }
1252
1253    #[tokio::test]
1254    async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
1255        let server = wiremock::MockServer::start().await;
1256        let (drive, sheets) = clients(&server).await;
1257        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1258            .expect(2)
1259            .mount(&server)
1260            .await;
1261        mount_folder("parent-1").expect(2).mount(&server).await;
1262
1263        let dry = write(&drive, &sheets, &opts(WriteVerb::Write, true), &[]).await;
1264        let real = write(&drive, &sheets, &opts(WriteVerb::Write, false), &[]).await;
1265        assert_eq!(dry.result, real.result);
1266    }
1267
1268    // ── successful mutations ───────────────────────────────────────────
1269
1270    #[tokio::test]
1271    async fn allowed_write_calls_values_update_once_with_the_input_option() {
1272        let server = wiremock::MockServer::start().await;
1273        let (drive, sheets) = clients(&server).await;
1274        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1275            .mount(&server)
1276            .await;
1277        mount_folder("parent-1").mount(&server).await;
1278        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1279            .and(wiremock::matchers::path(
1280                "/v4/spreadsheets/sheet-1/values/A1:B2",
1281            ))
1282            .and(wiremock::matchers::query_param(
1283                "valueInputOption",
1284                "USER_ENTERED",
1285            ))
1286            .respond_with(
1287                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1288                    "updatedRange": "'Q1'!A1:B2", "updatedRows": 1, "updatedCells": 2,
1289                })),
1290            )
1291            .expect(1)
1292            .mount(&server)
1293            .await;
1294
1295        let outcome = write(
1296            &drive,
1297            &sheets,
1298            &opts(WriteVerb::Write, false),
1299            &[allow_rule("parent-1")],
1300        )
1301        .await;
1302        assert_eq!(
1303            outcome.result,
1304            WriteResult::Written {
1305                updated_range: Some("'Q1'!A1:B2".to_string()),
1306                updated_rows: Some(1),
1307                updated_columns: None,
1308                updated_cells: Some(2),
1309            }
1310        );
1311        assert!(describe(&outcome).starts_with("Wrote 2 cell(s) "));
1312    }
1313
1314    #[tokio::test]
1315    async fn raw_input_option_is_sent_when_requested() {
1316        let server = wiremock::MockServer::start().await;
1317        let (drive, sheets) = clients(&server).await;
1318        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1319            .mount(&server)
1320            .await;
1321        mount_folder("parent-1").mount(&server).await;
1322        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1323            .and(wiremock::matchers::query_param("valueInputOption", "RAW"))
1324            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1325            .expect(1)
1326            .mount(&server)
1327            .await;
1328
1329        let mut o = opts(WriteVerb::Write, false);
1330        o.input = ValueInputOption::Raw;
1331        let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1332        assert!(matches!(outcome.result, WriteResult::Written { .. }));
1333    }
1334
1335    // ── the Drive write lease (ADR-0080 §9) ─────────────────────────────
1336
1337    fn allow_rule_no_lease(folder: &str) -> FolderPermissionRule {
1338        FolderPermissionRule {
1339            require_lease: false,
1340            ..allow_rule(folder)
1341        }
1342    }
1343
1344    #[tokio::test]
1345    async fn refuses_without_a_lease_when_the_rule_requires_one() {
1346        let server = wiremock::MockServer::start().await;
1347        let (drive, sheets) = clients(&server).await;
1348        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1349            .mount(&server)
1350            .await;
1351        mount_folder("parent-1").mount(&server).await;
1352        // No PUT mock mounted — a refusal must make zero mutating calls.
1353
1354        let mut o = opts(WriteVerb::Write, false);
1355        o.lease_token = None;
1356        let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1357        assert_eq!(outcome.result, WriteResult::RefusedNoLease);
1358    }
1359
1360    #[tokio::test]
1361    async fn refuses_an_unknown_lease_token() {
1362        let server = wiremock::MockServer::start().await;
1363        let (drive, sheets) = clients(&server).await;
1364        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1365            .mount(&server)
1366            .await;
1367        mount_folder("parent-1").mount(&server).await;
1368
1369        let mut o = opts(WriteVerb::Write, false);
1370        o.lease_token = Some("bogus-token".to_string());
1371        // Never seeded — no ledger exists at this fresh path.
1372        o.ledger_path = tempfile::tempdir()
1373            .unwrap()
1374            .keep()
1375            .join("lease-ledger.jsonl");
1376        let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1377        assert_eq!(outcome.result, WriteResult::RefusedLeaseExpired);
1378    }
1379
1380    #[tokio::test]
1381    async fn refuses_a_lease_bound_to_a_different_file() {
1382        let server = wiremock::MockServer::start().await;
1383        let (drive, sheets) = clients(&server).await;
1384        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1385            .mount(&server)
1386            .await;
1387        mount_folder("parent-1").mount(&server).await;
1388
1389        let ledger_path = tempfile::tempdir()
1390            .unwrap()
1391            .keep()
1392            .join("lease-ledger.jsonl");
1393        let token = seed_lease(&ledger_path, "other-sheet", "1");
1394        let mut o = opts(WriteVerb::Write, false);
1395        o.lease_token = Some(token);
1396        o.ledger_path = ledger_path;
1397        let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1398        assert_eq!(outcome.result, WriteResult::RefusedLeaseWrongFile);
1399    }
1400
1401    #[tokio::test]
1402    async fn refuses_a_stale_lease() {
1403        let server = wiremock::MockServer::start().await;
1404        let (drive, sheets) = clients(&server).await;
1405        // Live version "1" (`mount_file`'s default) but the lease was
1406        // acquired at "0" — the file has moved since.
1407        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1408            .mount(&server)
1409            .await;
1410        mount_folder("parent-1").mount(&server).await;
1411
1412        let ledger_path = tempfile::tempdir()
1413            .unwrap()
1414            .keep()
1415            .join("lease-ledger.jsonl");
1416        let token = seed_lease(&ledger_path, "sheet-1", "0");
1417        let mut o = opts(WriteVerb::Write, false);
1418        o.lease_token = Some(token);
1419        o.ledger_path = ledger_path;
1420        let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1421        assert_eq!(outcome.result, WriteResult::RefusedLeaseStale);
1422    }
1423
1424    #[tokio::test]
1425    async fn reports_a_lock_acquisition_failure_as_failed() {
1426        // A pre-existing lock file simulates another `drive lease`
1427        // operation genuinely in progress — reported as an operational
1428        // failure, not folded into `RefusedLeaseExpired`.
1429        let server = wiremock::MockServer::start().await;
1430        let (drive, sheets) = clients(&server).await;
1431        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1432            .mount(&server)
1433            .await;
1434        mount_folder("parent-1").mount(&server).await;
1435
1436        let o = opts(WriteVerb::Write, false);
1437        // Under `flock` (issue #1687), a busy lock now waits rather than
1438        // hard-failing (`check_and_lock_lease` -> `acquire_waiting`), so a
1439        // held `LedgerLock` no longer reproduces an immediate failure here.
1440        // A directory at the lock path does: opening it for write fails
1441        // outright with an I/O error, which is never retried.
1442        let mut lock_path = o.ledger_path.clone().into_os_string();
1443        lock_path.push(".lock");
1444        std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
1445
1446        let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1447        assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1448    }
1449
1450    #[tokio::test]
1451    async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_values_call() {
1452        // The gate's own resolve step succeeds off the first `files.get`,
1453        // but the fresh re-fetch feeding the staleness check (ADR-0080 §6)
1454        // fails — the write must report `Failed` and never reach the
1455        // values endpoint.
1456        let server = wiremock::MockServer::start().await;
1457        let (drive, sheets) = clients(&server).await;
1458        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1459            .up_to_n_times(1)
1460            .with_priority(1)
1461            .mount(&server)
1462            .await;
1463        wiremock::Mock::given(wiremock::matchers::method("GET"))
1464            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1465            .respond_with(wiremock::ResponseTemplate::new(500))
1466            .with_priority(2)
1467            .mount(&server)
1468            .await;
1469        mount_folder("parent-1").mount(&server).await;
1470        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1471            .respond_with(wiremock::ResponseTemplate::new(200))
1472            .expect(0)
1473            .mount(&server)
1474            .await;
1475
1476        let outcome = write(
1477            &drive,
1478            &sheets,
1479            &opts(WriteVerb::Write, false),
1480            &[allow_rule("parent-1")],
1481        )
1482        .await;
1483        assert!(matches!(outcome.result, WriteResult::Failed { .. }));
1484    }
1485
1486    #[tokio::test]
1487    async fn a_rule_that_does_not_require_a_lease_skips_the_check_entirely() {
1488        // No lease token and no ledger seeded, with a rule that sets
1489        // `require_lease: false` — the write must still succeed, taking
1490        // the `else { None }` branch and never consulting the ledger.
1491        let server = wiremock::MockServer::start().await;
1492        let (drive, sheets) = clients(&server).await;
1493        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1494            .mount(&server)
1495            .await;
1496        mount_folder("parent-1").mount(&server).await;
1497        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1498            .respond_with(
1499                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1500                    "updatedRange": "'Q1'!A1:B2", "updatedRows": 1, "updatedCells": 2,
1501                })),
1502            )
1503            .expect(1)
1504            .mount(&server)
1505            .await;
1506
1507        let mut o = opts(WriteVerb::Write, false);
1508        o.lease_token = None;
1509        let outcome = write(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
1510        assert!(matches!(outcome.result, WriteResult::Written { .. }));
1511    }
1512
1513    #[tokio::test]
1514    async fn a_rule_that_does_not_require_a_lease_still_refuses_a_stale_one_if_presented() {
1515        // ADR-0080 §13: `require_lease: false` relaxes the *requirement*,
1516        // not the *meaning* — a token volunteered anyway is checked exactly
1517        // like a required one, including staleness.
1518        let server = wiremock::MockServer::start().await;
1519        let (drive, sheets) = clients(&server).await;
1520        // Live version "1" (`mount_file`'s default) but the lease was
1521        // acquired at "0" — the file has moved since.
1522        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1523            .mount(&server)
1524            .await;
1525        mount_folder("parent-1").mount(&server).await;
1526        // No PUT mock mounted — a refusal must make zero mutating calls.
1527
1528        let ledger_path = tempfile::tempdir()
1529            .unwrap()
1530            .keep()
1531            .join("lease-ledger.jsonl");
1532        let token = seed_lease(&ledger_path, "sheet-1", "0");
1533        let mut o = opts(WriteVerb::Write, false);
1534        o.lease_token = Some(token);
1535        o.ledger_path = ledger_path;
1536        let outcome = write(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
1537        assert_eq!(outcome.result, WriteResult::RefusedLeaseStale);
1538    }
1539
1540    // ── describing an outcome ──────────────────────────────────────────
1541
1542    #[tokio::test]
1543    async fn a_write_whose_response_omits_the_counts_is_not_described_as_a_clear() {
1544        // `UpdateValuesResponse` deliberately tolerates missing counts, so
1545        // "no cell count" must never stand in for "this was a clear" — that
1546        // would report a destructive outcome for a plain write.
1547        let server = wiremock::MockServer::start().await;
1548        let (drive, sheets) = clients(&server).await;
1549        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1550            .mount(&server)
1551            .await;
1552        mount_folder("parent-1").mount(&server).await;
1553        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1554            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1555            .mount(&server)
1556            .await;
1557
1558        let outcome = write(
1559            &drive,
1560            &sheets,
1561            &opts(WriteVerb::Write, false),
1562            &[allow_rule("parent-1")],
1563        )
1564        .await;
1565        let text = describe(&outcome);
1566        assert!(!text.contains("Cleared"), "{text}");
1567        assert!(text.starts_with("Wrote to "), "{text}");
1568    }
1569
1570    #[tokio::test]
1571    async fn a_clear_dry_run_omits_the_always_zero_dimensions() {
1572        let server = wiremock::MockServer::start().await;
1573        let (drive, sheets) = clients(&server).await;
1574        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1575            .mount(&server)
1576            .await;
1577        mount_folder("parent-1").mount(&server).await;
1578
1579        let outcome = write(
1580            &drive,
1581            &sheets,
1582            &opts(WriteVerb::Clear, true),
1583            &[allow_rule("parent-1")],
1584        )
1585        .await;
1586        let text = describe(&outcome);
1587        assert!(!text.contains("row(s)"), "{text}");
1588        assert_eq!(text, "Would clear: A1:B2 of 'sheet-1'");
1589    }
1590
1591    #[tokio::test]
1592    async fn append_reads_its_counts_from_the_nested_updates_object() {
1593        let server = wiremock::MockServer::start().await;
1594        let (drive, sheets) = clients(&server).await;
1595        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1596            .mount(&server)
1597            .await;
1598        mount_folder("parent-1").mount(&server).await;
1599        wiremock::Mock::given(wiremock::matchers::method("POST"))
1600            .and(wiremock::matchers::path(
1601                "/v4/spreadsheets/sheet-1/values/A1:B2:append",
1602            ))
1603            .and(wiremock::matchers::query_param(
1604                "insertDataOption",
1605                "INSERT_ROWS",
1606            ))
1607            .respond_with(
1608                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1609                    "tableRange": "'Q1'!A1:B3",
1610                    "updates": {"updatedRange": "'Q1'!A4:B4", "updatedCells": 2},
1611                })),
1612            )
1613            .expect(1)
1614            .mount(&server)
1615            .await;
1616
1617        let outcome = write(
1618            &drive,
1619            &sheets,
1620            &opts(WriteVerb::Append, false),
1621            &[allow_rule("parent-1")],
1622        )
1623        .await;
1624        assert_eq!(
1625            outcome.result,
1626            WriteResult::Appended {
1627                updated_range: Some("'Q1'!A4:B4".to_string()),
1628                updated_rows: None,
1629                updated_columns: None,
1630                updated_cells: Some(2),
1631            }
1632        );
1633        // A successful append must not read identically to an overwrite.
1634        assert!(describe(&outcome).starts_with("Appended 2 cell(s) "));
1635    }
1636
1637    #[tokio::test]
1638    async fn clear_calls_the_clear_endpoint_and_reports_the_cleared_range() {
1639        let server = wiremock::MockServer::start().await;
1640        let (drive, sheets) = clients(&server).await;
1641        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1642            .mount(&server)
1643            .await;
1644        mount_folder("parent-1").mount(&server).await;
1645        wiremock::Mock::given(wiremock::matchers::method("POST"))
1646            .and(wiremock::matchers::path(
1647                "/v4/spreadsheets/sheet-1/values/A1:B2:clear",
1648            ))
1649            .respond_with(
1650                wiremock::ResponseTemplate::new(200)
1651                    .set_body_json(serde_json::json!({"clearedRange": "'Q1'!A1:B2"})),
1652            )
1653            .expect(1)
1654            .mount(&server)
1655            .await;
1656
1657        let outcome = write(
1658            &drive,
1659            &sheets,
1660            &opts(WriteVerb::Clear, false),
1661            &[allow_rule("parent-1")],
1662        )
1663        .await;
1664        assert_eq!(
1665            outcome.result,
1666            WriteResult::Cleared {
1667                updated_range: Some("'Q1'!A1:B2".to_string()),
1668            }
1669        );
1670        assert!(describe(&outcome).starts_with("Cleared "));
1671    }
1672
1673    #[tokio::test]
1674    async fn a_403_surfaces_the_write_scope_hint() {
1675        let server = wiremock::MockServer::start().await;
1676        let (drive, sheets) = clients(&server).await;
1677        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1678            .mount(&server)
1679            .await;
1680        mount_folder("parent-1").mount(&server).await;
1681        // The google.rpc envelope Sheets actually returns — the hint only
1682        // fires because `error_reason` understands `status` too.
1683        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1684            .respond_with(
1685                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1686                    "error": {"code": 403, "message": "The caller does not have permission",
1687                              "status": "PERMISSION_DENIED"},
1688                })),
1689            )
1690            .mount(&server)
1691            .await;
1692
1693        let outcome = write(
1694            &drive,
1695            &sheets,
1696            &opts(WriteVerb::Write, false),
1697            &[allow_rule("parent-1")],
1698        )
1699        .await;
1700        let WriteResult::Failed { detail } = &outcome.result else {
1701            panic!("expected Failed, got {:?}", outcome.result);
1702        };
1703        assert!(detail.contains("--write-file"), "{detail}");
1704        assert!(detail.contains("--write-full"), "{detail}");
1705    }
1706
1707    // ── range composition ──────────────────────────────────────────────
1708
1709    #[tokio::test]
1710    async fn a_conflicting_sheet_and_range_fails_before_any_request() {
1711        let server = wiremock::MockServer::start().await;
1712        let (drive, sheets) = clients(&server).await;
1713        // No mocks at all — not even files.get.
1714        let mut o = opts(WriteVerb::Write, false);
1715        o.range = Some("Other!A1".to_string());
1716        o.sheet = Some("Mine".to_string());
1717        let outcome = write(&drive, &sheets, &o, &[]).await;
1718        let WriteResult::Failed { detail } = &outcome.result else {
1719            panic!("expected Failed, got {:?}", outcome.result);
1720        };
1721        assert!(detail.contains("already names a sheet"), "{detail}");
1722    }
1723
1724    #[tokio::test]
1725    async fn a_sheet_title_with_a_space_is_encoded_on_the_wire() {
1726        let server = wiremock::MockServer::start().await;
1727        let (drive, sheets) = clients(&server).await;
1728        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1729            .mount(&server)
1730            .await;
1731        mount_folder("parent-1").mount(&server).await;
1732        wiremock::Mock::given(wiremock::matchers::method("GET"))
1733            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1734            .respond_with(
1735                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1736                    "spreadsheetId": "sheet-1",
1737                    "sheets": [{"properties": {"sheetId": 0, "title": "My Sheet"}}]
1738                })),
1739            )
1740            .mount(&server)
1741            .await;
1742        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1743            .and(wiremock::matchers::path(
1744                "/v4/spreadsheets/sheet-1/values/'My%20Sheet'!A1:B2",
1745            ))
1746            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1747            .expect(1)
1748            .mount(&server)
1749            .await;
1750
1751        let mut o = opts(WriteVerb::Write, false);
1752        o.sheet = Some("My Sheet".to_string());
1753        let outcome = write(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1754        assert!(matches!(outcome.result, WriteResult::Written { .. }));
1755    }
1756
1757    // ── verb metadata ──────────────────────────────────────────────────
1758
1759    #[test]
1760    fn log_operations_are_distinct_and_kebab_cased() {
1761        assert_eq!(WriteVerb::Write.log_operation(), "sheets-write");
1762        assert_eq!(WriteVerb::Append.log_operation(), "sheets-append");
1763        assert_eq!(WriteVerb::Clear.log_operation(), "sheets-clear");
1764    }
1765
1766    #[test]
1767    fn labels_are_distinct_present_tense_verbs() {
1768        assert_eq!(WriteVerb::Write.label(), "write");
1769        assert_eq!(WriteVerb::Append.label(), "append");
1770        assert_eq!(WriteVerb::Clear.label(), "clear");
1771    }
1772
1773    /// The JSON `status` names what happened, per verb (#1941): a clear's
1774    /// dry run carries no always-zero `rows`/`columns`, and a real append or
1775    /// clear is not reported as `written`.
1776    #[test]
1777    fn json_statuses_name_the_verb() {
1778        let json = |result: WriteResult| serde_json::to_value(&result).unwrap();
1779        assert_eq!(
1780            json(WriteResult::WouldClear),
1781            serde_json::json!({"status": "would-clear"})
1782        );
1783        assert_eq!(
1784            json(WriteResult::Cleared {
1785                updated_range: Some("Q1!A1:B2".to_string())
1786            }),
1787            serde_json::json!({"status": "cleared", "updated_range": "Q1!A1:B2"})
1788        );
1789        assert_eq!(
1790            json(WriteResult::Appended {
1791                updated_range: None,
1792                updated_rows: Some(1),
1793                updated_columns: Some(2),
1794                updated_cells: Some(2),
1795            })["status"],
1796            "appended"
1797        );
1798    }
1799
1800    #[test]
1801    fn log_status_covers_every_variant() {
1802        assert_eq!(
1803            WriteResult::WouldWrite {
1804                rows: 0,
1805                columns: 0
1806            }
1807            .log_status(),
1808            "would-write"
1809        );
1810        assert_eq!(
1811            WriteResult::RefusedNotASpreadsheet {
1812                mime_type: String::new()
1813            }
1814            .log_status(),
1815            "refused-not-a-spreadsheet"
1816        );
1817        assert_eq!(
1818            WriteResult::RefusedShortcut.log_status(),
1819            "refused-shortcut"
1820        );
1821        assert_eq!(
1822            WriteResult::RefusedNoVisibleParents.log_status(),
1823            "refused-no-visible-parents"
1824        );
1825        assert_eq!(
1826            WriteResult::Blocked { decided_by: None }.log_status(),
1827            "blocked"
1828        );
1829        assert_eq!(
1830            WriteResult::Written {
1831                updated_range: None,
1832                updated_rows: None,
1833                updated_columns: None,
1834                updated_cells: None,
1835            }
1836            .log_status(),
1837            "written"
1838        );
1839        assert_eq!(WriteResult::WouldClear.log_status(), "would-clear");
1840        assert_eq!(
1841            WriteResult::Appended {
1842                updated_range: None,
1843                updated_rows: None,
1844                updated_columns: None,
1845                updated_cells: None,
1846            }
1847            .log_status(),
1848            "appended"
1849        );
1850        assert_eq!(
1851            WriteResult::Cleared {
1852                updated_range: None
1853            }
1854            .log_status(),
1855            "cleared"
1856        );
1857        assert_eq!(
1858            WriteResult::Failed {
1859                detail: String::new()
1860            }
1861            .log_status(),
1862            "failed"
1863        );
1864        assert_eq!(
1865            WriteResult::RefusedEmptyValues.log_status(),
1866            "refused-empty-values"
1867        );
1868        assert_eq!(WriteResult::RefusedNoLease.log_status(), "refused-no-lease");
1869        assert_eq!(
1870            WriteResult::RefusedLeaseExpired.log_status(),
1871            "refused-lease-expired"
1872        );
1873        assert_eq!(
1874            WriteResult::RefusedLeaseWrongFile.log_status(),
1875            "refused-lease-wrong-file"
1876        );
1877        assert_eq!(
1878            WriteResult::RefusedLeaseStale.log_status(),
1879            "refused-lease-stale"
1880        );
1881    }
1882
1883    #[test]
1884    fn write_jsonl_emits_one_line_of_json() {
1885        let outcome = WriteOutcome {
1886            spreadsheet_id: "sheet-1".to_string(),
1887            file_name: Some("Budget".to_string()),
1888            range: Some("A1:B2".to_string()),
1889            resolved_folder_id: Some("parent-1".to_string()),
1890            verb: WriteVerb::Write,
1891            result: WriteResult::Written {
1892                updated_range: Some("A1:B2".to_string()),
1893                updated_rows: Some(1),
1894                updated_columns: Some(2),
1895                updated_cells: Some(2),
1896            },
1897        };
1898        let mut buf = Vec::new();
1899        outcome.write_jsonl(&mut buf).unwrap();
1900        let text = String::from_utf8(buf).unwrap();
1901        assert_eq!(text.matches('\n').count(), 1);
1902        let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
1903        assert_eq!(parsed["result"]["status"], "written");
1904    }
1905
1906    #[test]
1907    fn gate_denies_sheets_write_by_default() {
1908        let decision =
1909            write_gate::resolve(&["folder".to_string()], DriveOperation::SheetsWrite, &[]);
1910        assert_eq!(decision.verdict, Verdict::Deny);
1911    }
1912
1913    // ── file-id rules (issue #1612) ────────────────────────────────────
1914
1915    #[tokio::test]
1916    async fn a_file_rule_grants_a_sheet_with_no_visible_parents() {
1917        // The case issue #1612 exists for: before file rules there was no
1918        // rule an operator could write that would permit this at all.
1919        let server = wiremock::MockServer::start().await;
1920        let (drive, sheets) = clients(&server).await;
1921        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
1922            .mount(&server)
1923            .await;
1924        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1925            .and(wiremock::matchers::path(
1926                "/v4/spreadsheets/sheet-1/values/A1:B2",
1927            ))
1928            .respond_with(
1929                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1930                    "updatedRange": "'Q1'!A1:B2", "updatedRows": 1, "updatedCells": 2,
1931                })),
1932            )
1933            .expect(1)
1934            .mount(&server)
1935            .await;
1936
1937        let outcome = write(
1938            &drive,
1939            &sheets,
1940            &opts(WriteVerb::Write, false),
1941            &[FolderPermissionRule::file("sheet-1").allowing([DriveOperation::SheetsWrite])],
1942        )
1943        .await;
1944
1945        assert!(
1946            matches!(outcome.result, WriteResult::Written { .. }),
1947            "{:?}",
1948            outcome.result
1949        );
1950    }
1951
1952    #[tokio::test]
1953    async fn a_file_rule_denies_even_when_a_parent_folder_would_allow() {
1954        // Depth −1 beats depth 0 in the restrictive direction too.
1955        let server = wiremock::MockServer::start().await;
1956        let (drive, sheets) = clients(&server).await;
1957        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1958            .mount(&server)
1959            .await;
1960        // No mock for parent-1 and none for any Sheets endpoint: the file
1961        // rule must decide before either is reached.
1962
1963        let outcome = write(
1964            &drive,
1965            &sheets,
1966            &opts(WriteVerb::Write, false),
1967            &[
1968                allow_rule("parent-1"),
1969                FolderPermissionRule::file("sheet-1").denying([DriveOperation::SheetsWrite]),
1970            ],
1971        )
1972        .await;
1973
1974        match &outcome.result {
1975            WriteResult::Blocked { decided_by } => {
1976                let rule = decided_by.as_ref().expect("a file rule decided this");
1977                assert_eq!(rule.kind_label(), "file");
1978                assert_eq!(rule.id(), "sheet-1");
1979            }
1980            other => panic!("expected Blocked, got {other:?}"),
1981        }
1982        let text = describe(&outcome);
1983        assert!(text.contains("refused by rule on file sheet-1"), "{text}");
1984        assert!(!text.contains("depth"), "a file rule has no depth: {text}");
1985    }
1986
1987    #[tokio::test]
1988    async fn the_no_visible_parents_message_names_a_file_rule_as_the_fix() {
1989        let server = wiremock::MockServer::start().await;
1990        let (drive, sheets) = clients(&server).await;
1991        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
1992            .mount(&server)
1993            .await;
1994        let outcome = write(&drive, &sheets, &opts(WriteVerb::Write, false), &[]).await;
1995        let text = describe(&outcome);
1996        assert!(text.contains("file_id"), "{text}");
1997        assert!(text.contains("sheets-write"), "{text}");
1998    }
1999
2000    // ── the write's own audit trail (ADR-0080 §11) ─────────────────────
2001
2002    #[tokio::test]
2003    async fn a_leased_write_concludes_its_audit_pair_with_allowed() {
2004        let server = wiremock::MockServer::start().await;
2005        let (drive, sheets) = clients(&server).await;
2006        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
2007            .mount(&server)
2008            .await;
2009        mount_folder("parent-1").mount(&server).await;
2010        wiremock::Mock::given(wiremock::matchers::method("PUT"))
2011            .and(wiremock::matchers::path(
2012                "/v4/spreadsheets/sheet-1/values/A1:B2",
2013            ))
2014            .respond_with(
2015                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2016                    "updatedRange": "'Q1'!A1:B2", "updatedRows": 1, "updatedCells": 2,
2017                })),
2018            )
2019            .mount(&server)
2020            .await;
2021        let dir = tempfile::tempdir().unwrap();
2022        let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
2023
2024        let outcome = write(
2025            &drive,
2026            &sheets,
2027            &opts(WriteVerb::Write, false),
2028            &[allow_rule("parent-1")],
2029        )
2030        .await;
2031        assert!(matches!(outcome.result, WriteResult::Written { .. }));
2032
2033        let records = audit.records();
2034        assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
2035        // The verb, not the engine — the same `["drive", <log_operation>]`
2036        // this write's `drivemutation` record carries, so an auditor can
2037        // see which verb ran without joining back to `log.jsonl`.
2038        assert_eq!(records[0].command, ["drive", "sheets-write"]);
2039        // The post-write `files.get` the ledger refresh pays for also
2040        // feeds the outcome record (`mount_file` answers version "1").
2041        assert_eq!(
2042            records[1].context.get("version_after").map(String::as_str),
2043            Some("1")
2044        );
2045    }
2046
2047    #[tokio::test]
2048    async fn a_leased_write_that_fails_concludes_its_audit_pair_with_the_error() {
2049        let server = wiremock::MockServer::start().await;
2050        let (drive, sheets) = clients(&server).await;
2051        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
2052            .mount(&server)
2053            .await;
2054        mount_folder("parent-1").mount(&server).await;
2055        wiremock::Mock::given(wiremock::matchers::method("PUT"))
2056            .and(wiremock::matchers::path(
2057                "/v4/spreadsheets/sheet-1/values/A1:B2",
2058            ))
2059            .respond_with(wiremock::ResponseTemplate::new(500))
2060            .mount(&server)
2061            .await;
2062        let dir = tempfile::tempdir().unwrap();
2063        let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
2064
2065        let outcome = write(
2066            &drive,
2067            &sheets,
2068            &opts(WriteVerb::Write, false),
2069            &[allow_rule("parent-1")],
2070        )
2071        .await;
2072        let WriteResult::Failed { detail } = &outcome.result else {
2073            panic!("expected Failed, got {:?}", outcome.result);
2074        };
2075
2076        let records = audit.records();
2077        assert_eq!(audit.verdicts(), ["pending", "failed"], "{records:?}");
2078        assert_eq!(records[1].error.as_deref(), Some(detail.as_str()));
2079    }
2080}