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::{
86 applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi, ValueRenderOption,
87};
88use crate::drive::sheets::client::SheetsClient;
89use crate::drive::sheets::grid_range;
90use crate::drive::sheets::target_gate;
91use crate::drive::sheets::types::{
92 BatchUpdateRequestItem, DelimiterType, GridRange, Spreadsheet, TextToColumnsRequest, ValueRange,
93};
94use crate::drive::types::SheetTargetRefusal;
95use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
96use crate::request_log::{self, DriveMutationOutcome};
97
98const LOG_OPERATION: &str = "sheets-text-to-columns";
103
104const GATE_OPERATIONS: &[DriveOperation] =
110 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure];
111
112#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum Delimiter {
122 Comma,
124 Semicolon,
126 Period,
128 Space,
130 Auto,
132 Custom(String),
134}
135
136impl Delimiter {
137 fn wire_type(&self) -> DelimiterType {
139 match self {
140 Self::Comma => DelimiterType::Comma,
141 Self::Semicolon => DelimiterType::Semicolon,
142 Self::Period => DelimiterType::Period,
143 Self::Space => DelimiterType::Space,
144 Self::Auto => DelimiterType::Autodetect,
145 Self::Custom(_) => DelimiterType::Custom,
146 }
147 }
148
149 fn local_split_candidates(&self) -> Vec<&str> {
160 match self {
161 Self::Comma => vec![","],
162 Self::Semicolon => vec![";"],
163 Self::Period => vec!["."],
164 Self::Space => vec![" "],
165 Self::Custom(text) => vec![text.as_str()],
166 Self::Auto => vec![",", ";", ".", " "],
167 }
168 }
169
170 fn describe(&self) -> String {
172 match self {
173 Self::Comma => "comma".to_string(),
174 Self::Semicolon => "semicolon".to_string(),
175 Self::Period => "period".to_string(),
176 Self::Space => "space".to_string(),
177 Self::Auto => "an auto-detected separator".to_string(),
178 Self::Custom(text) => format!("custom ({text:?})"),
179 }
180 }
181}
182
183#[derive(Debug, Clone)]
185pub struct TextToColumnsOptions {
186 pub spreadsheet_id: String,
188 pub sheet: Option<String>,
190 pub source: Option<String>,
194 pub delimiter: Delimiter,
196 pub dry_run: bool,
198 pub lease_token: Option<String>,
203 pub ledger_path: PathBuf,
207}
208
209#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
211#[serde(tag = "status", rename_all = "kebab-case")]
212pub enum TextToColumnsResult {
213 WouldChange {
215 summary: String,
222 source: String,
224 #[serde(skip_serializing_if = "Option::is_none")]
229 spill: Option<String>,
230 width_upper_bound: usize,
237 #[serde(skip_serializing_if = "Vec::is_empty")]
246 overwritten_cells: Vec<String>,
247 past_grid_extent: bool,
251 },
252 RefusedNotASpreadsheet {
254 mime_type: String,
256 },
257 RefusedShortcut,
259 RefusedNoVisibleParents,
261 RefusedSheetNotFound {
263 title: String,
265 available: Vec<String>,
267 },
268 RefusedInvalidRange {
271 detail: String,
273 },
274 RefusedInvalidDelimiter {
276 detail: String,
278 },
279 Blocked {
281 operation: DriveOperation,
285 decided_by: Option<DecidingRule>,
287 },
288 RefusedNoLease,
291 RefusedLeaseExpired,
294 RefusedLeaseWrongFile,
296 RefusedLeaseStale,
299 Changed {
301 summary: String,
305 source: String,
307 #[serde(skip_serializing_if = "Option::is_none")]
309 spill: Option<String>,
310 width_upper_bound: usize,
312 #[serde(skip_serializing_if = "Vec::is_empty")]
316 overwritten_cells: Vec<String>,
317 past_grid_extent: bool,
319 },
320 AppliedReplyUnreadable {
324 summary: String,
326 detail: String,
328 source: String,
330 #[serde(skip_serializing_if = "Option::is_none")]
332 spill: Option<String>,
333 width_upper_bound: usize,
335 #[serde(skip_serializing_if = "Vec::is_empty")]
339 overwritten_cells: Vec<String>,
340 past_grid_extent: bool,
342 },
343 Failed {
345 detail: String,
347 },
348}
349
350impl FromLeaseRefusal for TextToColumnsResult {
351 fn from_no_lease() -> Self {
352 Self::RefusedNoLease
353 }
354 fn from_lease_expired() -> Self {
355 Self::RefusedLeaseExpired
356 }
357 fn from_lease_wrong_file() -> Self {
358 Self::RefusedLeaseWrongFile
359 }
360 fn from_lease_stale() -> Self {
361 Self::RefusedLeaseStale
362 }
363 fn from_lease_failed(detail: String) -> Self {
364 Self::Failed { detail }
365 }
366}
367
368impl TextToColumnsResult {
369 fn log_status(&self) -> &'static str {
370 match self {
371 Self::WouldChange { .. } => "would-change",
372 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
373 Self::RefusedShortcut => "refused-shortcut",
374 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
375 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
376 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
377 Self::RefusedInvalidDelimiter { .. } => "refused-invalid-delimiter",
378 Self::Blocked { .. } => "blocked",
379 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
380 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
381 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
382 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
383 Self::Changed { .. } => "changed",
384 Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
385 Self::Failed { .. } => "failed",
386 }
387 }
388}
389
390#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
392pub struct TextToColumnsOutcome {
393 pub spreadsheet_id: String,
395 #[serde(skip_serializing_if = "Option::is_none")]
397 pub file_name: Option<String>,
398 #[serde(skip_serializing_if = "Option::is_none")]
400 pub resolved_folder_id: Option<String>,
401 #[serde(skip_serializing_if = "Option::is_none")]
403 pub sheet_id: Option<i64>,
404 #[serde(skip)]
406 pub delimiter: Delimiter,
407 pub result: TextToColumnsResult,
409}
410
411impl JsonlSerialize for TextToColumnsOutcome {
412 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
413 write_scalar_jsonl(self, out)
414 }
415}
416
417pub async fn text_to_columns(
420 drive: &DriveClient,
421 sheets: &SheetsClient,
422 opts: &TextToColumnsOptions,
423 rules: &[FolderPermissionRule],
424) -> TextToColumnsOutcome {
425 let started = Instant::now();
426 let outcome = text_to_columns_inner(drive, sheets, opts, rules).await;
427 if !opts.dry_run {
428 record_attempt(&outcome, started.elapsed());
429 }
430 outcome
431}
432
433async fn text_to_columns_inner(
434 drive: &DriveClient,
435 sheets: &SheetsClient,
436 opts: &TextToColumnsOptions,
437 rules: &[FolderPermissionRule],
438) -> TextToColumnsOutcome {
439 let bare = |result| TextToColumnsOutcome {
440 spreadsheet_id: opts.spreadsheet_id.clone(),
441 file_name: None,
442 resolved_folder_id: None,
443 sheet_id: None,
444 delimiter: opts.delimiter.clone(),
445 result,
446 };
447
448 let composed = match a1::compose(opts.sheet.as_deref(), opts.source.as_deref()) {
453 Ok(composed) => composed,
454 Err(err) => {
455 return bare(TextToColumnsResult::RefusedInvalidRange {
456 detail: err.to_string(),
457 })
458 }
459 };
460 if let Delimiter::Custom(text) = &opts.delimiter {
461 if text.is_empty() {
462 return bare(TextToColumnsResult::RefusedInvalidDelimiter {
463 detail: "--custom-delimiter must not be empty".to_string(),
464 });
465 }
466 }
467
468 let (target, verdict, denied, resolved_folder_id, requires_lease) =
470 match target_gate::resolve_all(drive, &opts.spreadsheet_id, GATE_OPERATIONS, rules).await {
471 target_gate::TargetGateUnionOutcome::MetadataFetchFailed { detail } => {
472 return bare(TextToColumnsResult::Failed { detail })
473 }
474 target_gate::TargetGateUnionOutcome::Refused { target, refusal } => {
475 let result = match refusal {
476 SheetTargetRefusal::Shortcut => TextToColumnsResult::RefusedShortcut,
477 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
478 TextToColumnsResult::RefusedNotASpreadsheet { mime_type }
479 }
480 SheetTargetRefusal::NoVisibleParents => {
481 TextToColumnsResult::RefusedNoVisibleParents
482 }
483 };
484 return TextToColumnsOutcome {
485 spreadsheet_id: opts.spreadsheet_id.clone(),
486 file_name: Some(target.name),
487 resolved_folder_id: None,
488 sheet_id: None,
489 delimiter: opts.delimiter.clone(),
490 result,
491 };
492 }
493 target_gate::TargetGateUnionOutcome::GateFetchFailed { target, detail } => {
494 return TextToColumnsOutcome {
495 spreadsheet_id: opts.spreadsheet_id.clone(),
496 file_name: Some(target.name),
497 resolved_folder_id: None,
498 sheet_id: None,
499 delimiter: opts.delimiter.clone(),
500 result: TextToColumnsResult::Failed { detail },
501 };
502 }
503 target_gate::TargetGateUnionOutcome::Gated {
504 target,
505 verdict,
506 denied,
507 resolved_folder_id,
508 requires_lease,
509 } => (target, verdict, denied, resolved_folder_id, requires_lease),
510 };
511
512 let pre_gated = |result| TextToColumnsOutcome {
513 spreadsheet_id: opts.spreadsheet_id.clone(),
514 file_name: Some(target.name.clone()),
515 resolved_folder_id: resolved_folder_id.clone(),
516 sheet_id: None,
517 delimiter: opts.delimiter.clone(),
518 result,
519 };
520
521 if verdict == write_gate::Verdict::Deny {
522 let (operation, decided_by) = denied.unwrap_or((GATE_OPERATIONS[0], None));
526 return pre_gated(TextToColumnsResult::Blocked {
527 operation,
528 decided_by,
529 });
530 }
531
532 let api = SheetsApi::new(sheets);
533 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
534 Ok(workbook) => workbook,
535 Err(err) => {
536 return pre_gated(TextToColumnsResult::Failed {
537 detail: format!("{err:#}"),
538 })
539 }
540 };
541
542 let (sheet_title, source_grid) = match grid_range::resolve_grid_range(
543 &workbook,
544 &composed,
545 |detail| TextToColumnsResult::RefusedInvalidRange { detail },
546 |title, available| TextToColumnsResult::RefusedSheetNotFound { title, available },
547 ) {
548 Ok(resolved) => resolved,
549 Err(result) => return pre_gated(result),
550 };
551
552 let gated = |result| TextToColumnsOutcome {
553 spreadsheet_id: opts.spreadsheet_id.clone(),
554 file_name: Some(target.name.clone()),
555 resolved_folder_id: resolved_folder_id.clone(),
556 sheet_id: Some(source_grid.sheet_id),
557 delimiter: opts.delimiter.clone(),
558 result,
559 };
560
561 if !grid_range::is_bounded(&source_grid) {
562 return gated(TextToColumnsResult::RefusedInvalidRange {
563 detail: format!(
564 "{composed} is open-ended; text-to-columns needs a fully bounded single \
565 column (e.g. A2:A100)"
566 ),
567 });
568 }
569 if !is_single_column(&source_grid) {
570 return gated(TextToColumnsResult::RefusedInvalidRange {
571 detail: format!(
572 "{composed} spans more than one column; text-to-columns' source must span \
573 exactly one column"
574 ),
575 });
576 }
577
578 let source_a1 = grid_range::bounded_range_to_a1(&sheet_title, &source_grid).unwrap_or(composed);
580
581 let source_values = match api
582 .values_get(
583 &opts.spreadsheet_id,
584 &source_a1,
585 ValueRenderOption::Formatted,
586 )
587 .await
588 {
589 Ok(values) => values,
590 Err(err) => {
591 return gated(TextToColumnsResult::Failed {
592 detail: format!("{err:#}"),
593 })
594 }
595 };
596
597 let width_upper_bound = split_width(&source_values, &opts.delimiter);
598 let spill_grid = spill_span(source_grid, width_upper_bound);
599 let past_grid_extent = spill_grid.is_some_and(|spill| extends_past_grid(&workbook, &spill));
600
601 let (spill_a1, overwritten_cells) = match spill_grid {
604 None => (None, Vec::new()),
605 Some(spill_grid) => {
606 let overwritten_cells = match grid_range::clamp_to_sheet(&workbook, &spill_grid) {
607 None => Vec::new(),
608 Some(read_grid) => {
609 let read_a1 = grid_range::bounded_range_to_a1(&sheet_title, &read_grid)
610 .unwrap_or_else(|| sheet_title.clone());
611 match api
612 .values_get(&opts.spreadsheet_id, &read_a1, ValueRenderOption::Formatted)
613 .await
614 {
615 Ok(values) => grid_range::non_blank_locations(
616 &values,
617 read_grid.start_row_index.unwrap_or(0),
618 read_grid.start_column_index.unwrap_or(0),
619 ),
620 Err(err) => {
621 return gated(TextToColumnsResult::Failed {
622 detail: format!("{err:#}"),
623 })
624 }
625 }
626 }
627 };
628 (
629 grid_range::bounded_range_to_a1(&sheet_title, &spill_grid),
630 overwritten_cells,
631 )
632 }
633 };
634
635 let summary = describe_effect(
636 &source_a1,
637 spill_a1.as_deref(),
638 width_upper_bound,
639 &opts.delimiter,
640 );
641
642 if opts.dry_run {
643 return gated(TextToColumnsResult::WouldChange {
644 summary,
645 source: source_a1,
646 spill: spill_a1,
647 width_upper_bound,
648 overwritten_cells,
649 past_grid_extent,
650 });
651 }
652
653 let request = build_request(source_grid, &opts.delimiter);
656
657 let files_api = FilesApi::new(drive);
658 let leased = LeasedWrite {
659 log_prefix: "drive sheets text-to-columns",
660 operation: LOG_OPERATION,
661 ledger_path: &opts.ledger_path,
662 file_id: &opts.spreadsheet_id,
663 };
664 let lease_grant = match gate_optional_leased_write(
665 leased,
666 &files_api,
667 requires_lease,
668 opts.lease_token.as_deref(),
669 )
670 .await
671 {
672 Ok(grant) => grant,
673 Err(err) => return gated(err.into_result()),
674 };
675
676 let result = match conclude_native_leased_write(
677 leased,
678 &lease_grant,
679 &files_api,
680 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
681 |err| format!("{err:#}"),
682 )
683 .await
684 {
685 Ok(BatchUpdateOutcome::Applied(_)) => TextToColumnsResult::Changed {
686 summary,
687 source: source_a1,
688 spill: spill_a1,
689 width_upper_bound,
690 overwritten_cells,
691 past_grid_extent,
692 },
693 Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
695 TextToColumnsResult::AppliedReplyUnreadable {
696 summary,
697 detail,
698 source: source_a1,
699 spill: spill_a1,
700 width_upper_bound,
701 overwritten_cells,
702 past_grid_extent,
703 }
704 }
705 Err(err) => TextToColumnsResult::Failed {
706 detail: format!("{err:#}"),
707 },
708 };
709 drop(lease_grant);
710 gated(result)
711}
712
713fn is_single_column(grid: &GridRange) -> bool {
716 grid.end_column_index.unwrap_or(0) - grid.start_column_index.unwrap_or(0) == 1
717}
718
719fn cell_text(cell: &serde_json::Value) -> String {
728 match cell.as_str() {
729 Some(text) => text.to_string(),
730 None if cell.is_null() => String::new(),
731 None => cell.to_string(),
732 }
733}
734
735fn split_width(values: &ValueRange, delimiter: &Delimiter) -> usize {
747 let candidates = delimiter.local_split_candidates();
748 values
749 .values
750 .iter()
751 .filter_map(|row| row.first())
752 .map(cell_text)
753 .filter(|text| !text.is_empty())
754 .map(|text| {
755 candidates
756 .iter()
757 .map(|sep| text.split(sep).count())
758 .max()
759 .unwrap_or(1)
760 })
761 .max()
762 .unwrap_or(0)
763}
764
765fn spill_span(source: GridRange, width: usize) -> Option<GridRange> {
771 if width <= 1 {
772 return None;
773 }
774 let start_column = source.start_column_index?;
775 let width = i64::try_from(width).ok()?;
776 Some(GridRange {
777 sheet_id: source.sheet_id,
778 start_row_index: source.start_row_index,
779 end_row_index: source.end_row_index,
780 start_column_index: Some(start_column + 1),
781 end_column_index: Some(start_column + width),
782 })
783}
784
785fn extends_past_grid(workbook: &Spreadsheet, extent: &GridRange) -> bool {
794 let Some(sheet) = grid_range::find_sheet_by_id(workbook, extent.sheet_id) else {
795 return false;
796 };
797 let grid = sheet
798 .properties
799 .as_ref()
800 .and_then(|props| props.grid_properties.as_ref());
801 let row_count = grid.and_then(|g| g.row_count);
802 let column_count = grid.and_then(|g| g.column_count);
803 let rows_exceed = matches!(
804 (extent.end_row_index, row_count),
805 (Some(end), Some(count)) if end > count
806 );
807 let cols_exceed = matches!(
808 (extent.end_column_index, column_count),
809 (Some(end), Some(count)) if end > count
810 );
811 rows_exceed || cols_exceed
812}
813
814const UNPREVIEWABLE_SPLIT_CAVEAT: &str =
819 " the number of columns the split needs, and the values it writes, are computed by \
820 Sheets' own splitting and are never reported, before or after the request; the count above \
821 is a local upper-bound estimate only";
822
823const PAST_GRID_EXTENT_CAVEAT_DRY_RUN: &str =
827 " the spill extends past the sheet's current extent — Sheets may grow the sheet or refuse \
828 the request";
829const PAST_GRID_EXTENT_CAVEAT: &str =
830 " the spill extended past the sheet's current extent, so Sheets may have grown it";
831
832const AUTO_DELIMITER_CAVEAT: &str =
852 " --delimiter auto lets Sheets detect the separator itself, and it detects separators this \
853 preview does not try (a tab-separated column splits under auto, though none of comma, \
854 semicolon, period or space appears in it) — so for auto the width above and the cells \
855 listed are a guess in both directions, not a bound";
856
857fn describe_effect(
868 source_a1: &str,
869 spill_a1: Option<&str>,
870 width_upper_bound: usize,
871 delimiter: &Delimiter,
872) -> String {
873 match spill_a1 {
874 Some(spill_a1) => format!(
875 "split {source_a1} on {} into up to {width_upper_bound} column(s), spill {spill_a1}",
876 delimiter.describe(),
877 ),
878 None if matches!(delimiter, Delimiter::Auto) => format!(
884 "split {source_a1} on {} (none detected in the preview, so the spill span is \
885 unknown)",
886 delimiter.describe(),
887 ),
888 None => format!(
889 "split {source_a1} on {} into a single column each row; no row spills beyond \
890 the source under this delimiter",
891 delimiter.describe(),
892 ),
893 }
894}
895
896fn overwritten_line(
910 overwritten_cells: &[String],
911 delimiter: &Delimiter,
912 dry_run: bool,
913) -> Option<String> {
914 if overwritten_cells.is_empty() {
915 return match delimiter {
916 Delimiter::Auto => None,
917 _ => Some(" no non-blank cells in the spill columns".to_string()),
918 };
919 }
920 let tense = if dry_run {
921 "would be overwritten"
922 } else {
923 "were overwritten"
924 };
925 Some(format!(
926 " up to {} non-blank cell(s) {tense}: {}",
927 overwritten_cells.len(),
928 grid_range::render_locations(overwritten_cells)
929 ))
930}
931
932fn build_request(source: GridRange, delimiter: &Delimiter) -> BatchUpdateRequestItem {
938 let delimiter_text = match delimiter {
939 Delimiter::Custom(text) => Some(text.clone()),
940 _ => None,
941 };
942 BatchUpdateRequestItem::TextToColumns(TextToColumnsRequest {
943 source,
944 delimiter: delimiter_text,
945 delimiter_type: delimiter.wire_type(),
946 })
947}
948
949fn record_attempt(outcome: &TextToColumnsOutcome, duration: Duration) {
950 request_log::record_drive_mutation(mutation_outcome(outcome, duration));
951}
952
953fn mutation_outcome(outcome: &TextToColumnsOutcome, duration: Duration) -> DriveMutationOutcome {
956 let error = match &outcome.result {
957 TextToColumnsResult::Failed { detail }
958 | TextToColumnsResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
959 _ => None,
960 };
961 let decided_by = match &outcome.result {
962 TextToColumnsResult::Blocked { decided_by, .. } => decided_by.as_ref(),
963 _ => None,
964 };
965 let decided_by = write_gate::decided_by_log_fields(decided_by);
966 let (range, fields_changed, overwritten_cells) = match &outcome.result {
967 TextToColumnsResult::Changed {
970 summary,
971 source,
972 overwritten_cells,
973 ..
974 }
975 | TextToColumnsResult::AppliedReplyUnreadable {
976 summary,
977 source,
978 overwritten_cells,
979 ..
980 } => (
981 Some(source.clone()),
982 Some(summary.clone()),
983 overwritten_cells.clone(),
984 ),
985 _ => (None, None, Vec::new()),
986 };
987
988 DriveMutationOutcome {
989 operation: LOG_OPERATION,
990 file_id: outcome.spreadsheet_id.clone(),
991 file_name: outcome.file_name.clone().unwrap_or_default(),
992 status: outcome.result.log_status().to_string(),
993 resolved_folder_id: outcome.resolved_folder_id.clone(),
994 decided_by_folder_id: decided_by.folder_id,
995 decided_by_depth: decided_by.depth,
996 decided_by_file_id: decided_by.file_id,
997 sheet_id: outcome.sheet_id,
998 range,
999 fields_changed,
1000 overwritten_cells,
1001 overwritten_cells_upper_bound: true,
1008 error,
1009 duration,
1010 ..Default::default()
1011 }
1012}
1013
1014#[must_use]
1016pub fn describe(outcome: &TextToColumnsOutcome) -> String {
1017 describe_lines(outcome).join("\n")
1018}
1019
1020#[must_use]
1023pub fn describe_lines(outcome: &TextToColumnsOutcome) -> Vec<String> {
1024 let book = outcome.file_name.as_deref().map_or_else(
1025 || format!("'{}'", outcome.spreadsheet_id),
1026 |n| format!("'{n}'"),
1027 );
1028 match &outcome.result {
1029 TextToColumnsResult::WouldChange {
1030 summary,
1031 overwritten_cells,
1032 past_grid_extent,
1033 ..
1034 } => change_lines(
1035 &format!("Would {summary} in {book}"),
1036 overwritten_cells,
1037 *past_grid_extent,
1038 &outcome.delimiter,
1039 true,
1040 ),
1041 TextToColumnsResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1042 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
1043 `drive sheets text-to-columns` only works on spreadsheets"
1044 )],
1045 TextToColumnsResult::RefusedShortcut => vec![format!(
1046 "Refused: {book} is a shortcut; `drive sheets text-to-columns` doesn't follow \
1047 shortcuts"
1048 )],
1049 TextToColumnsResult::RefusedNoVisibleParents => vec![format!(
1050 "Refused: {book} has no parent folder visible to this account, so no folder \
1051 rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
1052 id>\", \"allow\": [\"sheets-write\"]}} to write_permissions.rules."
1053 )],
1054 TextToColumnsResult::RefusedSheetNotFound { title, available } => {
1055 vec![grid_range::no_sheet_refusal(&book, title, available)]
1056 }
1057 TextToColumnsResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
1058 TextToColumnsResult::RefusedInvalidDelimiter { detail } => {
1059 vec![format!("Refused: {detail}")]
1060 }
1061 TextToColumnsResult::Blocked {
1062 operation,
1063 decided_by,
1064 } => vec![match decided_by {
1065 Some(rule) => format!(
1066 "Blocked: text-to-columns on {book} refused by rule on {} {}{}",
1067 rule.kind_label(),
1068 rule.id(),
1069 rule.depth_suffix()
1070 ),
1071 None => format!(
1072 "Blocked: text-to-columns on {book} refused by default policy (no matching rule \
1073 for {operation})"
1074 ),
1075 }],
1076 TextToColumnsResult::RefusedNoLease => LeaseGateRefusal::NoLease
1077 .describe_line(&outcome.spreadsheet_id, &book)
1078 .into_iter()
1079 .collect(),
1080 TextToColumnsResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1081 .describe_line(&outcome.spreadsheet_id, &book)
1082 .into_iter()
1083 .collect(),
1084 TextToColumnsResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1085 .describe_line(&outcome.spreadsheet_id, &book)
1086 .into_iter()
1087 .collect(),
1088 TextToColumnsResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1089 .describe_line(&outcome.spreadsheet_id, &book)
1090 .into_iter()
1091 .collect(),
1092 TextToColumnsResult::Changed {
1093 summary,
1094 overwritten_cells,
1095 past_grid_extent,
1096 ..
1097 } => change_lines(
1098 &format!("Applied: {summary} in {book}"),
1099 overwritten_cells,
1100 *past_grid_extent,
1101 &outcome.delimiter,
1102 false,
1103 ),
1104 TextToColumnsResult::AppliedReplyUnreadable {
1105 summary,
1106 detail,
1107 overwritten_cells,
1108 past_grid_extent,
1109 ..
1110 } => change_lines(
1111 &applied_reply_unreadable_line(summary, &book, RetryHint::NotIdempotent, detail),
1112 overwritten_cells,
1113 *past_grid_extent,
1114 &outcome.delimiter,
1115 false,
1116 ),
1117 TextToColumnsResult::Failed { detail } => vec![format!("Failed: {detail}")],
1118 }
1119}
1120
1121fn change_lines(
1127 head: &str,
1128 overwritten_cells: &[String],
1129 past_grid_extent: bool,
1130 delimiter: &Delimiter,
1131 dry_run: bool,
1132) -> Vec<String> {
1133 let mut lines = vec![head.to_string()];
1134 lines.extend(overwritten_line(overwritten_cells, delimiter, dry_run));
1135 if past_grid_extent {
1136 lines.push(
1137 if dry_run {
1138 PAST_GRID_EXTENT_CAVEAT_DRY_RUN
1139 } else {
1140 PAST_GRID_EXTENT_CAVEAT
1141 }
1142 .to_string(),
1143 );
1144 }
1145 if matches!(delimiter, Delimiter::Auto) {
1146 lines.push(AUTO_DELIMITER_CAVEAT.to_string());
1147 }
1148 lines.push(UNPREVIEWABLE_SPLIT_CAVEAT.to_string());
1149 lines
1150}
1151
1152#[cfg(test)]
1153#[allow(clippy::unwrap_used, clippy::expect_used)]
1154mod tests {
1155 use super::*;
1156 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1157 use crate::drive::sheets::client::SHEETS_API_URL;
1158 use crate::drive::test_support::seed_lease;
1159 use crate::test_support::env::MapEnv;
1160 use crate::utils::secret::Secret;
1161
1162 fn bounded(sheet_id: i64, r0: i64, r1: i64, c0: i64, c1: i64) -> GridRange {
1165 GridRange {
1166 sheet_id,
1167 start_row_index: Some(r0),
1168 end_row_index: Some(r1),
1169 start_column_index: Some(c0),
1170 end_column_index: Some(c1),
1171 }
1172 }
1173
1174 fn values(rows: &[&str]) -> ValueRange {
1175 ValueRange {
1176 range: None,
1177 values: rows
1178 .iter()
1179 .map(|cell| {
1180 if cell.is_empty() {
1181 vec![]
1182 } else {
1183 vec![serde_json::json!(cell)]
1184 }
1185 })
1186 .collect(),
1187 }
1188 }
1189
1190 #[test]
1195 fn every_delimiter_maps_to_a_wire_type_its_candidates_and_a_name() {
1196 let cases = [
1197 (Delimiter::Comma, DelimiterType::Comma, vec![","], "comma"),
1198 (
1199 Delimiter::Semicolon,
1200 DelimiterType::Semicolon,
1201 vec![";"],
1202 "semicolon",
1203 ),
1204 (
1205 Delimiter::Period,
1206 DelimiterType::Period,
1207 vec!["."],
1208 "period",
1209 ),
1210 (Delimiter::Space, DelimiterType::Space, vec![" "], "space"),
1211 (
1212 Delimiter::Auto,
1213 DelimiterType::Autodetect,
1214 vec![",", ";", ".", " "],
1215 "an auto-detected separator",
1216 ),
1217 (
1218 Delimiter::Custom("||".to_string()),
1219 DelimiterType::Custom,
1220 vec!["||"],
1221 "custom (\"||\")",
1222 ),
1223 ];
1224 for (delimiter, wire, candidates, name) in cases {
1225 assert_eq!(delimiter.wire_type(), wire, "{delimiter:?}");
1226 assert_eq!(
1227 delimiter.local_split_candidates(),
1228 candidates,
1229 "{delimiter:?}"
1230 );
1231 assert_eq!(delimiter.describe(), name, "{delimiter:?}");
1232 }
1233 }
1234
1235 #[test]
1239 fn every_result_variant_has_its_own_log_status() {
1240 let statuses = [
1241 TextToColumnsResult::RefusedNotASpreadsheet {
1242 mime_type: String::new(),
1243 },
1244 TextToColumnsResult::RefusedShortcut,
1245 TextToColumnsResult::RefusedNoVisibleParents,
1246 TextToColumnsResult::RefusedSheetNotFound {
1247 title: String::new(),
1248 available: Vec::new(),
1249 },
1250 TextToColumnsResult::RefusedInvalidRange {
1251 detail: String::new(),
1252 },
1253 TextToColumnsResult::RefusedInvalidDelimiter {
1254 detail: String::new(),
1255 },
1256 TextToColumnsResult::Blocked {
1257 operation: DriveOperation::SheetsWrite,
1258 decided_by: None,
1259 },
1260 TextToColumnsResult::RefusedNoLease,
1261 TextToColumnsResult::RefusedLeaseExpired,
1262 TextToColumnsResult::RefusedLeaseWrongFile,
1263 TextToColumnsResult::RefusedLeaseStale,
1264 TextToColumnsResult::AppliedReplyUnreadable {
1265 summary: String::new(),
1266 detail: String::new(),
1267 source: String::new(),
1268 spill: None,
1269 width_upper_bound: 0,
1270 overwritten_cells: Vec::new(),
1271 past_grid_extent: false,
1272 },
1273 TextToColumnsResult::Failed {
1274 detail: String::new(),
1275 },
1276 ];
1277 let mut seen: Vec<&str> = statuses
1278 .iter()
1279 .map(TextToColumnsResult::log_status)
1280 .collect();
1281 seen.push(would_change_outcome(Vec::new(), false).result.log_status());
1282 let unique: std::collections::HashSet<&str> = seen.iter().copied().collect();
1283 assert_eq!(unique.len(), seen.len(), "duplicate log status in {seen:?}");
1284 assert_eq!(
1285 TextToColumnsResult::RefusedLeaseStale.log_status(),
1286 LeaseGateRefusal::Stale.log_status()
1287 );
1288 }
1289
1290 #[test]
1293 fn jsonl_writes_one_record_and_omits_the_delimiter() {
1294 let mut outcome = would_change_outcome(vec!["B2".to_string()], false);
1295 outcome.delimiter = Delimiter::Custom("not-in-the-record".to_string());
1296 let mut out = Vec::new();
1297 outcome.write_jsonl(&mut out).unwrap();
1298 let text = String::from_utf8(out).unwrap();
1299 assert_eq!(text.lines().count(), 1);
1300 assert!(!text.contains("not-in-the-record"));
1301 let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
1302 assert_eq!(parsed["result"]["status"], "would-change");
1303 }
1304
1305 #[test]
1306 fn is_single_column_accepts_a_one_column_span() {
1307 assert!(is_single_column(&bounded(0, 0, 10, 2, 3)));
1308 }
1309
1310 #[test]
1311 fn is_single_column_rejects_a_wider_span() {
1312 assert!(!is_single_column(&bounded(0, 0, 10, 2, 4)));
1313 }
1314
1315 #[test]
1316 fn cell_text_renders_a_string_verbatim() {
1317 assert_eq!(cell_text(&serde_json::json!("a,b")), "a,b");
1318 }
1319
1320 #[test]
1321 fn cell_text_is_empty_for_null() {
1322 assert_eq!(cell_text(&serde_json::Value::Null), "");
1323 }
1324
1325 #[test]
1326 fn cell_text_falls_back_to_json_rendering_for_a_non_string_non_null_cell() {
1327 assert_eq!(cell_text(&serde_json::json!(42)), "42");
1328 }
1329
1330 #[test]
1331 fn split_width_is_zero_for_no_rows() {
1332 assert_eq!(split_width(&values(&[]), &Delimiter::Comma), 0);
1333 }
1334
1335 #[test]
1336 fn split_width_ignores_a_blank_row() {
1337 assert_eq!(split_width(&values(&[""]), &Delimiter::Comma), 0);
1338 }
1339
1340 #[test]
1341 fn split_width_counts_comma_pieces() {
1342 assert_eq!(split_width(&values(&["a,b,c"]), &Delimiter::Comma), 3);
1343 }
1344
1345 #[test]
1346 fn split_width_is_the_widest_row() {
1347 assert_eq!(
1348 split_width(&values(&["a,b", "x,y,z", "solo"]), &Delimiter::Comma),
1349 3
1350 );
1351 }
1352
1353 #[test]
1354 fn split_width_with_a_custom_delimiter() {
1355 assert_eq!(
1356 split_width(&values(&["a|b|c|d"]), &Delimiter::Custom("|".to_string())),
1357 4
1358 );
1359 }
1360
1361 #[test]
1362 fn split_width_auto_takes_the_widest_of_the_four_fixed_delimiters() {
1363 assert_eq!(split_width(&values(&["a.b.c"]), &Delimiter::Auto), 3);
1365 }
1366
1367 #[test]
1368 fn split_width_with_no_delimiter_present_is_one() {
1369 assert_eq!(split_width(&values(&["solo"]), &Delimiter::Comma), 1);
1370 }
1371
1372 #[test]
1373 fn spill_span_is_none_for_a_width_of_one_or_less() {
1374 assert_eq!(spill_span(bounded(0, 0, 10, 0, 1), 1), None);
1375 assert_eq!(spill_span(bounded(0, 0, 10, 0, 1), 0), None);
1376 }
1377
1378 #[test]
1379 fn spill_span_covers_the_columns_to_the_right_of_the_source() {
1380 let spill = spill_span(bounded(0, 1, 10, 0, 1), 3).unwrap();
1382 assert_eq!(spill.start_column_index, Some(1));
1383 assert_eq!(spill.end_column_index, Some(3));
1384 assert_eq!(spill.start_row_index, Some(1));
1385 assert_eq!(spill.end_row_index, Some(10));
1386 }
1387
1388 #[test]
1389 fn extends_past_grid_flags_a_spill_past_the_column_count() {
1390 let workbook: Spreadsheet = serde_json::from_value(serde_json::json!({
1391 "spreadsheetId": "sheet-1",
1392 "sheets": [{"properties": {"sheetId": 0, "title": "Q1",
1393 "gridProperties": {"rowCount": 100, "columnCount": 3}}}],
1394 }))
1395 .unwrap();
1396 assert!(extends_past_grid(&workbook, &bounded(0, 0, 10, 0, 5)));
1397 assert!(!extends_past_grid(&workbook, &bounded(0, 0, 10, 0, 3)));
1398 }
1399
1400 #[test]
1401 fn extends_past_grid_is_false_when_the_sheet_is_unknown() {
1402 let workbook: Spreadsheet = serde_json::from_value(serde_json::json!({
1403 "spreadsheetId": "sheet-1",
1404 "sheets": [],
1405 }))
1406 .unwrap();
1407 assert!(!extends_past_grid(&workbook, &bounded(0, 0, 10, 0, 5)));
1408 }
1409
1410 #[test]
1411 fn describe_effect_with_a_spill() {
1412 let summary = describe_effect("'Q1'!A2:A10", Some("'Q1'!B2:D10"), 3, &Delimiter::Comma);
1413 assert_eq!(
1414 summary,
1415 "split 'Q1'!A2:A10 on comma into up to 3 column(s), spill 'Q1'!B2:D10"
1416 );
1417 }
1418
1419 #[test]
1420 fn describe_effect_with_no_spill() {
1421 let summary = describe_effect("'Q1'!A2:A10", None, 1, &Delimiter::Space);
1422 assert_eq!(
1423 summary,
1424 "split 'Q1'!A2:A10 on space into a single column each row; no row spills beyond \
1425 the source under this delimiter"
1426 );
1427 }
1428
1429 #[test]
1434 fn describe_effect_with_no_spill_under_auto() {
1435 let summary = describe_effect("'Q1'!A2:A10", None, 1, &Delimiter::Auto);
1436 assert_eq!(
1437 summary,
1438 "split 'Q1'!A2:A10 on an auto-detected separator (none detected in the preview, so \
1439 the spill span is unknown)"
1440 );
1441 }
1442
1443 #[test]
1449 fn every_summary_branch_is_tense_neutral() {
1450 for (spill, width) in [(Some("'Q1'!B2:B10"), 2), (None, 1)] {
1451 let summary = describe_effect("'Q1'!A2:A10", spill, width, &Delimiter::Comma);
1452 assert!(
1453 !summary.contains("would"),
1454 "summary is reused in the past tense: {summary}"
1455 );
1456 }
1457 }
1458
1459 #[test]
1460 fn overwritten_line_is_always_prefixed_up_to() {
1461 let line = overwritten_line(&["B2".to_string()], &Delimiter::Comma, true).unwrap();
1462 assert!(line.starts_with(" up to 1 non-blank cell(s) would be overwritten"));
1463 }
1464
1465 #[test]
1466 fn overwritten_line_empty_case() {
1467 assert_eq!(
1468 overwritten_line(&[], &Delimiter::Comma, false),
1469 Some(" no non-blank cells in the spill columns".to_string())
1470 );
1471 }
1472
1473 #[test]
1474 fn overwritten_line_elides_past_the_render_limit_but_keeps_the_true_count() {
1475 let cells: Vec<String> = (1..=60).map(|row| format!("A{row}")).collect();
1476 let line = overwritten_line(&cells, &Delimiter::Comma, true).unwrap();
1477 assert!(line.contains("… and 10 more"), "{line}");
1478 assert!(
1479 line.starts_with(" up to 60 non-blank cell(s) would be overwritten:"),
1480 "{line}"
1481 );
1482 assert_eq!(cells.len(), 60);
1483 }
1484
1485 #[test]
1488 fn auto_never_prints_an_all_clear() {
1489 assert_eq!(overwritten_line(&[], &Delimiter::Auto, true), None);
1490 assert!(overwritten_line(&["B2".to_string()], &Delimiter::Auto, true).is_some());
1492 }
1493
1494 #[test]
1495 fn build_request_omits_delimiter_for_a_fixed_type() {
1496 let request = build_request(bounded(0, 0, 10, 0, 1), &Delimiter::Comma);
1497 match request {
1498 BatchUpdateRequestItem::TextToColumns(req) => {
1499 assert_eq!(req.delimiter, None);
1500 assert_eq!(req.delimiter_type, DelimiterType::Comma);
1501 }
1502 other => panic!("unexpected request: {other:?}"), }
1504 }
1505
1506 #[test]
1507 fn build_request_carries_the_custom_delimiter_text() {
1508 let request = build_request(bounded(0, 0, 10, 0, 1), &Delimiter::Custom("|".to_string()));
1509 match request {
1510 BatchUpdateRequestItem::TextToColumns(req) => {
1511 assert_eq!(req.delimiter, Some("|".to_string()));
1512 assert_eq!(req.delimiter_type, DelimiterType::Custom);
1513 }
1514 other => panic!("unexpected request: {other:?}"), }
1516 }
1517
1518 fn would_change_outcome(
1521 overwritten_cells: Vec<String>,
1522 past_grid_extent: bool,
1523 ) -> TextToColumnsOutcome {
1524 TextToColumnsOutcome {
1525 spreadsheet_id: "sheet-1".to_string(),
1526 file_name: Some("Budget".to_string()),
1527 resolved_folder_id: Some("folder-1".to_string()),
1528 sheet_id: Some(0),
1529 delimiter: Delimiter::Comma,
1530 result: TextToColumnsResult::WouldChange {
1531 summary: "split 'Q1'!A2:A4 on comma into up to 2 column(s), spill 'Q1'!B2:B4"
1532 .to_string(),
1533 source: "'Q1'!A2:A4".to_string(),
1534 spill: Some("'Q1'!B2:B4".to_string()),
1535 width_upper_bound: 2,
1536 overwritten_cells,
1537 past_grid_extent,
1538 },
1539 }
1540 }
1541
1542 #[test]
1543 fn would_change_lines_carry_the_upper_bound_and_the_unpreviewable_caveat() {
1544 let outcome = would_change_outcome(vec!["B2".to_string()], false);
1545 let lines = describe_lines(&outcome);
1546 assert_eq!(
1547 lines[0],
1548 "Would split 'Q1'!A2:A4 on comma into up to 2 column(s), spill 'Q1'!B2:B4 in 'Budget'"
1549 );
1550 assert_eq!(
1551 lines[1],
1552 " up to 1 non-blank cell(s) would be overwritten: B2"
1553 );
1554 assert_eq!(lines[2], UNPREVIEWABLE_SPLIT_CAVEAT);
1555 }
1556
1557 #[test]
1558 fn past_grid_extent_adds_the_dry_run_caveat_line() {
1559 let outcome = would_change_outcome(vec![], true);
1560 let lines = describe_lines(&outcome);
1561 assert_eq!(lines[2], PAST_GRID_EXTENT_CAVEAT_DRY_RUN);
1562 assert_eq!(lines[3], UNPREVIEWABLE_SPLIT_CAVEAT);
1563 }
1564
1565 #[test]
1566 fn changed_lines_use_the_past_tense() {
1567 let mut outcome = would_change_outcome(vec!["B2".to_string()], true);
1568 outcome.result = match outcome.result {
1569 TextToColumnsResult::WouldChange {
1570 summary,
1571 source,
1572 spill,
1573 width_upper_bound,
1574 overwritten_cells,
1575 past_grid_extent,
1576 } => TextToColumnsResult::Changed {
1577 summary,
1578 source,
1579 spill,
1580 width_upper_bound,
1581 overwritten_cells,
1582 past_grid_extent,
1583 },
1584 other => other, };
1586 let lines = describe_lines(&outcome);
1587 assert!(lines[0].starts_with("Applied: "));
1588 assert!(lines[1].contains("were overwritten"));
1589 assert_eq!(lines[2], PAST_GRID_EXTENT_CAVEAT);
1590 }
1591
1592 #[test]
1598 fn every_result_variant_renders_as_non_empty_newline_free_lines() {
1599 let results = [
1600 TextToColumnsResult::RefusedNotASpreadsheet {
1601 mime_type: "application/pdf".to_string(),
1602 },
1603 TextToColumnsResult::RefusedShortcut,
1604 TextToColumnsResult::RefusedNoVisibleParents,
1605 TextToColumnsResult::RefusedSheetNotFound {
1606 title: "Q9".to_string(),
1607 available: vec!["Q1".to_string()],
1608 },
1609 TextToColumnsResult::RefusedSheetNotFound {
1612 title: "Q9".to_string(),
1613 available: Vec::new(),
1614 },
1615 TextToColumnsResult::RefusedInvalidRange {
1616 detail: "'A1:B2' spans more than one column".to_string(),
1617 },
1618 TextToColumnsResult::RefusedInvalidDelimiter {
1619 detail: "--custom-delimiter must not be empty".to_string(),
1620 },
1621 TextToColumnsResult::Blocked {
1622 operation: DriveOperation::SheetsWrite,
1623 decided_by: None,
1624 },
1625 TextToColumnsResult::RefusedNoLease,
1626 TextToColumnsResult::RefusedLeaseExpired,
1627 TextToColumnsResult::RefusedLeaseWrongFile,
1628 TextToColumnsResult::RefusedLeaseStale,
1629 TextToColumnsResult::AppliedReplyUnreadable {
1630 summary: "split 'Q1'!A2:A4 on comma".to_string(),
1631 detail: "bad reply".to_string(),
1632 source: "'Q1'!A2:A4".to_string(),
1633 spill: Some("'Q1'!B2:B4".to_string()),
1634 width_upper_bound: 2,
1635 overwritten_cells: Vec::new(),
1636 past_grid_extent: false,
1637 },
1638 TextToColumnsResult::Failed {
1639 detail: "HTTP 500".to_string(),
1640 },
1641 ];
1642 for result in results {
1643 let mut outcome = would_change_outcome(Vec::new(), false);
1644 let status = result.log_status();
1645 outcome.result = result;
1646 let lines = describe_lines(&outcome);
1647 assert!(!lines.is_empty(), "{status} rendered nothing");
1648 for line in &lines {
1649 assert!(!line.is_empty(), "{status} rendered an empty line");
1650 assert!(
1651 !line.contains('\n'),
1652 "{status} rendered an embedded newline"
1653 );
1654 }
1655 assert_eq!(describe(&outcome), lines.join("\n"), "{status}");
1657 }
1658 }
1659
1660 #[test]
1664 fn a_failed_lease_check_maps_to_failed() {
1665 match TextToColumnsResult::from_lease_failed("ledger unreadable".to_string()) {
1666 TextToColumnsResult::Failed { detail } => assert_eq!(detail, "ledger unreadable"),
1667 other => panic!("expected Failed, got {other:?}"), }
1669 }
1670
1671 #[test]
1677 fn describe_falls_back_to_the_spreadsheet_id_with_no_file_name() {
1678 let mut outcome = would_change_outcome(Vec::new(), false);
1679 outcome.file_name = None;
1680 assert!(describe(&outcome).contains("'sheet-1'"));
1681 }
1682
1683 #[test]
1684 fn auto_earns_its_own_caveat_line_and_no_other_delimiter_does() {
1685 let mut outcome = would_change_outcome(vec!["B2".to_string()], false);
1686 assert!(!describe_lines(&outcome).contains(&AUTO_DELIMITER_CAVEAT.to_string()));
1687 outcome.delimiter = Delimiter::Auto;
1688 let lines = describe_lines(&outcome);
1689 assert_eq!(lines[lines.len() - 2], AUTO_DELIMITER_CAVEAT);
1690 assert_eq!(lines[lines.len() - 1], UNPREVIEWABLE_SPLIT_CAVEAT);
1693 }
1694
1695 #[test]
1696 fn refused_sheet_not_found_lists_available_titles() {
1697 let outcome = TextToColumnsOutcome {
1698 spreadsheet_id: "sheet-1".to_string(),
1699 file_name: Some("Budget".to_string()),
1700 resolved_folder_id: Some("folder-1".to_string()),
1701 sheet_id: None,
1702 delimiter: Delimiter::Comma,
1703 result: TextToColumnsResult::RefusedSheetNotFound {
1704 title: "Q2".to_string(),
1705 available: vec!["Q1".to_string()],
1706 },
1707 };
1708 assert_eq!(
1709 describe_lines(&outcome),
1710 vec!["Refused: 'Budget' has no sheet titled 'Q2'. Available: 'Q1'".to_string()]
1711 );
1712 }
1713
1714 #[test]
1715 fn blocked_with_no_deciding_rule_names_default_policy() {
1716 let outcome = TextToColumnsOutcome {
1717 spreadsheet_id: "sheet-1".to_string(),
1718 file_name: Some("Budget".to_string()),
1719 resolved_folder_id: Some("folder-1".to_string()),
1720 sheet_id: None,
1721 delimiter: Delimiter::Comma,
1722 result: TextToColumnsResult::Blocked {
1723 operation: DriveOperation::SheetsWrite,
1724 decided_by: None,
1725 },
1726 };
1727 assert_eq!(
1728 describe_lines(&outcome),
1729 vec![
1730 "Blocked: text-to-columns on 'Budget' refused by default policy (no matching \
1731 rule for sheets-write)"
1732 .to_string()
1733 ]
1734 );
1735 }
1736
1737 fn test_credentials() -> DriveCredentials {
1740 DriveCredentials {
1741 client_id: "client-1".to_string(),
1742 client_secret: Secret::new("secret-1"),
1743 refresh_token: Secret::new("refresh-1"),
1744 scope: DriveGrantedScopes::READONLY,
1745 }
1746 }
1747
1748 async fn client(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1754 wiremock::Mock::given(wiremock::matchers::method("POST"))
1755 .and(wiremock::matchers::path("/token"))
1756 .respond_with(
1757 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1758 "access_token": "test-token", "expires_in": 3600,
1759 })),
1760 )
1761 .mount(server)
1762 .await;
1763 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1764 crate::drive::client::test_support::replace_session(
1765 &mut drive,
1766 &test_credentials(),
1767 &format!("{}/token", server.uri()),
1768 );
1769 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1770 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1771 (drive, sheets)
1772 }
1773
1774 fn rule_allowing(ops: &[DriveOperation], require_lease: bool) -> FolderPermissionRule {
1775 FolderPermissionRule {
1776 folder_id: Some("parent-1".to_string()),
1777 file_id: None,
1778 recursive: true,
1779 allow: ops.iter().copied().collect(),
1780 deny: std::collections::HashSet::default(),
1781 require_lease,
1782 }
1783 }
1784
1785 fn rule(_op: DriveOperation, require_lease: bool) -> FolderPermissionRule {
1790 rule_allowing(GATE_OPERATIONS, require_lease)
1791 }
1792
1793 fn base_opts(dry_run: bool) -> TextToColumnsOptions {
1794 TextToColumnsOptions {
1795 spreadsheet_id: "sheet-1".to_string(),
1796 sheet: None,
1797 source: Some("Q1!A2:A4".to_string()),
1798 delimiter: Delimiter::Comma,
1799 dry_run,
1800 lease_token: None,
1801 ledger_path: PathBuf::default(),
1802 }
1803 }
1804
1805 async fn mount_file_metadata(server: &wiremock::MockServer, body: serde_json::Value) {
1810 wiremock::Mock::given(wiremock::matchers::method("GET"))
1811 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1812 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
1813 .mount(server)
1814 .await;
1815 }
1816
1817 fn spreadsheet_metadata() -> serde_json::Value {
1820 serde_json::json!({
1821 "id": "sheet-1", "name": "Budget",
1822 "mimeType": "application/vnd.google-apps.spreadsheet",
1823 "parents": ["parent-1"], "version": "1",
1824 })
1825 }
1826
1827 async fn mount_parent(server: &wiremock::MockServer) {
1828 wiremock::Mock::given(wiremock::matchers::method("GET"))
1829 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
1830 .respond_with(
1831 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1832 "id": "parent-1", "name": "parent-1",
1833 "mimeType": "application/vnd.google-apps.folder", "parents": [],
1834 })),
1835 )
1836 .mount(server)
1837 .await;
1838 }
1839
1840 async fn mount_workbook(server: &wiremock::MockServer, row_count: i64, column_count: i64) {
1844 wiremock::Mock::given(wiremock::matchers::method("GET"))
1845 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1846 .respond_with(
1847 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1848 "spreadsheetId": "sheet-1",
1849 "sheets": [{"properties": {"sheetId": 0, "title": "Q1",
1850 "gridProperties": {
1851 "rowCount": row_count, "columnCount": column_count,
1852 }}}],
1853 })),
1854 )
1855 .mount(server)
1856 .await;
1857 }
1858
1859 async fn mount_metadata(server: &wiremock::MockServer) {
1860 mount_file_metadata(server, spreadsheet_metadata()).await;
1861 mount_parent(server).await;
1862 mount_workbook(server, 1000, 26).await;
1863 }
1864
1865 async fn mount_values_failure(server: &wiremock::MockServer, range: &str) {
1868 wiremock::Mock::given(wiremock::matchers::method("GET"))
1869 .and(wiremock::matchers::path(format!(
1870 "/v4/spreadsheets/sheet-1/values/{range}"
1871 )))
1872 .respond_with(wiremock::ResponseTemplate::new(500))
1873 .mount(server)
1874 .await;
1875 }
1876
1877 async fn mount_batch_update(server: &wiremock::MockServer, status: u16) {
1878 let template = if status == 200 {
1879 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1880 "spreadsheetId": "sheet-1", "replies": [{}],
1881 }))
1882 } else {
1883 wiremock::ResponseTemplate::new(status)
1884 };
1885 wiremock::Mock::given(wiremock::matchers::method("POST"))
1886 .and(wiremock::matchers::path(
1887 "/v4/spreadsheets/sheet-1:batchUpdate",
1888 ))
1889 .respond_with(template)
1890 .mount(server)
1891 .await;
1892 }
1893
1894 fn batch_update_was_called(requests: &[wiremock::Request]) -> bool {
1895 requests
1896 .iter()
1897 .any(|r| r.url.path() == "/v4/spreadsheets/sheet-1:batchUpdate")
1898 }
1899
1900 async fn mount_values(server: &wiremock::MockServer, range: &str, rows: serde_json::Value) {
1905 wiremock::Mock::given(wiremock::matchers::method("GET"))
1906 .and(wiremock::matchers::path(format!(
1907 "/v4/spreadsheets/sheet-1/values/{range}"
1908 )))
1909 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(rows))
1910 .mount(server)
1911 .await;
1912 }
1913
1914 #[tokio::test]
1915 async fn dry_run_reads_source_and_spill_and_never_calls_batch_update() {
1916 let server = wiremock::MockServer::start().await;
1917 let (client, sheets) = client(&server).await;
1918 mount_metadata(&server).await;
1919 mount_values(
1920 &server,
1921 "'Q1'!A2:A4",
1922 serde_json::json!({"values": [["a,b"], ["x,y,z"], [""]]}),
1923 )
1924 .await;
1925 mount_values(
1929 &server,
1930 "'Q1'!B2:C4",
1931 serde_json::json!({"values": [["", ""], ["old", ""]]}),
1932 )
1933 .await;
1934 let opts = base_opts(true);
1935 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
1936 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
1937 match &outcome.result {
1938 TextToColumnsResult::WouldChange {
1939 width_upper_bound,
1940 spill,
1941 overwritten_cells,
1942 ..
1943 } => {
1944 assert_eq!(*width_upper_bound, 3);
1945 assert_eq!(spill.as_deref(), Some("'Q1'!B2:C4"));
1946 assert_eq!(overwritten_cells, &vec!["B3".to_string()]);
1947 }
1948 other => panic!("expected WouldChange, got {other:?}"),
1949 }
1950 assert!(server
1951 .received_requests()
1952 .await
1953 .unwrap()
1954 .iter()
1955 .all(|r| r.method != wiremock::http::Method::POST
1956 || r.url.path() != "/v4/spreadsheets/sheet-1:batchUpdate"));
1957 }
1958
1959 #[tokio::test]
1960 async fn real_run_posts_exactly_one_text_to_columns_request() {
1961 let server = wiremock::MockServer::start().await;
1962 let (client, sheets) = client(&server).await;
1963 mount_metadata(&server).await;
1964 mount_values(
1965 &server,
1966 "'Q1'!A2:A4",
1967 serde_json::json!({"values": [["a,b"]]}),
1968 )
1969 .await;
1970 mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
1971 wiremock::Mock::given(wiremock::matchers::method("POST"))
1972 .and(wiremock::matchers::path(
1973 "/v4/spreadsheets/sheet-1:batchUpdate",
1974 ))
1975 .respond_with(
1976 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1977 "spreadsheetId": "sheet-1", "replies": [{}],
1978 })),
1979 )
1980 .mount(&server)
1981 .await;
1982 let opts = base_opts(false);
1983 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
1984 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
1985 assert!(matches!(
1986 outcome.result,
1987 TextToColumnsResult::Changed { .. }
1988 ));
1989
1990 let batch_requests: Vec<_> = server
1991 .received_requests()
1992 .await
1993 .unwrap()
1994 .into_iter()
1995 .filter(|r| r.url.path() == "/v4/spreadsheets/sheet-1:batchUpdate")
1996 .collect();
1997 assert_eq!(batch_requests.len(), 1);
1998 let body: serde_json::Value = serde_json::from_slice(&batch_requests[0].body).unwrap();
1999 assert_eq!(body["requests"].as_array().unwrap().len(), 1);
2000 assert!(body["requests"][0]["textToColumns"].is_object());
2001 }
2002
2003 #[tokio::test]
2004 async fn default_policy_blocks_with_no_configured_rules() {
2005 let server = wiremock::MockServer::start().await;
2006 let (client, sheets) = client(&server).await;
2007 wiremock::Mock::given(wiremock::matchers::method("GET"))
2008 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2009 .respond_with(
2010 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2011 "id": "sheet-1", "name": "Budget",
2012 "mimeType": "application/vnd.google-apps.spreadsheet",
2013 "parents": ["parent-1"], "version": "1",
2014 })),
2015 )
2016 .mount(&server)
2017 .await;
2018 wiremock::Mock::given(wiremock::matchers::method("GET"))
2019 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
2020 .respond_with(
2021 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2022 "id": "parent-1", "name": "parent-1",
2023 "mimeType": "application/vnd.google-apps.folder", "parents": [],
2024 })),
2025 )
2026 .mount(&server)
2027 .await;
2028 let opts = base_opts(true);
2029 let outcome = text_to_columns(&client, &sheets, &opts, &[]).await;
2030 assert!(matches!(
2031 outcome.result,
2032 TextToColumnsResult::Blocked {
2033 operation: DriveOperation::SheetsWrite,
2034 decided_by: None,
2035 }
2036 ));
2037 }
2038
2039 #[tokio::test]
2047 async fn neither_half_of_the_gate_opens_it_alone() {
2048 for (granted, missing) in [
2049 (DriveOperation::SheetsWrite, DriveOperation::SheetsStructure),
2050 (DriveOperation::SheetsStructure, DriveOperation::SheetsWrite),
2051 ] {
2052 let server = wiremock::MockServer::start().await;
2053 let (client, sheets) = client(&server).await;
2054 mount_metadata(&server).await;
2055 let rules = vec![rule_allowing(&[granted], false)];
2056 let outcome = text_to_columns(&client, &sheets, &base_opts(true), &rules).await;
2057 match &outcome.result {
2058 TextToColumnsResult::Blocked { operation, .. } => {
2059 assert_eq!(*operation, missing, "granted {granted}");
2060 }
2061 other => panic!("expected Blocked with {granted} granted, got {other:?}"),
2062 }
2063 assert!(describe_lines(&outcome)[0].contains(&missing.to_string()));
2064 }
2065 }
2066
2067 #[tokio::test]
2069 async fn both_operations_together_open_the_gate() {
2070 let server = wiremock::MockServer::start().await;
2071 let (client, sheets) = client(&server).await;
2072 mount_metadata(&server).await;
2073 mount_values(
2074 &server,
2075 "'Q1'!A2:A4",
2076 serde_json::json!({"values": [["a,b"]]}),
2077 )
2078 .await;
2079 mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2080 let rules = vec![rule_allowing(GATE_OPERATIONS, false)];
2081 let outcome = text_to_columns(&client, &sheets, &base_opts(true), &rules).await;
2082 assert!(matches!(
2083 outcome.result,
2084 TextToColumnsResult::WouldChange { .. }
2085 ));
2086 }
2087
2088 #[tokio::test]
2093 async fn a_blocked_by_rule_names_the_deciding_folder_in_the_message() {
2094 let server = wiremock::MockServer::start().await;
2095 let (client, sheets) = client(&server).await;
2096 mount_metadata(&server).await;
2097 let deny_by_rule = FolderPermissionRule {
2098 folder_id: Some("parent-1".to_string()),
2099 file_id: None,
2100 recursive: true,
2101 allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
2102 deny: std::iter::once(DriveOperation::SheetsStructure).collect(),
2103 require_lease: false,
2104 };
2105 let outcome = text_to_columns(&client, &sheets, &base_opts(false), &[deny_by_rule]).await;
2106 assert!(
2107 matches!(
2108 outcome.result,
2109 TextToColumnsResult::Blocked {
2110 decided_by: Some(_),
2111 ..
2112 }
2113 ),
2114 "{:?}",
2115 outcome.result
2116 );
2117 let text = describe(&outcome);
2118 assert!(
2119 text.contains("refused by rule on folder parent-1"),
2120 "{text}"
2121 );
2122 }
2123
2124 #[tokio::test]
2125 async fn open_ended_source_is_refused() {
2126 let server = wiremock::MockServer::start().await;
2127 let (client, sheets) = client(&server).await;
2128 mount_metadata(&server).await;
2129 let mut opts = base_opts(true);
2130 opts.source = Some("Q1!A:A".to_string());
2131 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2132 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2133 match outcome.result {
2134 TextToColumnsResult::RefusedInvalidRange { detail } => {
2135 assert!(detail.starts_with("Q1!A:A is open-ended"), "{detail}");
2136 }
2137 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2138 }
2139 }
2140
2141 #[tokio::test]
2142 async fn multi_column_source_is_refused() {
2143 let server = wiremock::MockServer::start().await;
2144 let (client, sheets) = client(&server).await;
2145 mount_metadata(&server).await;
2146 let mut opts = base_opts(true);
2147 opts.source = Some("Q1!A2:B4".to_string());
2148 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2149 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2150 match outcome.result {
2151 TextToColumnsResult::RefusedInvalidRange { detail } => {
2152 assert!(detail.contains("exactly one column"));
2153 }
2154 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2155 }
2156 }
2157
2158 #[tokio::test]
2159 async fn empty_custom_delimiter_is_refused_before_any_network_call() {
2160 let server = wiremock::MockServer::start().await;
2161 let (client, sheets) = client(&server).await;
2162 let mut opts = base_opts(true);
2163 opts.delimiter = Delimiter::Custom(String::new());
2164 let outcome = text_to_columns(&client, &sheets, &opts, &[]).await;
2165 assert!(matches!(
2166 outcome.result,
2167 TextToColumnsResult::RefusedInvalidDelimiter { .. }
2168 ));
2169 assert!(server.received_requests().await.unwrap().is_empty());
2170 assert!(outcome.file_name.is_none());
2172 }
2173
2174 #[tokio::test]
2175 async fn lease_required_and_absent_is_refused() {
2176 let server = wiremock::MockServer::start().await;
2177 let (client, sheets) = client(&server).await;
2178 mount_metadata(&server).await;
2179 mount_values(
2180 &server,
2181 "'Q1'!A2:A4",
2182 serde_json::json!({"values": [["a,b"]]}),
2183 )
2184 .await;
2185 mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2186 let opts = base_opts(false);
2187 let rules = vec![rule(DriveOperation::SheetsWrite, true)];
2188 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2189 assert!(matches!(
2190 outcome.result,
2191 TextToColumnsResult::RefusedNoLease
2192 ));
2193 }
2194
2195 #[tokio::test]
2196 async fn a_valid_lease_lets_the_real_run_succeed() {
2197 let ledger_dir = tempfile::tempdir().unwrap();
2198 let ledger_path = ledger_dir.path().join("leases.json");
2199 let token = seed_lease(&ledger_path, "sheet-1", "1");
2200
2201 let server = wiremock::MockServer::start().await;
2202 let (client, sheets) = client(&server).await;
2203 mount_metadata(&server).await;
2204 mount_values(
2205 &server,
2206 "'Q1'!A2:A4",
2207 serde_json::json!({"values": [["a,b"]]}),
2208 )
2209 .await;
2210 mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2211 wiremock::Mock::given(wiremock::matchers::method("POST"))
2212 .and(wiremock::matchers::path(
2213 "/v4/spreadsheets/sheet-1:batchUpdate",
2214 ))
2215 .respond_with(
2216 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2217 "spreadsheetId": "sheet-1", "replies": [{}],
2218 })),
2219 )
2220 .mount(&server)
2221 .await;
2222 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
2223 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2224 .respond_with(
2225 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2226 "id": "sheet-1", "name": "Budget", "version": "2",
2227 })),
2228 )
2229 .mount(&server)
2230 .await;
2231 let mut opts = base_opts(false);
2232 opts.lease_token = Some(token);
2233 opts.ledger_path = ledger_path;
2234 let rules = vec![rule(DriveOperation::SheetsWrite, true)];
2235 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2236 assert!(matches!(
2237 outcome.result,
2238 TextToColumnsResult::Changed { .. }
2239 ));
2240 }
2241
2242 #[tokio::test]
2247 async fn a_valid_lease_records_the_failure_when_batch_update_fails() {
2248 let ledger_dir = tempfile::tempdir().unwrap();
2249 let ledger_path = ledger_dir.path().join("leases.json");
2250 let token = seed_lease(&ledger_path, "sheet-1", "1");
2251
2252 let server = wiremock::MockServer::start().await;
2253 let (client, sheets) = client(&server).await;
2254 mount_metadata(&server).await;
2255 mount_values(
2256 &server,
2257 "'Q1'!A2:A4",
2258 serde_json::json!({"values": [["a,b"]]}),
2259 )
2260 .await;
2261 mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2262 mount_batch_update(&server, 400).await;
2263 let mut opts = base_opts(false);
2264 opts.lease_token = Some(token);
2265 opts.ledger_path = ledger_path;
2266 let rules = vec![rule(DriveOperation::SheetsWrite, true)];
2267 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2268 assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2269 }
2270
2271 #[tokio::test]
2281 async fn no_cell_value_from_either_read_reaches_the_output_or_the_outcome() {
2282 const SECRET: &str = "super-secret-cell-value";
2283 let server = wiremock::MockServer::start().await;
2284 let (client, sheets) = client(&server).await;
2285 mount_metadata(&server).await;
2286 mount_values(
2287 &server,
2288 "'Q1'!A2:A4",
2289 serde_json::json!({"values": [[format!("{SECRET},{SECRET}")]]}),
2290 )
2291 .await;
2292 mount_values(
2293 &server,
2294 "'Q1'!B2:B4",
2295 serde_json::json!({"values": [[SECRET]]}),
2296 )
2297 .await;
2298 let opts = base_opts(true);
2299 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2300 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2301
2302 match &outcome.result {
2305 TextToColumnsResult::WouldChange {
2306 overwritten_cells, ..
2307 } => assert_eq!(overwritten_cells, &vec!["B2".to_string()]),
2308 other => panic!("expected WouldChange, got {other:?}"), }
2310 let rendered = describe_lines(&outcome).join("\n");
2311 assert!(
2312 !rendered.contains(SECRET),
2313 "leaked into the text: {rendered}"
2314 );
2315 let serialized = serde_json::to_string(&outcome).unwrap();
2316 assert!(
2317 !serialized.contains(SECRET),
2318 "leaked into the outcome: {serialized}"
2319 );
2320 }
2321
2322 #[tokio::test]
2329 async fn a_spill_straddling_the_grid_edge_reads_only_the_in_grid_part() {
2330 let server = wiremock::MockServer::start().await;
2331 let (client, sheets) = client(&server).await;
2332 mount_file_metadata(&server, spreadsheet_metadata()).await;
2333 mount_parent(&server).await;
2334 mount_workbook(&server, 1000, 3).await; mount_values(
2336 &server,
2337 "'Q1'!A2:A4",
2338 serde_json::json!({"values": [["a,b,c,d,e"]]}),
2339 )
2340 .await;
2341 mount_values(
2344 &server,
2345 "'Q1'!B2:C4",
2346 serde_json::json!({"values": [["", "old"]]}),
2347 )
2348 .await;
2349 let opts = base_opts(true);
2350 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2351 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2352 match &outcome.result {
2353 TextToColumnsResult::WouldChange {
2354 spill,
2355 width_upper_bound,
2356 overwritten_cells,
2357 past_grid_extent,
2358 ..
2359 } => {
2360 assert_eq!(spill.as_deref(), Some("'Q1'!B2:E4"));
2363 assert_eq!(*width_upper_bound, 5);
2364 assert_eq!(overwritten_cells, &vec!["C2".to_string()]);
2365 assert!(past_grid_extent);
2366 }
2367 other => panic!("expected WouldChange, got {other:?}"),
2368 }
2369 assert!(describe_lines(&outcome).contains(&PAST_GRID_EXTENT_CAVEAT_DRY_RUN.to_string()));
2370 }
2371
2372 #[tokio::test]
2375 async fn a_spill_wholly_past_the_grid_extent_skips_the_read_and_still_splits() {
2376 let server = wiremock::MockServer::start().await;
2377 let (client, sheets) = client(&server).await;
2378 mount_file_metadata(&server, spreadsheet_metadata()).await;
2379 mount_parent(&server).await;
2380 mount_workbook(&server, 1000, 3).await; mount_values(
2382 &server,
2383 "'Q1'!C2:C4",
2384 serde_json::json!({"values": [["a,b"]]}),
2385 )
2386 .await;
2387 mount_batch_update(&server, 200).await;
2388 let mut opts = base_opts(false);
2389 opts.source = Some("Q1!C2:C4".to_string());
2390 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2391 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2392 match &outcome.result {
2393 TextToColumnsResult::Changed {
2394 spill,
2395 overwritten_cells,
2396 past_grid_extent,
2397 ..
2398 } => {
2399 assert_eq!(spill.as_deref(), Some("'Q1'!D2:D4"));
2400 assert!(overwritten_cells.is_empty());
2401 assert!(past_grid_extent);
2402 }
2403 other => panic!("expected Changed, got {other:?}"), }
2405 let reads: Vec<_> = server
2407 .received_requests()
2408 .await
2409 .unwrap()
2410 .into_iter()
2411 .filter(|r| r.url.path().contains("/values/"))
2412 .collect();
2413 assert_eq!(reads.len(), 1);
2414 assert!(describe_lines(&outcome).contains(&PAST_GRID_EXTENT_CAVEAT.to_string()));
2415 }
2416
2417 #[tokio::test]
2420 async fn a_source_read_failure_is_reported_as_failed() {
2421 let server = wiremock::MockServer::start().await;
2422 let (client, sheets) = client(&server).await;
2423 mount_metadata(&server).await;
2424 mount_values_failure(&server, "'Q1'!A2:A4").await;
2425 let outcome = text_to_columns(
2426 &client,
2427 &sheets,
2428 &base_opts(true),
2429 &[rule(DriveOperation::SheetsWrite, false)],
2430 )
2431 .await;
2432 assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2433 }
2434
2435 #[tokio::test]
2436 async fn a_spill_read_failure_is_reported_as_failed() {
2437 let server = wiremock::MockServer::start().await;
2438 let (client, sheets) = client(&server).await;
2439 mount_metadata(&server).await;
2440 mount_values(
2441 &server,
2442 "'Q1'!A2:A4",
2443 serde_json::json!({"values": [["a,b"]]}),
2444 )
2445 .await;
2446 mount_values_failure(&server, "'Q1'!B2:B4").await;
2447 let outcome = text_to_columns(
2448 &client,
2449 &sheets,
2450 &base_opts(true),
2451 &[rule(DriveOperation::SheetsWrite, false)],
2452 )
2453 .await;
2454 assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2455 }
2456
2457 #[tokio::test]
2458 async fn text_to_columns_with_an_unreadable_reply_reports_applied_reply_unreadable() {
2459 let server = wiremock::MockServer::start().await;
2460 let (client, sheets) = client(&server).await;
2461 mount_metadata(&server).await;
2462 mount_values(
2463 &server,
2464 "'Q1'!A2:A4",
2465 serde_json::json!({"values": [["a,b"]]}),
2466 )
2467 .await;
2468 mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2469 wiremock::Mock::given(wiremock::matchers::method("POST"))
2471 .and(wiremock::matchers::path(
2472 "/v4/spreadsheets/sheet-1:batchUpdate",
2473 ))
2474 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2475 .expect(1)
2476 .mount(&server)
2477 .await;
2478 let outcome = text_to_columns(
2479 &client,
2480 &sheets,
2481 &base_opts(false),
2482 &[rule(DriveOperation::SheetsWrite, false)],
2483 )
2484 .await;
2485 assert_eq!(
2486 outcome.result.log_status(),
2487 "applied-reply-unreadable",
2488 "{outcome:?}"
2489 );
2490 assert!(
2491 describe(&outcome).starts_with("Applied, but the reply could not be read"),
2492 "{}",
2493 describe(&outcome)
2494 );
2495 }
2496
2497 fn unreadable_outcome(
2498 overwritten_cells: Vec<String>,
2499 past_grid_extent: bool,
2500 ) -> TextToColumnsOutcome {
2501 let mut outcome = would_change_outcome(Vec::new(), false);
2502 outcome.result = TextToColumnsResult::AppliedReplyUnreadable {
2503 summary: "split 'Q1'!A2:A4 on comma".to_string(),
2504 detail: "bad reply".to_string(),
2505 source: "'Q1'!A2:A4".to_string(),
2506 spill: Some("'Q1'!B2:B4".to_string()),
2507 width_upper_bound: 2,
2508 overwritten_cells,
2509 past_grid_extent,
2510 };
2511 outcome
2512 }
2513
2514 #[test]
2515 fn describe_lines_renders_applied_reply_unreadable_with_the_plan_time_lines() {
2516 let outcome = unreadable_outcome(vec!["B2".to_string()], true);
2517 let lines = describe_lines(&outcome);
2518 assert_eq!(
2519 lines[0],
2520 "Applied, but the reply could not be read: split 'Q1'!A2:A4 on comma in \
2521 'Budget' — do not retry; check the spreadsheet first (bad reply)"
2522 );
2523 assert_eq!(
2524 lines[1..],
2525 [
2526 " up to 1 non-blank cell(s) were overwritten: B2".to_string(),
2527 PAST_GRID_EXTENT_CAVEAT.to_string(),
2528 UNPREVIEWABLE_SPLIT_CAVEAT.to_string(),
2529 ]
2530 );
2531 let mut changed = outcome;
2533 changed.result = TextToColumnsResult::Changed {
2534 summary: "split 'Q1'!A2:A4 on comma".to_string(),
2535 source: "'Q1'!A2:A4".to_string(),
2536 spill: Some("'Q1'!B2:B4".to_string()),
2537 width_upper_bound: 2,
2538 overwritten_cells: vec!["B2".to_string()],
2539 past_grid_extent: true,
2540 };
2541 assert_eq!(lines[1..], describe_lines(&changed)[1..]);
2542 }
2543
2544 #[test]
2545 fn the_unreadable_reply_logs_everything_changed_logs() {
2546 let outcome = unreadable_outcome(vec!["B2".to_string(), "B3".to_string()], false);
2547 let logged = mutation_outcome(&outcome, Duration::ZERO);
2548 assert_eq!(logged.status, "applied-reply-unreadable");
2549 assert_eq!(logged.sheet_id, Some(0));
2550 assert_eq!(logged.range.as_deref(), Some("'Q1'!A2:A4"));
2551 assert_eq!(
2552 logged.fields_changed.as_deref(),
2553 Some("split 'Q1'!A2:A4 on comma")
2554 );
2555 assert_eq!(logged.overwritten_cells, vec!["B2", "B3"]);
2556 assert!(logged.overwritten_cells_upper_bound);
2557 assert_eq!(logged.error.as_deref(), Some("bad reply"));
2558 }
2559
2560 #[tokio::test]
2561 async fn a_batch_update_failure_is_reported_as_failed() {
2562 let server = wiremock::MockServer::start().await;
2563 let (client, sheets) = client(&server).await;
2564 mount_metadata(&server).await;
2565 mount_values(
2566 &server,
2567 "'Q1'!A2:A4",
2568 serde_json::json!({"values": [["a,b"]]}),
2569 )
2570 .await;
2571 mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2572 mount_batch_update(&server, 400).await;
2573 let outcome = text_to_columns(
2574 &client,
2575 &sheets,
2576 &base_opts(false),
2577 &[rule(DriveOperation::SheetsWrite, false)],
2578 )
2579 .await;
2580 assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2581 }
2582
2583 #[tokio::test]
2584 async fn a_workbook_fetch_failure_is_reported_as_failed() {
2585 let server = wiremock::MockServer::start().await;
2586 let (client, sheets) = client(&server).await;
2587 mount_file_metadata(&server, spreadsheet_metadata()).await;
2588 mount_parent(&server).await;
2589 wiremock::Mock::given(wiremock::matchers::method("GET"))
2590 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2591 .respond_with(wiremock::ResponseTemplate::new(500))
2592 .mount(&server)
2593 .await;
2594 let outcome = text_to_columns(
2595 &client,
2596 &sheets,
2597 &base_opts(true),
2598 &[rule(DriveOperation::SheetsWrite, false)],
2599 )
2600 .await;
2601 assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2602 }
2603
2604 #[tokio::test]
2607 async fn a_metadata_fetch_failure_is_reported_as_failed() {
2608 let server = wiremock::MockServer::start().await;
2609 let (client, sheets) = client(&server).await;
2610 wiremock::Mock::given(wiremock::matchers::method("GET"))
2611 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2612 .respond_with(wiremock::ResponseTemplate::new(500))
2613 .mount(&server)
2614 .await;
2615 let outcome = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
2616 assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2617 assert!(outcome.file_name.is_none());
2618 }
2619
2620 #[tokio::test]
2623 async fn a_gate_fetch_failure_is_reported_as_failed_and_still_names_the_file() {
2624 let server = wiremock::MockServer::start().await;
2625 let (client, sheets) = client(&server).await;
2626 mount_file_metadata(&server, spreadsheet_metadata()).await;
2627 wiremock::Mock::given(wiremock::matchers::method("GET"))
2628 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
2629 .respond_with(wiremock::ResponseTemplate::new(500))
2630 .mount(&server)
2631 .await;
2632 let outcome = text_to_columns(
2633 &client,
2634 &sheets,
2635 &base_opts(true),
2636 &[rule(DriveOperation::SheetsWrite, false)],
2637 )
2638 .await;
2639 assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2640 assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
2641 assert!(outcome.resolved_folder_id.is_none());
2642 }
2643
2644 #[tokio::test]
2647 async fn a_target_that_is_not_a_spreadsheet_is_refused() {
2648 let server = wiremock::MockServer::start().await;
2649 let (client, sheets) = client(&server).await;
2650 mount_file_metadata(
2651 &server,
2652 serde_json::json!({
2653 "id": "sheet-1", "name": "Budget",
2654 "mimeType": "application/vnd.google-apps.document",
2655 "parents": ["parent-1"], "version": "1",
2656 }),
2657 )
2658 .await;
2659 let outcome = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
2660 match &outcome.result {
2661 TextToColumnsResult::RefusedNotASpreadsheet { mime_type } => {
2662 assert_eq!(mime_type, "application/vnd.google-apps.document");
2663 }
2664 other => panic!("expected RefusedNotASpreadsheet, got {other:?}"),
2665 }
2666 assert!(describe_lines(&outcome)[0].contains("is not a Google Sheet"));
2667 }
2668
2669 #[tokio::test]
2670 async fn a_shortcut_target_is_refused_rather_than_followed() {
2671 let server = wiremock::MockServer::start().await;
2672 let (client, sheets) = client(&server).await;
2673 mount_file_metadata(
2674 &server,
2675 serde_json::json!({
2676 "id": "sheet-1", "name": "Budget",
2677 "mimeType": "application/vnd.google-apps.shortcut",
2678 "parents": ["parent-1"], "version": "1",
2679 "shortcutDetails": {
2680 "targetId": "real-sheet",
2681 "targetMimeType": "application/vnd.google-apps.spreadsheet",
2682 },
2683 }),
2684 )
2685 .await;
2686 let outcome = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
2687 assert!(matches!(
2688 outcome.result,
2689 TextToColumnsResult::RefusedShortcut
2690 ));
2691 assert!(describe_lines(&outcome)[0].contains("doesn't follow shortcuts"));
2692 }
2693
2694 #[tokio::test]
2695 async fn a_target_with_no_visible_parents_is_refused_with_the_by_id_hint() {
2696 let server = wiremock::MockServer::start().await;
2697 let (client, sheets) = client(&server).await;
2698 mount_file_metadata(
2699 &server,
2700 serde_json::json!({
2701 "id": "sheet-1", "name": "Budget",
2702 "mimeType": "application/vnd.google-apps.spreadsheet",
2703 "parents": [], "version": "1",
2704 }),
2705 )
2706 .await;
2707 let outcome = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
2708 assert!(matches!(
2709 outcome.result,
2710 TextToColumnsResult::RefusedNoVisibleParents
2711 ));
2712 assert!(describe_lines(&outcome)[0].contains("write_permissions.rules"));
2713 }
2714
2715 #[tokio::test]
2716 async fn a_source_on_a_missing_sheet_is_refused_and_names_the_available_titles() {
2717 let server = wiremock::MockServer::start().await;
2718 let (client, sheets) = client(&server).await;
2719 mount_metadata(&server).await;
2720 let mut opts = base_opts(true);
2721 opts.source = Some("Q9!A2:A4".to_string());
2722 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2723 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2724 match &outcome.result {
2725 TextToColumnsResult::RefusedSheetNotFound { title, available } => {
2726 assert_eq!(title, "Q9");
2727 assert_eq!(available, &vec!["Q1".to_string()]);
2728 }
2729 other => panic!("expected RefusedSheetNotFound, got {other:?}"),
2730 }
2731 }
2732
2733 fn ledger_in_a_tempdir() -> PathBuf {
2736 tempfile::tempdir()
2737 .unwrap()
2738 .keep()
2739 .join("lease-ledger.jsonl")
2740 }
2741
2742 async fn run_with_lease(
2747 server: &wiremock::MockServer,
2748 lease_token: Option<String>,
2749 ledger_path: PathBuf,
2750 ) -> TextToColumnsOutcome {
2751 let (client, sheets) = client(server).await;
2752 mount_metadata(server).await;
2753 mount_values(
2754 server,
2755 "'Q1'!A2:A4",
2756 serde_json::json!({"values": [["a,b"]]}),
2757 )
2758 .await;
2759 mount_values(server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2760 mount_batch_update(server, 200).await;
2761 let opts = TextToColumnsOptions {
2762 lease_token,
2763 ledger_path,
2764 ..base_opts(false)
2765 };
2766 let outcome = text_to_columns(
2767 &client,
2768 &sheets,
2769 &opts,
2770 &[rule(DriveOperation::SheetsWrite, true)],
2771 )
2772 .await;
2773 assert!(
2774 !batch_update_was_called(&server.received_requests().await.unwrap()),
2775 "a refused lease must not reach batchUpdate"
2776 );
2777 outcome
2778 }
2779
2780 #[tokio::test]
2784 async fn a_sheet_that_conflicts_with_a_prefixed_source_is_refused_before_any_request() {
2785 let server = wiremock::MockServer::start().await;
2786 let (client, sheets) = client(&server).await;
2787 let mut opts = base_opts(true);
2788 opts.sheet = Some("Q1".to_string());
2789 opts.source = Some("'Q2'!A2:A4".to_string());
2790 let outcome = text_to_columns(&client, &sheets, &opts, &[]).await;
2791 assert!(matches!(
2792 outcome.result,
2793 TextToColumnsResult::RefusedInvalidRange { .. }
2794 ));
2795 assert!(server.received_requests().await.unwrap().is_empty());
2796 }
2797
2798 #[tokio::test]
2802 async fn an_unparseable_source_is_refused_with_the_resolvers_own_detail() {
2803 let server = wiremock::MockServer::start().await;
2804 let (client, sheets) = client(&server).await;
2805 mount_metadata(&server).await;
2806 let mut opts = base_opts(true);
2807 opts.source = Some("Q1!not-a-range".to_string());
2808 let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2809 let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2810 match &outcome.result {
2811 TextToColumnsResult::RefusedInvalidRange { detail } => {
2812 assert!(
2813 !detail.is_empty(),
2814 "the resolver's detail is passed through"
2815 );
2816 }
2817 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2818 }
2819 }
2820
2821 #[tokio::test]
2825 async fn a_source_that_never_splits_reads_no_spill_and_still_sends_the_request() {
2826 let server = wiremock::MockServer::start().await;
2827 let (client, sheets) = client(&server).await;
2828 mount_metadata(&server).await;
2829 mount_values(
2830 &server,
2831 "'Q1'!A2:A4",
2832 serde_json::json!({"values": [["no separator here"]]}),
2833 )
2834 .await;
2835 mount_batch_update(&server, 200).await;
2836 let outcome = text_to_columns(
2837 &client,
2838 &sheets,
2839 &base_opts(false),
2840 &[rule(DriveOperation::SheetsWrite, false)],
2841 )
2842 .await;
2843 match &outcome.result {
2844 TextToColumnsResult::Changed {
2845 summary,
2846 spill,
2847 width_upper_bound,
2848 overwritten_cells,
2849 past_grid_extent,
2850 ..
2851 } => {
2852 assert_eq!(*width_upper_bound, 1);
2853 assert!(spill.is_none());
2854 assert!(overwritten_cells.is_empty());
2855 assert!(!past_grid_extent);
2856 assert!(summary.contains("no row spills beyond the source"));
2857 }
2858 other => panic!("expected Changed, got {other:?}"), }
2860 let reads: Vec<_> = server
2861 .received_requests()
2862 .await
2863 .unwrap()
2864 .into_iter()
2865 .filter(|r| r.url.path().contains("/values/"))
2866 .collect();
2867 assert_eq!(reads.len(), 1, "no spill span means no second read");
2868 }
2869
2870 #[tokio::test]
2871 async fn an_expired_or_unknown_lease_is_refused() {
2872 let server = wiremock::MockServer::start().await;
2873 let outcome = run_with_lease(
2876 &server,
2877 Some("never-issued".to_string()),
2878 ledger_in_a_tempdir(),
2879 )
2880 .await;
2881 assert!(matches!(
2882 outcome.result,
2883 TextToColumnsResult::RefusedLeaseExpired
2884 ));
2885 }
2886
2887 #[tokio::test]
2888 async fn a_lease_bound_to_another_file_is_refused() {
2889 let server = wiremock::MockServer::start().await;
2890 let ledger_path = ledger_in_a_tempdir();
2891 let token = seed_lease(&ledger_path, "some-other-sheet", "1");
2892 let outcome = run_with_lease(&server, Some(token), ledger_path).await;
2893 assert!(matches!(
2894 outcome.result,
2895 TextToColumnsResult::RefusedLeaseWrongFile
2896 ));
2897 }
2898
2899 #[tokio::test]
2900 async fn a_stale_lease_is_refused_when_the_file_has_moved() {
2901 let server = wiremock::MockServer::start().await;
2902 let ledger_path = ledger_in_a_tempdir();
2903 let token = seed_lease(&ledger_path, "sheet-1", "0");
2906 let outcome = run_with_lease(&server, Some(token), ledger_path).await;
2907 assert!(matches!(
2908 outcome.result,
2909 TextToColumnsResult::RefusedLeaseStale
2910 ));
2911 }
2912}