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