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