1use std::path::PathBuf;
73use std::time::{Duration, Instant};
74
75use serde::Serialize;
76
77use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
78use crate::drive::client::DriveClient;
79use crate::drive::files_api::FilesApi;
80use crate::drive::lease::check::{
81 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
82 LeasedWrite,
83};
84use crate::drive::sheets::a1;
85use crate::drive::sheets::api::{SheetsApi, ValueRenderOption};
86use crate::drive::sheets::client::SheetsClient;
87use crate::drive::sheets::grid_range;
88use crate::drive::sheets::target_gate;
89use crate::drive::sheets::types::{
90 BatchUpdateRequestItem, DelimiterType, GridRange, Spreadsheet, TextToColumnsRequest, ValueRange,
91};
92use crate::drive::types::SheetTargetRefusal;
93use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
94use crate::request_log::{self, DriveMutationOutcome};
95
96const LOG_OPERATION: &str = "sheets-text-to-columns";
101
102const GATE_OPERATIONS: &[DriveOperation] =
108 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure];
109
110#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum Delimiter {
120 Comma,
122 Semicolon,
124 Period,
126 Space,
128 Auto,
130 Custom(String),
132}
133
134impl Delimiter {
135 fn wire_type(&self) -> DelimiterType {
137 match self {
138 Self::Comma => DelimiterType::Comma,
139 Self::Semicolon => DelimiterType::Semicolon,
140 Self::Period => DelimiterType::Period,
141 Self::Space => DelimiterType::Space,
142 Self::Auto => DelimiterType::Autodetect,
143 Self::Custom(_) => DelimiterType::Custom,
144 }
145 }
146
147 fn local_split_candidates(&self) -> Vec<&str> {
158 match self {
159 Self::Comma => vec![","],
160 Self::Semicolon => vec![";"],
161 Self::Period => vec!["."],
162 Self::Space => vec![" "],
163 Self::Custom(text) => vec![text.as_str()],
164 Self::Auto => vec![",", ";", ".", " "],
165 }
166 }
167
168 fn describe(&self) -> String {
170 match self {
171 Self::Comma => "comma".to_string(),
172 Self::Semicolon => "semicolon".to_string(),
173 Self::Period => "period".to_string(),
174 Self::Space => "space".to_string(),
175 Self::Auto => "an auto-detected separator".to_string(),
176 Self::Custom(text) => format!("custom ({text:?})"),
177 }
178 }
179}
180
181#[derive(Debug, Clone)]
183pub struct TextToColumnsOptions {
184 pub spreadsheet_id: String,
186 pub sheet: Option<String>,
188 pub source: Option<String>,
192 pub delimiter: Delimiter,
194 pub dry_run: bool,
196 pub lease_token: Option<String>,
201 pub ledger_path: PathBuf,
205}
206
207#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
209#[serde(tag = "status", rename_all = "kebab-case")]
210pub enum TextToColumnsResult {
211 WouldChange {
213 summary: String,
220 source: String,
222 #[serde(skip_serializing_if = "Option::is_none")]
227 spill: Option<String>,
228 width_upper_bound: usize,
235 #[serde(skip_serializing_if = "Vec::is_empty")]
244 overwritten_cells: Vec<String>,
245 past_grid_extent: bool,
249 },
250 RefusedNotASpreadsheet {
252 mime_type: String,
254 },
255 RefusedShortcut,
257 RefusedNoVisibleParents,
259 RefusedSheetNotFound {
261 title: String,
263 available: Vec<String>,
265 },
266 RefusedInvalidRange {
269 detail: String,
271 },
272 RefusedInvalidDelimiter {
274 detail: String,
276 },
277 Blocked {
279 operation: DriveOperation,
283 decided_by: Option<DecidingRule>,
285 },
286 RefusedNoLease,
289 RefusedLeaseExpired,
292 RefusedLeaseWrongFile,
294 RefusedLeaseStale,
297 Changed {
299 summary: String,
303 source: String,
305 #[serde(skip_serializing_if = "Option::is_none")]
307 spill: Option<String>,
308 width_upper_bound: usize,
310 #[serde(skip_serializing_if = "Vec::is_empty")]
314 overwritten_cells: Vec<String>,
315 past_grid_extent: bool,
317 },
318 Failed {
320 detail: String,
322 },
323}
324
325impl FromLeaseRefusal for TextToColumnsResult {
326 fn from_no_lease() -> Self {
327 Self::RefusedNoLease
328 }
329 fn from_lease_expired() -> Self {
330 Self::RefusedLeaseExpired
331 }
332 fn from_lease_wrong_file() -> Self {
333 Self::RefusedLeaseWrongFile
334 }
335 fn from_lease_stale() -> Self {
336 Self::RefusedLeaseStale
337 }
338 fn from_lease_failed(detail: String) -> Self {
339 Self::Failed { detail }
340 }
341}
342
343impl TextToColumnsResult {
344 fn log_status(&self) -> &'static str {
345 match self {
346 Self::WouldChange { .. } => "would-change",
347 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
348 Self::RefusedShortcut => "refused-shortcut",
349 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
350 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
351 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
352 Self::RefusedInvalidDelimiter { .. } => "refused-invalid-delimiter",
353 Self::Blocked { .. } => "blocked",
354 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
355 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
356 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
357 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
358 Self::Changed { .. } => "changed",
359 Self::Failed { .. } => "failed",
360 }
361 }
362}
363
364#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
366pub struct TextToColumnsOutcome {
367 pub spreadsheet_id: String,
369 #[serde(skip_serializing_if = "Option::is_none")]
371 pub file_name: Option<String>,
372 #[serde(skip_serializing_if = "Option::is_none")]
374 pub resolved_folder_id: Option<String>,
375 #[serde(skip_serializing_if = "Option::is_none")]
377 pub sheet_id: Option<i64>,
378 #[serde(skip)]
380 pub delimiter: Delimiter,
381 pub result: TextToColumnsResult,
383}
384
385impl JsonlSerialize for TextToColumnsOutcome {
386 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
387 write_scalar_jsonl(self, out)
388 }
389}
390
391pub async fn text_to_columns(
394 drive: &DriveClient,
395 sheets: &SheetsClient,
396 opts: &TextToColumnsOptions,
397 rules: &[FolderPermissionRule],
398) -> TextToColumnsOutcome {
399 let started = Instant::now();
400 let outcome = text_to_columns_inner(drive, sheets, opts, rules).await;
401 if !opts.dry_run {
402 record_attempt(&outcome, started.elapsed());
403 }
404 outcome
405}
406
407async fn text_to_columns_inner(
408 drive: &DriveClient,
409 sheets: &SheetsClient,
410 opts: &TextToColumnsOptions,
411 rules: &[FolderPermissionRule],
412) -> TextToColumnsOutcome {
413 let bare = |result| TextToColumnsOutcome {
414 spreadsheet_id: opts.spreadsheet_id.clone(),
415 file_name: None,
416 resolved_folder_id: None,
417 sheet_id: None,
418 delimiter: opts.delimiter.clone(),
419 result,
420 };
421
422 let composed = match a1::compose(opts.sheet.as_deref(), opts.source.as_deref()) {
427 Ok(composed) => composed,
428 Err(err) => {
429 return bare(TextToColumnsResult::RefusedInvalidRange {
430 detail: err.to_string(),
431 })
432 }
433 };
434 if let Delimiter::Custom(text) = &opts.delimiter {
435 if text.is_empty() {
436 return bare(TextToColumnsResult::RefusedInvalidDelimiter {
437 detail: "--custom-delimiter must not be empty".to_string(),
438 });
439 }
440 }
441
442 let (target, verdict, denied, resolved_folder_id, requires_lease) =
444 match target_gate::resolve_all(drive, &opts.spreadsheet_id, GATE_OPERATIONS, rules).await {
445 target_gate::TargetGateUnionOutcome::MetadataFetchFailed { detail } => {
446 return bare(TextToColumnsResult::Failed { detail })
447 }
448 target_gate::TargetGateUnionOutcome::Refused { target, refusal } => {
449 let result = match refusal {
450 SheetTargetRefusal::Shortcut => TextToColumnsResult::RefusedShortcut,
451 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
452 TextToColumnsResult::RefusedNotASpreadsheet { mime_type }
453 }
454 SheetTargetRefusal::NoVisibleParents => {
455 TextToColumnsResult::RefusedNoVisibleParents
456 }
457 };
458 return TextToColumnsOutcome {
459 spreadsheet_id: opts.spreadsheet_id.clone(),
460 file_name: Some(target.name),
461 resolved_folder_id: None,
462 sheet_id: None,
463 delimiter: opts.delimiter.clone(),
464 result,
465 };
466 }
467 target_gate::TargetGateUnionOutcome::GateFetchFailed { target, detail } => {
468 return TextToColumnsOutcome {
469 spreadsheet_id: opts.spreadsheet_id.clone(),
470 file_name: Some(target.name),
471 resolved_folder_id: None,
472 sheet_id: None,
473 delimiter: opts.delimiter.clone(),
474 result: TextToColumnsResult::Failed { detail },
475 };
476 }
477 target_gate::TargetGateUnionOutcome::Gated {
478 target,
479 verdict,
480 denied,
481 resolved_folder_id,
482 requires_lease,
483 } => (target, verdict, denied, resolved_folder_id, requires_lease),
484 };
485
486 let pre_gated = |result| TextToColumnsOutcome {
487 spreadsheet_id: opts.spreadsheet_id.clone(),
488 file_name: Some(target.name.clone()),
489 resolved_folder_id: resolved_folder_id.clone(),
490 sheet_id: None,
491 delimiter: opts.delimiter.clone(),
492 result,
493 };
494
495 if verdict == write_gate::Verdict::Deny {
496 let (operation, decided_by) = denied.unwrap_or((GATE_OPERATIONS[0], None));
500 return pre_gated(TextToColumnsResult::Blocked {
501 operation,
502 decided_by,
503 });
504 }
505
506 let api = SheetsApi::new(sheets);
507 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
508 Ok(workbook) => workbook,
509 Err(err) => {
510 return pre_gated(TextToColumnsResult::Failed {
511 detail: format!("{err:#}"),
512 })
513 }
514 };
515
516 let (sheet_title, source_grid) = match grid_range::resolve_grid_range(
517 &workbook,
518 &composed,
519 |detail| TextToColumnsResult::RefusedInvalidRange { detail },
520 |title, available| TextToColumnsResult::RefusedSheetNotFound { title, available },
521 ) {
522 Ok(resolved) => resolved,
523 Err(result) => return pre_gated(result),
524 };
525
526 let gated = |result| TextToColumnsOutcome {
527 spreadsheet_id: opts.spreadsheet_id.clone(),
528 file_name: Some(target.name.clone()),
529 resolved_folder_id: resolved_folder_id.clone(),
530 sheet_id: Some(source_grid.sheet_id),
531 delimiter: opts.delimiter.clone(),
532 result,
533 };
534
535 if !grid_range::is_bounded(&source_grid) {
536 return gated(TextToColumnsResult::RefusedInvalidRange {
537 detail: format!(
538 "{composed} is open-ended; text-to-columns needs a fully bounded single \
539 column (e.g. A2:A100)"
540 ),
541 });
542 }
543 if !is_single_column(&source_grid) {
544 return gated(TextToColumnsResult::RefusedInvalidRange {
545 detail: format!(
546 "{composed} spans more than one column; text-to-columns' source must span \
547 exactly one column"
548 ),
549 });
550 }
551
552 let source_a1 = grid_range::bounded_range_to_a1(&sheet_title, &source_grid).unwrap_or(composed);
554
555 let source_values = match api
556 .values_get(
557 &opts.spreadsheet_id,
558 &source_a1,
559 ValueRenderOption::Formatted,
560 )
561 .await
562 {
563 Ok(values) => values,
564 Err(err) => {
565 return gated(TextToColumnsResult::Failed {
566 detail: format!("{err:#}"),
567 })
568 }
569 };
570
571 let width_upper_bound = split_width(&source_values, &opts.delimiter);
572 let spill_grid = spill_span(source_grid, width_upper_bound);
573 let past_grid_extent = spill_grid.is_some_and(|spill| extends_past_grid(&workbook, &spill));
574
575 let (spill_a1, overwritten_cells) = match spill_grid {
578 None => (None, Vec::new()),
579 Some(spill_grid) => {
580 let overwritten_cells = match grid_range::clamp_to_sheet(&workbook, &spill_grid) {
581 None => Vec::new(),
582 Some(read_grid) => {
583 let read_a1 = grid_range::bounded_range_to_a1(&sheet_title, &read_grid)
584 .unwrap_or_else(|| sheet_title.clone());
585 match api
586 .values_get(&opts.spreadsheet_id, &read_a1, ValueRenderOption::Formatted)
587 .await
588 {
589 Ok(values) => grid_range::non_blank_locations(
590 &values,
591 read_grid.start_row_index.unwrap_or(0),
592 read_grid.start_column_index.unwrap_or(0),
593 ),
594 Err(err) => {
595 return gated(TextToColumnsResult::Failed {
596 detail: format!("{err:#}"),
597 })
598 }
599 }
600 }
601 };
602 (
603 grid_range::bounded_range_to_a1(&sheet_title, &spill_grid),
604 overwritten_cells,
605 )
606 }
607 };
608
609 let summary = describe_effect(
610 &source_a1,
611 spill_a1.as_deref(),
612 width_upper_bound,
613 &opts.delimiter,
614 );
615
616 if opts.dry_run {
617 return gated(TextToColumnsResult::WouldChange {
618 summary,
619 source: source_a1,
620 spill: spill_a1,
621 width_upper_bound,
622 overwritten_cells,
623 past_grid_extent,
624 });
625 }
626
627 let request = build_request(source_grid, &opts.delimiter);
630
631 let files_api = FilesApi::new(drive);
632 let leased = LeasedWrite {
633 log_prefix: "drive sheets text-to-columns",
634 operation: LOG_OPERATION,
635 ledger_path: &opts.ledger_path,
636 file_id: &opts.spreadsheet_id,
637 };
638 let lease_grant = match gate_optional_leased_write(
639 leased,
640 &files_api,
641 requires_lease,
642 opts.lease_token.as_deref(),
643 )
644 .await
645 {
646 Ok(grant) => grant,
647 Err(err) => return gated(err.into_result()),
648 };
649
650 let result = match conclude_native_leased_write(
651 leased,
652 &lease_grant,
653 &files_api,
654 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
655 |err| format!("{err:#}"),
656 )
657 .await
658 {
659 Ok(_response) => TextToColumnsResult::Changed {
660 summary,
661 source: source_a1,
662 spill: spill_a1,
663 width_upper_bound,
664 overwritten_cells,
665 past_grid_extent,
666 },
667 Err(err) => TextToColumnsResult::Failed {
668 detail: format!("{err:#}"),
669 },
670 };
671 drop(lease_grant);
672 gated(result)
673}
674
675fn is_single_column(grid: &GridRange) -> bool {
678 grid.end_column_index.unwrap_or(0) - grid.start_column_index.unwrap_or(0) == 1
679}
680
681fn cell_text(cell: &serde_json::Value) -> String {
690 match cell.as_str() {
691 Some(text) => text.to_string(),
692 None if cell.is_null() => String::new(),
693 None => cell.to_string(),
694 }
695}
696
697fn split_width(values: &ValueRange, delimiter: &Delimiter) -> usize {
709 let candidates = delimiter.local_split_candidates();
710 values
711 .values
712 .iter()
713 .filter_map(|row| row.first())
714 .map(cell_text)
715 .filter(|text| !text.is_empty())
716 .map(|text| {
717 candidates
718 .iter()
719 .map(|sep| text.split(sep).count())
720 .max()
721 .unwrap_or(1)
722 })
723 .max()
724 .unwrap_or(0)
725}
726
727fn spill_span(source: GridRange, width: usize) -> Option<GridRange> {
733 if width <= 1 {
734 return None;
735 }
736 let start_column = source.start_column_index?;
737 let width = i64::try_from(width).ok()?;
738 Some(GridRange {
739 sheet_id: source.sheet_id,
740 start_row_index: source.start_row_index,
741 end_row_index: source.end_row_index,
742 start_column_index: Some(start_column + 1),
743 end_column_index: Some(start_column + width),
744 })
745}
746
747fn extends_past_grid(workbook: &Spreadsheet, extent: &GridRange) -> bool {
756 let Some(sheet) = grid_range::find_sheet_by_id(workbook, extent.sheet_id) else {
757 return false;
758 };
759 let grid = sheet
760 .properties
761 .as_ref()
762 .and_then(|props| props.grid_properties.as_ref());
763 let row_count = grid.and_then(|g| g.row_count);
764 let column_count = grid.and_then(|g| g.column_count);
765 let rows_exceed = matches!(
766 (extent.end_row_index, row_count),
767 (Some(end), Some(count)) if end > count
768 );
769 let cols_exceed = matches!(
770 (extent.end_column_index, column_count),
771 (Some(end), Some(count)) if end > count
772 );
773 rows_exceed || cols_exceed
774}
775
776const UNPREVIEWABLE_SPLIT_CAVEAT: &str =
781 " the number of columns the split needs, and the values it writes, are computed by \
782 Sheets' own splitting and are never reported, before or after the request; the count above \
783 is a local upper-bound estimate only";
784
785const PAST_GRID_EXTENT_CAVEAT_DRY_RUN: &str =
789 " the spill extends past the sheet's current extent — Sheets may grow the sheet or refuse \
790 the request";
791const PAST_GRID_EXTENT_CAVEAT: &str =
792 " the spill extended past the sheet's current extent, so Sheets may have grown it";
793
794const AUTO_DELIMITER_CAVEAT: &str =
814 " --delimiter auto lets Sheets detect the separator itself, and it detects separators this \
815 preview does not try (a tab-separated column splits under auto, though none of comma, \
816 semicolon, period or space appears in it) — so for auto the width above and the cells \
817 listed are a guess in both directions, not a bound";
818
819fn describe_effect(
830 source_a1: &str,
831 spill_a1: Option<&str>,
832 width_upper_bound: usize,
833 delimiter: &Delimiter,
834) -> String {
835 match spill_a1 {
836 Some(spill_a1) => format!(
837 "split {source_a1} on {} into up to {width_upper_bound} column(s), spill {spill_a1}",
838 delimiter.describe(),
839 ),
840 None if matches!(delimiter, Delimiter::Auto) => format!(
846 "split {source_a1} on {} (none detected in the preview, so the spill span is \
847 unknown)",
848 delimiter.describe(),
849 ),
850 None => format!(
851 "split {source_a1} on {} into a single column each row; no row spills beyond \
852 the source under this delimiter",
853 delimiter.describe(),
854 ),
855 }
856}
857
858fn overwritten_line(
872 overwritten_cells: &[String],
873 delimiter: &Delimiter,
874 dry_run: bool,
875) -> Option<String> {
876 if overwritten_cells.is_empty() {
877 return match delimiter {
878 Delimiter::Auto => None,
879 _ => Some(" no non-blank cells in the spill columns".to_string()),
880 };
881 }
882 let tense = if dry_run {
883 "would be overwritten"
884 } else {
885 "were overwritten"
886 };
887 Some(format!(
888 " up to {} non-blank cell(s) {tense}: {}",
889 overwritten_cells.len(),
890 grid_range::render_locations(overwritten_cells)
891 ))
892}
893
894fn build_request(source: GridRange, delimiter: &Delimiter) -> BatchUpdateRequestItem {
900 let delimiter_text = match delimiter {
901 Delimiter::Custom(text) => Some(text.clone()),
902 _ => None,
903 };
904 BatchUpdateRequestItem::TextToColumns(TextToColumnsRequest {
905 source,
906 delimiter: delimiter_text,
907 delimiter_type: delimiter.wire_type(),
908 })
909}
910
911fn record_attempt(outcome: &TextToColumnsOutcome, duration: Duration) {
912 let error = match &outcome.result {
913 TextToColumnsResult::Failed { detail } => Some(detail.clone()),
914 _ => None,
915 };
916 let decided_by = match &outcome.result {
917 TextToColumnsResult::Blocked { decided_by, .. } => decided_by.as_ref(),
918 _ => None,
919 };
920 let decided_by = write_gate::decided_by_log_fields(decided_by);
921 let (range, fields_changed, overwritten_cells) = match &outcome.result {
922 TextToColumnsResult::Changed {
923 summary,
924 source,
925 overwritten_cells,
926 ..
927 } => (
928 Some(source.clone()),
929 Some(summary.clone()),
930 overwritten_cells.clone(),
931 ),
932 _ => (None, None, Vec::new()),
933 };
934
935 request_log::record_drive_mutation(DriveMutationOutcome {
936 operation: LOG_OPERATION,
937 file_id: outcome.spreadsheet_id.clone(),
938 file_name: outcome.file_name.clone().unwrap_or_default(),
939 status: outcome.result.log_status().to_string(),
940 resolved_folder_id: outcome.resolved_folder_id.clone(),
941 decided_by_folder_id: decided_by.folder_id,
942 decided_by_depth: decided_by.depth,
943 decided_by_file_id: decided_by.file_id,
944 sheet_id: outcome.sheet_id,
945 range,
946 fields_changed,
947 overwritten_cells,
948 overwritten_cells_upper_bound: true,
955 error,
956 duration,
957 ..Default::default()
958 });
959}
960
961#[must_use]
963pub fn describe(outcome: &TextToColumnsOutcome) -> String {
964 describe_lines(outcome).join("\n")
965}
966
967#[must_use]
970pub fn describe_lines(outcome: &TextToColumnsOutcome) -> Vec<String> {
971 let book = outcome.file_name.as_deref().map_or_else(
972 || format!("'{}'", outcome.spreadsheet_id),
973 |n| format!("'{n}'"),
974 );
975 match &outcome.result {
976 TextToColumnsResult::WouldChange {
977 summary,
978 overwritten_cells,
979 past_grid_extent,
980 ..
981 } => change_lines(
982 &format!("Would {summary} in {book}"),
983 overwritten_cells,
984 *past_grid_extent,
985 &outcome.delimiter,
986 true,
987 ),
988 TextToColumnsResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
989 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
990 `drive sheets text-to-columns` only works on spreadsheets"
991 )],
992 TextToColumnsResult::RefusedShortcut => vec![format!(
993 "Refused: {book} is a shortcut; `drive sheets text-to-columns` doesn't follow \
994 shortcuts"
995 )],
996 TextToColumnsResult::RefusedNoVisibleParents => vec![format!(
997 "Refused: {book} has no parent folder visible to this account, so no folder \
998 rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
999 id>\", \"allow\": [\"sheets-write\"]}} to write_permissions.rules."
1000 )],
1001 TextToColumnsResult::RefusedSheetNotFound { title, available } => {
1002 vec![grid_range::no_sheet_refusal(&book, title, available)]
1003 }
1004 TextToColumnsResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
1005 TextToColumnsResult::RefusedInvalidDelimiter { detail } => {
1006 vec![format!("Refused: {detail}")]
1007 }
1008 TextToColumnsResult::Blocked {
1009 operation,
1010 decided_by,
1011 } => vec![match decided_by {
1012 Some(rule) => format!(
1013 "Blocked: text-to-columns on {book} refused by rule on {} {}{}",
1014 rule.kind_label(),
1015 rule.id(),
1016 rule.depth_suffix()
1017 ),
1018 None => format!(
1019 "Blocked: text-to-columns on {book} refused by default policy (no matching rule \
1020 for {operation})"
1021 ),
1022 }],
1023 TextToColumnsResult::RefusedNoLease => LeaseGateRefusal::NoLease
1024 .describe_line(&outcome.spreadsheet_id, &book)
1025 .into_iter()
1026 .collect(),
1027 TextToColumnsResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1028 .describe_line(&outcome.spreadsheet_id, &book)
1029 .into_iter()
1030 .collect(),
1031 TextToColumnsResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1032 .describe_line(&outcome.spreadsheet_id, &book)
1033 .into_iter()
1034 .collect(),
1035 TextToColumnsResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1036 .describe_line(&outcome.spreadsheet_id, &book)
1037 .into_iter()
1038 .collect(),
1039 TextToColumnsResult::Changed {
1040 summary,
1041 overwritten_cells,
1042 past_grid_extent,
1043 ..
1044 } => change_lines(
1045 &format!("Applied: {summary} in {book}"),
1046 overwritten_cells,
1047 *past_grid_extent,
1048 &outcome.delimiter,
1049 false,
1050 ),
1051 TextToColumnsResult::Failed { detail } => vec![format!("Failed: {detail}")],
1052 }
1053}
1054
1055fn change_lines(
1061 head: &str,
1062 overwritten_cells: &[String],
1063 past_grid_extent: bool,
1064 delimiter: &Delimiter,
1065 dry_run: bool,
1066) -> Vec<String> {
1067 let mut lines = vec![head.to_string()];
1068 lines.extend(overwritten_line(overwritten_cells, delimiter, dry_run));
1069 if past_grid_extent {
1070 lines.push(
1071 if dry_run {
1072 PAST_GRID_EXTENT_CAVEAT_DRY_RUN
1073 } else {
1074 PAST_GRID_EXTENT_CAVEAT
1075 }
1076 .to_string(),
1077 );
1078 }
1079 if matches!(delimiter, Delimiter::Auto) {
1080 lines.push(AUTO_DELIMITER_CAVEAT.to_string());
1081 }
1082 lines.push(UNPREVIEWABLE_SPLIT_CAVEAT.to_string());
1083 lines
1084}
1085
1086#[cfg(test)]
1087#[allow(clippy::unwrap_used, clippy::expect_used)]
1088mod tests {
1089 use super::*;
1090 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1091 use crate::drive::sheets::client::SHEETS_API_URL;
1092 use crate::drive::test_support::seed_lease;
1093 use crate::test_support::env::MapEnv;
1094 use crate::utils::secret::Secret;
1095
1096 fn bounded(sheet_id: i64, r0: i64, r1: i64, c0: i64, c1: i64) -> GridRange {
1099 GridRange {
1100 sheet_id,
1101 start_row_index: Some(r0),
1102 end_row_index: Some(r1),
1103 start_column_index: Some(c0),
1104 end_column_index: Some(c1),
1105 }
1106 }
1107
1108 fn values(rows: &[&str]) -> ValueRange {
1109 ValueRange {
1110 range: None,
1111 values: rows
1112 .iter()
1113 .map(|cell| {
1114 if cell.is_empty() {
1115 vec![]
1116 } else {
1117 vec![serde_json::json!(cell)]
1118 }
1119 })
1120 .collect(),
1121 }
1122 }
1123
1124 #[test]
1129 fn every_delimiter_maps_to_a_wire_type_its_candidates_and_a_name() {
1130 let cases = [
1131 (Delimiter::Comma, DelimiterType::Comma, vec![","], "comma"),
1132 (
1133 Delimiter::Semicolon,
1134 DelimiterType::Semicolon,
1135 vec![";"],
1136 "semicolon",
1137 ),
1138 (
1139 Delimiter::Period,
1140 DelimiterType::Period,
1141 vec!["."],
1142 "period",
1143 ),
1144 (Delimiter::Space, DelimiterType::Space, vec![" "], "space"),
1145 (
1146 Delimiter::Auto,
1147 DelimiterType::Autodetect,
1148 vec![",", ";", ".", " "],
1149 "an auto-detected separator",
1150 ),
1151 (
1152 Delimiter::Custom("||".to_string()),
1153 DelimiterType::Custom,
1154 vec!["||"],
1155 "custom (\"||\")",
1156 ),
1157 ];
1158 for (delimiter, wire, candidates, name) in cases {
1159 assert_eq!(delimiter.wire_type(), wire, "{delimiter:?}");
1160 assert_eq!(
1161 delimiter.local_split_candidates(),
1162 candidates,
1163 "{delimiter:?}"
1164 );
1165 assert_eq!(delimiter.describe(), name, "{delimiter:?}");
1166 }
1167 }
1168
1169 #[test]
1173 fn every_result_variant_has_its_own_log_status() {
1174 let statuses = [
1175 TextToColumnsResult::RefusedNotASpreadsheet {
1176 mime_type: String::new(),
1177 },
1178 TextToColumnsResult::RefusedShortcut,
1179 TextToColumnsResult::RefusedNoVisibleParents,
1180 TextToColumnsResult::RefusedSheetNotFound {
1181 title: String::new(),
1182 available: Vec::new(),
1183 },
1184 TextToColumnsResult::RefusedInvalidRange {
1185 detail: String::new(),
1186 },
1187 TextToColumnsResult::RefusedInvalidDelimiter {
1188 detail: String::new(),
1189 },
1190 TextToColumnsResult::Blocked {
1191 operation: DriveOperation::SheetsWrite,
1192 decided_by: None,
1193 },
1194 TextToColumnsResult::RefusedNoLease,
1195 TextToColumnsResult::RefusedLeaseExpired,
1196 TextToColumnsResult::RefusedLeaseWrongFile,
1197 TextToColumnsResult::RefusedLeaseStale,
1198 TextToColumnsResult::Failed {
1199 detail: String::new(),
1200 },
1201 ];
1202 let mut seen: Vec<&str> = statuses
1203 .iter()
1204 .map(TextToColumnsResult::log_status)
1205 .collect();
1206 seen.push(would_change_outcome(Vec::new(), false).result.log_status());
1207 let unique: std::collections::HashSet<&str> = seen.iter().copied().collect();
1208 assert_eq!(unique.len(), seen.len(), "duplicate log status in {seen:?}");
1209 assert_eq!(
1210 TextToColumnsResult::RefusedLeaseStale.log_status(),
1211 LeaseGateRefusal::Stale.log_status()
1212 );
1213 }
1214
1215 #[test]
1218 fn jsonl_writes_one_record_and_omits_the_delimiter() {
1219 let mut outcome = would_change_outcome(vec!["B2".to_string()], false);
1220 outcome.delimiter = Delimiter::Custom("not-in-the-record".to_string());
1221 let mut out = Vec::new();
1222 outcome.write_jsonl(&mut out).unwrap();
1223 let text = String::from_utf8(out).unwrap();
1224 assert_eq!(text.lines().count(), 1);
1225 assert!(!text.contains("not-in-the-record"));
1226 let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
1227 assert_eq!(parsed["result"]["status"], "would-change");
1228 }
1229
1230 #[test]
1231 fn is_single_column_accepts_a_one_column_span() {
1232 assert!(is_single_column(&bounded(0, 0, 10, 2, 3)));
1233 }
1234
1235 #[test]
1236 fn is_single_column_rejects_a_wider_span() {
1237 assert!(!is_single_column(&bounded(0, 0, 10, 2, 4)));
1238 }
1239
1240 #[test]
1241 fn cell_text_renders_a_string_verbatim() {
1242 assert_eq!(cell_text(&serde_json::json!("a,b")), "a,b");
1243 }
1244
1245 #[test]
1246 fn cell_text_is_empty_for_null() {
1247 assert_eq!(cell_text(&serde_json::Value::Null), "");
1248 }
1249
1250 #[test]
1251 fn cell_text_falls_back_to_json_rendering_for_a_non_string_non_null_cell() {
1252 assert_eq!(cell_text(&serde_json::json!(42)), "42");
1253 }
1254
1255 #[test]
1256 fn split_width_is_zero_for_no_rows() {
1257 assert_eq!(split_width(&values(&[]), &Delimiter::Comma), 0);
1258 }
1259
1260 #[test]
1261 fn split_width_ignores_a_blank_row() {
1262 assert_eq!(split_width(&values(&[""]), &Delimiter::Comma), 0);
1263 }
1264
1265 #[test]
1266 fn split_width_counts_comma_pieces() {
1267 assert_eq!(split_width(&values(&["a,b,c"]), &Delimiter::Comma), 3);
1268 }
1269
1270 #[test]
1271 fn split_width_is_the_widest_row() {
1272 assert_eq!(
1273 split_width(&values(&["a,b", "x,y,z", "solo"]), &Delimiter::Comma),
1274 3
1275 );
1276 }
1277
1278 #[test]
1279 fn split_width_with_a_custom_delimiter() {
1280 assert_eq!(
1281 split_width(&values(&["a|b|c|d"]), &Delimiter::Custom("|".to_string())),
1282 4
1283 );
1284 }
1285
1286 #[test]
1287 fn split_width_auto_takes_the_widest_of_the_four_fixed_delimiters() {
1288 assert_eq!(split_width(&values(&["a.b.c"]), &Delimiter::Auto), 3);
1290 }
1291
1292 #[test]
1293 fn split_width_with_no_delimiter_present_is_one() {
1294 assert_eq!(split_width(&values(&["solo"]), &Delimiter::Comma), 1);
1295 }
1296
1297 #[test]
1298 fn spill_span_is_none_for_a_width_of_one_or_less() {
1299 assert_eq!(spill_span(bounded(0, 0, 10, 0, 1), 1), None);
1300 assert_eq!(spill_span(bounded(0, 0, 10, 0, 1), 0), None);
1301 }
1302
1303 #[test]
1304 fn spill_span_covers_the_columns_to_the_right_of_the_source() {
1305 let spill = spill_span(bounded(0, 1, 10, 0, 1), 3).unwrap();
1307 assert_eq!(spill.start_column_index, Some(1));
1308 assert_eq!(spill.end_column_index, Some(3));
1309 assert_eq!(spill.start_row_index, Some(1));
1310 assert_eq!(spill.end_row_index, Some(10));
1311 }
1312
1313 #[test]
1314 fn extends_past_grid_flags_a_spill_past_the_column_count() {
1315 let workbook: Spreadsheet = serde_json::from_value(serde_json::json!({
1316 "spreadsheetId": "sheet-1",
1317 "sheets": [{"properties": {"sheetId": 0, "title": "Q1",
1318 "gridProperties": {"rowCount": 100, "columnCount": 3}}}],
1319 }))
1320 .unwrap();
1321 assert!(extends_past_grid(&workbook, &bounded(0, 0, 10, 0, 5)));
1322 assert!(!extends_past_grid(&workbook, &bounded(0, 0, 10, 0, 3)));
1323 }
1324
1325 #[test]
1326 fn extends_past_grid_is_false_when_the_sheet_is_unknown() {
1327 let workbook: Spreadsheet = serde_json::from_value(serde_json::json!({
1328 "spreadsheetId": "sheet-1",
1329 "sheets": [],
1330 }))
1331 .unwrap();
1332 assert!(!extends_past_grid(&workbook, &bounded(0, 0, 10, 0, 5)));
1333 }
1334
1335 #[test]
1336 fn describe_effect_with_a_spill() {
1337 let summary = describe_effect("'Q1'!A2:A10", Some("'Q1'!B2:D10"), 3, &Delimiter::Comma);
1338 assert_eq!(
1339 summary,
1340 "split 'Q1'!A2:A10 on comma into up to 3 column(s), spill 'Q1'!B2:D10"
1341 );
1342 }
1343
1344 #[test]
1345 fn describe_effect_with_no_spill() {
1346 let summary = describe_effect("'Q1'!A2:A10", None, 1, &Delimiter::Space);
1347 assert_eq!(
1348 summary,
1349 "split 'Q1'!A2:A10 on space into a single column each row; no row spills beyond \
1350 the source under this delimiter"
1351 );
1352 }
1353
1354 #[test]
1359 fn describe_effect_with_no_spill_under_auto() {
1360 let summary = describe_effect("'Q1'!A2:A10", None, 1, &Delimiter::Auto);
1361 assert_eq!(
1362 summary,
1363 "split 'Q1'!A2:A10 on an auto-detected separator (none detected in the preview, so \
1364 the spill span is unknown)"
1365 );
1366 }
1367
1368 #[test]
1374 fn every_summary_branch_is_tense_neutral() {
1375 for (spill, width) in [(Some("'Q1'!B2:B10"), 2), (None, 1)] {
1376 let summary = describe_effect("'Q1'!A2:A10", spill, width, &Delimiter::Comma);
1377 assert!(
1378 !summary.contains("would"),
1379 "summary is reused in the past tense: {summary}"
1380 );
1381 }
1382 }
1383
1384 #[test]
1385 fn overwritten_line_is_always_prefixed_up_to() {
1386 let line = overwritten_line(&["B2".to_string()], &Delimiter::Comma, true).unwrap();
1387 assert!(line.starts_with(" up to 1 non-blank cell(s) would be overwritten"));
1388 }
1389
1390 #[test]
1391 fn overwritten_line_empty_case() {
1392 assert_eq!(
1393 overwritten_line(&[], &Delimiter::Comma, false),
1394 Some(" no non-blank cells in the spill columns".to_string())
1395 );
1396 }
1397
1398 #[test]
1399 fn overwritten_line_elides_past_the_render_limit_but_keeps_the_true_count() {
1400 let cells: Vec<String> = (1..=60).map(|row| format!("A{row}")).collect();
1401 let line = overwritten_line(&cells, &Delimiter::Comma, true).unwrap();
1402 assert!(line.contains("… and 10 more"), "{line}");
1403 assert!(
1404 line.starts_with(" up to 60 non-blank cell(s) would be overwritten:"),
1405 "{line}"
1406 );
1407 assert_eq!(cells.len(), 60);
1408 }
1409
1410 #[test]
1413 fn auto_never_prints_an_all_clear() {
1414 assert_eq!(overwritten_line(&[], &Delimiter::Auto, true), None);
1415 assert!(overwritten_line(&["B2".to_string()], &Delimiter::Auto, true).is_some());
1417 }
1418
1419 #[test]
1420 fn build_request_omits_delimiter_for_a_fixed_type() {
1421 let request = build_request(bounded(0, 0, 10, 0, 1), &Delimiter::Comma);
1422 match request {
1423 BatchUpdateRequestItem::TextToColumns(req) => {
1424 assert_eq!(req.delimiter, None);
1425 assert_eq!(req.delimiter_type, DelimiterType::Comma);
1426 }
1427 other => panic!("unexpected request: {other:?}"), }
1429 }
1430
1431 #[test]
1432 fn build_request_carries_the_custom_delimiter_text() {
1433 let request = build_request(bounded(0, 0, 10, 0, 1), &Delimiter::Custom("|".to_string()));
1434 match request {
1435 BatchUpdateRequestItem::TextToColumns(req) => {
1436 assert_eq!(req.delimiter, Some("|".to_string()));
1437 assert_eq!(req.delimiter_type, DelimiterType::Custom);
1438 }
1439 other => panic!("unexpected request: {other:?}"), }
1441 }
1442
1443 fn would_change_outcome(
1446 overwritten_cells: Vec<String>,
1447 past_grid_extent: bool,
1448 ) -> TextToColumnsOutcome {
1449 TextToColumnsOutcome {
1450 spreadsheet_id: "sheet-1".to_string(),
1451 file_name: Some("Budget".to_string()),
1452 resolved_folder_id: Some("folder-1".to_string()),
1453 sheet_id: Some(0),
1454 delimiter: Delimiter::Comma,
1455 result: TextToColumnsResult::WouldChange {
1456 summary: "split 'Q1'!A2:A4 on comma into up to 2 column(s), spill 'Q1'!B2:B4"
1457 .to_string(),
1458 source: "'Q1'!A2:A4".to_string(),
1459 spill: Some("'Q1'!B2:B4".to_string()),
1460 width_upper_bound: 2,
1461 overwritten_cells,
1462 past_grid_extent,
1463 },
1464 }
1465 }
1466
1467 #[test]
1468 fn would_change_lines_carry_the_upper_bound_and_the_unpreviewable_caveat() {
1469 let outcome = would_change_outcome(vec!["B2".to_string()], false);
1470 let lines = describe_lines(&outcome);
1471 assert_eq!(
1472 lines[0],
1473 "Would split 'Q1'!A2:A4 on comma into up to 2 column(s), spill 'Q1'!B2:B4 in 'Budget'"
1474 );
1475 assert_eq!(
1476 lines[1],
1477 " up to 1 non-blank cell(s) would be overwritten: B2"
1478 );
1479 assert_eq!(lines[2], UNPREVIEWABLE_SPLIT_CAVEAT);
1480 }
1481
1482 #[test]
1483 fn past_grid_extent_adds_the_dry_run_caveat_line() {
1484 let outcome = would_change_outcome(vec![], true);
1485 let lines = describe_lines(&outcome);
1486 assert_eq!(lines[2], PAST_GRID_EXTENT_CAVEAT_DRY_RUN);
1487 assert_eq!(lines[3], UNPREVIEWABLE_SPLIT_CAVEAT);
1488 }
1489
1490 #[test]
1491 fn changed_lines_use_the_past_tense() {
1492 let mut outcome = would_change_outcome(vec!["B2".to_string()], true);
1493 outcome.result = match outcome.result {
1494 TextToColumnsResult::WouldChange {
1495 summary,
1496 source,
1497 spill,
1498 width_upper_bound,
1499 overwritten_cells,
1500 past_grid_extent,
1501 } => TextToColumnsResult::Changed {
1502 summary,
1503 source,
1504 spill,
1505 width_upper_bound,
1506 overwritten_cells,
1507 past_grid_extent,
1508 },
1509 other => other, };
1511 let lines = describe_lines(&outcome);
1512 assert!(lines[0].starts_with("Applied: "));
1513 assert!(lines[1].contains("were overwritten"));
1514 assert_eq!(lines[2], PAST_GRID_EXTENT_CAVEAT);
1515 }
1516
1517 #[test]
1523 fn every_result_variant_renders_as_non_empty_newline_free_lines() {
1524 let results = [
1525 TextToColumnsResult::RefusedNotASpreadsheet {
1526 mime_type: "application/pdf".to_string(),
1527 },
1528 TextToColumnsResult::RefusedShortcut,
1529 TextToColumnsResult::RefusedNoVisibleParents,
1530 TextToColumnsResult::RefusedSheetNotFound {
1531 title: "Q9".to_string(),
1532 available: vec!["Q1".to_string()],
1533 },
1534 TextToColumnsResult::RefusedSheetNotFound {
1537 title: "Q9".to_string(),
1538 available: Vec::new(),
1539 },
1540 TextToColumnsResult::RefusedInvalidRange {
1541 detail: "'A1:B2' spans more than one column".to_string(),
1542 },
1543 TextToColumnsResult::RefusedInvalidDelimiter {
1544 detail: "--custom-delimiter must not be empty".to_string(),
1545 },
1546 TextToColumnsResult::Blocked {
1547 operation: DriveOperation::SheetsWrite,
1548 decided_by: None,
1549 },
1550 TextToColumnsResult::RefusedNoLease,
1551 TextToColumnsResult::RefusedLeaseExpired,
1552 TextToColumnsResult::RefusedLeaseWrongFile,
1553 TextToColumnsResult::RefusedLeaseStale,
1554 TextToColumnsResult::Failed {
1555 detail: "HTTP 500".to_string(),
1556 },
1557 ];
1558 for result in results {
1559 let mut outcome = would_change_outcome(Vec::new(), false);
1560 let status = result.log_status();
1561 outcome.result = result;
1562 let lines = describe_lines(&outcome);
1563 assert!(!lines.is_empty(), "{status} rendered nothing");
1564 for line in &lines {
1565 assert!(!line.is_empty(), "{status} rendered an empty line");
1566 assert!(
1567 !line.contains('\n'),
1568 "{status} rendered an embedded newline"
1569 );
1570 }
1571 assert_eq!(describe(&outcome), lines.join("\n"), "{status}");
1573 }
1574 }
1575
1576 #[test]
1580 fn a_failed_lease_check_maps_to_failed() {
1581 match TextToColumnsResult::from_lease_failed("ledger unreadable".to_string()) {
1582 TextToColumnsResult::Failed { detail } => assert_eq!(detail, "ledger unreadable"),
1583 other => panic!("expected Failed, got {other:?}"), }
1585 }
1586
1587 #[test]
1593 fn describe_falls_back_to_the_spreadsheet_id_with_no_file_name() {
1594 let mut outcome = would_change_outcome(Vec::new(), false);
1595 outcome.file_name = None;
1596 assert!(describe(&outcome).contains("'sheet-1'"));
1597 }
1598
1599 #[test]
1600 fn auto_earns_its_own_caveat_line_and_no_other_delimiter_does() {
1601 let mut outcome = would_change_outcome(vec!["B2".to_string()], false);
1602 assert!(!describe_lines(&outcome).contains(&AUTO_DELIMITER_CAVEAT.to_string()));
1603 outcome.delimiter = Delimiter::Auto;
1604 let lines = describe_lines(&outcome);
1605 assert_eq!(lines[lines.len() - 2], AUTO_DELIMITER_CAVEAT);
1606 assert_eq!(lines[lines.len() - 1], UNPREVIEWABLE_SPLIT_CAVEAT);
1609 }
1610
1611 #[test]
1612 fn refused_sheet_not_found_lists_available_titles() {
1613 let outcome = TextToColumnsOutcome {
1614 spreadsheet_id: "sheet-1".to_string(),
1615 file_name: Some("Budget".to_string()),
1616 resolved_folder_id: Some("folder-1".to_string()),
1617 sheet_id: None,
1618 delimiter: Delimiter::Comma,
1619 result: TextToColumnsResult::RefusedSheetNotFound {
1620 title: "Q2".to_string(),
1621 available: vec!["Q1".to_string()],
1622 },
1623 };
1624 assert_eq!(
1625 describe_lines(&outcome),
1626 vec!["Refused: 'Budget' has no sheet titled 'Q2'. Available: 'Q1'".to_string()]
1627 );
1628 }
1629
1630 #[test]
1631 fn blocked_with_no_deciding_rule_names_default_policy() {
1632 let outcome = TextToColumnsOutcome {
1633 spreadsheet_id: "sheet-1".to_string(),
1634 file_name: Some("Budget".to_string()),
1635 resolved_folder_id: Some("folder-1".to_string()),
1636 sheet_id: None,
1637 delimiter: Delimiter::Comma,
1638 result: TextToColumnsResult::Blocked {
1639 operation: DriveOperation::SheetsWrite,
1640 decided_by: None,
1641 },
1642 };
1643 assert_eq!(
1644 describe_lines(&outcome),
1645 vec![
1646 "Blocked: text-to-columns on 'Budget' refused by default policy (no matching \
1647 rule for sheets-write)"
1648 .to_string()
1649 ]
1650 );
1651 }
1652
1653 fn test_credentials() -> DriveCredentials {
1656 DriveCredentials {
1657 client_id: "client-1".to_string(),
1658 client_secret: Secret::new("secret-1"),
1659 refresh_token: Secret::new("refresh-1"),
1660 scope: DriveGrantedScopes::READONLY,
1661 }
1662 }
1663
1664 async fn client(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1670 wiremock::Mock::given(wiremock::matchers::method("POST"))
1671 .and(wiremock::matchers::path("/token"))
1672 .respond_with(
1673 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1674 "access_token": "test-token", "expires_in": 3600,
1675 })),
1676 )
1677 .mount(server)
1678 .await;
1679 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1680 crate::drive::client::test_support::replace_session(
1681 &mut drive,
1682 &test_credentials(),
1683 &format!("{}/token", server.uri()),
1684 );
1685 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1686 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1687 (drive, sheets)
1688 }
1689
1690 fn rule_allowing(ops: &[DriveOperation], require_lease: bool) -> FolderPermissionRule {
1691 FolderPermissionRule {
1692 folder_id: Some("parent-1".to_string()),
1693 file_id: None,
1694 recursive: true,
1695 allow: ops.iter().copied().collect(),
1696 deny: std::collections::HashSet::default(),
1697 require_lease,
1698 }
1699 }
1700
1701 fn rule(_op: DriveOperation, require_lease: bool) -> FolderPermissionRule {
1706 rule_allowing(GATE_OPERATIONS, require_lease)
1707 }
1708
1709 fn base_opts(dry_run: bool) -> TextToColumnsOptions {
1710 TextToColumnsOptions {
1711 spreadsheet_id: "sheet-1".to_string(),
1712 sheet: None,
1713 source: Some("Q1!A2:A4".to_string()),
1714 delimiter: Delimiter::Comma,
1715 dry_run,
1716 lease_token: None,
1717 ledger_path: PathBuf::default(),
1718 }
1719 }
1720
1721 async fn mount_file_metadata(server: &wiremock::MockServer, body: serde_json::Value) {
1726 wiremock::Mock::given(wiremock::matchers::method("GET"))
1727 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1728 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
1729 .mount(server)
1730 .await;
1731 }
1732
1733 fn spreadsheet_metadata() -> serde_json::Value {
1736 serde_json::json!({
1737 "id": "sheet-1", "name": "Budget",
1738 "mimeType": "application/vnd.google-apps.spreadsheet",
1739 "parents": ["parent-1"], "version": "1",
1740 })
1741 }
1742
1743 async fn mount_parent(server: &wiremock::MockServer) {
1744 wiremock::Mock::given(wiremock::matchers::method("GET"))
1745 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
1746 .respond_with(
1747 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1748 "id": "parent-1", "name": "parent-1",
1749 "mimeType": "application/vnd.google-apps.folder", "parents": [],
1750 })),
1751 )
1752 .mount(server)
1753 .await;
1754 }
1755
1756 async fn mount_workbook(server: &wiremock::MockServer, row_count: i64, column_count: i64) {
1760 wiremock::Mock::given(wiremock::matchers::method("GET"))
1761 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1762 .respond_with(
1763 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1764 "spreadsheetId": "sheet-1",
1765 "sheets": [{"properties": {"sheetId": 0, "title": "Q1",
1766 "gridProperties": {
1767 "rowCount": row_count, "columnCount": column_count,
1768 }}}],
1769 })),
1770 )
1771 .mount(server)
1772 .await;
1773 }
1774
1775 async fn mount_metadata(server: &wiremock::MockServer) {
1776 mount_file_metadata(server, spreadsheet_metadata()).await;
1777 mount_parent(server).await;
1778 mount_workbook(server, 1000, 26).await;
1779 }
1780
1781 async fn mount_values_failure(server: &wiremock::MockServer, range: &str) {
1784 wiremock::Mock::given(wiremock::matchers::method("GET"))
1785 .and(wiremock::matchers::path(format!(
1786 "/v4/spreadsheets/sheet-1/values/{range}"
1787 )))
1788 .respond_with(wiremock::ResponseTemplate::new(500))
1789 .mount(server)
1790 .await;
1791 }
1792
1793 async fn mount_batch_update(server: &wiremock::MockServer, status: u16) {
1794 let template = if status == 200 {
1795 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1796 "spreadsheetId": "sheet-1", "replies": [{}],
1797 }))
1798 } else {
1799 wiremock::ResponseTemplate::new(status)
1800 };
1801 wiremock::Mock::given(wiremock::matchers::method("POST"))
1802 .and(wiremock::matchers::path(
1803 "/v4/spreadsheets/sheet-1:batchUpdate",
1804 ))
1805 .respond_with(template)
1806 .mount(server)
1807 .await;
1808 }
1809
1810 fn batch_update_was_called(requests: &[wiremock::Request]) -> bool {
1811 requests
1812 .iter()
1813 .any(|r| r.url.path() == "/v4/spreadsheets/sheet-1:batchUpdate")
1814 }
1815
1816 async fn mount_values(server: &wiremock::MockServer, range: &str, rows: serde_json::Value) {
1821 wiremock::Mock::given(wiremock::matchers::method("GET"))
1822 .and(wiremock::matchers::path(format!(
1823 "/v4/spreadsheets/sheet-1/values/{range}"
1824 )))
1825 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(rows))
1826 .mount(server)
1827 .await;
1828 }
1829
1830 #[tokio::test]
1831 async fn dry_run_reads_source_and_spill_and_never_calls_batch_update() {
1832 let server = wiremock::MockServer::start().await;
1833 let (client, sheets) = client(&server).await;
1834 mount_metadata(&server).await;
1835 mount_values(
1836 &server,
1837 "'Q1'!A2:A4",
1838 serde_json::json!({"values": [["a,b"], ["x,y,z"], [""]]}),
1839 )
1840 .await;
1841 mount_values(
1845 &server,
1846 "'Q1'!B2:C4",
1847 serde_json::json!({"values": [["", ""], ["old", ""]]}),
1848 )
1849 .await;
1850 let opts = base_opts(true);
1851 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
1852 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
1853 match &outcome.result {
1854 TextToColumnsResult::WouldChange {
1855 width_upper_bound,
1856 spill,
1857 overwritten_cells,
1858 ..
1859 } => {
1860 assert_eq!(*width_upper_bound, 3);
1861 assert_eq!(spill.as_deref(), Some("'Q1'!B2:C4"));
1862 assert_eq!(overwritten_cells, &vec!["B3".to_string()]);
1863 }
1864 other => panic!("expected WouldChange, got {other:?}"),
1865 }
1866 assert!(server
1867 .received_requests()
1868 .await
1869 .unwrap()
1870 .iter()
1871 .all(|r| r.method != wiremock::http::Method::POST
1872 || r.url.path() != "/v4/spreadsheets/sheet-1:batchUpdate"));
1873 }
1874
1875 #[tokio::test]
1876 async fn real_run_posts_exactly_one_text_to_columns_request() {
1877 let server = wiremock::MockServer::start().await;
1878 let (client, sheets) = client(&server).await;
1879 mount_metadata(&server).await;
1880 mount_values(
1881 &server,
1882 "'Q1'!A2:A4",
1883 serde_json::json!({"values": [["a,b"]]}),
1884 )
1885 .await;
1886 mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
1887 wiremock::Mock::given(wiremock::matchers::method("POST"))
1888 .and(wiremock::matchers::path(
1889 "/v4/spreadsheets/sheet-1:batchUpdate",
1890 ))
1891 .respond_with(
1892 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1893 "spreadsheetId": "sheet-1", "replies": [{}],
1894 })),
1895 )
1896 .mount(&server)
1897 .await;
1898 let opts = base_opts(false);
1899 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
1900 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
1901 assert!(matches!(
1902 outcome.result,
1903 TextToColumnsResult::Changed { .. }
1904 ));
1905
1906 let batch_requests: Vec<_> = server
1907 .received_requests()
1908 .await
1909 .unwrap()
1910 .into_iter()
1911 .filter(|r| r.url.path() == "/v4/spreadsheets/sheet-1:batchUpdate")
1912 .collect();
1913 assert_eq!(batch_requests.len(), 1);
1914 let body: serde_json::Value = serde_json::from_slice(&batch_requests[0].body).unwrap();
1915 assert_eq!(body["requests"].as_array().unwrap().len(), 1);
1916 assert!(body["requests"][0]["textToColumns"].is_object());
1917 }
1918
1919 #[tokio::test]
1920 async fn default_policy_blocks_with_no_configured_rules() {
1921 let server = wiremock::MockServer::start().await;
1922 let (client, sheets) = client(&server).await;
1923 wiremock::Mock::given(wiremock::matchers::method("GET"))
1924 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1925 .respond_with(
1926 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1927 "id": "sheet-1", "name": "Budget",
1928 "mimeType": "application/vnd.google-apps.spreadsheet",
1929 "parents": ["parent-1"], "version": "1",
1930 })),
1931 )
1932 .mount(&server)
1933 .await;
1934 wiremock::Mock::given(wiremock::matchers::method("GET"))
1935 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
1936 .respond_with(
1937 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1938 "id": "parent-1", "name": "parent-1",
1939 "mimeType": "application/vnd.google-apps.folder", "parents": [],
1940 })),
1941 )
1942 .mount(&server)
1943 .await;
1944 let opts = base_opts(true);
1945 let outcome = text_to_columns(&client, &sheets, &opts, &[]).await;
1946 assert!(matches!(
1947 outcome.result,
1948 TextToColumnsResult::Blocked {
1949 operation: DriveOperation::SheetsWrite,
1950 decided_by: None,
1951 }
1952 ));
1953 }
1954
1955 #[tokio::test]
1963 async fn neither_half_of_the_gate_opens_it_alone() {
1964 for (granted, missing) in [
1965 (DriveOperation::SheetsWrite, DriveOperation::SheetsStructure),
1966 (DriveOperation::SheetsStructure, DriveOperation::SheetsWrite),
1967 ] {
1968 let server = wiremock::MockServer::start().await;
1969 let (client, sheets) = client(&server).await;
1970 mount_metadata(&server).await;
1971 let rules = vec![rule_allowing(&[granted], false)];
1972 let outcome = text_to_columns(&client, &sheets, &base_opts(true), &rules).await;
1973 match &outcome.result {
1974 TextToColumnsResult::Blocked { operation, .. } => {
1975 assert_eq!(*operation, missing, "granted {granted}");
1976 }
1977 other => panic!("expected Blocked with {granted} granted, got {other:?}"),
1978 }
1979 assert!(describe_lines(&outcome)[0].contains(&missing.to_string()));
1980 }
1981 }
1982
1983 #[tokio::test]
1985 async fn both_operations_together_open_the_gate() {
1986 let server = wiremock::MockServer::start().await;
1987 let (client, sheets) = client(&server).await;
1988 mount_metadata(&server).await;
1989 mount_values(
1990 &server,
1991 "'Q1'!A2:A4",
1992 serde_json::json!({"values": [["a,b"]]}),
1993 )
1994 .await;
1995 mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
1996 let rules = vec![rule_allowing(GATE_OPERATIONS, false)];
1997 let outcome = text_to_columns(&client, &sheets, &base_opts(true), &rules).await;
1998 assert!(matches!(
1999 outcome.result,
2000 TextToColumnsResult::WouldChange { .. }
2001 ));
2002 }
2003
2004 #[tokio::test]
2009 async fn a_blocked_by_rule_names_the_deciding_folder_in_the_message() {
2010 let server = wiremock::MockServer::start().await;
2011 let (client, sheets) = client(&server).await;
2012 mount_metadata(&server).await;
2013 let deny_by_rule = FolderPermissionRule {
2014 folder_id: Some("parent-1".to_string()),
2015 file_id: None,
2016 recursive: true,
2017 allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
2018 deny: std::iter::once(DriveOperation::SheetsStructure).collect(),
2019 require_lease: false,
2020 };
2021 let outcome = text_to_columns(&client, &sheets, &base_opts(false), &[deny_by_rule]).await;
2022 assert!(
2023 matches!(
2024 outcome.result,
2025 TextToColumnsResult::Blocked {
2026 decided_by: Some(_),
2027 ..
2028 }
2029 ),
2030 "{:?}",
2031 outcome.result
2032 );
2033 let text = describe(&outcome);
2034 assert!(
2035 text.contains("refused by rule on folder parent-1"),
2036 "{text}"
2037 );
2038 }
2039
2040 #[tokio::test]
2041 async fn open_ended_source_is_refused() {
2042 let server = wiremock::MockServer::start().await;
2043 let (client, sheets) = client(&server).await;
2044 mount_metadata(&server).await;
2045 let mut opts = base_opts(true);
2046 opts.source = Some("Q1!A:A".to_string());
2047 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2048 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2049 match outcome.result {
2050 TextToColumnsResult::RefusedInvalidRange { detail } => {
2051 assert!(detail.starts_with("Q1!A:A is open-ended"), "{detail}");
2052 }
2053 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2054 }
2055 }
2056
2057 #[tokio::test]
2058 async fn multi_column_source_is_refused() {
2059 let server = wiremock::MockServer::start().await;
2060 let (client, sheets) = client(&server).await;
2061 mount_metadata(&server).await;
2062 let mut opts = base_opts(true);
2063 opts.source = Some("Q1!A2:B4".to_string());
2064 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2065 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2066 match outcome.result {
2067 TextToColumnsResult::RefusedInvalidRange { detail } => {
2068 assert!(detail.contains("exactly one column"));
2069 }
2070 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2071 }
2072 }
2073
2074 #[tokio::test]
2075 async fn empty_custom_delimiter_is_refused_before_any_network_call() {
2076 let server = wiremock::MockServer::start().await;
2077 let (client, sheets) = client(&server).await;
2078 let mut opts = base_opts(true);
2079 opts.delimiter = Delimiter::Custom(String::new());
2080 let outcome = text_to_columns(&client, &sheets, &opts, &[]).await;
2081 assert!(matches!(
2082 outcome.result,
2083 TextToColumnsResult::RefusedInvalidDelimiter { .. }
2084 ));
2085 assert!(server.received_requests().await.unwrap().is_empty());
2086 assert!(outcome.file_name.is_none());
2088 }
2089
2090 #[tokio::test]
2091 async fn lease_required_and_absent_is_refused() {
2092 let server = wiremock::MockServer::start().await;
2093 let (client, sheets) = client(&server).await;
2094 mount_metadata(&server).await;
2095 mount_values(
2096 &server,
2097 "'Q1'!A2:A4",
2098 serde_json::json!({"values": [["a,b"]]}),
2099 )
2100 .await;
2101 mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2102 let opts = base_opts(false);
2103 let rules = vec![rule(DriveOperation::SheetsWrite, true)];
2104 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2105 assert!(matches!(
2106 outcome.result,
2107 TextToColumnsResult::RefusedNoLease
2108 ));
2109 }
2110
2111 #[tokio::test]
2112 async fn a_valid_lease_lets_the_real_run_succeed() {
2113 let ledger_dir = tempfile::tempdir().unwrap();
2114 let ledger_path = ledger_dir.path().join("leases.json");
2115 let token = seed_lease(&ledger_path, "sheet-1", "1");
2116
2117 let server = wiremock::MockServer::start().await;
2118 let (client, sheets) = client(&server).await;
2119 mount_metadata(&server).await;
2120 mount_values(
2121 &server,
2122 "'Q1'!A2:A4",
2123 serde_json::json!({"values": [["a,b"]]}),
2124 )
2125 .await;
2126 mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2127 wiremock::Mock::given(wiremock::matchers::method("POST"))
2128 .and(wiremock::matchers::path(
2129 "/v4/spreadsheets/sheet-1:batchUpdate",
2130 ))
2131 .respond_with(
2132 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2133 "spreadsheetId": "sheet-1", "replies": [{}],
2134 })),
2135 )
2136 .mount(&server)
2137 .await;
2138 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
2139 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2140 .respond_with(
2141 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2142 "id": "sheet-1", "name": "Budget", "version": "2",
2143 })),
2144 )
2145 .mount(&server)
2146 .await;
2147 let mut opts = base_opts(false);
2148 opts.lease_token = Some(token);
2149 opts.ledger_path = ledger_path;
2150 let rules = vec![rule(DriveOperation::SheetsWrite, true)];
2151 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2152 assert!(matches!(
2153 outcome.result,
2154 TextToColumnsResult::Changed { .. }
2155 ));
2156 }
2157
2158 #[tokio::test]
2163 async fn a_valid_lease_records_the_failure_when_batch_update_fails() {
2164 let ledger_dir = tempfile::tempdir().unwrap();
2165 let ledger_path = ledger_dir.path().join("leases.json");
2166 let token = seed_lease(&ledger_path, "sheet-1", "1");
2167
2168 let server = wiremock::MockServer::start().await;
2169 let (client, sheets) = client(&server).await;
2170 mount_metadata(&server).await;
2171 mount_values(
2172 &server,
2173 "'Q1'!A2:A4",
2174 serde_json::json!({"values": [["a,b"]]}),
2175 )
2176 .await;
2177 mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2178 mount_batch_update(&server, 400).await;
2179 let mut opts = base_opts(false);
2180 opts.lease_token = Some(token);
2181 opts.ledger_path = ledger_path;
2182 let rules = vec![rule(DriveOperation::SheetsWrite, true)];
2183 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2184 assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2185 }
2186
2187 #[tokio::test]
2197 async fn no_cell_value_from_either_read_reaches_the_output_or_the_outcome() {
2198 const SECRET: &str = "super-secret-cell-value";
2199 let server = wiremock::MockServer::start().await;
2200 let (client, sheets) = client(&server).await;
2201 mount_metadata(&server).await;
2202 mount_values(
2203 &server,
2204 "'Q1'!A2:A4",
2205 serde_json::json!({"values": [[format!("{SECRET},{SECRET}")]]}),
2206 )
2207 .await;
2208 mount_values(
2209 &server,
2210 "'Q1'!B2:B4",
2211 serde_json::json!({"values": [[SECRET]]}),
2212 )
2213 .await;
2214 let opts = base_opts(true);
2215 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2216 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2217
2218 match &outcome.result {
2221 TextToColumnsResult::WouldChange {
2222 overwritten_cells, ..
2223 } => assert_eq!(overwritten_cells, &vec!["B2".to_string()]),
2224 other => panic!("expected WouldChange, got {other:?}"), }
2226 let rendered = describe_lines(&outcome).join("\n");
2227 assert!(
2228 !rendered.contains(SECRET),
2229 "leaked into the text: {rendered}"
2230 );
2231 let serialized = serde_json::to_string(&outcome).unwrap();
2232 assert!(
2233 !serialized.contains(SECRET),
2234 "leaked into the outcome: {serialized}"
2235 );
2236 }
2237
2238 #[tokio::test]
2245 async fn a_spill_straddling_the_grid_edge_reads_only_the_in_grid_part() {
2246 let server = wiremock::MockServer::start().await;
2247 let (client, sheets) = client(&server).await;
2248 mount_file_metadata(&server, spreadsheet_metadata()).await;
2249 mount_parent(&server).await;
2250 mount_workbook(&server, 1000, 3).await; mount_values(
2252 &server,
2253 "'Q1'!A2:A4",
2254 serde_json::json!({"values": [["a,b,c,d,e"]]}),
2255 )
2256 .await;
2257 mount_values(
2260 &server,
2261 "'Q1'!B2:C4",
2262 serde_json::json!({"values": [["", "old"]]}),
2263 )
2264 .await;
2265 let opts = base_opts(true);
2266 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2267 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2268 match &outcome.result {
2269 TextToColumnsResult::WouldChange {
2270 spill,
2271 width_upper_bound,
2272 overwritten_cells,
2273 past_grid_extent,
2274 ..
2275 } => {
2276 assert_eq!(spill.as_deref(), Some("'Q1'!B2:E4"));
2279 assert_eq!(*width_upper_bound, 5);
2280 assert_eq!(overwritten_cells, &vec!["C2".to_string()]);
2281 assert!(past_grid_extent);
2282 }
2283 other => panic!("expected WouldChange, got {other:?}"),
2284 }
2285 assert!(describe_lines(&outcome).contains(&PAST_GRID_EXTENT_CAVEAT_DRY_RUN.to_string()));
2286 }
2287
2288 #[tokio::test]
2291 async fn a_spill_wholly_past_the_grid_extent_skips_the_read_and_still_splits() {
2292 let server = wiremock::MockServer::start().await;
2293 let (client, sheets) = client(&server).await;
2294 mount_file_metadata(&server, spreadsheet_metadata()).await;
2295 mount_parent(&server).await;
2296 mount_workbook(&server, 1000, 3).await; mount_values(
2298 &server,
2299 "'Q1'!C2:C4",
2300 serde_json::json!({"values": [["a,b"]]}),
2301 )
2302 .await;
2303 mount_batch_update(&server, 200).await;
2304 let mut opts = base_opts(false);
2305 opts.source = Some("Q1!C2:C4".to_string());
2306 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2307 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2308 match &outcome.result {
2309 TextToColumnsResult::Changed {
2310 spill,
2311 overwritten_cells,
2312 past_grid_extent,
2313 ..
2314 } => {
2315 assert_eq!(spill.as_deref(), Some("'Q1'!D2:D4"));
2316 assert!(overwritten_cells.is_empty());
2317 assert!(past_grid_extent);
2318 }
2319 other => panic!("expected Changed, got {other:?}"), }
2321 let reads: Vec<_> = server
2323 .received_requests()
2324 .await
2325 .unwrap()
2326 .into_iter()
2327 .filter(|r| r.url.path().contains("/values/"))
2328 .collect();
2329 assert_eq!(reads.len(), 1);
2330 assert!(describe_lines(&outcome).contains(&PAST_GRID_EXTENT_CAVEAT.to_string()));
2331 }
2332
2333 #[tokio::test]
2336 async fn a_source_read_failure_is_reported_as_failed() {
2337 let server = wiremock::MockServer::start().await;
2338 let (client, sheets) = client(&server).await;
2339 mount_metadata(&server).await;
2340 mount_values_failure(&server, "'Q1'!A2:A4").await;
2341 let outcome = text_to_columns(
2342 &client,
2343 &sheets,
2344 &base_opts(true),
2345 &[rule(DriveOperation::SheetsWrite, false)],
2346 )
2347 .await;
2348 assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2349 }
2350
2351 #[tokio::test]
2352 async fn a_spill_read_failure_is_reported_as_failed() {
2353 let server = wiremock::MockServer::start().await;
2354 let (client, sheets) = client(&server).await;
2355 mount_metadata(&server).await;
2356 mount_values(
2357 &server,
2358 "'Q1'!A2:A4",
2359 serde_json::json!({"values": [["a,b"]]}),
2360 )
2361 .await;
2362 mount_values_failure(&server, "'Q1'!B2:B4").await;
2363 let outcome = text_to_columns(
2364 &client,
2365 &sheets,
2366 &base_opts(true),
2367 &[rule(DriveOperation::SheetsWrite, false)],
2368 )
2369 .await;
2370 assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2371 }
2372
2373 #[tokio::test]
2374 async fn a_batch_update_failure_is_reported_as_failed() {
2375 let server = wiremock::MockServer::start().await;
2376 let (client, sheets) = client(&server).await;
2377 mount_metadata(&server).await;
2378 mount_values(
2379 &server,
2380 "'Q1'!A2:A4",
2381 serde_json::json!({"values": [["a,b"]]}),
2382 )
2383 .await;
2384 mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2385 mount_batch_update(&server, 400).await;
2386 let outcome = text_to_columns(
2387 &client,
2388 &sheets,
2389 &base_opts(false),
2390 &[rule(DriveOperation::SheetsWrite, false)],
2391 )
2392 .await;
2393 assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2394 }
2395
2396 #[tokio::test]
2397 async fn a_workbook_fetch_failure_is_reported_as_failed() {
2398 let server = wiremock::MockServer::start().await;
2399 let (client, sheets) = client(&server).await;
2400 mount_file_metadata(&server, spreadsheet_metadata()).await;
2401 mount_parent(&server).await;
2402 wiremock::Mock::given(wiremock::matchers::method("GET"))
2403 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2404 .respond_with(wiremock::ResponseTemplate::new(500))
2405 .mount(&server)
2406 .await;
2407 let outcome = text_to_columns(
2408 &client,
2409 &sheets,
2410 &base_opts(true),
2411 &[rule(DriveOperation::SheetsWrite, false)],
2412 )
2413 .await;
2414 assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2415 }
2416
2417 #[tokio::test]
2420 async fn a_metadata_fetch_failure_is_reported_as_failed() {
2421 let server = wiremock::MockServer::start().await;
2422 let (client, sheets) = client(&server).await;
2423 wiremock::Mock::given(wiremock::matchers::method("GET"))
2424 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2425 .respond_with(wiremock::ResponseTemplate::new(500))
2426 .mount(&server)
2427 .await;
2428 let outcome = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
2429 assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2430 assert!(outcome.file_name.is_none());
2431 }
2432
2433 #[tokio::test]
2436 async fn a_gate_fetch_failure_is_reported_as_failed_and_still_names_the_file() {
2437 let server = wiremock::MockServer::start().await;
2438 let (client, sheets) = client(&server).await;
2439 mount_file_metadata(&server, spreadsheet_metadata()).await;
2440 wiremock::Mock::given(wiremock::matchers::method("GET"))
2441 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
2442 .respond_with(wiremock::ResponseTemplate::new(500))
2443 .mount(&server)
2444 .await;
2445 let outcome = text_to_columns(
2446 &client,
2447 &sheets,
2448 &base_opts(true),
2449 &[rule(DriveOperation::SheetsWrite, false)],
2450 )
2451 .await;
2452 assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2453 assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
2454 assert!(outcome.resolved_folder_id.is_none());
2455 }
2456
2457 #[tokio::test]
2460 async fn a_target_that_is_not_a_spreadsheet_is_refused() {
2461 let server = wiremock::MockServer::start().await;
2462 let (client, sheets) = client(&server).await;
2463 mount_file_metadata(
2464 &server,
2465 serde_json::json!({
2466 "id": "sheet-1", "name": "Budget",
2467 "mimeType": "application/vnd.google-apps.document",
2468 "parents": ["parent-1"], "version": "1",
2469 }),
2470 )
2471 .await;
2472 let outcome = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
2473 match &outcome.result {
2474 TextToColumnsResult::RefusedNotASpreadsheet { mime_type } => {
2475 assert_eq!(mime_type, "application/vnd.google-apps.document");
2476 }
2477 other => panic!("expected RefusedNotASpreadsheet, got {other:?}"),
2478 }
2479 assert!(describe_lines(&outcome)[0].contains("is not a Google Sheet"));
2480 }
2481
2482 #[tokio::test]
2483 async fn a_shortcut_target_is_refused_rather_than_followed() {
2484 let server = wiremock::MockServer::start().await;
2485 let (client, sheets) = client(&server).await;
2486 mount_file_metadata(
2487 &server,
2488 serde_json::json!({
2489 "id": "sheet-1", "name": "Budget",
2490 "mimeType": "application/vnd.google-apps.shortcut",
2491 "parents": ["parent-1"], "version": "1",
2492 "shortcutDetails": {
2493 "targetId": "real-sheet",
2494 "targetMimeType": "application/vnd.google-apps.spreadsheet",
2495 },
2496 }),
2497 )
2498 .await;
2499 let outcome = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
2500 assert!(matches!(
2501 outcome.result,
2502 TextToColumnsResult::RefusedShortcut
2503 ));
2504 assert!(describe_lines(&outcome)[0].contains("doesn't follow shortcuts"));
2505 }
2506
2507 #[tokio::test]
2508 async fn a_target_with_no_visible_parents_is_refused_with_the_by_id_hint() {
2509 let server = wiremock::MockServer::start().await;
2510 let (client, sheets) = client(&server).await;
2511 mount_file_metadata(
2512 &server,
2513 serde_json::json!({
2514 "id": "sheet-1", "name": "Budget",
2515 "mimeType": "application/vnd.google-apps.spreadsheet",
2516 "parents": [], "version": "1",
2517 }),
2518 )
2519 .await;
2520 let outcome = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
2521 assert!(matches!(
2522 outcome.result,
2523 TextToColumnsResult::RefusedNoVisibleParents
2524 ));
2525 assert!(describe_lines(&outcome)[0].contains("write_permissions.rules"));
2526 }
2527
2528 #[tokio::test]
2529 async fn a_source_on_a_missing_sheet_is_refused_and_names_the_available_titles() {
2530 let server = wiremock::MockServer::start().await;
2531 let (client, sheets) = client(&server).await;
2532 mount_metadata(&server).await;
2533 let mut opts = base_opts(true);
2534 opts.source = Some("Q9!A2:A4".to_string());
2535 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2536 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2537 match &outcome.result {
2538 TextToColumnsResult::RefusedSheetNotFound { title, available } => {
2539 assert_eq!(title, "Q9");
2540 assert_eq!(available, &vec!["Q1".to_string()]);
2541 }
2542 other => panic!("expected RefusedSheetNotFound, got {other:?}"),
2543 }
2544 }
2545
2546 fn ledger_in_a_tempdir() -> PathBuf {
2549 tempfile::tempdir()
2550 .unwrap()
2551 .keep()
2552 .join("lease-ledger.jsonl")
2553 }
2554
2555 async fn run_with_lease(
2560 server: &wiremock::MockServer,
2561 lease_token: Option<String>,
2562 ledger_path: PathBuf,
2563 ) -> TextToColumnsOutcome {
2564 let (client, sheets) = client(server).await;
2565 mount_metadata(server).await;
2566 mount_values(
2567 server,
2568 "'Q1'!A2:A4",
2569 serde_json::json!({"values": [["a,b"]]}),
2570 )
2571 .await;
2572 mount_values(server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2573 mount_batch_update(server, 200).await;
2574 let opts = TextToColumnsOptions {
2575 lease_token,
2576 ledger_path,
2577 ..base_opts(false)
2578 };
2579 let outcome = text_to_columns(
2580 &client,
2581 &sheets,
2582 &opts,
2583 &[rule(DriveOperation::SheetsWrite, true)],
2584 )
2585 .await;
2586 assert!(
2587 !batch_update_was_called(&server.received_requests().await.unwrap()),
2588 "a refused lease must not reach batchUpdate"
2589 );
2590 outcome
2591 }
2592
2593 #[tokio::test]
2597 async fn a_sheet_that_conflicts_with_a_prefixed_source_is_refused_before_any_request() {
2598 let server = wiremock::MockServer::start().await;
2599 let (client, sheets) = client(&server).await;
2600 let mut opts = base_opts(true);
2601 opts.sheet = Some("Q1".to_string());
2602 opts.source = Some("'Q2'!A2:A4".to_string());
2603 let outcome = text_to_columns(&client, &sheets, &opts, &[]).await;
2604 assert!(matches!(
2605 outcome.result,
2606 TextToColumnsResult::RefusedInvalidRange { .. }
2607 ));
2608 assert!(server.received_requests().await.unwrap().is_empty());
2609 }
2610
2611 #[tokio::test]
2615 async fn an_unparseable_source_is_refused_with_the_resolvers_own_detail() {
2616 let server = wiremock::MockServer::start().await;
2617 let (client, sheets) = client(&server).await;
2618 mount_metadata(&server).await;
2619 let mut opts = base_opts(true);
2620 opts.source = Some("Q1!not-a-range".to_string());
2621 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2622 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2623 match &outcome.result {
2624 TextToColumnsResult::RefusedInvalidRange { detail } => {
2625 assert!(
2626 !detail.is_empty(),
2627 "the resolver's detail is passed through"
2628 );
2629 }
2630 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2631 }
2632 }
2633
2634 #[tokio::test]
2638 async fn a_source_that_never_splits_reads_no_spill_and_still_sends_the_request() {
2639 let server = wiremock::MockServer::start().await;
2640 let (client, sheets) = client(&server).await;
2641 mount_metadata(&server).await;
2642 mount_values(
2643 &server,
2644 "'Q1'!A2:A4",
2645 serde_json::json!({"values": [["no separator here"]]}),
2646 )
2647 .await;
2648 mount_batch_update(&server, 200).await;
2649 let outcome = text_to_columns(
2650 &client,
2651 &sheets,
2652 &base_opts(false),
2653 &[rule(DriveOperation::SheetsWrite, false)],
2654 )
2655 .await;
2656 match &outcome.result {
2657 TextToColumnsResult::Changed {
2658 summary,
2659 spill,
2660 width_upper_bound,
2661 overwritten_cells,
2662 past_grid_extent,
2663 ..
2664 } => {
2665 assert_eq!(*width_upper_bound, 1);
2666 assert!(spill.is_none());
2667 assert!(overwritten_cells.is_empty());
2668 assert!(!past_grid_extent);
2669 assert!(summary.contains("no row spills beyond the source"));
2670 }
2671 other => panic!("expected Changed, got {other:?}"), }
2673 let reads: Vec<_> = server
2674 .received_requests()
2675 .await
2676 .unwrap()
2677 .into_iter()
2678 .filter(|r| r.url.path().contains("/values/"))
2679 .collect();
2680 assert_eq!(reads.len(), 1, "no spill span means no second read");
2681 }
2682
2683 #[tokio::test]
2684 async fn an_expired_or_unknown_lease_is_refused() {
2685 let server = wiremock::MockServer::start().await;
2686 let outcome = run_with_lease(
2689 &server,
2690 Some("never-issued".to_string()),
2691 ledger_in_a_tempdir(),
2692 )
2693 .await;
2694 assert!(matches!(
2695 outcome.result,
2696 TextToColumnsResult::RefusedLeaseExpired
2697 ));
2698 }
2699
2700 #[tokio::test]
2701 async fn a_lease_bound_to_another_file_is_refused() {
2702 let server = wiremock::MockServer::start().await;
2703 let ledger_path = ledger_in_a_tempdir();
2704 let token = seed_lease(&ledger_path, "some-other-sheet", "1");
2705 let outcome = run_with_lease(&server, Some(token), ledger_path).await;
2706 assert!(matches!(
2707 outcome.result,
2708 TextToColumnsResult::RefusedLeaseWrongFile
2709 ));
2710 }
2711
2712 #[tokio::test]
2713 async fn a_stale_lease_is_refused_when_the_file_has_moved() {
2714 let server = wiremock::MockServer::start().await;
2715 let ledger_path = ledger_in_a_tempdir();
2716 let token = seed_lease(&ledger_path, "sheet-1", "0");
2719 let outcome = run_with_lease(&server, Some(token), ledger_path).await;
2720 assert!(matches!(
2721 outcome.result,
2722 TextToColumnsResult::RefusedLeaseStale
2723 ));
2724 }
2725}