Skip to main content

omni_dev/drive/sheets/
randomize_range.rs

1//! `randomize-range` — shuffles the row order within a bounded range into
2//! an unpredictable, server-chosen order (issue #1845).
3//!
4//! Split out of #1830, unblocked by #1831's [ADR-0083](../../../docs/adrs/adr-0083.md).
5//! `sort_range.rs`'s near-exact template, minus the sort-key machinery.
6//!
7//! ADR-0083 §3 initially placed `randomizeRange` under `SheetsWrite` alone,
8//! on a values-only reading — a permutation of the range's own cells is a
9//! strict special case of what a `sheets-write` grant already permits via
10//! `clear`+`write`. §5 made that provisional on live verification: whether
11//! the server carries a row's formatting, notes and data-validation rules
12//! along with it when reordering, and what happens to an in-range formula.
13//!
14//! Live-verified 2026-09-22 against a probe sheet in `omni-dev-test`
15//! (issue #1845's plan comments): **formatting, notes and data-validation
16//! rules move with the row**, and **an in-range formula moves with its row
17//! with its relative references rewritten** to keep pointing at its own
18//! row. Formatting is `SheetsStructure`'s own subject matter, so §5's
19//! fixed consequence applies and the gate is the union of `SheetsWrite`
20//! and `SheetsStructure` — [`target_gate::resolve_all`], the same shape
21//! `text_to_columns.rs` took. The same live run also confirmed §3's
22//! record-decoupling caveat: cells in the same rows but outside the
23//! selected columns do not move, so a range narrower than its rows can
24//! silently detach a record's other columns.
25//!
26//! **The resulting order can never be previewed, and this crate never sees
27//! it even after a real run.** `randomizeRange` carries no response
28//! object, and which order the server settles on is entirely its own
29//! choice — `--dry-run` (and the real run) instead report the range being
30//! reordered, leading with the range-width-vs-sheet-width record-integrity
31//! caveat before the smaller "references outside the range may see a
32//! different row's value" caveat, and stating plainly that the resulting
33//! order is not knowable — never a cell's contents (ADR-0083 §6).
34
35#![allow(missing_docs)]
36
37use std::path::PathBuf;
38use std::time::{Duration, Instant};
39
40use serde::Serialize;
41
42use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
43use crate::drive::client::DriveClient;
44use crate::drive::files_api::FilesApi;
45use crate::drive::lease::check::{
46    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
47    LeasedWrite,
48};
49use crate::drive::sheets::api::{
50    applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
51};
52use crate::drive::sheets::client::SheetsClient;
53use crate::drive::sheets::grid_range::width_warning;
54use crate::drive::sheets::types::{BatchUpdateRequestItem, RandomizeRangeRequest};
55use crate::drive::sheets::{a1, grid_range, target_gate};
56use crate::drive::types::SheetTargetRefusal;
57use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
58use crate::request_log::{self, DriveMutationOutcome};
59
60const LOG_OPERATION: &str = "sheets-randomize-range";
61
62/// The operations this verb's gate is the union of, in the order a
63/// refusal reports them. `SheetsWrite` for the cell values a reorder
64/// permutes, `SheetsStructure` for the formatting, notes and
65/// data-validation rules live verification found travelling with each
66/// row — see the module docs.
67const GATE_OPERATIONS: &[DriveOperation] =
68    &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure];
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct RandomizeRangeOptions {
72    pub spreadsheet_id: String,
73    pub sheet: Option<String>,
74    pub range: Option<String>,
75    pub dry_run: bool,
76    pub lease_token: Option<String>,
77    pub ledger_path: PathBuf,
78}
79
80#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
81#[serde(tag = "status", rename_all = "kebab-case")]
82pub enum RandomizeRangeResult {
83    WouldChange {
84        range: String,
85        width_warning: Option<String>,
86    },
87    Changed {
88        range: String,
89        width_warning: Option<String>,
90    },
91    RefusedInvalidRequest {
92        detail: String,
93    },
94    RefusedNotASpreadsheet {
95        mime_type: String,
96    },
97    RefusedShortcut,
98    RefusedNoVisibleParents,
99    RefusedSheetNotFound {
100        title: String,
101        available: Vec<String>,
102    },
103    Blocked {
104        /// Which of [`GATE_OPERATIONS`] denied first — the union gate
105        /// refuses as soon as one of the two does, and which one it was
106        /// is the only actionable part of the message.
107        operation: DriveOperation,
108        decided_by: Option<DecidingRule>,
109    },
110    RefusedNoLease,
111    RefusedLeaseExpired,
112    RefusedLeaseWrongFile,
113    RefusedLeaseStale,
114    /// The API answered 2xx — the shuffle **was applied** — but its reply
115    /// could not be read (issue #2021). Distinct from [`Self::Failed`].
116    AppliedReplyUnreadable {
117        /// What was randomized, as recorded for `Changed`.
118        summary: String,
119        /// Why the reply could not be read.
120        detail: String,
121        /// Same as [`Self::Changed`].
122        range: String,
123        /// Same as [`Self::Changed`].
124        width_warning: Option<String>,
125    },
126    Failed {
127        detail: String,
128    },
129}
130
131impl FromLeaseRefusal for RandomizeRangeResult {
132    fn from_no_lease() -> Self {
133        Self::RefusedNoLease
134    }
135    fn from_lease_expired() -> Self {
136        Self::RefusedLeaseExpired
137    }
138    fn from_lease_wrong_file() -> Self {
139        Self::RefusedLeaseWrongFile
140    }
141    fn from_lease_stale() -> Self {
142        Self::RefusedLeaseStale
143    }
144    fn from_lease_failed(detail: String) -> Self {
145        Self::Failed { detail }
146    }
147}
148
149impl RandomizeRangeResult {
150    fn log_status(&self) -> &'static str {
151        match self {
152            Self::WouldChange { .. } => "would-change",
153            Self::Changed { .. } => "changed",
154            Self::RefusedInvalidRequest { .. } => "refused-invalid-request",
155            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
156            Self::RefusedShortcut => "refused-shortcut",
157            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
158            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
159            Self::Blocked { .. } => "blocked",
160            Self::RefusedNoLease => "refused-no-lease",
161            Self::RefusedLeaseExpired => "refused-lease-expired",
162            Self::RefusedLeaseWrongFile => "refused-lease-wrong-file",
163            Self::RefusedLeaseStale => "refused-lease-stale",
164            Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
165            Self::Failed { .. } => "failed",
166        }
167    }
168}
169
170#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
171pub struct RandomizeRangeOutcome {
172    pub spreadsheet_id: String,
173    pub file_name: Option<String>,
174    pub resolved_folder_id: Option<String>,
175    pub result: RandomizeRangeResult,
176}
177
178impl JsonlSerialize for RandomizeRangeOutcome {
179    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
180        write_scalar_jsonl(self, out)
181    }
182}
183
184pub async fn randomize_range(
185    drive: &DriveClient,
186    sheets: &SheetsClient,
187    opts: &RandomizeRangeOptions,
188    rules: &[FolderPermissionRule],
189) -> RandomizeRangeOutcome {
190    let started = Instant::now();
191    let outcome = randomize_range_inner(drive, sheets, opts, rules).await;
192    if !opts.dry_run {
193        record_attempt(&outcome, started.elapsed());
194    }
195    outcome
196}
197
198async fn randomize_range_inner(
199    drive: &DriveClient,
200    sheets: &SheetsClient,
201    opts: &RandomizeRangeOptions,
202    rules: &[FolderPermissionRule],
203) -> RandomizeRangeOutcome {
204    let bare = |result| RandomizeRangeOutcome {
205        spreadsheet_id: opts.spreadsheet_id.clone(),
206        file_name: None,
207        resolved_folder_id: None,
208        result,
209    };
210    let composed = match a1::compose(opts.sheet.as_deref(), opts.range.as_deref()) {
211        Ok(range) => range,
212        Err(err) => {
213            return bare(RandomizeRangeResult::RefusedInvalidRequest {
214                detail: err.to_string(),
215            })
216        }
217    };
218
219    let (target, verdict, denied, resolved_folder_id, requires_lease) =
220        match target_gate::resolve_all(drive, &opts.spreadsheet_id, GATE_OPERATIONS, rules).await {
221            target_gate::TargetGateUnionOutcome::MetadataFetchFailed { detail } => {
222                return bare(RandomizeRangeResult::Failed { detail })
223            }
224            target_gate::TargetGateUnionOutcome::Refused { target, refusal } => {
225                let result = match refusal {
226                    SheetTargetRefusal::Shortcut => RandomizeRangeResult::RefusedShortcut,
227                    SheetTargetRefusal::NotASpreadsheet { mime_type } => {
228                        RandomizeRangeResult::RefusedNotASpreadsheet { mime_type }
229                    }
230                    SheetTargetRefusal::NoVisibleParents => {
231                        RandomizeRangeResult::RefusedNoVisibleParents
232                    }
233                };
234                return RandomizeRangeOutcome {
235                    spreadsheet_id: opts.spreadsheet_id.clone(),
236                    file_name: Some(target.name),
237                    resolved_folder_id: None,
238                    result,
239                };
240            }
241            target_gate::TargetGateUnionOutcome::GateFetchFailed { target, detail } => {
242                return RandomizeRangeOutcome {
243                    spreadsheet_id: opts.spreadsheet_id.clone(),
244                    file_name: Some(target.name),
245                    resolved_folder_id: None,
246                    result: RandomizeRangeResult::Failed { detail },
247                }
248            }
249            target_gate::TargetGateUnionOutcome::Gated {
250                target,
251                verdict,
252                denied,
253                resolved_folder_id,
254                requires_lease,
255            } => (target, verdict, denied, resolved_folder_id, requires_lease),
256        };
257    let gated = |result| RandomizeRangeOutcome {
258        spreadsheet_id: opts.spreadsheet_id.clone(),
259        file_name: Some(target.name.clone()),
260        resolved_folder_id: resolved_folder_id.clone(),
261        result,
262    };
263    if verdict == write_gate::Verdict::Deny {
264        // `denied` is `Some` whenever the verdict is `Deny`; the fallback
265        // names the first operation rather than inventing one, matching
266        // `text_to_columns.rs`'s own arm.
267        let (operation, decided_by) = denied.unwrap_or((GATE_OPERATIONS[0], None));
268        return gated(RandomizeRangeResult::Blocked {
269            operation,
270            decided_by,
271        });
272    }
273    let api = SheetsApi::new(sheets);
274    let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
275        Ok(book) => book,
276        Err(err) => {
277            return gated(RandomizeRangeResult::Failed {
278                detail: format!("{err:#}"),
279            })
280        }
281    };
282    let (_, grid) = match grid_range::resolve_grid_range(
283        &workbook,
284        &composed,
285        |detail| RandomizeRangeResult::RefusedInvalidRequest { detail },
286        |title, available| RandomizeRangeResult::RefusedSheetNotFound { title, available },
287    ) {
288        Ok(value) => value,
289        Err(result) => return gated(result),
290    };
291    if !grid_range::is_bounded(&grid) {
292        return gated(RandomizeRangeResult::RefusedInvalidRequest {
293            detail: format!(
294                "{composed} is open-ended; randomize-range needs a fully bounded range (e.g. A1:D10)"
295            ),
296        });
297    }
298    let start_column = grid.start_column_index.unwrap_or(0);
299    let end_column = grid.end_column_index.unwrap_or(0);
300    let allocated_columns = grid_range::find_sheet_by_id(&workbook, grid.sheet_id)
301        .and_then(|sheet| sheet.properties.as_ref())
302        .and_then(|props| props.grid_properties.as_ref())
303        .and_then(|props| props.column_count);
304    let width_warning = width_warning("randomizing", start_column, end_column, allocated_columns);
305    let request = BatchUpdateRequestItem::RandomizeRange(RandomizeRangeRequest { range: grid });
306    if opts.dry_run {
307        return gated(RandomizeRangeResult::WouldChange {
308            range: composed,
309            width_warning,
310        });
311    }
312    let leased = LeasedWrite {
313        log_prefix: "drive sheets randomize-range",
314        operation: LOG_OPERATION,
315        ledger_path: &opts.ledger_path,
316        file_id: &opts.spreadsheet_id,
317    };
318    let files_api = FilesApi::new(drive);
319    let lease_grant = match gate_optional_leased_write(
320        leased,
321        &files_api,
322        requires_lease,
323        opts.lease_token.as_deref(),
324    )
325    .await
326    {
327        Ok(grant) => grant,
328        Err(err) => return gated(err.into_result()),
329    };
330    let result = match conclude_native_leased_write(
331        leased,
332        &lease_grant,
333        &files_api,
334        api.batch_update(&opts.spreadsheet_id, vec![request]).await,
335        |err| format!("{err:#}"),
336    )
337    .await
338    {
339        Ok(BatchUpdateOutcome::Applied(_)) => RandomizeRangeResult::Changed {
340            range: composed,
341            width_warning,
342        },
343        // Not idempotent: every run yields a different order.
344        Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
345            RandomizeRangeResult::AppliedReplyUnreadable {
346                summary: format!("randomized {composed}"),
347                detail,
348                range: composed,
349                width_warning,
350            }
351        }
352        Err(err) => RandomizeRangeResult::Failed {
353            detail: format!("{err:#}"),
354        },
355    };
356    gated(result)
357}
358
359fn record_attempt(outcome: &RandomizeRangeOutcome, duration: Duration) {
360    request_log::record_drive_mutation(mutation_outcome(outcome, duration));
361}
362
363/// The `drivemutation` record for one attempt. Split out from
364/// [`record_attempt`] so the logged fields are unit-testable.
365fn mutation_outcome(outcome: &RandomizeRangeOutcome, duration: Duration) -> DriveMutationOutcome {
366    let decided_by = match &outcome.result {
367        RandomizeRangeResult::Blocked { decided_by, .. } => decided_by.as_ref(),
368        _ => None,
369    };
370    let decided_by = write_gate::decided_by_log_fields(decided_by);
371    let fields_changed = match &outcome.result {
372        RandomizeRangeResult::Changed { range, .. } => Some(format!("randomized {range}")),
373        RandomizeRangeResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
374        _ => None,
375    };
376    let error = match &outcome.result {
377        RandomizeRangeResult::RefusedInvalidRequest { detail }
378        | RandomizeRangeResult::Failed { detail }
379        | RandomizeRangeResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
380        _ => None,
381    };
382    DriveMutationOutcome {
383        operation: LOG_OPERATION,
384        file_id: outcome.spreadsheet_id.clone(),
385        file_name: outcome.file_name.clone().unwrap_or_default(),
386        status: outcome.result.log_status().to_string(),
387        resolved_folder_id: outcome.resolved_folder_id.clone(),
388        decided_by_folder_id: decided_by.folder_id,
389        decided_by_depth: decided_by.depth,
390        decided_by_file_id: decided_by.file_id,
391        fields_changed,
392        error,
393        duration,
394        ..Default::default()
395    }
396}
397
398/// The line stating that the resulting row order can never be known ahead
399/// of, or reported after, the request — server-side randomness, the same
400/// class of limitation `auto-fill` has for a different reason.
401const ORDER_CAVEAT: &str =
402    "the resulting order is chosen by the server and cannot be previewed or reported; the previous row order is not preserved";
403
404pub fn describe_lines(outcome: &RandomizeRangeOutcome) -> Vec<String> {
405    let book = outcome.file_name.as_deref().map_or_else(
406        || format!("'{}'", outcome.spreadsheet_id),
407        |name| format!("'{name}'"),
408    );
409    match &outcome.result {
410        RandomizeRangeResult::WouldChange {
411            range,
412            width_warning,
413        } => grid_range::reorder_caveat_lines(
414            "randomizing",
415            format!("Would randomize the row order of {range} in {book}"),
416            width_warning.as_deref(),
417            false,
418            Some(ORDER_CAVEAT),
419        ),
420        RandomizeRangeResult::Changed {
421            range,
422            width_warning,
423        } => grid_range::reorder_caveat_lines(
424            "randomizing",
425            format!("Randomized the row order of {range} in {book}"),
426            width_warning.as_deref(),
427            true,
428            Some(ORDER_CAVEAT),
429        ),
430        RandomizeRangeResult::RefusedInvalidRequest { detail } => {
431            vec![format!("Refused: {detail}")]
432        }
433        RandomizeRangeResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
434            "Refused: {book} is not a Google Sheet (mimeType: {mime_type})"
435        )],
436        RandomizeRangeResult::RefusedShortcut => vec![format!(
437            "Refused: {book} is a shortcut; randomize-range doesn't follow shortcuts"
438        )],
439        RandomizeRangeResult::RefusedNoVisibleParents => {
440            vec![format!("Refused: {book} has no visible parent folder")]
441        }
442        RandomizeRangeResult::RefusedSheetNotFound { title, available } => {
443            vec![grid_range::no_sheet_refusal(&book, title, available)]
444        }
445        RandomizeRangeResult::Blocked {
446            operation,
447            decided_by,
448        } => vec![match decided_by {
449            Some(rule) => format!(
450                "Blocked: randomize-range on {book} refused for {operation} by rule on {} {}{}",
451                rule.kind_label(),
452                rule.id(),
453                rule.depth_suffix()
454            ),
455            None => format!(
456                "Blocked: randomize-range on {book} refused by default policy (no matching rule \
457                 for {operation})"
458            ),
459        }],
460        RandomizeRangeResult::RefusedNoLease => LeaseGateRefusal::NoLease
461            .describe_line(&outcome.spreadsheet_id, &book)
462            .into_iter()
463            .collect(),
464        RandomizeRangeResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
465            .describe_line(&outcome.spreadsheet_id, &book)
466            .into_iter()
467            .collect(),
468        RandomizeRangeResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
469            .describe_line(&outcome.spreadsheet_id, &book)
470            .into_iter()
471            .collect(),
472        RandomizeRangeResult::RefusedLeaseStale => LeaseGateRefusal::Stale
473            .describe_line(&outcome.spreadsheet_id, &book)
474            .into_iter()
475            .collect(),
476        RandomizeRangeResult::AppliedReplyUnreadable {
477            summary,
478            detail,
479            width_warning,
480            ..
481        } => {
482            let mut lines = grid_range::reorder_caveat_lines(
483                "randomizing",
484                applied_reply_unreadable_line(summary, &book, RetryHint::NotIdempotent, detail),
485                None,
486                true,
487                Some(ORDER_CAVEAT),
488            );
489            // The width warning is plan-time data, so it survives the
490            // unreadable reply; it follows the shared line here.
491            lines.splice(
492                1..1,
493                width_warning
494                    .iter()
495                    .map(|warning| format!("Warning: {warning}")),
496            );
497            lines
498        }
499        RandomizeRangeResult::Failed { detail } => vec![format!("Failed: {detail}")],
500    }
501}
502
503#[cfg(test)]
504#[allow(clippy::unwrap_used)]
505mod tests {
506    use super::*;
507    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
508    use crate::drive::sheets::client::SHEETS_API_URL;
509    use crate::drive::test_support::seed_lease;
510    use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
511    use crate::test_support::env::MapEnv;
512    use crate::utils::secret::Secret;
513    use std::collections::HashSet;
514
515    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
516        let credentials = DriveCredentials {
517            client_id: "client-1".into(),
518            client_secret: Secret::new("secret-1"),
519            refresh_token: Secret::new("refresh-1"),
520            scope: DriveGrantedScopes::READONLY,
521        };
522        wiremock::Mock::given(wiremock::matchers::method("POST"))
523            .and(wiremock::matchers::path("/token"))
524            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
525                serde_json::json!({"access_token": "test-token", "expires_in": 3600}),
526            ))
527            .mount(server)
528            .await;
529        let mut drive = DriveClient::new(&server.uri(), &credentials).unwrap();
530        crate::drive::client::test_support::replace_session(
531            &mut drive,
532            &credentials,
533            &format!("{}/token", server.uri()),
534        );
535        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
536        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
537        (drive, sheets)
538    }
539
540    async fn mount_metadata(server: &wiremock::MockServer) {
541        wiremock::Mock::given(wiremock::matchers::method("GET"))
542            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
543            .respond_with(
544                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
545                    "id": "sheet-1", "name": "Budget", "mimeType": GOOGLE_SHEET_MIME_TYPE,
546                    "parents": ["parent-1"], "version": "1"
547                })),
548            )
549            .mount(server)
550            .await;
551        wiremock::Mock::given(wiremock::matchers::method("GET"))
552            .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
553            .respond_with(
554                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
555                    "id": "parent-1", "name": "parent-1",
556                    "mimeType": "application/vnd.google-apps.folder", "parents": []
557                })),
558            )
559            .mount(server)
560            .await;
561        wiremock::Mock::given(wiremock::matchers::method("GET"))
562            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
563            .respond_with(
564                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
565                    "spreadsheetId": "sheet-1",
566                    "sheets": [{"properties": {
567                        "sheetId": 0, "title": "Q1",
568                        "gridProperties": {"rowCount": 100, "columnCount": 6}
569                    }}]
570                })),
571            )
572            .mount(server)
573            .await;
574    }
575
576    /// `version: "1"` throughout — matches [`options`]'s default (no seeded
577    /// lease).
578    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
579        let parents: Vec<&str> = parents.to_vec();
580        wiremock::Mock::given(wiremock::matchers::method("GET"))
581            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
582            .respond_with(
583                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
584                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
585                    "version": "1",
586                })),
587            )
588    }
589
590    fn mount_folder(id: &str) -> wiremock::Mock {
591        mount_file(id, "application/vnd.google-apps.folder", &[])
592    }
593
594    /// A `spreadsheets.get` reply for `sheet-1` with a single `Q1` sheet
595    /// (sheetId 0) whose `gridProperties` omits `columnCount` — so the
596    /// shared `width_warning` helper can never learn the sheet's
597    /// allocated width.
598    fn mount_workbook_no_column_count() -> wiremock::Mock {
599        wiremock::Mock::given(wiremock::matchers::method("GET"))
600            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
601            .respond_with(
602                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
603                    "spreadsheetId": "sheet-1",
604                    "sheets": [{"properties": {
605                        "sheetId": 0, "title": "Q1", "gridProperties": {"rowCount": 100}
606                    }}]
607                })),
608            )
609    }
610
611    fn options(dry_run: bool) -> RandomizeRangeOptions {
612        RandomizeRangeOptions {
613            spreadsheet_id: "sheet-1".into(),
614            sheet: Some("Q1".into()),
615            range: Some("A2:C10".into()),
616            dry_run,
617            lease_token: None,
618            ledger_path: PathBuf::from("/tmp/unused-randomize-range-ledger"),
619        }
620    }
621
622    fn rule() -> FolderPermissionRule {
623        FolderPermissionRule {
624            folder_id: Some("parent-1".into()),
625            file_id: None,
626            recursive: true,
627            allow: GATE_OPERATIONS.iter().copied().collect(),
628            deny: HashSet::default(),
629            require_lease: false,
630        }
631    }
632
633    fn rule_requiring_lease() -> FolderPermissionRule {
634        FolderPermissionRule {
635            require_lease: true,
636            ..rule()
637        }
638    }
639
640    /// Allows `sheets-write` but explicitly denies `sheets-structure` — the
641    /// union gate must refuse even though one of its two operations would
642    /// have allowed it.
643    fn rule_missing_sheets_structure() -> FolderPermissionRule {
644        FolderPermissionRule {
645            folder_id: Some("parent-1".into()),
646            file_id: None,
647            recursive: true,
648            allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
649            deny: std::iter::once(DriveOperation::SheetsStructure).collect(),
650            require_lease: false,
651        }
652    }
653
654    #[test]
655    fn preview_never_claims_to_predict_the_resulting_order() {
656        let outcome = RandomizeRangeOutcome {
657            spreadsheet_id: "id".into(),
658            file_name: Some("Book".into()),
659            resolved_folder_id: None,
660            result: RandomizeRangeResult::WouldChange {
661                range: "Q1!A1:B3".into(),
662                width_warning: width_warning("randomizing", 0, 2, Some(5)),
663            },
664        };
665        let lines = describe_lines(&outcome).join("\n");
666        assert!(lines.contains("separate records"));
667        assert!(lines.contains("cannot be previewed"));
668        assert!(lines.contains("references outside"));
669    }
670
671    #[tokio::test]
672    async fn dry_run_uses_metadata_without_reading_values_or_mutating() {
673        let server = wiremock::MockServer::start().await;
674        let (drive, sheets) = clients(&server).await;
675        mount_metadata(&server).await;
676        let outcome = randomize_range(&drive, &sheets, &options(true), &[rule()]).await;
677        let RandomizeRangeResult::WouldChange { width_warning, .. } = outcome.result else {
678            panic!("expected would-change"); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
679        };
680        assert!(width_warning
681            .unwrap()
682            .contains("3 of the sheet's 6 allocated columns"));
683        let requests = server.received_requests().await.unwrap();
684        // OAuth refresh, the target and workbook reads, plus the parent
685        // folder read once per gated operation: `resolve_all` walks the
686        // ancestor chain independently for each of `GATE_OPERATIONS`'s two
687        // operations, so `parent-1` is fetched twice.
688        assert_eq!(requests.len(), 5);
689        assert!(requests
690            .iter()
691            .all(|request| { request.method.as_str() == "GET" || request.url.path() == "/token" }));
692    }
693
694    #[tokio::test]
695    async fn real_run_sends_one_randomize_range_request() {
696        let server = wiremock::MockServer::start().await;
697        let (drive, sheets) = clients(&server).await;
698        mount_metadata(&server).await;
699        wiremock::Mock::given(wiremock::matchers::method("POST"))
700            .and(wiremock::matchers::path(
701                "/v4/spreadsheets/sheet-1:batchUpdate",
702            ))
703            .respond_with(
704                wiremock::ResponseTemplate::new(200)
705                    .set_body_json(serde_json::json!({"replies": [{}]})),
706            )
707            .mount(&server)
708            .await;
709        let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
710        assert!(
711            matches!(outcome.result, RandomizeRangeResult::Changed { .. }),
712            "{outcome:?}"
713        );
714        let requests = server.received_requests().await.unwrap();
715        let batch = requests
716            .iter()
717            .find(|request| request.url.path().ends_with(":batchUpdate"))
718            .unwrap();
719        let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
720        assert_eq!(
721            body["requests"][0]["randomizeRange"]["range"],
722            serde_json::json!({
723                "sheetId": 0, "startRowIndex": 1, "endRowIndex": 10,
724                "startColumnIndex": 0, "endColumnIndex": 3
725            })
726        );
727    }
728
729    // ── refusals before any network call ────────────────────────────────
730
731    #[tokio::test]
732    async fn rejects_a_range_with_conflicting_sheet_prefix_before_any_network_call() {
733        let server = wiremock::MockServer::start().await;
734        let (drive, sheets) = clients(&server).await;
735        let mut opts = options(false);
736        opts.sheet = Some("Other".into());
737        opts.range = Some("Sheet1!A1:B2".into());
738        let outcome = randomize_range(&drive, &sheets, &opts, &[rule()]).await;
739        let RandomizeRangeResult::RefusedInvalidRequest { detail } = &outcome.result else {
740            panic!("expected RefusedInvalidRequest, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
741        };
742        assert!(detail.contains("already names a sheet"), "{detail}");
743        assert!(server.received_requests().await.unwrap().is_empty());
744    }
745
746    // ── refusals surfaced by the target gate ────────────────────────────
747
748    #[tokio::test]
749    async fn a_metadata_fetch_failure_surfaces_as_failed() {
750        let server = wiremock::MockServer::start().await;
751        let (drive, sheets) = clients(&server).await;
752        wiremock::Mock::given(wiremock::matchers::method("GET"))
753            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
754            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
755            .mount(&server)
756            .await;
757        let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
758        assert!(matches!(
759            outcome.result,
760            RandomizeRangeResult::Failed { .. }
761        ));
762    }
763
764    #[tokio::test]
765    async fn non_spreadsheet_is_refused_before_any_gate_or_sheets_call() {
766        let server = wiremock::MockServer::start().await;
767        let (drive, sheets) = clients(&server).await;
768        mount_file("sheet-1", "application/pdf", &["parent-1"])
769            .mount(&server)
770            .await;
771        // Deliberately no mock for parent-1 (the gate never runs) and none
772        // for any Sheets endpoint.
773        let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
774        assert!(matches!(
775            outcome.result,
776            RandomizeRangeResult::RefusedNotASpreadsheet { .. }
777        ));
778        let text = describe_lines(&outcome).join("\n");
779        assert!(text.contains("is not a Google Sheet"), "{text}");
780    }
781
782    #[tokio::test]
783    async fn shortcut_is_refused_with_its_own_message_not_the_generic_one() {
784        let server = wiremock::MockServer::start().await;
785        let (drive, sheets) = clients(&server).await;
786        mount_file(
787            "sheet-1",
788            "application/vnd.google-apps.shortcut",
789            &["parent-1"],
790        )
791        .mount(&server)
792        .await;
793        let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
794        assert!(matches!(
795            outcome.result,
796            RandomizeRangeResult::RefusedShortcut
797        ));
798        let text = describe_lines(&outcome).join("\n");
799        assert!(text.contains("is a shortcut"), "{text}");
800    }
801
802    #[tokio::test]
803    async fn a_sheet_with_no_visible_parents_is_refused_distinctly_from_a_blocked_one() {
804        let server = wiremock::MockServer::start().await;
805        let (drive, sheets) = clients(&server).await;
806        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
807            .mount(&server)
808            .await;
809        let outcome = randomize_range(&drive, &sheets, &options(false), &[]).await;
810        assert!(matches!(
811            outcome.result,
812            RandomizeRangeResult::RefusedNoVisibleParents
813        ));
814    }
815
816    #[tokio::test]
817    async fn a_gate_fetch_failure_surfaces_as_failed() {
818        let server = wiremock::MockServer::start().await;
819        let (drive, sheets) = clients(&server).await;
820        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
821            .mount(&server)
822            .await;
823        wiremock::Mock::given(wiremock::matchers::method("GET"))
824            .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
825            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
826            .mount(&server)
827            .await;
828        let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
829        assert!(matches!(
830            outcome.result,
831            RandomizeRangeResult::Failed { .. }
832        ));
833    }
834
835    // ── the gate itself ───────────────────────────────────────────────────
836
837    #[tokio::test]
838    async fn denied_target_is_blocked_and_records_the_attempt() {
839        let server = wiremock::MockServer::start().await;
840        let (drive, sheets) = clients(&server).await;
841        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
842            .mount(&server)
843            .await;
844        mount_folder("parent-1").mount(&server).await;
845        // No workbook or batchUpdate mock: either call would 404.
846        let outcome = randomize_range(&drive, &sheets, &options(false), &[]).await;
847        assert!(
848            matches!(
849                outcome.result,
850                RandomizeRangeResult::Blocked {
851                    decided_by: None,
852                    ..
853                }
854            ),
855            "{:?}",
856            outcome.result
857        );
858    }
859
860    #[tokio::test]
861    async fn a_rule_allowing_sheets_write_but_denying_sheets_structure_is_still_blocked() {
862        let server = wiremock::MockServer::start().await;
863        let (drive, sheets) = clients(&server).await;
864        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
865            .mount(&server)
866            .await;
867        mount_folder("parent-1").mount(&server).await;
868        let outcome = randomize_range(
869            &drive,
870            &sheets,
871            &options(false),
872            &[rule_missing_sheets_structure()],
873        )
874        .await;
875        let lines = describe_lines(&outcome).join("\n");
876        assert!(lines.contains("sheets-structure"), "{lines}");
877        assert!(lines.contains("rule on folder parent-1"), "{lines}");
878        let RandomizeRangeResult::Blocked { operation, .. } = outcome.result else {
879            panic!("expected Blocked, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
880        };
881        assert_eq!(operation, DriveOperation::SheetsStructure);
882    }
883
884    #[tokio::test]
885    async fn a_workbook_fetch_failure_surfaces_as_failed() {
886        let server = wiremock::MockServer::start().await;
887        let (drive, sheets) = clients(&server).await;
888        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
889            .mount(&server)
890            .await;
891        mount_folder("parent-1").mount(&server).await;
892        wiremock::Mock::given(wiremock::matchers::method("GET"))
893            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
894            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
895            .mount(&server)
896            .await;
897        let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
898        assert!(matches!(
899            outcome.result,
900            RandomizeRangeResult::Failed { .. }
901        ));
902    }
903
904    // ── the range itself, once the workbook is in hand ───────────────────
905
906    #[tokio::test]
907    async fn a_range_without_a_sheet_prefix_is_refused_as_invalid() {
908        let server = wiremock::MockServer::start().await;
909        let (drive, sheets) = clients(&server).await;
910        mount_metadata(&server).await;
911        let mut opts = options(false);
912        opts.sheet = None;
913        opts.range = Some("A2:C10".into()); // no `Sheet!` prefix, and no `--sheet` either.
914        let outcome = randomize_range(&drive, &sheets, &opts, &[rule()]).await;
915        let RandomizeRangeResult::RefusedInvalidRequest { detail } = &outcome.result else {
916            panic!("expected RefusedInvalidRequest, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
917        };
918        assert!(detail.contains("does not name a sheet"), "{detail}");
919        assert!(server
920            .received_requests()
921            .await
922            .unwrap()
923            .iter()
924            .all(|request| { request.method.as_str() == "GET" || request.url.path() == "/token" }));
925    }
926
927    #[tokio::test]
928    async fn a_range_naming_an_unknown_sheet_is_refused_with_available_titles() {
929        let server = wiremock::MockServer::start().await;
930        let (drive, sheets) = clients(&server).await;
931        mount_metadata(&server).await;
932        let mut opts = options(false);
933        opts.sheet = None;
934        opts.range = Some("Nope!A2:C10".into());
935        let outcome = randomize_range(&drive, &sheets, &opts, &[rule()]).await;
936        let RandomizeRangeResult::RefusedSheetNotFound { title, available } = &outcome.result
937        else {
938            panic!("expected RefusedSheetNotFound, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
939        };
940        assert_eq!(title, "Nope");
941        assert_eq!(available, &["Q1".to_string()]);
942    }
943
944    #[tokio::test]
945    async fn an_open_ended_range_is_refused_as_invalid() {
946        let server = wiremock::MockServer::start().await;
947        let (drive, sheets) = clients(&server).await;
948        mount_metadata(&server).await;
949        let mut opts = options(false);
950        opts.sheet = None;
951        opts.range = Some("Q1!A:A".into());
952        let outcome = randomize_range(&drive, &sheets, &opts, &[rule()]).await;
953        let RandomizeRangeResult::RefusedInvalidRequest { detail } = &outcome.result else {
954            panic!("expected RefusedInvalidRequest, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
955        };
956        assert!(
957            detail.starts_with("Q1!A:A is open-ended"),
958            "the range is echoed as given, with no extra quotes: {detail}"
959        );
960        assert!(server
961            .received_requests()
962            .await
963            .unwrap()
964            .iter()
965            .all(|request| { request.method.as_str() == "GET" || request.url.path() == "/token" }));
966    }
967
968    #[tokio::test]
969    async fn a_sheet_with_no_recorded_column_count_gets_the_generic_width_warning() {
970        let server = wiremock::MockServer::start().await;
971        let (drive, sheets) = clients(&server).await;
972        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
973            .mount(&server)
974            .await;
975        mount_folder("parent-1").mount(&server).await;
976        mount_workbook_no_column_count().mount(&server).await;
977        let outcome = randomize_range(&drive, &sheets, &options(true), &[rule()]).await;
978        let RandomizeRangeResult::WouldChange { width_warning, .. } = outcome.result else {
979            panic!("expected would-change"); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
980        };
981        assert!(width_warning.unwrap().contains("may be narrower"));
982    }
983
984    #[tokio::test]
985    async fn a_batch_update_error_is_reported_as_failed() {
986        let server = wiremock::MockServer::start().await;
987        let (drive, sheets) = clients(&server).await;
988        mount_metadata(&server).await;
989        wiremock::Mock::given(wiremock::matchers::method("POST"))
990            .and(wiremock::matchers::path(
991                "/v4/spreadsheets/sheet-1:batchUpdate",
992            ))
993            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
994            .mount(&server)
995            .await;
996        let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
997        assert!(matches!(
998            outcome.result,
999            RandomizeRangeResult::Failed { .. }
1000        ));
1001    }
1002
1003    #[tokio::test]
1004    async fn randomize_with_an_unreadable_reply_reports_applied_reply_unreadable() {
1005        let server = wiremock::MockServer::start().await;
1006        let (drive, sheets) = clients(&server).await;
1007        mount_metadata(&server).await;
1008        // 2xx, so the shuffle WAS applied — but the body is not the reply shape.
1009        wiremock::Mock::given(wiremock::matchers::method("POST"))
1010            .and(wiremock::matchers::path(
1011                "/v4/spreadsheets/sheet-1:batchUpdate",
1012            ))
1013            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
1014            .expect(1)
1015            .mount(&server)
1016            .await;
1017        let outcome = randomize_range(&drive, &sheets, &options(false), &[rule()]).await;
1018        assert_eq!(
1019            outcome.result.log_status(),
1020            "applied-reply-unreadable",
1021            "{outcome:?}"
1022        );
1023        let lines = describe_lines(&outcome);
1024        assert!(
1025            lines[0].starts_with("Applied, but the reply could not be read"),
1026            "{lines:?}"
1027        );
1028    }
1029
1030    #[test]
1031    fn describe_lines_renders_applied_reply_unreadable() {
1032        let outcome = RandomizeRangeOutcome {
1033            spreadsheet_id: "sheet-1".into(),
1034            file_name: Some("Budget".into()),
1035            resolved_folder_id: None,
1036            result: RandomizeRangeResult::AppliedReplyUnreadable {
1037                summary: "randomized Q1!A2:C10".into(),
1038                detail: "bad reply".into(),
1039                range: "Q1!A2:C10".into(),
1040                width_warning: Some("narrow".into()),
1041            },
1042        };
1043        // The plan-time width warning and the order/reference caveats
1044        // survive, exactly as `Changed` renders them.
1045        assert_eq!(
1046            describe_lines(&outcome),
1047            vec![
1048                "Applied, but the reply could not be read: randomized Q1!A2:C10 in 'Budget' — \
1049                 do not retry; check the spreadsheet first (bad reply)"
1050                    .to_string(),
1051                "Warning: narrow".to_string(),
1052                format!("  {ORDER_CAVEAT}"),
1053                "  references outside the range may now observe values from a different row"
1054                    .to_string(),
1055            ]
1056        );
1057    }
1058
1059    #[test]
1060    fn the_unreadable_reply_is_logged_with_its_summary_and_detail() {
1061        let outcome = RandomizeRangeOutcome {
1062            spreadsheet_id: "sheet-1".into(),
1063            file_name: Some("Budget".into()),
1064            resolved_folder_id: None,
1065            result: RandomizeRangeResult::AppliedReplyUnreadable {
1066                summary: "randomized Q1!A2:C10".into(),
1067                detail: "bad reply".into(),
1068                range: "Q1!A2:C10".into(),
1069                width_warning: None,
1070            },
1071        };
1072        let logged = mutation_outcome(&outcome, Duration::ZERO);
1073        assert_eq!(logged.status, "applied-reply-unreadable");
1074        assert_eq!(
1075            logged.fields_changed.as_deref(),
1076            Some("randomized Q1!A2:C10")
1077        );
1078        assert_eq!(logged.error.as_deref(), Some("bad reply"));
1079    }
1080
1081    #[tokio::test]
1082    async fn a_batch_update_error_under_an_active_lease_records_the_failure() {
1083        // Unlike `a_batch_update_error_is_reported_as_failed` (no lease held
1084        // at all), this holds a real lease grant so the failure path also
1085        // exercises `conclude_native_leased_write`'s own error recording.
1086        let server = wiremock::MockServer::start().await;
1087        let (drive, sheets) = clients(&server).await;
1088        mount_metadata(&server).await;
1089        wiremock::Mock::given(wiremock::matchers::method("POST"))
1090            .and(wiremock::matchers::path(
1091                "/v4/spreadsheets/sheet-1:batchUpdate",
1092            ))
1093            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1094            .mount(&server)
1095            .await;
1096        let ledger_path = tempfile::tempdir().unwrap().keep().join("ledger.jsonl");
1097        let token = seed_lease(&ledger_path, "sheet-1", "1");
1098        let mut opts = options(false);
1099        opts.lease_token = Some(token);
1100        opts.ledger_path = ledger_path;
1101        let outcome = randomize_range(&drive, &sheets, &opts, &[rule_requiring_lease()]).await;
1102        assert!(matches!(
1103            outcome.result,
1104            RandomizeRangeResult::Failed { .. }
1105        ));
1106    }
1107
1108    // ── the Drive write lease (ADR-0080 §9) ─────────────────────────────
1109
1110    #[tokio::test]
1111    async fn refuses_without_a_lease_when_the_rule_requires_one() {
1112        let server = wiremock::MockServer::start().await;
1113        let (drive, sheets) = clients(&server).await;
1114        mount_metadata(&server).await;
1115        // No batchUpdate mock: a refusal must make zero mutating calls.
1116        let outcome =
1117            randomize_range(&drive, &sheets, &options(false), &[rule_requiring_lease()]).await;
1118        assert_eq!(outcome.result, RandomizeRangeResult::RefusedNoLease);
1119    }
1120
1121    #[tokio::test]
1122    async fn refuses_an_unknown_lease_token() {
1123        let server = wiremock::MockServer::start().await;
1124        let (drive, sheets) = clients(&server).await;
1125        mount_metadata(&server).await;
1126        let mut opts = options(false);
1127        opts.lease_token = Some("bogus-token".into());
1128        // Never seeded — no ledger exists at this fresh path.
1129        opts.ledger_path = tempfile::tempdir().unwrap().keep().join("ledger.jsonl");
1130        let outcome = randomize_range(&drive, &sheets, &opts, &[rule_requiring_lease()]).await;
1131        assert_eq!(outcome.result, RandomizeRangeResult::RefusedLeaseExpired);
1132    }
1133
1134    #[tokio::test]
1135    async fn refuses_a_lease_bound_to_a_different_file() {
1136        let server = wiremock::MockServer::start().await;
1137        let (drive, sheets) = clients(&server).await;
1138        mount_metadata(&server).await;
1139        let ledger_path = tempfile::tempdir().unwrap().keep().join("ledger.jsonl");
1140        let token = seed_lease(&ledger_path, "other-sheet", "1");
1141        let mut opts = options(false);
1142        opts.lease_token = Some(token);
1143        opts.ledger_path = ledger_path;
1144        let outcome = randomize_range(&drive, &sheets, &opts, &[rule_requiring_lease()]).await;
1145        assert_eq!(outcome.result, RandomizeRangeResult::RefusedLeaseWrongFile);
1146    }
1147
1148    #[tokio::test]
1149    async fn refuses_a_stale_lease() {
1150        let server = wiremock::MockServer::start().await;
1151        let (drive, sheets) = clients(&server).await;
1152        // Live version "1" (`mount_metadata`'s default) but the lease was
1153        // acquired at "0" — the file has moved since.
1154        mount_metadata(&server).await;
1155        let ledger_path = tempfile::tempdir().unwrap().keep().join("ledger.jsonl");
1156        let token = seed_lease(&ledger_path, "sheet-1", "0");
1157        let mut opts = options(false);
1158        opts.lease_token = Some(token);
1159        opts.ledger_path = ledger_path;
1160        let outcome = randomize_range(&drive, &sheets, &opts, &[rule_requiring_lease()]).await;
1161        assert_eq!(outcome.result, RandomizeRangeResult::RefusedLeaseStale);
1162    }
1163
1164    // ── pure unit coverage: lease-refusal mapping, JSONL, describe/log ──
1165
1166    #[test]
1167    fn lease_refusals_map_to_their_randomize_range_results() {
1168        assert_eq!(
1169            RandomizeRangeResult::from_no_lease(),
1170            RandomizeRangeResult::RefusedNoLease
1171        );
1172        assert_eq!(
1173            RandomizeRangeResult::from_lease_expired(),
1174            RandomizeRangeResult::RefusedLeaseExpired
1175        );
1176        assert_eq!(
1177            RandomizeRangeResult::from_lease_wrong_file(),
1178            RandomizeRangeResult::RefusedLeaseWrongFile
1179        );
1180        assert_eq!(
1181            RandomizeRangeResult::from_lease_stale(),
1182            RandomizeRangeResult::RefusedLeaseStale
1183        );
1184        assert_eq!(
1185            RandomizeRangeResult::from_lease_failed("ledger unavailable".into()),
1186            RandomizeRangeResult::Failed {
1187                detail: "ledger unavailable".into()
1188            }
1189        );
1190    }
1191
1192    #[test]
1193    fn write_jsonl_emits_one_line_of_json() {
1194        let outcome = RandomizeRangeOutcome {
1195            spreadsheet_id: "sheet-1".into(),
1196            file_name: Some("Budget".into()),
1197            resolved_folder_id: Some("parent-1".into()),
1198            result: RandomizeRangeResult::Changed {
1199                range: "Q1!A2:C10".into(),
1200                width_warning: None,
1201            },
1202        };
1203        let mut buf = Vec::new();
1204        outcome.write_jsonl(&mut buf).unwrap();
1205        let text = String::from_utf8(buf).unwrap();
1206        assert_eq!(text.matches('\n').count(), 1);
1207        let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
1208        assert_eq!(parsed["result"]["status"], "changed");
1209    }
1210
1211    /// One of every [`RandomizeRangeResult`] variant.
1212    ///
1213    /// The `match` below is exhaustive and wildcard-free on purpose: adding
1214    /// a variant breaks this build, which is what forces the new arm
1215    /// through the tests below.
1216    fn every_randomize_range_result() -> Vec<RandomizeRangeResult> {
1217        let all = vec![
1218            RandomizeRangeResult::WouldChange {
1219                range: "Q1!A1:B3".into(),
1220                width_warning: None,
1221            },
1222            RandomizeRangeResult::Changed {
1223                range: "Q1!A1:B3".into(),
1224                width_warning: Some("narrow".to_string()),
1225            },
1226            RandomizeRangeResult::RefusedInvalidRequest {
1227                detail: "bad".into(),
1228            },
1229            RandomizeRangeResult::RefusedNotASpreadsheet {
1230                mime_type: "application/pdf".into(),
1231            },
1232            RandomizeRangeResult::RefusedShortcut,
1233            RandomizeRangeResult::RefusedNoVisibleParents,
1234            RandomizeRangeResult::RefusedSheetNotFound {
1235                title: "Nope".into(),
1236                available: vec!["Q1".into()],
1237            },
1238            RandomizeRangeResult::Blocked {
1239                operation: DriveOperation::SheetsWrite,
1240                decided_by: None,
1241            },
1242            RandomizeRangeResult::Blocked {
1243                operation: DriveOperation::SheetsStructure,
1244                decided_by: Some(DecidingRule::Folder {
1245                    folder_id: "folder-1".into(),
1246                    depth: 2,
1247                }),
1248            },
1249            RandomizeRangeResult::Blocked {
1250                operation: DriveOperation::SheetsWrite,
1251                decided_by: Some(DecidingRule::File {
1252                    file_id: "sheet-1".into(),
1253                }),
1254            },
1255            RandomizeRangeResult::RefusedNoLease,
1256            RandomizeRangeResult::RefusedLeaseExpired,
1257            RandomizeRangeResult::RefusedLeaseWrongFile,
1258            RandomizeRangeResult::RefusedLeaseStale,
1259            RandomizeRangeResult::AppliedReplyUnreadable {
1260                summary: "randomized Q1!A1:B3".into(),
1261                detail: "bad reply".into(),
1262                range: "Q1!A1:B3".into(),
1263                width_warning: Some("narrow".into()),
1264            },
1265            RandomizeRangeResult::Failed {
1266                detail: "boom".into(),
1267            },
1268        ];
1269        for result in &all {
1270            match result {
1271                RandomizeRangeResult::WouldChange { .. }
1272                | RandomizeRangeResult::Changed { .. }
1273                | RandomizeRangeResult::RefusedInvalidRequest { .. }
1274                | RandomizeRangeResult::RefusedNotASpreadsheet { .. }
1275                | RandomizeRangeResult::RefusedShortcut
1276                | RandomizeRangeResult::RefusedNoVisibleParents
1277                | RandomizeRangeResult::RefusedSheetNotFound { .. }
1278                | RandomizeRangeResult::Blocked { .. }
1279                | RandomizeRangeResult::RefusedNoLease
1280                | RandomizeRangeResult::RefusedLeaseExpired
1281                | RandomizeRangeResult::RefusedLeaseWrongFile
1282                | RandomizeRangeResult::RefusedLeaseStale
1283                | RandomizeRangeResult::AppliedReplyUnreadable { .. }
1284                | RandomizeRangeResult::Failed { .. } => (),
1285            }
1286        }
1287        all
1288    }
1289
1290    #[test]
1291    fn every_describe_arm_renders_at_least_one_line_with_no_control_characters() {
1292        for result in every_randomize_range_result() {
1293            let outcome = RandomizeRangeOutcome {
1294                spreadsheet_id: "sheet-1".into(),
1295                file_name: Some("Quarterly Plan".into()),
1296                resolved_folder_id: None,
1297                result,
1298            };
1299            let lines = describe_lines(&outcome);
1300            assert!(
1301                !lines.is_empty(),
1302                "describe_lines emitted no lines for {:?}",
1303                outcome.result
1304            );
1305            for line in &lines {
1306                assert!(
1307                    !line.chars().any(char::is_control),
1308                    "describe_lines emitted a control character for {:?}: {lines:?}",
1309                    outcome.result
1310                );
1311            }
1312        }
1313    }
1314
1315    #[test]
1316    fn describe_lines_falls_back_to_the_spreadsheet_id_with_no_file_name() {
1317        let outcome = RandomizeRangeOutcome {
1318            spreadsheet_id: "sheet-1".into(),
1319            file_name: None,
1320            resolved_folder_id: None,
1321            result: RandomizeRangeResult::RefusedShortcut,
1322        };
1323        let lines = describe_lines(&outcome);
1324        assert_eq!(lines.len(), 1);
1325        assert!(lines[0].contains("'sheet-1'"), "{lines:?}");
1326    }
1327
1328    #[test]
1329    fn blocked_with_no_deciding_rule_names_the_denied_operation() {
1330        let outcome = RandomizeRangeOutcome {
1331            spreadsheet_id: "sheet-1".into(),
1332            file_name: Some("Budget".into()),
1333            resolved_folder_id: None,
1334            result: RandomizeRangeResult::Blocked {
1335                operation: DriveOperation::SheetsStructure,
1336                decided_by: None,
1337            },
1338        };
1339        let lines = describe_lines(&outcome).join("\n");
1340        assert!(lines.contains("sheets-structure"), "{lines}");
1341    }
1342
1343    #[test]
1344    fn log_status_covers_every_variant() {
1345        for result in every_randomize_range_result() {
1346            assert!(!result.log_status().is_empty());
1347        }
1348        assert_eq!(
1349            RandomizeRangeResult::Blocked {
1350                operation: DriveOperation::SheetsWrite,
1351                decided_by: None
1352            }
1353            .log_status(),
1354            "blocked"
1355        );
1356        assert_eq!(
1357            RandomizeRangeResult::Changed {
1358                range: "Q1!A1:B2".into(),
1359                width_warning: None
1360            }
1361            .log_status(),
1362            "changed"
1363        );
1364        assert_eq!(
1365            RandomizeRangeResult::RefusedLeaseStale.log_status(),
1366            "refused-lease-stale"
1367        );
1368    }
1369}