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