Skip to main content

omni_dev/drive/sheets/
developer_metadata.rs

1//! Developer metadata (issue #1795,
2//! [ADR-0081](../../../docs/adrs/adr-0081.md) §4).
3//!
4//! `set-developer-metadata`/`delete-developer-metadata` (via
5//! `spreadsheets.batchUpdate`) and `search-developer-metadata` (via the
6//! distinct `developerMetadata:search` endpoint).
7//!
8//! `set-developer-metadata`/`delete-developer-metadata` are gated by
9//! [`DriveOperation::SheetsStructure`], shaped like `format.rs`:
10//! `target_gate::resolve` for the target and gate, then a
11//! `spreadsheets.get` (needed to resolve a `--sheet` title to its numeric
12//! id, and to render a location back to a sheet *title* for display), then
13//! a `developerMetadata:search` call — this module's `read_discarded_cells`
14//! equivalent — that both decides what to do (create vs. update; what a
15//! delete would remove) and backs the mandatory preview from the very same
16//! read, then a `--dry-run` early return, then the mutation, then one
17//! `drivemutation` record.
18//!
19//! **`search-developer-metadata` is read-only and consults no gate** —
20//! [`search`] is a thin wrapper with no `target_gate::resolve` call, no
21//! lease, matching `read.rs`'s own ungated shape.
22//!
23//! **Restricted to `DOCUMENT` visibility everywhere, in two layers.** Every
24//! filter this module builds includes `visibility: DOCUMENT_VISIBILITY`, so
25//! the server itself never returns or is asked to touch a `PROJECT`
26//! entry — the primary mitigation. [`assert_document_visibility`] is the
27//! defense-in-depth second layer, applied to every entry this module ever
28//! reads, on every path (`Set`, `Delete`, `search`). There is no
29//! `--visibility` flag on any of the three CLI commands, so no user input
30//! can reach anything but [`DOCUMENT_VISIBILITY`].
31//!
32//! **`delete-developer-metadata` never assumes exactly one match.**
33//! `DeleteDeveloperMetadataRequest.data_filter` is singular but can match
34//! (and delete) several entries in one call — Sheets bulk-operates by
35//! filter, not by id — so the preview lists every entry a real run would
36//! remove, and a real run removes all of them in the one request.
37
38use std::path::PathBuf;
39use std::time::{Duration, Instant};
40
41use serde::Serialize;
42
43use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
44use crate::drive::client::DriveClient;
45use crate::drive::files_api::FilesApi;
46use crate::drive::lease::check::{
47    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
48    LeasedWrite,
49};
50use crate::drive::sheets::api::{
51    applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
52};
53use crate::drive::sheets::client::SheetsClient;
54use crate::drive::sheets::grid_range;
55use crate::drive::sheets::target_gate;
56use crate::drive::sheets::types::{
57    BatchUpdateRequestItem, CreateDeveloperMetadataRequest, DataFilter,
58    DeleteDeveloperMetadataRequest, DeveloperMetadata, DeveloperMetadataLocation,
59    DeveloperMetadataLookupFilter, DeveloperMetadataValueUpdate, Dimension, DimensionRange,
60    NewDeveloperMetadata, Spreadsheet, UpdateDeveloperMetadataRequest, DOCUMENT_VISIBILITY,
61};
62use crate::drive::types::SheetTargetRefusal;
63use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
64use crate::request_log::{self, DriveMutationOutcome};
65
66/// Which mutation to perform.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum DeveloperMetadataVerb {
69    /// Create a new entry, or update every existing entry matching `key`
70    /// and location.
71    Set {
72        /// The key this entry is looked up by.
73        key: String,
74        /// The value to write.
75        value: String,
76        /// Sheet title, when the location is sheet- or dimension-scoped.
77        /// `None` (together with `dimension`/`start`/`end`) means
78        /// spreadsheet-scoped.
79        sheet: Option<String>,
80        /// Rows or columns, when the location is a dimension span.
81        dimension: Option<Dimension>,
82        /// 1-based first row/column, inclusive, when scoped to a dimension.
83        start: Option<i64>,
84        /// 1-based last row/column, inclusive, when scoped to a dimension.
85        end: Option<i64>,
86    },
87    /// Remove every entry matching `key` and location.
88    Delete {
89        /// The key to remove.
90        key: String,
91        /// See [`Self::Set::sheet`].
92        sheet: Option<String>,
93        /// See [`Self::Set::dimension`].
94        dimension: Option<Dimension>,
95        /// See [`Self::Set::start`].
96        start: Option<i64>,
97        /// See [`Self::Set::end`].
98        end: Option<i64>,
99    },
100}
101
102impl DeveloperMetadataVerb {
103    /// The `operation` this verb records in the request log.
104    const fn log_operation(&self) -> &'static str {
105        match self {
106            Self::Set { .. } => "sheets-set-developer-metadata",
107            Self::Delete { .. } => "sheets-delete-developer-metadata",
108        }
109    }
110
111    /// The CLI subcommand that spells this verb, for error messages.
112    const fn label(&self) -> &'static str {
113        match self {
114            Self::Set { .. } => "set-developer-metadata",
115            Self::Delete { .. } => "delete-developer-metadata",
116        }
117    }
118}
119
120/// Per-call options.
121#[derive(Debug, Clone)]
122pub struct DeveloperMetadataOptions {
123    /// Spreadsheet id.
124    pub spreadsheet_id: String,
125    /// Which mutation to perform.
126    pub verb: DeveloperMetadataVerb,
127    /// Classify and describe only; never call `batchUpdate`.
128    pub dry_run: bool,
129    /// The lease token presented via `--lease`. Checked only when the
130    /// deciding rule requires one (ADR-0080 §1/§9); `None` is only ever
131    /// valid when it does not.
132    pub lease_token: Option<String>,
133    /// Path to the lease ledger the token is checked against.
134    pub ledger_path: PathBuf,
135}
136
137/// One developer-metadata entry as reported in a preview or a completed
138/// result — the key, value and location the ADR-0081 §4 preview names.
139#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
140pub struct DeveloperMetadataEntry {
141    /// The server-assigned id.
142    pub metadata_id: i64,
143    /// The key this entry is looked up by.
144    pub key: String,
145    /// The value stored under `key`.
146    pub value: String,
147    /// A human-readable rendering of where this entry is attached, e.g.
148    /// `"sheet 'Q1'"` or `"row 2 of sheet 'Q1'"`.
149    pub location: String,
150}
151
152impl DeveloperMetadataEntry {
153    fn from_entry(workbook: &Spreadsheet, entry: &DeveloperMetadata) -> Self {
154        Self {
155            metadata_id: entry.metadata_id,
156            key: entry.metadata_key.clone(),
157            value: entry.metadata_value.clone(),
158            location: describe_location(workbook, &entry.location),
159        }
160    }
161}
162
163/// What happened (or, under `--dry-run`, would happen).
164#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
165#[serde(tag = "status", rename_all = "kebab-case")]
166pub enum DeveloperMetadataResult {
167    /// `--dry-run`: no existing entry matched; a new one would be created.
168    WouldCreate {
169        /// The key that would be created.
170        key: String,
171        /// The value it would carry.
172        value: String,
173        /// Where it would be attached.
174        location: String,
175    },
176    /// `--dry-run`: one or more existing entries matched; their value
177    /// would change. `previous` is every matched entry as it stands now.
178    WouldUpdate {
179        /// The value every matched entry would be changed to.
180        new_value: String,
181        /// Every entry that matched, before the change.
182        previous: Vec<DeveloperMetadataEntry>,
183    },
184    /// `--dry-run` for `delete-developer-metadata`: every entry that would
185    /// be removed.
186    WouldDelete {
187        /// The entries that would be removed.
188        entries: Vec<DeveloperMetadataEntry>,
189    },
190    /// The target is not a Google Sheet.
191    RefusedNotASpreadsheet {
192        /// The target's actual MIME type.
193        mime_type: String,
194    },
195    /// The target is a shortcut, which we do not follow.
196    RefusedShortcut,
197    /// The target has no parents this account can see.
198    RefusedNoVisibleParents,
199    /// The named sheet does not exist in this workbook.
200    RefusedSheetNotFound {
201        /// The title that was not found.
202        title: String,
203        /// The titles that do exist.
204        available: Vec<String>,
205    },
206    /// The `--sheet`/`--dimension`/`--start`/`--end` combination was
207    /// invalid — a partial combination, or an out-of-order span.
208    RefusedInvalidLocation {
209        /// What was wrong and why.
210        detail: String,
211    },
212    /// `delete-developer-metadata` found no entry matching `key` and the
213    /// given location — nothing to preview or remove.
214    RefusedNotFound {
215        /// The key that was searched for.
216        key: String,
217    },
218    /// The folder write-permission gate refused it.
219    Blocked {
220        /// The rule that decided the refusal, if any.
221        decided_by: Option<DecidingRule>,
222    },
223    /// No `--lease` was presented, and the deciding rule requires one
224    /// (ADR-0080 §9).
225    RefusedNoLease,
226    /// The presented lease has expired, or was never a token this ledger
227    /// knows about.
228    RefusedLeaseExpired,
229    /// The presented lease is bound to a different file id.
230    RefusedLeaseWrongFile,
231    /// The file has moved since the lease's recorded `version`.
232    RefusedLeaseStale,
233    /// A new entry was created.
234    Created {
235        /// The key that was created.
236        key: String,
237        /// The value it carries.
238        value: String,
239        /// Where it is attached.
240        location: String,
241    },
242    /// Every matched entry's value was changed.
243    Updated {
244        /// The value every matched entry was changed to.
245        new_value: String,
246        /// Every entry that matched, before the change.
247        previous: Vec<DeveloperMetadataEntry>,
248    },
249    /// Every matched entry was removed.
250    Deleted {
251        /// The entries that were removed.
252        entries: Vec<DeveloperMetadataEntry>,
253    },
254    /// The API answered 2xx — the mutation **was applied** — but its reply
255    /// could not be read (issue #2021). Distinct from [`Self::Failed`]: the
256    /// change is already in the spreadsheet, so check it before retrying.
257    AppliedReplyUnreadable {
258        /// What was applied, in the same wording the request log's
259        /// `fields_changed` carries for [`Self::Created`] / [`Self::Updated`]
260        /// / [`Self::Deleted`].
261        summary: String,
262        /// The [`Self::Created`] / [`Self::Updated`] / [`Self::Deleted`] result
263        /// that would have been reported had the reply been readable. All of
264        /// it is plan-time data (the entries were read before the call), so
265        /// nothing an applied change carries is lost.
266        applied: Box<Self>,
267        /// Why the reply could not be read.
268        detail: String,
269    },
270    /// An API or validation error.
271    Failed {
272        /// A human-readable summary of what failed.
273        detail: String,
274    },
275}
276
277impl FromLeaseRefusal for DeveloperMetadataResult {
278    fn from_no_lease() -> Self {
279        Self::RefusedNoLease
280    }
281    fn from_lease_expired() -> Self {
282        Self::RefusedLeaseExpired
283    }
284    fn from_lease_wrong_file() -> Self {
285        Self::RefusedLeaseWrongFile
286    }
287    fn from_lease_stale() -> Self {
288        Self::RefusedLeaseStale
289    }
290    fn from_lease_failed(detail: String) -> Self {
291        Self::Failed { detail }
292    }
293}
294
295impl DeveloperMetadataResult {
296    fn log_status(&self) -> &'static str {
297        match self {
298            Self::WouldCreate { .. } | Self::WouldUpdate { .. } | Self::WouldDelete { .. } => {
299                "would-change"
300            }
301            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
302            Self::RefusedShortcut => "refused-shortcut",
303            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
304            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
305            Self::RefusedInvalidLocation { .. } => "refused-invalid-range",
306            Self::RefusedNotFound { .. } => "refused-not-found",
307            Self::Blocked { .. } => "blocked",
308            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
309            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
310            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
311            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
312            Self::Created { .. } | Self::Updated { .. } | Self::Deleted { .. } => "changed",
313            Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
314            Self::Failed { .. } => "failed",
315        }
316    }
317}
318
319/// The full outcome of one attempt.
320#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
321pub struct DeveloperMetadataOutcome {
322    /// The spreadsheet acted on.
323    pub spreadsheet_id: String,
324    /// Its Drive file name, when the metadata fetch got that far.
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub file_name: Option<String>,
327    /// The folder the gate evaluated against.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub resolved_folder_id: Option<String>,
330    /// Which mutation was attempted. Not serialised — carried so `describe`
331    /// can render from the outcome alone.
332    #[serde(skip)]
333    pub verb: DeveloperMetadataVerb,
334    /// What happened.
335    pub result: DeveloperMetadataResult,
336}
337
338impl JsonlSerialize for DeveloperMetadataOutcome {
339    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
340        write_scalar_jsonl(self, out)
341    }
342}
343
344fn find_sheet_id(workbook: &Spreadsheet, title: &str) -> Result<i64, DeveloperMetadataResult> {
345    grid_range::find_sheet_id(workbook, title, |title, available| {
346        DeveloperMetadataResult::RefusedSheetNotFound { title, available }
347    })
348}
349
350/// Resolves `--sheet`/`--dimension`/`--start`/`--end` into exactly one of
351/// the three location shapes Sheets models — inferred from which flags
352/// were given, never from a separate `--scope` flag: none given means
353/// spreadsheet-scoped, `--sheet` alone means sheet-scoped, and all four
354/// together mean dimension-scoped, applying the same 1-based-inclusive to
355/// 0-based-half-open conversion as `format.rs::resolve_target` does for
356/// `update-dimension-properties`. The bounds check is stricter here,
357/// though: unlike a formatting range, a developer-metadata `DimensionRange`
358/// can only ever cover a **single** row or column — Sheets rejects a wider
359/// one outright (issue #1933) — so `--start` and `--end` must be equal.
360/// Any other combination — a partial one — is refused rather than silently
361/// falling back to a different scope than the caller likely intended.
362fn resolve_location(
363    workbook: &Spreadsheet,
364    sheet: Option<&str>,
365    dimension: Option<Dimension>,
366    start: Option<i64>,
367    end: Option<i64>,
368) -> Result<DeveloperMetadataLocation, DeveloperMetadataResult> {
369    match (sheet, dimension, start, end) {
370        (None, None, None, None) => Ok(DeveloperMetadataLocation::spreadsheet()),
371        (Some(sheet), None, None, None) => {
372            let sheet_id = find_sheet_id(workbook, sheet)?;
373            Ok(DeveloperMetadataLocation::sheet(sheet_id))
374        }
375        (Some(sheet), Some(dimension), Some(start), Some(end)) => {
376            let sheet_id = find_sheet_id(workbook, sheet)?;
377            if start < 1 {
378                return Err(DeveloperMetadataResult::RefusedInvalidLocation {
379                    detail: format!("--start must be at least 1 (got --start {start})"),
380                });
381            }
382            // Issue #1933: Sheets rejects a developer-metadata DimensionRange
383            // spanning more than one row/column with a bare HTTP 400
384            // ("DimensionRange must represent a single row or column"), so
385            // --start and --end must name the same one. Checked here rather
386            // than left to the API so the failure is a clear local refusal,
387            // not an opaque round-trip.
388            if end != start {
389                return Err(DeveloperMetadataResult::RefusedInvalidLocation {
390                    detail: format!(
391                        "developer metadata can only be attached to a single row or \
392                         column — Sheets rejects a span of more than one. Pass the \
393                         same value for --start and --end (e.g. --start {start} --end \
394                         {start} selects that row/column alone); got --start {start} \
395                         --end {end}"
396                    ),
397                });
398            }
399            Ok(DeveloperMetadataLocation::dimension(DimensionRange {
400                sheet_id,
401                dimension,
402                start_index: start - 1,
403                end_index: end,
404            }))
405        }
406        (None, Some(_) | None, ..) if dimension.is_some() || start.is_some() || end.is_some() => {
407            Err(DeveloperMetadataResult::RefusedInvalidLocation {
408                detail: "--dimension/--start/--end need --sheet too".to_string(),
409            })
410        }
411        _ => Err(DeveloperMetadataResult::RefusedInvalidLocation {
412            detail: "--dimension, --start and --end must be given together (a row/column \
413                     location), or none of the three alongside --sheet (a whole-sheet \
414                     location), or none of --sheet/--dimension/--start/--end at all (a \
415                     whole-spreadsheet location)"
416                .to_string(),
417        }),
418    }
419}
420
421/// Converts a [`resolve_location`] error into a plain message, for
422/// [`search`], which has no `DeveloperMetadataOutcome`/verb to attach a
423/// structured result to.
424fn location_error_to_string(result: DeveloperMetadataResult) -> String {
425    match result {
426        DeveloperMetadataResult::RefusedSheetNotFound { title, available } => format!(
427            "no sheet titled '{title}'. Available: {}",
428            grid_range::available_sheets(&available)
429        ),
430        DeveloperMetadataResult::RefusedInvalidLocation { detail } => detail,
431        other => unreachable!(
432            "resolve_location only ever returns RefusedSheetNotFound or \
433             RefusedInvalidLocation, got {other:?}"
434        ),
435    }
436}
437
438/// Renders a location back to a human-readable string, for previews and
439/// [`DeveloperMetadataEntry::location`].
440fn describe_location(workbook: &Spreadsheet, location: &DeveloperMetadataLocation) -> String {
441    if location.spreadsheet == Some(true) {
442        return "the whole spreadsheet".to_string();
443    }
444    if let Some(sheet_id) = location.sheet_id {
445        return format!("sheet {}", sheet_title_or_id(workbook, sheet_id));
446    }
447    if let Some(range) = &location.dimension_range {
448        let sheet = sheet_title_or_id(workbook, range.sheet_id);
449        // Sheets only ever accepts a single row/column here (issue #1933),
450        // so that is the shape every entry has; the span form is kept only
451        // as a fallback should the API ever hand back a wider range.
452        if range.end_index == range.start_index + 1 {
453            return format!(
454                "{} {} of sheet {sheet}",
455                range.dimension.noun(),
456                range.end_index
457            );
458        }
459        return format!(
460            "{}(s) {}-{} of sheet {}",
461            range.dimension.noun(),
462            range.start_index + 1,
463            range.end_index,
464            sheet
465        );
466    }
467    "an unknown location".to_string()
468}
469
470fn sheet_title_or_id(workbook: &Spreadsheet, sheet_id: i64) -> String {
471    workbook
472        .sheets
473        .iter()
474        .filter_map(|sheet| sheet.properties.as_ref())
475        .find(|props| props.sheet_id == Some(sheet_id))
476        .map_or_else(
477            || format!("id {sheet_id}"),
478            |props| format!("'{}'", props.title),
479        )
480}
481
482/// The defense-in-depth half of the DOCUMENT-only guarantee described in
483/// the module doc comment: every entry this module ever reads must have
484/// `visibility == DOCUMENT_VISIBILITY`. Every filter already restricts the
485/// *search* to `DOCUMENT`, so this should never actually fire — a failure
486/// here means the server returned something the filter should have
487/// excluded, treated as a hard error worth surfacing loudly, never a
488/// silent drop (issue #1795, ADR-0081 §4).
489fn assert_document_visibility(entries: &[DeveloperMetadata]) -> Result<(), String> {
490    for entry in entries {
491        if entry.visibility != DOCUMENT_VISIBILITY {
492            return Err(format!(
493                "developerMetadata.search returned an entry (id {}) with visibility \
494                 '{}', not '{DOCUMENT_VISIBILITY}' — refusing to act on or display it \
495                 (issue #1795, ADR-0081 §4)",
496                entry.metadata_id, entry.visibility
497            ));
498        }
499    }
500    Ok(())
501}
502
503/// Searches for entries matching `key` and/or `location`, asserting the
504/// DOCUMENT-only guarantee on the result. Shared by [`developer_metadata`]
505/// (via `Set`/`Delete`'s preview-and-decide read) and [`search`].
506async fn find_document_metadata(
507    api: &SheetsApi<'_>,
508    spreadsheet_id: &str,
509    key: Option<&str>,
510    location: Option<&DeveloperMetadataLocation>,
511) -> Result<Vec<DeveloperMetadata>, String> {
512    let filter = build_filter(key, location);
513    let response = api
514        .search_developer_metadata(spreadsheet_id, vec![filter])
515        .await
516        .map_err(|err| format!("{err:#}"))?;
517    let entries: Vec<DeveloperMetadata> = response
518        .matched_developer_metadata
519        .into_iter()
520        .map(|matched| matched.developer_metadata)
521        .collect();
522    assert_document_visibility(&entries)?;
523    Ok(entries)
524}
525
526/// Builds the one `DataFilter` shape every caller in this module sends —
527/// always restricted to [`DOCUMENT_VISIBILITY`], `EXACT_LOCATION` matching
528/// whenever a location is given (never `INTERSECTING_LOCATION`, which
529/// would also match a dimension range merely straddling the requested
530/// one).
531fn build_filter(key: Option<&str>, location: Option<&DeveloperMetadataLocation>) -> DataFilter {
532    DataFilter {
533        developer_metadata_lookup: DeveloperMetadataLookupFilter {
534            metadata_location: location.cloned(),
535            metadata_key: key.map(str::to_string),
536            visibility: Some(DOCUMENT_VISIBILITY.to_string()),
537            location_matching_strategy: location.map(|_| "EXACT_LOCATION".to_string()),
538        },
539    }
540}
541
542/// Runs one `set-developer-metadata`/`delete-developer-metadata` mutation,
543/// logging every attempt that isn't a dry run. Never returns `Err`,
544/// matching `format.rs::format`.
545pub async fn developer_metadata(
546    drive: &DriveClient,
547    sheets: &SheetsClient,
548    opts: &DeveloperMetadataOptions,
549    rules: &[FolderPermissionRule],
550) -> DeveloperMetadataOutcome {
551    let started = Instant::now();
552    let outcome = developer_metadata_inner(drive, sheets, opts, rules).await;
553    if !opts.dry_run {
554        record_attempt(&outcome, opts, started.elapsed());
555    }
556    outcome
557}
558
559async fn developer_metadata_inner(
560    drive: &DriveClient,
561    sheets: &SheetsClient,
562    opts: &DeveloperMetadataOptions,
563    rules: &[FolderPermissionRule],
564) -> DeveloperMetadataOutcome {
565    let bare = |result| DeveloperMetadataOutcome {
566        spreadsheet_id: opts.spreadsheet_id.clone(),
567        file_name: None,
568        resolved_folder_id: None,
569        verb: opts.verb.clone(),
570        result,
571    };
572
573    let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
574        drive,
575        &opts.spreadsheet_id,
576        DriveOperation::SheetsStructure,
577        rules,
578    )
579    .await
580    {
581        target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
582            return bare(DeveloperMetadataResult::Failed { detail })
583        }
584        target_gate::TargetGateOutcome::Refused { target, refusal } => {
585            let result = match refusal {
586                SheetTargetRefusal::Shortcut => DeveloperMetadataResult::RefusedShortcut,
587                SheetTargetRefusal::NotASpreadsheet { mime_type } => {
588                    DeveloperMetadataResult::RefusedNotASpreadsheet { mime_type }
589                }
590                SheetTargetRefusal::NoVisibleParents => {
591                    DeveloperMetadataResult::RefusedNoVisibleParents
592                }
593            };
594            return DeveloperMetadataOutcome {
595                spreadsheet_id: opts.spreadsheet_id.clone(),
596                file_name: Some(target.name),
597                resolved_folder_id: None,
598                verb: opts.verb.clone(),
599                result,
600            };
601        }
602        target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
603            return DeveloperMetadataOutcome {
604                spreadsheet_id: opts.spreadsheet_id.clone(),
605                file_name: Some(target.name),
606                resolved_folder_id: None,
607                verb: opts.verb.clone(),
608                result: DeveloperMetadataResult::Failed { detail },
609            };
610        }
611        target_gate::TargetGateOutcome::Gated {
612            target,
613            decision,
614            resolved_folder_id,
615            requires_lease,
616        } => (target, decision, resolved_folder_id, requires_lease),
617    };
618
619    let gated = |result| DeveloperMetadataOutcome {
620        spreadsheet_id: opts.spreadsheet_id.clone(),
621        file_name: Some(target.name.clone()),
622        resolved_folder_id: resolved_folder_id.clone(),
623        verb: opts.verb.clone(),
624        result,
625    };
626
627    if decision.verdict == write_gate::Verdict::Deny {
628        return gated(DeveloperMetadataResult::Blocked {
629            decided_by: decision.decided_by,
630        });
631    }
632
633    let api = SheetsApi::new(sheets);
634    let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
635        Ok(workbook) => workbook,
636        Err(err) => {
637            return gated(DeveloperMetadataResult::Failed {
638                detail: format!("{err:#}"),
639            })
640        }
641    };
642
643    let (key, sheet, dimension, start, end): (
644        &str,
645        Option<&str>,
646        Option<Dimension>,
647        Option<i64>,
648        Option<i64>,
649    ) = match &opts.verb {
650        DeveloperMetadataVerb::Set {
651            key,
652            sheet,
653            dimension,
654            start,
655            end,
656            ..
657        }
658        | DeveloperMetadataVerb::Delete {
659            key,
660            sheet,
661            dimension,
662            start,
663            end,
664        } => (key.as_str(), sheet.as_deref(), *dimension, *start, *end),
665    };
666
667    let location = match resolve_location(&workbook, sheet, dimension, start, end) {
668        Ok(location) => location,
669        Err(result) => return gated(result),
670    };
671
672    // The read that both decides what to do (create vs. update; what a
673    // delete would remove) and backs the mandatory preview — same "one
674    // read serves both the dry-run and the real path" reasoning as
675    // `format.rs::read_discarded_cells` for `merge-cells`.
676    let matches = match find_document_metadata(
677        &api,
678        &opts.spreadsheet_id,
679        Some(key),
680        Some(&location),
681    )
682    .await
683    {
684        Ok(matches) => matches,
685        Err(detail) => return gated(DeveloperMetadataResult::Failed { detail }),
686    };
687    let entries: Vec<DeveloperMetadataEntry> = matches
688        .iter()
689        .map(|entry| DeveloperMetadataEntry::from_entry(&workbook, entry))
690        .collect();
691    let filter = build_filter(Some(key), Some(&location));
692
693    // No fallible step follows building `request` — unlike `format.rs`'s
694    // `build_request` (whose `parse_hex_color` can fail), everything
695    // needed here is already known, so there is no "build the request
696    // last" ordering concern (#1688) to observe.
697    let (preview_result, changed_result, request) = match &opts.verb {
698        DeveloperMetadataVerb::Set { value, .. } => {
699            if entries.is_empty() {
700                let location_desc = describe_location(&workbook, &location);
701                (
702                    DeveloperMetadataResult::WouldCreate {
703                        key: key.to_string(),
704                        value: value.clone(),
705                        location: location_desc.clone(),
706                    },
707                    DeveloperMetadataResult::Created {
708                        key: key.to_string(),
709                        value: value.clone(),
710                        location: location_desc,
711                    },
712                    BatchUpdateRequestItem::CreateDeveloperMetadata(
713                        CreateDeveloperMetadataRequest {
714                            developer_metadata: NewDeveloperMetadata {
715                                metadata_key: key.to_string(),
716                                metadata_value: value.clone(),
717                                location,
718                                visibility: DOCUMENT_VISIBILITY.to_string(),
719                            },
720                        },
721                    ),
722                )
723            } else {
724                (
725                    DeveloperMetadataResult::WouldUpdate {
726                        new_value: value.clone(),
727                        previous: entries.clone(),
728                    },
729                    DeveloperMetadataResult::Updated {
730                        new_value: value.clone(),
731                        previous: entries.clone(),
732                    },
733                    BatchUpdateRequestItem::UpdateDeveloperMetadata(
734                        UpdateDeveloperMetadataRequest {
735                            data_filters: vec![filter],
736                            developer_metadata: DeveloperMetadataValueUpdate {
737                                metadata_value: value.clone(),
738                            },
739                            fields: "metadataValue".to_string(),
740                        },
741                    ),
742                )
743            }
744        }
745        DeveloperMetadataVerb::Delete { .. } => {
746            if entries.is_empty() {
747                return gated(DeveloperMetadataResult::RefusedNotFound {
748                    key: key.to_string(),
749                });
750            }
751            (
752                DeveloperMetadataResult::WouldDelete {
753                    entries: entries.clone(),
754                },
755                DeveloperMetadataResult::Deleted {
756                    entries: entries.clone(),
757                },
758                BatchUpdateRequestItem::DeleteDeveloperMetadata(DeleteDeveloperMetadataRequest {
759                    data_filter: filter,
760                }),
761            )
762        }
763    };
764
765    if opts.dry_run {
766        return gated(preview_result);
767    }
768
769    let files_api = FilesApi::new(drive);
770    let leased = LeasedWrite {
771        log_prefix: "drive sheets developer-metadata",
772        operation: opts.verb.log_operation(),
773        ledger_path: &opts.ledger_path,
774        file_id: &opts.spreadsheet_id,
775    };
776    let lease_grant = match gate_optional_leased_write(
777        leased,
778        &files_api,
779        requires_lease,
780        opts.lease_token.as_deref(),
781    )
782    .await
783    {
784        Ok(grant) => grant,
785        Err(err) => return gated(err.into_result()),
786    };
787
788    let result = match conclude_native_leased_write(
789        leased,
790        &lease_grant,
791        &files_api,
792        api.batch_update(&opts.spreadsheet_id, vec![request]).await,
793        |err| format!("{err:#}"),
794    )
795    .await
796    {
797        Ok(BatchUpdateOutcome::Applied(_response)) => changed_result,
798        // NotIdempotent: conservative — a second run is normally harmless (`set`
799        // upserts, `delete` finds nothing), but the advice is to look first.
800        Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
801            DeveloperMetadataResult::AppliedReplyUnreadable {
802                summary: changed_summary(&changed_result).unwrap_or_default(),
803                applied: Box::new(changed_result),
804                detail,
805            }
806        }
807        Err(err) => DeveloperMetadataResult::Failed {
808            detail: format!("{err:#}"),
809        },
810    };
811    drop(lease_grant);
812    gated(result)
813}
814
815/// The raw `--sheet`/`--dimension`/`--start`/`--end` flags [`search`]
816/// resolves into an optional location filter.
817///
818/// Bundled into one struct so `search` stays under clippy's
819/// argument-count limit, and named exactly like
820/// [`DeveloperMetadataVerb::Set`]'s own location fields.
821#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
822pub struct SearchLocationFilter<'a> {
823    /// See [`DeveloperMetadataVerb::Set::sheet`].
824    pub sheet: Option<&'a str>,
825    /// See [`DeveloperMetadataVerb::Set::dimension`].
826    pub dimension: Option<Dimension>,
827    /// See [`DeveloperMetadataVerb::Set::start`].
828    pub start: Option<i64>,
829    /// See [`DeveloperMetadataVerb::Set::end`].
830    pub end: Option<i64>,
831}
832
833impl SearchLocationFilter<'_> {
834    fn is_empty(self) -> bool {
835        self.sheet.is_none()
836            && self.dimension.is_none()
837            && self.start.is_none()
838            && self.end.is_none()
839    }
840}
841
842/// Searches for developer-metadata entries matching `key` and/or `filter`.
843///
844/// An empty `filter` (every field `None`) means "every `DOCUMENT`-visibility
845/// entry in the workbook". Read-only, so — unlike [`developer_metadata`] —
846/// this consults no write gate, matching `read.rs`'s own ungated shape
847/// (issue #1795).
848pub async fn search(
849    api: &SheetsApi<'_>,
850    spreadsheet_id: &str,
851    workbook: &Spreadsheet,
852    key: Option<&str>,
853    filter: SearchLocationFilter<'_>,
854) -> Result<Vec<DeveloperMetadataEntry>, String> {
855    let location = if filter.is_empty() {
856        None
857    } else {
858        Some(
859            resolve_location(
860                workbook,
861                filter.sheet,
862                filter.dimension,
863                filter.start,
864                filter.end,
865            )
866            .map_err(location_error_to_string)?,
867        )
868    };
869    let matches = find_document_metadata(api, spreadsheet_id, key, location.as_ref()).await?;
870    Ok(matches
871        .iter()
872        .map(|entry| DeveloperMetadataEntry::from_entry(workbook, entry))
873        .collect())
874}
875
876fn record_attempt(
877    outcome: &DeveloperMetadataOutcome,
878    opts: &DeveloperMetadataOptions,
879    duration: Duration,
880) {
881    request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
882}
883
884/// The request-log record for one attempt. Split from [`record_attempt`] so
885/// the logged fields are unit-testable.
886fn mutation_record(
887    outcome: &DeveloperMetadataOutcome,
888    opts: &DeveloperMetadataOptions,
889    duration: Duration,
890) -> DriveMutationOutcome {
891    let error = match &outcome.result {
892        DeveloperMetadataResult::Failed { detail }
893        | DeveloperMetadataResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
894        _ => None,
895    };
896    let decided_by = match &outcome.result {
897        DeveloperMetadataResult::Blocked { decided_by } => decided_by.as_ref(),
898        _ => None,
899    };
900    let decided_by = write_gate::decided_by_log_fields(decided_by);
901    let fields_changed = match &outcome.result {
902        DeveloperMetadataResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
903        other => changed_summary(other),
904    };
905
906    DriveMutationOutcome {
907        operation: opts.verb.log_operation(),
908        file_id: outcome.spreadsheet_id.clone(),
909        file_name: outcome.file_name.clone().unwrap_or_default(),
910        status: outcome.result.log_status().to_string(),
911        resolved_folder_id: outcome.resolved_folder_id.clone(),
912        decided_by_folder_id: decided_by.folder_id,
913        decided_by_depth: decided_by.depth,
914        decided_by_file_id: decided_by.file_id,
915        fields_changed,
916        error,
917        duration,
918        ..Default::default()
919    }
920}
921
922/// A one-line summary of the change a `Created` / `Updated` / `Deleted`
923/// result records, for the request log and the unreadable-reply line. `None`
924/// for every other variant.
925fn changed_summary(result: &DeveloperMetadataResult) -> Option<String> {
926    match result {
927        DeveloperMetadataResult::Created { key, value, .. } => {
928            Some(format!("create key={key:?} value={value:?}"))
929        }
930        DeveloperMetadataResult::Updated {
931            new_value,
932            previous,
933        } => Some(format!(
934            "update {} entr{} to value={new_value:?}",
935            previous.len(),
936            plural(previous.len())
937        )),
938        DeveloperMetadataResult::Deleted { entries } => Some(format!(
939            "delete {} entr{}",
940            entries.len(),
941            plural(entries.len())
942        )),
943        _ => None,
944    }
945}
946
947fn plural(n: usize) -> &'static str {
948    if n == 1 {
949        "y"
950    } else {
951        "ies"
952    }
953}
954
955fn describe_entry(entry: &DeveloperMetadataEntry) -> String {
956    format!(
957        "  id {}: {:?}={:?} at {}",
958        entry.metadata_id, entry.key, entry.value, entry.location
959    )
960}
961
962/// Like [`describe_entry`], but renders `new_value` in place of the entry's
963/// own (pre-update) value. [`DeveloperMetadataResult::Updated`] carries its
964/// entries as they were fetched *before* the update — reusing
965/// [`describe_entry`] there rendered the just-replaced value under a header
966/// naming the value it was replaced with (#1941).
967fn describe_updated_entry(entry: &DeveloperMetadataEntry, new_value: &str) -> String {
968    format!(
969        "  id {}: {:?}={:?} at {}",
970        entry.metadata_id, entry.key, new_value, entry.location
971    )
972}
973
974/// Renders an outcome as human-readable text.
975#[must_use]
976pub fn describe(outcome: &DeveloperMetadataOutcome) -> String {
977    describe_lines(outcome).join("\n")
978}
979
980/// Renders an outcome as its individual lines, none of which contains a
981/// newline — see `structure.rs::describe_lines` for why this shape exists.
982#[must_use]
983pub fn describe_lines(outcome: &DeveloperMetadataOutcome) -> Vec<String> {
984    let verb = &outcome.verb;
985    let book = outcome.file_name.as_deref().map_or_else(
986        || format!("'{}'", outcome.spreadsheet_id),
987        |n| format!("'{n}'"),
988    );
989    match &outcome.result {
990        DeveloperMetadataResult::WouldCreate {
991            key,
992            value,
993            location,
994        } => vec![format!(
995            "Would create developer metadata {key:?}={value:?} at {location} in {book}"
996        )],
997        DeveloperMetadataResult::WouldUpdate {
998            new_value,
999            previous,
1000        } => {
1001            let mut lines = vec![format!(
1002                "Would update {} existing entr{} to {new_value:?} in {book}:",
1003                previous.len(),
1004                plural(previous.len())
1005            )];
1006            lines.extend(previous.iter().map(describe_entry));
1007            lines
1008        }
1009        DeveloperMetadataResult::WouldDelete { entries } => {
1010            let mut lines = vec![format!(
1011                "Would delete {} entr{} in {book}:",
1012                entries.len(),
1013                plural(entries.len())
1014            )];
1015            lines.extend(entries.iter().map(describe_entry));
1016            lines
1017        }
1018        DeveloperMetadataResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1019            "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
1020             `drive sheets {}` only works on spreadsheets",
1021            verb.label()
1022        )],
1023        DeveloperMetadataResult::RefusedShortcut => vec![format!(
1024            "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
1025            verb.label()
1026        )],
1027        DeveloperMetadataResult::RefusedNoVisibleParents => vec![format!(
1028            "Refused: {book} has no parent folder visible to this account, so no folder \
1029             rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
1030             id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
1031        )],
1032        DeveloperMetadataResult::RefusedSheetNotFound { title, available } => {
1033            vec![grid_range::no_sheet_refusal(&book, title, available)]
1034        }
1035        DeveloperMetadataResult::RefusedInvalidLocation { detail } => {
1036            vec![format!("Refused: {detail}")]
1037        }
1038        DeveloperMetadataResult::RefusedNotFound { key } => vec![format!(
1039            "Refused: no developer metadata matching key {key:?} at that location in {book}"
1040        )],
1041        DeveloperMetadataResult::Blocked { decided_by } => vec![match decided_by {
1042            Some(rule) => format!(
1043                "Blocked: {} on {book} refused by rule on {} {}{}",
1044                verb.label(),
1045                rule.kind_label(),
1046                rule.id(),
1047                rule.depth_suffix()
1048            ),
1049            None => format!(
1050                "Blocked: {} on {book} refused by default policy (no matching rule for \
1051                 sheets-structure)",
1052                verb.label()
1053            ),
1054        }],
1055        DeveloperMetadataResult::RefusedNoLease => LeaseGateRefusal::NoLease
1056            .describe_line(&outcome.spreadsheet_id, &book)
1057            .into_iter()
1058            .collect(),
1059        DeveloperMetadataResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1060            .describe_line(&outcome.spreadsheet_id, &book)
1061            .into_iter()
1062            .collect(),
1063        DeveloperMetadataResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1064            .describe_line(&outcome.spreadsheet_id, &book)
1065            .into_iter()
1066            .collect(),
1067        DeveloperMetadataResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1068            .describe_line(&outcome.spreadsheet_id, &book)
1069            .into_iter()
1070            .collect(),
1071        DeveloperMetadataResult::Created {
1072            key,
1073            value,
1074            location,
1075        } => vec![format!(
1076            "Created developer metadata {key:?}={value:?} at {location} in {book}"
1077        )],
1078        DeveloperMetadataResult::Updated {
1079            new_value,
1080            previous,
1081        } => {
1082            let mut lines = vec![format!(
1083                "Updated {} entr{} to {new_value:?} in {book}:",
1084                previous.len(),
1085                plural(previous.len())
1086            )];
1087            lines.extend(
1088                previous
1089                    .iter()
1090                    .map(|entry| describe_updated_entry(entry, new_value)),
1091            );
1092            lines
1093        }
1094        DeveloperMetadataResult::Deleted { entries } => {
1095            let mut lines = vec![format!(
1096                "Deleted {} entr{} in {book}:",
1097                entries.len(),
1098                plural(entries.len())
1099            )];
1100            lines.extend(entries.iter().map(describe_entry));
1101            lines
1102        }
1103        // Neither verb learns anything from the reply (a created entry's id is
1104        // not reported even on success), so there is no lost value to name;
1105        // what `Created`/`Updated`/`Deleted` print beyond their header is
1106        // plan-time data and follows the shared line.
1107        DeveloperMetadataResult::AppliedReplyUnreadable {
1108            summary,
1109            applied,
1110            detail,
1111        } => {
1112            let (location, entry_lines): (String, Vec<String>) = match &**applied {
1113                DeveloperMetadataResult::Created { location, .. } => {
1114                    (format!(" at {location}"), Vec::new())
1115                }
1116                DeveloperMetadataResult::Updated {
1117                    new_value,
1118                    previous,
1119                } => (
1120                    String::new(),
1121                    previous
1122                        .iter()
1123                        .map(|entry| describe_updated_entry(entry, new_value))
1124                        .collect(),
1125                ),
1126                DeveloperMetadataResult::Deleted { entries } => {
1127                    (String::new(), entries.iter().map(describe_entry).collect())
1128                }
1129                _ => (String::new(), Vec::new()),
1130            };
1131            let mut lines = vec![applied_reply_unreadable_line(
1132                &format!("{summary}{location}"),
1133                &book,
1134                RetryHint::NotIdempotent,
1135                detail,
1136            )];
1137            lines.extend(entry_lines);
1138            lines
1139        }
1140        DeveloperMetadataResult::Failed { detail } => vec![format!("Failed: {detail}")],
1141    }
1142}
1143
1144#[cfg(test)]
1145#[allow(clippy::unwrap_used, clippy::expect_used)]
1146mod tests {
1147    use super::*;
1148    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1149    use crate::drive::sheets::client::SHEETS_API_URL;
1150    use crate::drive::test_support::seed_lease;
1151    use crate::test_support::env::MapEnv;
1152    use crate::utils::secret::Secret;
1153    use std::collections::HashSet;
1154
1155    fn test_workbook() -> Spreadsheet {
1156        serde_json::from_value(serde_json::json!({
1157            "spreadsheetId": "sheet-1",
1158            "properties": {"title": "Budget"},
1159            "sheets": [
1160                {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1161            ],
1162        }))
1163        .unwrap()
1164    }
1165
1166    #[test]
1167    fn resolve_location_with_no_flags_is_spreadsheet_scoped() {
1168        let workbook = test_workbook();
1169        let location = resolve_location(&workbook, None, None, None, None).unwrap();
1170        assert_eq!(location, DeveloperMetadataLocation::spreadsheet());
1171    }
1172
1173    #[test]
1174    fn resolve_location_with_only_sheet_is_sheet_scoped() {
1175        let workbook = test_workbook();
1176        let location = resolve_location(&workbook, Some("Q1"), None, None, None).unwrap();
1177        assert_eq!(location, DeveloperMetadataLocation::sheet(0));
1178    }
1179
1180    #[test]
1181    fn resolve_location_rejects_an_unknown_sheet() {
1182        let workbook = test_workbook();
1183        let result = resolve_location(&workbook, Some("Nope"), None, None, None).unwrap_err();
1184        assert!(matches!(
1185            result,
1186            DeveloperMetadataResult::RefusedSheetNotFound { title, .. } if title == "Nope"
1187        ));
1188    }
1189
1190    #[test]
1191    fn resolve_location_with_full_dimension_converts_1_based_to_0_based() {
1192        let workbook = test_workbook();
1193        let location = resolve_location(
1194            &workbook,
1195            Some("Q1"),
1196            Some(Dimension::Rows),
1197            Some(2),
1198            Some(2),
1199        )
1200        .unwrap();
1201        assert_eq!(
1202            location,
1203            DeveloperMetadataLocation::dimension(DimensionRange {
1204                sheet_id: 0,
1205                dimension: Dimension::Rows,
1206                start_index: 1,
1207                end_index: 2,
1208            })
1209        );
1210    }
1211
1212    #[test]
1213    fn resolve_location_rejects_end_before_start() {
1214        let workbook = test_workbook();
1215        let result = resolve_location(
1216            &workbook,
1217            Some("Q1"),
1218            Some(Dimension::Rows),
1219            Some(5),
1220            Some(2),
1221        )
1222        .unwrap_err();
1223        assert!(matches!(
1224            result,
1225            DeveloperMetadataResult::RefusedInvalidLocation { .. }
1226        ));
1227    }
1228
1229    #[test]
1230    fn resolve_location_rejects_a_multi_row_span() {
1231        // issue #1933's exact repro: Sheets rejects any DimensionRange
1232        // spanning more than one row/column with an opaque HTTP 400. This
1233        // must be caught locally with a clear message instead.
1234        let workbook = test_workbook();
1235        let result = resolve_location(
1236            &workbook,
1237            Some("Q1"),
1238            Some(Dimension::Rows),
1239            Some(2),
1240            Some(7),
1241        )
1242        .unwrap_err();
1243        match result {
1244            DeveloperMetadataResult::RefusedInvalidLocation { detail } => {
1245                assert!(detail.contains("single row or column"), "{detail}");
1246                assert!(detail.contains("--start 2"), "{detail}");
1247                assert!(detail.contains("--end 7"), "{detail}");
1248            }
1249            other => panic!("expected RefusedInvalidLocation, got {other:?}"), // omni-dev: coverage ignore-line reason="guards this test's assumption; the multi-row span above always fails resolve_location with RefusedInvalidLocation"
1250        }
1251    }
1252
1253    #[test]
1254    fn resolve_location_accepts_a_single_column() {
1255        let workbook = test_workbook();
1256        let location = resolve_location(
1257            &workbook,
1258            Some("Q1"),
1259            Some(Dimension::Columns),
1260            Some(3),
1261            Some(3),
1262        )
1263        .unwrap();
1264        assert_eq!(
1265            location,
1266            DeveloperMetadataLocation::dimension(DimensionRange {
1267                sheet_id: 0,
1268                dimension: Dimension::Columns,
1269                start_index: 2,
1270                end_index: 3,
1271            })
1272        );
1273    }
1274
1275    #[test]
1276    fn resolve_location_rejects_dimension_without_start_and_end() {
1277        let workbook = test_workbook();
1278        let result =
1279            resolve_location(&workbook, Some("Q1"), Some(Dimension::Rows), None, None).unwrap_err();
1280        assert!(matches!(
1281            result,
1282            DeveloperMetadataResult::RefusedInvalidLocation { .. }
1283        ));
1284    }
1285
1286    #[test]
1287    fn resolve_location_rejects_start_end_without_sheet() {
1288        let workbook = test_workbook();
1289        let result =
1290            resolve_location(&workbook, None, Some(Dimension::Rows), Some(1), Some(2)).unwrap_err();
1291        assert!(matches!(
1292            result,
1293            DeveloperMetadataResult::RefusedInvalidLocation { .. }
1294        ));
1295    }
1296
1297    fn document_entry(id: i64, visibility: &str) -> DeveloperMetadata {
1298        DeveloperMetadata {
1299            metadata_id: id,
1300            metadata_key: "owner".to_string(),
1301            metadata_value: "team-a".to_string(),
1302            location: DeveloperMetadataLocation::spreadsheet(),
1303            visibility: visibility.to_string(),
1304        }
1305    }
1306
1307    #[test]
1308    fn assert_document_visibility_accepts_every_document_entry() {
1309        let entries = vec![document_entry(1, "DOCUMENT"), document_entry(2, "DOCUMENT")];
1310        assert!(assert_document_visibility(&entries).is_ok());
1311    }
1312
1313    #[test]
1314    fn assert_document_visibility_rejects_a_project_entry() {
1315        // Should never happen in practice — every filter already restricts
1316        // the search to DOCUMENT — but this is the defense-in-depth layer,
1317        // so it must fire loudly if it ever does (issue #1795, ADR-0081
1318        // §4).
1319        let entries = vec![document_entry(1, "PROJECT")];
1320        let err = assert_document_visibility(&entries).unwrap_err();
1321        assert!(err.contains("PROJECT"), "{err}");
1322        assert!(err.contains('1'), "{err}");
1323    }
1324
1325    fn test_credentials() -> DriveCredentials {
1326        DriveCredentials {
1327            client_id: "client-1".to_string(),
1328            client_secret: Secret::new("secret-1"),
1329            refresh_token: Secret::new("refresh-1"),
1330            scope: DriveGrantedScopes::READONLY,
1331        }
1332    }
1333
1334    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1335        wiremock::Mock::given(wiremock::matchers::method("POST"))
1336            .and(wiremock::matchers::path("/token"))
1337            .respond_with(
1338                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1339                    "access_token": "test-token", "expires_in": 3600,
1340                })),
1341            )
1342            .mount(server)
1343            .await;
1344        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1345        crate::drive::client::test_support::replace_session(
1346            &mut drive,
1347            &test_credentials(),
1348            &format!("{}/token", server.uri()),
1349        );
1350        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1351        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1352        (drive, sheets)
1353    }
1354
1355    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1356        let parents: Vec<&str> = parents.to_vec();
1357        wiremock::Mock::given(wiremock::matchers::method("GET"))
1358            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1359            .respond_with(
1360                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1361                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1362                    "version": "1",
1363                })),
1364            )
1365    }
1366
1367    fn mount_folder(id: &str) -> wiremock::Mock {
1368        mount_file(id, "application/vnd.google-apps.folder", &[])
1369    }
1370
1371    fn allow_rule(folder: &str) -> FolderPermissionRule {
1372        FolderPermissionRule {
1373            folder_id: Some(folder.to_string()),
1374            file_id: None,
1375            recursive: true,
1376            allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
1377            deny: HashSet::default(),
1378            require_lease: true,
1379        }
1380    }
1381
1382    fn deny_rule() -> Vec<FolderPermissionRule> {
1383        Vec::new()
1384    }
1385
1386    fn mount_workbook() -> wiremock::Mock {
1387        wiremock::Mock::given(wiremock::matchers::method("GET"))
1388            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1389            .respond_with(
1390                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1391                    "spreadsheetId": "sheet-1",
1392                    "properties": {"title": "Budget"},
1393                    "sheets": [
1394                        {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1395                    ],
1396                })),
1397            )
1398    }
1399
1400    fn mount_search(body: serde_json::Value) -> wiremock::Mock {
1401        wiremock::Mock::given(wiremock::matchers::method("POST"))
1402            .and(wiremock::matchers::path(
1403                "/v4/spreadsheets/sheet-1/developerMetadata:search",
1404            ))
1405            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
1406    }
1407
1408    fn mount_batch_update(body: serde_json::Value) -> wiremock::Mock {
1409        wiremock::Mock::given(wiremock::matchers::method("POST"))
1410            .and(wiremock::matchers::path(
1411                "/v4/spreadsheets/sheet-1:batchUpdate",
1412            ))
1413            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
1414    }
1415
1416    /// Finds the one recorded request whose path ends in `suffix` and parses
1417    /// its body as JSON — the `format.rs::find_batch_update` pattern,
1418    /// generalised to also match `developerMetadata:search` requests.
1419    fn find_request(requests: &[wiremock::Request], suffix: &str) -> serde_json::Value {
1420        let req = requests
1421            .iter()
1422            .find(|r| r.url.path().ends_with(suffix))
1423            .unwrap_or_else(|| panic!("no request ending in {suffix:?} was sent"));
1424        serde_json::from_slice(&req.body).unwrap()
1425    }
1426
1427    fn set_opts(dry_run: bool) -> DeveloperMetadataOptions {
1428        let ledger_path = tempfile::tempdir()
1429            .unwrap()
1430            .keep()
1431            .join("lease-ledger.jsonl");
1432        let token = seed_lease(&ledger_path, "sheet-1", "1");
1433        DeveloperMetadataOptions {
1434            spreadsheet_id: "sheet-1".to_string(),
1435            verb: DeveloperMetadataVerb::Set {
1436                key: "owner".to_string(),
1437                value: "team-a".to_string(),
1438                sheet: None,
1439                dimension: None,
1440                start: None,
1441                end: None,
1442            },
1443            dry_run,
1444            lease_token: Some(token),
1445            ledger_path,
1446        }
1447    }
1448
1449    fn delete_opts(dry_run: bool) -> DeveloperMetadataOptions {
1450        let mut opts = set_opts(dry_run);
1451        opts.verb = DeveloperMetadataVerb::Delete {
1452            key: "owner".to_string(),
1453            sheet: None,
1454            dimension: None,
1455            start: None,
1456            end: None,
1457        };
1458        opts
1459    }
1460
1461    async fn setup(
1462        server: &wiremock::MockServer,
1463    ) -> (DriveClient, SheetsClient, Vec<FolderPermissionRule>) {
1464        let (drive, sheets) = clients(server).await;
1465        mount_file(
1466            "sheet-1",
1467            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1468            &["folder-1"],
1469        )
1470        .mount(server)
1471        .await;
1472        mount_folder("folder-1").mount(server).await;
1473        mount_workbook().mount(server).await;
1474        (drive, sheets, vec![allow_rule("folder-1")])
1475    }
1476
1477    #[tokio::test]
1478    async fn dry_run_set_with_no_existing_entry_reports_would_create() {
1479        let server = wiremock::MockServer::start().await;
1480        let (drive, sheets, rules) = setup(&server).await;
1481        mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1482            .mount(&server)
1483            .await;
1484
1485        let outcome = developer_metadata(&drive, &sheets, &set_opts(true), &rules).await;
1486        match &outcome.result {
1487            DeveloperMetadataResult::WouldCreate { key, value, .. } => {
1488                assert_eq!(key, "owner");
1489                assert_eq!(value, "team-a");
1490            }
1491            other => panic!("expected WouldCreate, got {other:?}"),
1492        }
1493        // No batchUpdate mock is mounted, so a stray call would 404 and the
1494        // outcome would be `Failed` instead.
1495        let text = describe(&outcome);
1496        assert!(text.contains("Would create developer metadata"), "{text}");
1497    }
1498
1499    #[tokio::test]
1500    async fn dry_run_set_with_an_existing_entry_reports_would_update_with_previous() {
1501        let server = wiremock::MockServer::start().await;
1502        let (drive, sheets, rules) = setup(&server).await;
1503        mount_search(serde_json::json!({"matchedDeveloperMetadata": [
1504            {"developerMetadata": {
1505                "metadataId": 42, "metadataKey": "owner", "metadataValue": "team-b",
1506                "location": {"spreadsheet": true}, "visibility": "DOCUMENT",
1507            }},
1508        ]}))
1509        .mount(&server)
1510        .await;
1511
1512        let outcome = developer_metadata(&drive, &sheets, &set_opts(true), &rules).await;
1513        match &outcome.result {
1514            DeveloperMetadataResult::WouldUpdate {
1515                new_value,
1516                previous,
1517            } => {
1518                assert_eq!(new_value, "team-a");
1519                assert_eq!(previous.len(), 1);
1520                assert_eq!(previous[0].value, "team-b");
1521                assert_eq!(previous[0].metadata_id, 42);
1522            }
1523            other => panic!("expected WouldUpdate, got {other:?}"),
1524        }
1525        let text = describe(&outcome);
1526        assert!(
1527            text.contains("Would update 1 existing entry to \"team-a\""),
1528            "{text}"
1529        );
1530        assert!(text.contains("id 42"), "{text}");
1531    }
1532
1533    #[tokio::test]
1534    async fn set_creates_when_no_existing_entry_matches() {
1535        let server = wiremock::MockServer::start().await;
1536        let (drive, sheets, rules) = setup(&server).await;
1537        mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1538            .mount(&server)
1539            .await;
1540        mount_batch_update(serde_json::json!({"replies": [{}]}))
1541            .mount(&server)
1542            .await;
1543
1544        let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
1545        match &outcome.result {
1546            DeveloperMetadataResult::Created { key, value, .. } => {
1547                assert_eq!(key, "owner");
1548                assert_eq!(value, "team-a");
1549            }
1550            other => panic!("expected Created, got {other:?}"),
1551        }
1552        let text = describe(&outcome);
1553        assert!(text.contains("Created developer metadata"), "{text}");
1554    }
1555
1556    #[tokio::test]
1557    async fn set_with_an_unreadable_reply_reports_applied_reply_unreadable() {
1558        let server = wiremock::MockServer::start().await;
1559        let (drive, sheets, rules) = setup(&server).await;
1560        mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1561            .mount(&server)
1562            .await;
1563        // 2xx, so the entry WAS created — but the body is not the reply shape.
1564        wiremock::Mock::given(wiremock::matchers::method("POST"))
1565            .and(wiremock::matchers::path(
1566                "/v4/spreadsheets/sheet-1:batchUpdate",
1567            ))
1568            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
1569            .expect(1)
1570            .mount(&server)
1571            .await;
1572
1573        let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
1574        assert_eq!(
1575            outcome.result.log_status(),
1576            "applied-reply-unreadable",
1577            "{:?}",
1578            outcome.result
1579        );
1580        assert!(
1581            describe(&outcome).starts_with("Applied, but the reply could not be read"),
1582            "{}",
1583            describe(&outcome)
1584        );
1585    }
1586
1587    #[tokio::test]
1588    async fn set_create_hardcodes_document_visibility_on_every_outgoing_request() {
1589        // Pins the module's single highest-stakes property at the wire
1590        // level, not just by code inspection: both the search that decides
1591        // create-vs-update and the create request it issues must carry
1592        // `visibility: "DOCUMENT"` literally, never a caller-supplied value
1593        // (there is no `--visibility` flag to supply one) (issue #1795,
1594        // ADR-0081 §4).
1595        let server = wiremock::MockServer::start().await;
1596        let (drive, sheets, rules) = setup(&server).await;
1597        mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1598            .mount(&server)
1599            .await;
1600        mount_batch_update(serde_json::json!({"replies": [{}]}))
1601            .mount(&server)
1602            .await;
1603
1604        let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
1605        assert!(
1606            matches!(outcome.result, DeveloperMetadataResult::Created { .. }),
1607            "{:?}",
1608            outcome.result
1609        );
1610
1611        let requests = server.received_requests().await.unwrap();
1612        let search_body = find_request(&requests, "developerMetadata:search");
1613        assert_eq!(
1614            search_body["dataFilters"][0]["developerMetadataLookup"]["visibility"],
1615            "DOCUMENT"
1616        );
1617        let batch_body = find_request(&requests, ":batchUpdate");
1618        assert_eq!(
1619            batch_body["requests"][0]["createDeveloperMetadata"]["developerMetadata"]["visibility"],
1620            "DOCUMENT"
1621        );
1622    }
1623
1624    #[tokio::test]
1625    async fn delete_reports_not_found_when_nothing_matches() {
1626        let server = wiremock::MockServer::start().await;
1627        let (drive, sheets, rules) = setup(&server).await;
1628        mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
1629            .mount(&server)
1630            .await;
1631
1632        let outcome = developer_metadata(&drive, &sheets, &delete_opts(false), &rules).await;
1633        match &outcome.result {
1634            DeveloperMetadataResult::RefusedNotFound { key } => assert_eq!(key, "owner"),
1635            other => panic!("expected RefusedNotFound, got {other:?}"),
1636        }
1637        // No batchUpdate mock is mounted — the absence of a resulting
1638        // `Failed` outcome is itself the assertion that no delete was
1639        // attempted.
1640        let text = describe(&outcome);
1641        assert!(
1642            text.contains("no developer metadata matching key \"owner\""),
1643            "{text}"
1644        );
1645    }
1646
1647    #[tokio::test]
1648    async fn delete_previews_every_matched_entry_before_deleting() {
1649        let server = wiremock::MockServer::start().await;
1650        let (drive, sheets, rules) = setup(&server).await;
1651        mount_search(serde_json::json!({"matchedDeveloperMetadata": [
1652            {"developerMetadata": {
1653                "metadataId": 1, "metadataKey": "owner", "metadataValue": "team-a",
1654                "location": {"spreadsheet": true}, "visibility": "DOCUMENT",
1655            }},
1656            {"developerMetadata": {
1657                "metadataId": 2, "metadataKey": "owner", "metadataValue": "team-b",
1658                "location": {"spreadsheet": true}, "visibility": "DOCUMENT",
1659            }},
1660        ]}))
1661        .mount(&server)
1662        .await;
1663
1664        let outcome = developer_metadata(&drive, &sheets, &delete_opts(true), &rules).await;
1665        match &outcome.result {
1666            DeveloperMetadataResult::WouldDelete { entries } => {
1667                assert_eq!(entries.len(), 2);
1668                assert_eq!(entries[0].metadata_id, 1);
1669                assert_eq!(entries[1].metadata_id, 2);
1670            }
1671            other => panic!("expected WouldDelete, got {other:?}"),
1672        }
1673        // No batchUpdate mock is mounted, so a stray delete call would 404.
1674        let text = describe(&outcome);
1675        assert!(text.contains("Would delete 2 entries"), "{text}");
1676    }
1677
1678    #[tokio::test]
1679    async fn delete_hardcodes_document_visibility_on_every_outgoing_request() {
1680        // Same wire-level pin as `set_create_hardcodes_document_visibility_on_every_outgoing_request`,
1681        // for the delete path's search-then-`deleteDeveloperMetadata` pair.
1682        let server = wiremock::MockServer::start().await;
1683        let (drive, sheets, rules) = setup(&server).await;
1684        mount_search(serde_json::json!({"matchedDeveloperMetadata": [
1685            {"developerMetadata": {
1686                "metadataId": 1, "metadataKey": "owner", "metadataValue": "team-a",
1687                "location": {"spreadsheet": true}, "visibility": "DOCUMENT",
1688            }},
1689        ]}))
1690        .mount(&server)
1691        .await;
1692        mount_batch_update(serde_json::json!({"replies": [{}]}))
1693            .mount(&server)
1694            .await;
1695
1696        let outcome = developer_metadata(&drive, &sheets, &delete_opts(false), &rules).await;
1697        assert!(
1698            matches!(outcome.result, DeveloperMetadataResult::Deleted { .. }),
1699            "{:?}",
1700            outcome.result
1701        );
1702
1703        let requests = server.received_requests().await.unwrap();
1704        let search_body = find_request(&requests, "developerMetadata:search");
1705        assert_eq!(
1706            search_body["dataFilters"][0]["developerMetadataLookup"]["visibility"],
1707            "DOCUMENT"
1708        );
1709        let batch_body = find_request(&requests, ":batchUpdate");
1710        assert_eq!(
1711            batch_body["requests"][0]["deleteDeveloperMetadata"]["dataFilter"]
1712                ["developerMetadataLookup"]["visibility"],
1713            "DOCUMENT"
1714        );
1715        let text = describe(&outcome);
1716        assert!(text.contains("Deleted 1 entry"), "{text}");
1717    }
1718
1719    #[tokio::test]
1720    async fn a_denied_gate_blocks_before_any_search_or_batch_update_call() {
1721        let server = wiremock::MockServer::start().await;
1722        let (drive, sheets, _rules) = setup(&server).await;
1723
1724        let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &deny_rule()).await;
1725        assert!(
1726            matches!(
1727                outcome.result,
1728                DeveloperMetadataResult::Blocked { decided_by: None }
1729            ),
1730            "{:?}",
1731            outcome.result
1732        );
1733        // No developerMetadata:search or batchUpdate mock is mounted — the
1734        // gate must short-circuit before either is ever reached.
1735        let text = describe(&outcome);
1736        assert!(
1737            text.contains("refused by default policy (no matching rule for sheets-structure)"),
1738            "{text}"
1739        );
1740    }
1741
1742    #[tokio::test]
1743    async fn search_returns_every_document_visibility_match() {
1744        let server = wiremock::MockServer::start().await;
1745        let (drive, sheets) = clients(&server).await;
1746        mount_workbook().mount(&server).await;
1747        mount_search(serde_json::json!({"matchedDeveloperMetadata": [
1748            {"developerMetadata": {
1749                "metadataId": 7, "metadataKey": "owner", "metadataValue": "team-a",
1750                "location": {"sheetId": 0}, "visibility": "DOCUMENT",
1751            }},
1752        ]}))
1753        .mount(&server)
1754        .await;
1755
1756        let api = SheetsApi::new(&sheets);
1757        let workbook = api.get_spreadsheet("sheet-1").await.unwrap();
1758        let entries = search(
1759            &api,
1760            "sheet-1",
1761            &workbook,
1762            None,
1763            SearchLocationFilter::default(),
1764        )
1765        .await
1766        .unwrap();
1767        assert_eq!(entries.len(), 1);
1768        assert_eq!(entries[0].location, "sheet 'Q1'");
1769        // No drive/v3/files, folder, or batchUpdate mock is mounted — a
1770        // search needs none of the write-gate machinery.
1771        let _ = drive;
1772    }
1773
1774    // ── refusals that must precede the gate and the network ─────────────
1775
1776    #[tokio::test]
1777    async fn non_spreadsheet_is_refused_before_any_gate_or_search_call() {
1778        let server = wiremock::MockServer::start().await;
1779        let (drive, sheets) = clients(&server).await;
1780        mount_file("sheet-1", "application/pdf", &["folder-1"])
1781            .mount(&server)
1782            .await;
1783        // Deliberately no folder or developerMetadata:search mock — the
1784        // refusal must short-circuit both, even though the rule below
1785        // would otherwise permit the write.
1786        let outcome =
1787            developer_metadata(&drive, &sheets, &set_opts(false), &[allow_rule("folder-1")]).await;
1788        assert!(
1789            matches!(
1790                outcome.result,
1791                DeveloperMetadataResult::RefusedNotASpreadsheet { .. }
1792            ),
1793            "{:?}",
1794            outcome.result
1795        );
1796        let text = describe(&outcome);
1797        assert!(text.contains("is not a Google Sheet"), "{text}");
1798        assert!(
1799            text.contains("drive sheets set-developer-metadata"),
1800            "{text}"
1801        );
1802    }
1803
1804    #[tokio::test]
1805    async fn shortcut_is_refused_with_its_own_message_not_the_generic_one() {
1806        let server = wiremock::MockServer::start().await;
1807        let (drive, sheets) = clients(&server).await;
1808        mount_file(
1809            "sheet-1",
1810            "application/vnd.google-apps.shortcut",
1811            &["folder-1"],
1812        )
1813        .mount(&server)
1814        .await;
1815        let outcome = developer_metadata(
1816            &drive,
1817            &sheets,
1818            &delete_opts(false),
1819            &[allow_rule("folder-1")],
1820        )
1821        .await;
1822        assert!(
1823            matches!(outcome.result, DeveloperMetadataResult::RefusedShortcut),
1824            "{:?}",
1825            outcome.result
1826        );
1827        let text = describe(&outcome);
1828        assert!(text.contains("is a shortcut"), "{text}");
1829        assert!(
1830            text.contains("drive sheets delete-developer-metadata"),
1831            "{text}"
1832        );
1833        assert!(!text.contains("is not a Google Sheet"), "{text}");
1834    }
1835
1836    #[tokio::test]
1837    async fn a_sheet_with_no_visible_parents_is_refused_distinctly_from_a_blocked_one() {
1838        let server = wiremock::MockServer::start().await;
1839        let (drive, sheets) = clients(&server).await;
1840        // The shape a Sheet shared by link comes back as.
1841        mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
1842            .mount(&server)
1843            .await;
1844        let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &[]).await;
1845        assert!(
1846            matches!(
1847                outcome.result,
1848                DeveloperMetadataResult::RefusedNoVisibleParents
1849            ),
1850            "{:?}",
1851            outcome.result
1852        );
1853        let text = describe(&outcome);
1854        assert!(text.contains("no parent folder visible"), "{text}");
1855    }
1856
1857    #[tokio::test]
1858    async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
1859        let server = wiremock::MockServer::start().await;
1860        let (drive, sheets) = clients(&server).await;
1861        mount_file(
1862            "sheet-1",
1863            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1864            &["folder-1"],
1865        )
1866        .mount(&server)
1867        .await;
1868        wiremock::Mock::given(wiremock::matchers::method("GET"))
1869            .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
1870            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1871            .mount(&server)
1872            .await;
1873        let outcome =
1874            developer_metadata(&drive, &sheets, &set_opts(false), &[allow_rule("folder-1")]).await;
1875        assert!(
1876            matches!(outcome.result, DeveloperMetadataResult::Failed { .. }),
1877            "{:?}",
1878            outcome.result
1879        );
1880    }
1881
1882    // ── the gate ──────────────────────────────────────────────────────
1883
1884    #[tokio::test]
1885    async fn a_blocked_by_rule_names_the_deciding_folder_in_the_message() {
1886        let server = wiremock::MockServer::start().await;
1887        let (drive, sheets) = clients(&server).await;
1888        mount_file(
1889            "sheet-1",
1890            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1891            &["folder-1"],
1892        )
1893        .mount(&server)
1894        .await;
1895        mount_folder("folder-1").mount(&server).await;
1896        let deny_by_rule = FolderPermissionRule {
1897            folder_id: Some("folder-1".to_string()),
1898            file_id: None,
1899            recursive: true,
1900            allow: HashSet::default(),
1901            deny: std::iter::once(DriveOperation::SheetsStructure).collect(),
1902            require_lease: true,
1903        };
1904        let outcome =
1905            developer_metadata(&drive, &sheets, &delete_opts(false), &[deny_by_rule]).await;
1906        assert!(
1907            matches!(
1908                outcome.result,
1909                DeveloperMetadataResult::Blocked {
1910                    decided_by: Some(_)
1911                }
1912            ),
1913            "{:?}",
1914            outcome.result
1915        );
1916        let text = describe(&outcome);
1917        assert!(
1918            text.contains("refused by rule on folder folder-1"),
1919            "{text}"
1920        );
1921    }
1922
1923    // ── location resolution, via the full flow ───────────────────────────
1924
1925    #[tokio::test]
1926    async fn set_with_an_unknown_sheet_is_refused_via_the_full_flow() {
1927        let server = wiremock::MockServer::start().await;
1928        let (drive, sheets, rules) = setup(&server).await;
1929        // No developerMetadata:search mock — resolve_location must refuse
1930        // before any search call.
1931        let mut o = set_opts(false);
1932        o.verb = DeveloperMetadataVerb::Set {
1933            key: "owner".to_string(),
1934            value: "team-a".to_string(),
1935            sheet: Some("Nope".to_string()),
1936            dimension: None,
1937            start: None,
1938            end: None,
1939        };
1940        let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
1941        assert!(
1942            matches!(
1943                outcome.result,
1944                DeveloperMetadataResult::RefusedSheetNotFound { .. }
1945            ),
1946            "{:?}",
1947            outcome.result
1948        );
1949        let text = describe(&outcome);
1950        assert!(text.contains("no sheet titled 'Nope'"), "{text}");
1951    }
1952
1953    #[tokio::test]
1954    async fn set_with_a_dimension_but_no_span_is_refused_via_the_full_flow() {
1955        let server = wiremock::MockServer::start().await;
1956        let (drive, sheets, rules) = setup(&server).await;
1957        let mut o = set_opts(false);
1958        o.verb = DeveloperMetadataVerb::Set {
1959            key: "owner".to_string(),
1960            value: "team-a".to_string(),
1961            sheet: Some("Q1".to_string()),
1962            dimension: Some(Dimension::Rows),
1963            start: None,
1964            end: None,
1965        };
1966        let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
1967        assert!(
1968            matches!(
1969                outcome.result,
1970                DeveloperMetadataResult::RefusedInvalidLocation { .. }
1971            ),
1972            "{:?}",
1973            outcome.result
1974        );
1975        let text = describe(&outcome);
1976        assert!(text.starts_with("Refused:"), "{text}");
1977    }
1978
1979    // ── a real search or batchUpdate failure ─────────────────────────────
1980
1981    #[tokio::test]
1982    async fn a_failed_search_is_reported_as_failed() {
1983        let server = wiremock::MockServer::start().await;
1984        let (drive, sheets, rules) = setup(&server).await;
1985        wiremock::Mock::given(wiremock::matchers::method("POST"))
1986            .and(wiremock::matchers::path(
1987                "/v4/spreadsheets/sheet-1/developerMetadata:search",
1988            ))
1989            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1990            .mount(&server)
1991            .await;
1992        let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
1993        assert!(
1994            matches!(outcome.result, DeveloperMetadataResult::Failed { .. }),
1995            "{:?}",
1996            outcome.result
1997        );
1998        let text = describe(&outcome);
1999        assert!(text.starts_with("Failed:"), "{text}");
2000    }
2001
2002    #[tokio::test]
2003    async fn a_failed_batch_update_is_reported_as_failed() {
2004        let server = wiremock::MockServer::start().await;
2005        let (drive, sheets, rules) = setup(&server).await;
2006        mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2007            .mount(&server)
2008            .await;
2009        wiremock::Mock::given(wiremock::matchers::method("POST"))
2010            .and(wiremock::matchers::path(
2011                "/v4/spreadsheets/sheet-1:batchUpdate",
2012            ))
2013            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2014            .mount(&server)
2015            .await;
2016        let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
2017        assert!(
2018            matches!(outcome.result, DeveloperMetadataResult::Failed { .. }),
2019            "{:?}",
2020            outcome.result
2021        );
2022    }
2023
2024    #[tokio::test]
2025    async fn set_updates_every_matching_entry_when_one_exists() {
2026        let server = wiremock::MockServer::start().await;
2027        let (drive, sheets, rules) = setup(&server).await;
2028        mount_search(serde_json::json!({"matchedDeveloperMetadata": [
2029            {"developerMetadata": {
2030                "metadataId": 42, "metadataKey": "owner", "metadataValue": "team-b",
2031                "location": {"spreadsheet": true}, "visibility": "DOCUMENT",
2032            }},
2033        ]}))
2034        .mount(&server)
2035        .await;
2036        mount_batch_update(serde_json::json!({"replies": [{}]}))
2037            .mount(&server)
2038            .await;
2039
2040        let outcome = developer_metadata(&drive, &sheets, &set_opts(false), &rules).await;
2041        match &outcome.result {
2042            DeveloperMetadataResult::Updated {
2043                new_value,
2044                previous,
2045            } => {
2046                assert_eq!(new_value, "team-a");
2047                assert_eq!(previous.len(), 1);
2048            }
2049            other => panic!("expected Updated, got {other:?}"),
2050        }
2051        let text = describe(&outcome);
2052        assert!(text.contains("Updated 1 entry to \"team-a\""), "{text}");
2053        // The listed entry must show the *new* value it was updated to, not
2054        // the pre-update value the search matched on (#1941).
2055        assert!(text.contains(r#"id 42: "owner"="team-a""#), "{text}");
2056        assert!(!text.contains(r#"id 42: "owner"="team-b""#), "{text}");
2057    }
2058
2059    // ── the Drive write lease (ADR-0080 §9) ──────────────────────────────
2060
2061    #[tokio::test]
2062    async fn refuses_without_a_lease_when_the_rule_requires_one() {
2063        let server = wiremock::MockServer::start().await;
2064        let (drive, sheets, rules) = setup(&server).await;
2065        mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2066            .mount(&server)
2067            .await;
2068        // No batchUpdate mock is mounted — a refusal must make zero
2069        // mutating calls.
2070        let mut o = set_opts(false);
2071        o.lease_token = None;
2072        let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2073        assert_eq!(outcome.result, DeveloperMetadataResult::RefusedNoLease);
2074        let text = describe(&outcome);
2075        assert!(text.contains("lease"), "{text}");
2076    }
2077
2078    #[tokio::test]
2079    async fn refuses_an_unknown_lease_token() {
2080        let server = wiremock::MockServer::start().await;
2081        let (drive, sheets, rules) = setup(&server).await;
2082        mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2083            .mount(&server)
2084            .await;
2085        let mut o = set_opts(false);
2086        o.lease_token = Some("bogus-token".to_string());
2087        // Never seeded — no ledger exists at this fresh path.
2088        o.ledger_path = tempfile::tempdir()
2089            .unwrap()
2090            .keep()
2091            .join("lease-ledger.jsonl");
2092        let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2093        assert_eq!(outcome.result, DeveloperMetadataResult::RefusedLeaseExpired);
2094    }
2095
2096    #[tokio::test]
2097    async fn refuses_a_lease_bound_to_a_different_file() {
2098        let server = wiremock::MockServer::start().await;
2099        let (drive, sheets, rules) = setup(&server).await;
2100        mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2101            .mount(&server)
2102            .await;
2103        let ledger_path = tempfile::tempdir()
2104            .unwrap()
2105            .keep()
2106            .join("lease-ledger.jsonl");
2107        let token = seed_lease(&ledger_path, "other-sheet", "1");
2108        let mut o = set_opts(false);
2109        o.lease_token = Some(token);
2110        o.ledger_path = ledger_path;
2111        let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2112        assert_eq!(
2113            outcome.result,
2114            DeveloperMetadataResult::RefusedLeaseWrongFile
2115        );
2116    }
2117
2118    #[tokio::test]
2119    async fn refuses_a_stale_lease() {
2120        let server = wiremock::MockServer::start().await;
2121        let (drive, sheets, rules) = setup(&server).await;
2122        mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2123            .mount(&server)
2124            .await;
2125        // Live version "1" (`mount_file`'s default, via `setup`) but the
2126        // lease was acquired at "0" — the file has moved since.
2127        let ledger_path = tempfile::tempdir()
2128            .unwrap()
2129            .keep()
2130            .join("lease-ledger.jsonl");
2131        let token = seed_lease(&ledger_path, "sheet-1", "0");
2132        let mut o = set_opts(false);
2133        o.lease_token = Some(token);
2134        o.ledger_path = ledger_path;
2135        let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2136        assert_eq!(outcome.result, DeveloperMetadataResult::RefusedLeaseStale);
2137    }
2138
2139    // ── JSONL output ──────────────────────────────────────────────────
2140
2141    #[test]
2142    fn write_jsonl_serializes_the_outcome_as_one_json_line() {
2143        let outcome = DeveloperMetadataOutcome {
2144            spreadsheet_id: "sheet-1".to_string(),
2145            file_name: Some("Budget".to_string()),
2146            resolved_folder_id: None,
2147            verb: DeveloperMetadataVerb::Set {
2148                key: "owner".to_string(),
2149                value: "team-a".to_string(),
2150                sheet: None,
2151                dimension: None,
2152                start: None,
2153                end: None,
2154            },
2155            result: DeveloperMetadataResult::Created {
2156                key: "owner".to_string(),
2157                value: "team-a".to_string(),
2158                location: "the whole spreadsheet".to_string(),
2159            },
2160        };
2161        let mut buf = Vec::new();
2162        outcome.write_jsonl(&mut buf).unwrap();
2163        let text = String::from_utf8(buf).unwrap();
2164        assert!(text.contains("\"status\":\"created\""), "{text}");
2165        assert!(text.contains("\"spreadsheet_id\":\"sheet-1\""), "{text}");
2166    }
2167
2168    // ── the remaining lease-refusal describe_lines arms ──────────────────
2169
2170    #[tokio::test]
2171    async fn describe_of_an_unknown_lease_token_names_it_a_lease_refusal() {
2172        let server = wiremock::MockServer::start().await;
2173        let (drive, sheets, rules) = setup(&server).await;
2174        mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2175            .mount(&server)
2176            .await;
2177        let mut o = set_opts(false);
2178        o.lease_token = Some("bogus-token".to_string());
2179        o.ledger_path = tempfile::tempdir()
2180            .unwrap()
2181            .keep()
2182            .join("lease-ledger.jsonl");
2183        let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2184        let text = describe(&outcome);
2185        assert!(text.contains("lease"), "{text}");
2186    }
2187
2188    #[tokio::test]
2189    async fn describe_of_a_lease_bound_to_a_different_file_names_it_a_lease_refusal() {
2190        let server = wiremock::MockServer::start().await;
2191        let (drive, sheets, rules) = setup(&server).await;
2192        mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2193            .mount(&server)
2194            .await;
2195        let ledger_path = tempfile::tempdir()
2196            .unwrap()
2197            .keep()
2198            .join("lease-ledger.jsonl");
2199        let token = seed_lease(&ledger_path, "other-sheet", "1");
2200        let mut o = set_opts(false);
2201        o.lease_token = Some(token);
2202        o.ledger_path = ledger_path;
2203        let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2204        let text = describe(&outcome);
2205        assert!(text.contains("lease"), "{text}");
2206    }
2207
2208    #[tokio::test]
2209    async fn describe_of_a_stale_lease_names_it_a_lease_refusal() {
2210        let server = wiremock::MockServer::start().await;
2211        let (drive, sheets, rules) = setup(&server).await;
2212        mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2213            .mount(&server)
2214            .await;
2215        let ledger_path = tempfile::tempdir()
2216            .unwrap()
2217            .keep()
2218            .join("lease-ledger.jsonl");
2219        let token = seed_lease(&ledger_path, "sheet-1", "0");
2220        let mut o = set_opts(false);
2221        o.lease_token = Some(token);
2222        o.ledger_path = ledger_path;
2223        let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2224        let text = describe(&outcome);
2225        assert!(text.contains("lease"), "{text}");
2226    }
2227
2228    // ── from_lease_failed (a lock-acquisition failure) ───────────────────
2229
2230    #[tokio::test]
2231    async fn a_lock_acquisition_failure_is_reported_as_failed() {
2232        // A directory at the lock path simulates another `drive lease`
2233        // operation genuinely in progress — opening it for write fails
2234        // outright with an I/O error, which `FromLeaseRefusal::from_lease_failed`
2235        // folds into `Failed` rather than any of the lease-expiry variants
2236        // (mirrors `write.rs::reports_a_lock_acquisition_failure_as_failed`).
2237        let server = wiremock::MockServer::start().await;
2238        let (drive, sheets, rules) = setup(&server).await;
2239        mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2240            .mount(&server)
2241            .await;
2242        let o = set_opts(false);
2243        let mut lock_path = o.ledger_path.clone().into_os_string();
2244        lock_path.push(".lock");
2245        std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
2246
2247        let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2248        assert!(
2249            matches!(outcome.result, DeveloperMetadataResult::Failed { .. }),
2250            "{:?}",
2251            outcome.result
2252        );
2253    }
2254
2255    // ── a real spreadsheets.get failure after the gate allows ────────────
2256
2257    #[tokio::test]
2258    async fn a_failed_spreadsheet_refetch_after_the_gate_is_reported_as_failed() {
2259        let server = wiremock::MockServer::start().await;
2260        let (drive, sheets) = clients(&server).await;
2261        mount_file(
2262            "sheet-1",
2263            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2264            &["folder-1"],
2265        )
2266        .mount(&server)
2267        .await;
2268        mount_folder("folder-1").mount(&server).await;
2269        wiremock::Mock::given(wiremock::matchers::method("GET"))
2270            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2271            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2272            .mount(&server)
2273            .await;
2274        let outcome =
2275            developer_metadata(&drive, &sheets, &set_opts(false), &[allow_rule("folder-1")]).await;
2276        assert!(
2277            matches!(outcome.result, DeveloperMetadataResult::Failed { .. }),
2278            "{:?}",
2279            outcome.result
2280        );
2281    }
2282
2283    // ── location rendering ────────────────────────────────────────────
2284
2285    #[tokio::test]
2286    async fn set_at_a_dimension_location_describes_the_row_span() {
2287        let server = wiremock::MockServer::start().await;
2288        let (drive, sheets, rules) = setup(&server).await;
2289        mount_search(serde_json::json!({"matchedDeveloperMetadata": []}))
2290            .mount(&server)
2291            .await;
2292        let mut o = set_opts(true);
2293        o.verb = DeveloperMetadataVerb::Set {
2294            key: "owner".to_string(),
2295            value: "team-a".to_string(),
2296            sheet: Some("Q1".to_string()),
2297            dimension: Some(Dimension::Rows),
2298            start: Some(2),
2299            end: Some(2),
2300        };
2301        let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2302        let text = describe(&outcome);
2303        assert!(text.contains("row 2 of sheet 'Q1'"), "{text}");
2304    }
2305
2306    #[tokio::test]
2307    async fn set_at_a_multi_row_dimension_location_is_refused() {
2308        // issue #1933: Sheets rejects a DimensionRange spanning more than
2309        // one row/column, so this must be caught before the batchUpdate
2310        // call, not just at the resolve_location unit-test level.
2311        let server = wiremock::MockServer::start().await;
2312        let (drive, sheets, rules) = setup(&server).await;
2313        let mut o = set_opts(true);
2314        o.verb = DeveloperMetadataVerb::Set {
2315            key: "source".to_string(),
2316            value: "import".to_string(),
2317            sheet: Some("Q1".to_string()),
2318            dimension: Some(Dimension::Rows),
2319            start: Some(2),
2320            end: Some(7),
2321        };
2322        let outcome = developer_metadata(&drive, &sheets, &o, &rules).await;
2323        match outcome.result {
2324            DeveloperMetadataResult::RefusedInvalidLocation { detail } => {
2325                assert!(detail.contains("single row or column"), "{detail}");
2326            }
2327            other => panic!("expected RefusedInvalidLocation, got {other:?}"),
2328        }
2329    }
2330
2331    #[tokio::test]
2332    async fn set_with_an_unknown_sheet_in_an_empty_workbook_lists_no_available_sheets() {
2333        let server = wiremock::MockServer::start().await;
2334        let (drive, sheets) = clients(&server).await;
2335        mount_file(
2336            "sheet-1",
2337            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2338            &["folder-1"],
2339        )
2340        .mount(&server)
2341        .await;
2342        mount_folder("folder-1").mount(&server).await;
2343        wiremock::Mock::given(wiremock::matchers::method("GET"))
2344            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2345            .respond_with(
2346                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2347                    "spreadsheetId": "sheet-1",
2348                    "properties": {"title": "Budget"},
2349                    "sheets": [],
2350                })),
2351            )
2352            .mount(&server)
2353            .await;
2354
2355        let mut o = set_opts(false);
2356        o.verb = DeveloperMetadataVerb::Set {
2357            key: "owner".to_string(),
2358            value: "team-a".to_string(),
2359            sheet: Some("Nope".to_string()),
2360            dimension: None,
2361            start: None,
2362            end: None,
2363        };
2364        let outcome = developer_metadata(&drive, &sheets, &o, &[allow_rule("folder-1")]).await;
2365        let text = describe(&outcome);
2366        assert!(text.contains("Available: none"), "{text}");
2367    }
2368
2369    #[test]
2370    fn sheet_title_or_id_falls_back_to_the_raw_id_when_unknown() {
2371        let workbook = test_workbook();
2372        assert_eq!(sheet_title_or_id(&workbook, 999), "id 999");
2373    }
2374
2375    #[test]
2376    fn describe_location_falls_back_for_an_unrecognized_shape() {
2377        let workbook = test_workbook();
2378        let empty_location = DeveloperMetadataLocation::default();
2379        assert_eq!(
2380            describe_location(&workbook, &empty_location),
2381            "an unknown location"
2382        );
2383    }
2384
2385    #[test]
2386    fn log_status_maps_a_would_change_variant_to_would_change() {
2387        // Never actually reached through `record_attempt` (only called when
2388        // `!opts.dry_run`, and a `Would*` result only ever comes back under
2389        // `--dry-run`) — exercised directly since the match must still
2390        // handle it exhaustively.
2391        let would_create = DeveloperMetadataResult::WouldCreate {
2392            key: "owner".to_string(),
2393            value: "team-a".to_string(),
2394            location: "the whole spreadsheet".to_string(),
2395        };
2396        assert_eq!(would_create.log_status(), "would-change");
2397    }
2398
2399    fn created(location: &str) -> DeveloperMetadataResult {
2400        DeveloperMetadataResult::Created {
2401            key: "owner".to_string(),
2402            value: "team-a".to_string(),
2403            location: location.to_string(),
2404        }
2405    }
2406
2407    fn entry_42() -> DeveloperMetadataEntry {
2408        DeveloperMetadataEntry {
2409            metadata_id: 42,
2410            key: "owner".to_string(),
2411            value: "team-b".to_string(),
2412            location: "the whole spreadsheet".to_string(),
2413        }
2414    }
2415
2416    fn updated() -> DeveloperMetadataResult {
2417        DeveloperMetadataResult::Updated {
2418            new_value: "team-a".to_string(),
2419            previous: vec![entry_42()],
2420        }
2421    }
2422
2423    fn deleted() -> DeveloperMetadataResult {
2424        DeveloperMetadataResult::Deleted {
2425            entries: vec![entry_42()],
2426        }
2427    }
2428
2429    fn unreadable(applied: DeveloperMetadataResult) -> DeveloperMetadataResult {
2430        DeveloperMetadataResult::AppliedReplyUnreadable {
2431            summary: changed_summary(&applied).unwrap(),
2432            applied: Box::new(applied),
2433            detail: "bad reply".to_string(),
2434        }
2435    }
2436
2437    #[test]
2438    fn log_status_names_every_result_variant_distinctly() {
2439        let statuses = [
2440            created("").log_status(),
2441            unreadable(created("")).log_status(),
2442            DeveloperMetadataResult::Failed {
2443                detail: String::new(),
2444            }
2445            .log_status(),
2446        ];
2447        assert_eq!(statuses, ["changed", "applied-reply-unreadable", "failed"]);
2448    }
2449
2450    fn describe_of(verb: DeveloperMetadataVerb, result: DeveloperMetadataResult) -> Vec<String> {
2451        describe_lines(&DeveloperMetadataOutcome {
2452            spreadsheet_id: "sheet-1".to_string(),
2453            file_name: Some("Budget".to_string()),
2454            resolved_folder_id: None,
2455            verb,
2456            result,
2457        })
2458    }
2459
2460    /// Everything `Created`/`Updated`/`Deleted` print that is not derived
2461    /// from the reply — the location, and the entries with their ids — must
2462    /// survive an unreadable reply.
2463    #[test]
2464    fn describe_lines_keeps_the_plan_time_detail_for_an_unreadable_reply() {
2465        assert_eq!(
2466            describe_of(set_opts(false).verb, unreadable(created("sheet 'Q1'"))),
2467            vec![
2468                "Applied, but the reply could not be read: create key=\"owner\" \
2469                 value=\"team-a\" at sheet 'Q1' in 'Budget' — do not retry; check the \
2470                 spreadsheet first (bad reply)"
2471                    .to_string()
2472            ]
2473        );
2474        assert_eq!(
2475            describe_of(set_opts(false).verb, unreadable(updated())),
2476            vec![
2477                "Applied, but the reply could not be read: update 1 entry to \
2478                 value=\"team-a\" in 'Budget' — do not retry; check the spreadsheet first \
2479                 (bad reply)"
2480                    .to_string(),
2481                "  id 42: \"owner\"=\"team-a\" at the whole spreadsheet".to_string(),
2482            ]
2483        );
2484        assert_eq!(
2485            describe_of(delete_opts(false).verb, unreadable(deleted())),
2486            vec![
2487                "Applied, but the reply could not be read: delete 1 entry in 'Budget' — \
2488                 do not retry; check the spreadsheet first (bad reply)"
2489                    .to_string(),
2490                "  id 42: \"owner\"=\"team-b\" at the whole spreadsheet".to_string(),
2491            ]
2492        );
2493    }
2494
2495    /// The wrapped result is always Created/Updated/Deleted in practice; if it
2496    /// were anything else the line still prints, with no location or entries.
2497    #[test]
2498    fn describe_lines_prints_a_bare_line_when_the_wrapped_result_carries_no_detail() {
2499        let wrapped = DeveloperMetadataResult::AppliedReplyUnreadable {
2500            summary: "create key=\"owner\"".to_string(),
2501            applied: Box::new(DeveloperMetadataResult::Failed {
2502                detail: String::new(),
2503            }),
2504            detail: "bad reply".to_string(),
2505        };
2506        assert_eq!(
2507            describe_of(set_opts(false).verb, wrapped),
2508            vec![
2509                "Applied, but the reply could not be read: create key=\"owner\" in 'Budget' \
2510                 — do not retry; check the spreadsheet first (bad reply)"
2511                    .to_string()
2512            ]
2513        );
2514    }
2515
2516    #[test]
2517    fn mutation_record_logs_the_same_fields_for_an_unreadable_reply_as_for_changed() {
2518        let opts = set_opts(false);
2519        let record = |result| {
2520            let out = DeveloperMetadataOutcome {
2521                spreadsheet_id: "sheet-1".to_string(),
2522                file_name: Some("Budget".to_string()),
2523                resolved_folder_id: None,
2524                verb: opts.verb.clone(),
2525                result,
2526            };
2527            mutation_record(&out, &opts, Duration::ZERO)
2528        };
2529        let changed = record(updated());
2530        let unreadable = record(unreadable(updated()));
2531        assert_eq!(unreadable.fields_changed, changed.fields_changed);
2532        assert_eq!(
2533            unreadable.fields_changed.as_deref(),
2534            Some("update 1 entry to value=\"team-a\"")
2535        );
2536        assert_eq!(unreadable.status, "applied-reply-unreadable");
2537        assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
2538    }
2539
2540    // ── search()'s own location-error rendering ───────────────────────
2541
2542    #[tokio::test]
2543    async fn search_rejects_an_unknown_sheet_when_the_workbook_has_none() {
2544        let server = wiremock::MockServer::start().await;
2545        let (_drive, sheets) = clients(&server).await;
2546        let api = SheetsApi::new(&sheets);
2547        let workbook: Spreadsheet = serde_json::from_value(serde_json::json!({
2548            "spreadsheetId": "sheet-1",
2549            "properties": {"title": "Budget"},
2550            "sheets": [],
2551        }))
2552        .unwrap();
2553        let err = search(
2554            &api,
2555            "sheet-1",
2556            &workbook,
2557            None,
2558            SearchLocationFilter {
2559                sheet: Some("Nope"),
2560                ..Default::default()
2561            },
2562        )
2563        .await
2564        .unwrap_err();
2565        assert!(err.contains("Available: none"), "{err}");
2566    }
2567
2568    #[tokio::test]
2569    async fn search_rejects_an_invalid_location_combination() {
2570        let server = wiremock::MockServer::start().await;
2571        let (_drive, sheets) = clients(&server).await;
2572        let api = SheetsApi::new(&sheets);
2573        let workbook = test_workbook();
2574        let err = search(
2575            &api,
2576            "sheet-1",
2577            &workbook,
2578            None,
2579            SearchLocationFilter {
2580                dimension: Some(Dimension::Rows),
2581                ..Default::default()
2582            },
2583        )
2584        .await
2585        .unwrap_err();
2586        assert!(
2587            err.contains("--dimension/--start/--end need --sheet too"),
2588            "{err}"
2589        );
2590    }
2591
2592    #[test]
2593    fn describe_location_renders_a_single_row_without_a_span() {
2594        let workbook = test_workbook();
2595        let location = DeveloperMetadataLocation::dimension(DimensionRange {
2596            sheet_id: 0,
2597            dimension: Dimension::Columns,
2598            start_index: 2,
2599            end_index: 3,
2600        });
2601        assert_eq!(
2602            describe_location(&workbook, &location),
2603            "column 3 of sheet 'Q1'"
2604        );
2605    }
2606
2607    #[test]
2608    fn describe_location_falls_back_to_a_span_for_a_wider_range() {
2609        let workbook = test_workbook();
2610        let location = DeveloperMetadataLocation::dimension(DimensionRange {
2611            sheet_id: 0,
2612            dimension: Dimension::Rows,
2613            start_index: 1,
2614            end_index: 5,
2615        });
2616        assert_eq!(
2617            describe_location(&workbook, &location),
2618            "row(s) 2-5 of sheet 'Q1'"
2619        );
2620    }
2621
2622    #[test]
2623    fn resolve_location_still_rejects_a_start_below_one() {
2624        let workbook = test_workbook();
2625        let result = resolve_location(
2626            &workbook,
2627            Some("Q1"),
2628            Some(Dimension::Rows),
2629            Some(0),
2630            Some(0),
2631        )
2632        .unwrap_err();
2633        match result {
2634            DeveloperMetadataResult::RefusedInvalidLocation { detail } => {
2635                assert!(detail.contains("--start must be at least 1"), "{detail}");
2636            }
2637            other => panic!("expected RefusedInvalidLocation, got {other:?}"), // omni-dev: coverage ignore-line reason="guards this test's assumption; a start of 0 above always fails resolve_location with RefusedInvalidLocation"
2638        }
2639    }
2640
2641    #[tokio::test]
2642    async fn search_rejects_a_multi_row_span_before_calling_the_api() {
2643        // issue #1933: `search-developer-metadata --start 2 --end 7` hit the
2644        // same HTTP 400; no mock is mounted, so any API call would fail the
2645        // test with a different error.
2646        let server = wiremock::MockServer::start().await;
2647        let (_drive, sheets) = clients(&server).await;
2648        let api = SheetsApi::new(&sheets);
2649        let workbook = test_workbook();
2650        let err = search(
2651            &api,
2652            "sheet-1",
2653            &workbook,
2654            None,
2655            SearchLocationFilter {
2656                sheet: Some("Q1"),
2657                dimension: Some(Dimension::Rows),
2658                start: Some(2),
2659                end: Some(7),
2660            },
2661        )
2662        .await
2663        .unwrap_err();
2664        assert!(err.contains("single row or column"), "{err}");
2665        assert!(server.received_requests().await.unwrap().is_empty());
2666    }
2667}