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