1#![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
56const 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 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 operation: DriveOperation,
106 decided_by: Option<DecidingRule>,
107 },
108 RefusedNoLease,
109 RefusedLeaseExpired,
110 RefusedLeaseWrongFile,
111 RefusedLeaseStale,
112 AppliedReplyUnreadable {
115 summary: String,
117 detail: String,
119 range: String,
121 sort_specs: Vec<SortSpec>,
123 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 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 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
416fn 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
439fn 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 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
581fn 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 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 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 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"); };
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 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 #[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 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); };
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); };
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 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); };
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); };
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); };
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); };
959 assert!(detail.contains("already names a sheet"), "{detail}");
960 assert!(server.received_requests().await.unwrap().is_empty());
961 }
962
963 #[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 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 #[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 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); };
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 #[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()); let outcome = sort_range(&drive, &sheets, &opts, &[rule()]).await;
1120 let SortRangeResult::RefusedInvalidRequest { detail } = &outcome.result else {
1121 panic!("expected RefusedInvalidRequest, got {:?}", outcome.result); };
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); };
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); };
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"); };
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 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 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 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 #[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 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 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 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 #[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 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}