Skip to main content

omni_dev/drive/sheets/
sort_range.rs

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