1use std::path::PathBuf;
87use std::time::{Duration, Instant};
88
89use serde::Serialize;
90
91use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
92use crate::drive::client::DriveClient;
93use crate::drive::files_api::FilesApi;
94use crate::drive::lease::check::{
95 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
96 LeasedWrite,
97};
98use crate::drive::sheets::a1;
99use crate::drive::sheets::api::{SheetsApi, ValueRenderOption};
100use crate::drive::sheets::client::SheetsClient;
101use crate::drive::sheets::grid_range;
102use crate::drive::sheets::target_gate;
103use crate::drive::sheets::types::{
104 AutoFillRequest, BatchUpdateRequestItem, Dimension, GridRange, Sheet, SourceAndDestination,
105};
106use crate::drive::types::SheetTargetRefusal;
107use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
108use crate::request_log::{self, DriveMutationOutcome};
109
110const LOG_OPERATION: &str = "sheets-auto-fill";
115
116#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum AutoFillForm {
122 Range {
125 sheet: Option<String>,
127 range: Option<String>,
129 },
130 SourceAndDestination {
133 sheet: Option<String>,
135 source: Option<String>,
138 dimension: Dimension,
140 fill_length: i64,
143 },
144}
145
146impl AutoFillForm {
147 fn sheet_and_range(&self) -> (Option<&str>, Option<&str>) {
150 match self {
151 Self::Range { sheet, range } => (sheet.as_deref(), range.as_deref()),
152 Self::SourceAndDestination { sheet, source, .. } => {
153 (sheet.as_deref(), source.as_deref())
154 }
155 }
156 }
157}
158
159#[derive(Debug, Clone)]
161pub struct AutoFillOptions {
162 pub spreadsheet_id: String,
164 pub form: AutoFillForm,
166 pub use_alternate_series: bool,
169 pub dry_run: bool,
171 pub lease_token: Option<String>,
176 pub ledger_path: PathBuf,
180}
181
182#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
184#[serde(tag = "status", rename_all = "kebab-case")]
185pub enum AutoFillResult {
186 WouldChange {
188 summary: String,
196 destination: String,
202 #[serde(skip_serializing_if = "Option::is_none")]
207 requested_destination: Option<String>,
208 #[serde(skip_serializing_if = "Vec::is_empty")]
212 overwritten_cells: Vec<String>,
213 destination_is_upper_bound: bool,
219 },
220 RefusedNotASpreadsheet {
222 mime_type: String,
224 },
225 RefusedShortcut,
227 RefusedNoVisibleParents,
229 RefusedSheetNotFound {
231 title: String,
233 available: Vec<String>,
235 },
236 RefusedInvalidRange {
239 detail: String,
241 },
242 RefusedDestinationPastGrid {
249 requested_destination: String,
251 row_count: Option<i64>,
253 column_count: Option<i64>,
255 },
256 Blocked {
258 decided_by: Option<DecidingRule>,
260 },
261 RefusedNoLease,
264 RefusedLeaseExpired,
267 RefusedLeaseWrongFile,
269 RefusedLeaseStale,
272 Changed {
274 summary: String,
278 destination: String,
280 #[serde(skip_serializing_if = "Option::is_none")]
282 requested_destination: Option<String>,
283 #[serde(skip_serializing_if = "Vec::is_empty")]
287 overwritten_cells: Vec<String>,
288 destination_is_upper_bound: bool,
290 },
291 Failed {
293 detail: String,
295 },
296}
297
298impl FromLeaseRefusal for AutoFillResult {
299 fn from_no_lease() -> Self {
300 Self::RefusedNoLease
301 }
302 fn from_lease_expired() -> Self {
303 Self::RefusedLeaseExpired
304 }
305 fn from_lease_wrong_file() -> Self {
306 Self::RefusedLeaseWrongFile
307 }
308 fn from_lease_stale() -> Self {
309 Self::RefusedLeaseStale
310 }
311 fn from_lease_failed(detail: String) -> Self {
312 Self::Failed { detail }
313 }
314}
315
316impl AutoFillResult {
317 fn log_status(&self) -> &'static str {
318 match self {
319 Self::WouldChange { .. } => "would-change",
320 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
321 Self::RefusedShortcut => "refused-shortcut",
322 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
323 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
324 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
325 Self::RefusedDestinationPastGrid { .. } => "refused-destination-past-grid",
326 Self::Blocked { .. } => "blocked",
327 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
328 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
329 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
330 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
331 Self::Changed { .. } => "changed",
332 Self::Failed { .. } => "failed",
333 }
334 }
335}
336
337#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
339pub struct AutoFillOutcome {
340 pub spreadsheet_id: String,
342 #[serde(skip_serializing_if = "Option::is_none")]
344 pub file_name: Option<String>,
345 #[serde(skip_serializing_if = "Option::is_none")]
347 pub resolved_folder_id: Option<String>,
348 #[serde(skip_serializing_if = "Option::is_none")]
350 pub sheet_id: Option<i64>,
351 #[serde(skip)]
353 pub form: AutoFillForm,
354 pub result: AutoFillResult,
356}
357
358impl JsonlSerialize for AutoFillOutcome {
359 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
360 write_scalar_jsonl(self, out)
361 }
362}
363
364pub async fn auto_fill(
366 drive: &DriveClient,
367 sheets: &SheetsClient,
368 opts: &AutoFillOptions,
369 rules: &[FolderPermissionRule],
370) -> AutoFillOutcome {
371 let started = Instant::now();
372 let outcome = auto_fill_inner(drive, sheets, opts, rules).await;
373 if !opts.dry_run {
374 record_attempt(&outcome, started.elapsed());
375 }
376 outcome
377}
378
379async fn auto_fill_inner(
380 drive: &DriveClient,
381 sheets: &SheetsClient,
382 opts: &AutoFillOptions,
383 rules: &[FolderPermissionRule],
384) -> AutoFillOutcome {
385 let bare = |result| AutoFillOutcome {
386 spreadsheet_id: opts.spreadsheet_id.clone(),
387 file_name: None,
388 resolved_folder_id: None,
389 sheet_id: None,
390 form: opts.form.clone(),
391 result,
392 };
393
394 let (sheet, range) = opts.form.sheet_and_range();
398 let composed = match a1::compose(sheet, range) {
399 Ok(composed) => composed,
400 Err(err) => {
401 return bare(AutoFillResult::RefusedInvalidRange {
402 detail: err.to_string(),
403 })
404 }
405 };
406 if let AutoFillForm::SourceAndDestination { fill_length, .. } = &opts.form {
407 if *fill_length == 0 {
408 return bare(AutoFillResult::RefusedInvalidRange {
409 detail: "--fill-length must not be 0".to_string(),
410 });
411 }
412 }
413
414 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
416 drive,
417 &opts.spreadsheet_id,
418 DriveOperation::SheetsWrite,
419 rules,
420 )
421 .await
422 {
423 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
424 return bare(AutoFillResult::Failed { detail })
425 }
426 target_gate::TargetGateOutcome::Refused { target, refusal } => {
427 let result = match refusal {
428 SheetTargetRefusal::Shortcut => AutoFillResult::RefusedShortcut,
429 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
430 AutoFillResult::RefusedNotASpreadsheet { mime_type }
431 }
432 SheetTargetRefusal::NoVisibleParents => AutoFillResult::RefusedNoVisibleParents,
433 };
434 return AutoFillOutcome {
435 spreadsheet_id: opts.spreadsheet_id.clone(),
436 file_name: Some(target.name),
437 resolved_folder_id: None,
438 sheet_id: None,
439 form: opts.form.clone(),
440 result,
441 };
442 }
443 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
444 return AutoFillOutcome {
445 spreadsheet_id: opts.spreadsheet_id.clone(),
446 file_name: Some(target.name),
447 resolved_folder_id: None,
448 sheet_id: None,
449 form: opts.form.clone(),
450 result: AutoFillResult::Failed { detail },
451 };
452 }
453 target_gate::TargetGateOutcome::Gated {
454 target,
455 decision,
456 resolved_folder_id,
457 requires_lease,
458 } => (target, decision, resolved_folder_id, requires_lease),
459 };
460
461 let pre_gated = |result| AutoFillOutcome {
462 spreadsheet_id: opts.spreadsheet_id.clone(),
463 file_name: Some(target.name.clone()),
464 resolved_folder_id: resolved_folder_id.clone(),
465 sheet_id: None,
466 form: opts.form.clone(),
467 result,
468 };
469
470 if decision.verdict == write_gate::Verdict::Deny {
471 return pre_gated(AutoFillResult::Blocked {
472 decided_by: decision.decided_by,
473 });
474 }
475
476 let api = SheetsApi::new(sheets);
477 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
478 Ok(workbook) => workbook,
479 Err(err) => {
480 return pre_gated(AutoFillResult::Failed {
481 detail: format!("{err:#}"),
482 })
483 }
484 };
485
486 let (sheet_title, source_grid) = match grid_range::resolve_grid_range(
487 &workbook,
488 &composed,
489 |detail| AutoFillResult::RefusedInvalidRange { detail },
490 |title, available| AutoFillResult::RefusedSheetNotFound { title, available },
491 ) {
492 Ok(resolved) => resolved,
493 Err(result) => return pre_gated(result),
494 };
495
496 let gated = |result| AutoFillOutcome {
497 spreadsheet_id: opts.spreadsheet_id.clone(),
498 file_name: Some(target.name.clone()),
499 resolved_folder_id: resolved_folder_id.clone(),
500 sheet_id: Some(source_grid.sheet_id),
501 form: opts.form.clone(),
502 result,
503 };
504
505 if !grid_range::is_bounded(&source_grid) {
506 return gated(AutoFillResult::RefusedInvalidRange {
507 detail: format!(
508 "{composed} is open-ended; auto-fill needs a fully bounded range (e.g. A1:D10)"
509 ),
510 });
511 }
512
513 let (destination, is_upper_bound) = match &opts.form {
514 AutoFillForm::Range { .. } => (source_grid, true),
515 AutoFillForm::SourceAndDestination {
516 dimension,
517 fill_length,
518 ..
519 } => match compute_destination(source_grid, *dimension, *fill_length) {
520 Ok(destination) => (destination, false),
521 Err(detail) => return gated(AutoFillResult::RefusedInvalidRange { detail }),
522 },
523 };
524 let requested_destination_a1 = grid_range_to_a1(&sheet_title, &destination);
525 let source_a1 = grid_range_to_a1(&sheet_title, &source_grid);
530
531 let sheet = grid_range::find_sheet_by_id(&workbook, source_grid.sheet_id);
541 let applied_destination = if let Some(sheet) = sheet {
542 if let Some(applied) = clamp_to_grid(sheet, &destination) {
543 applied
544 } else {
545 let grid = sheet
551 .properties
552 .as_ref()
553 .and_then(|props| props.grid_properties.as_ref());
554 return gated(AutoFillResult::RefusedDestinationPastGrid {
555 requested_destination: requested_destination_a1,
556 row_count: grid.and_then(|g| g.row_count),
557 column_count: grid.and_then(|g| g.column_count),
558 });
559 }
560 } else {
561 destination };
563 let destination_a1 = grid_range_to_a1(&sheet_title, &applied_destination);
564 let requested_destination =
567 (applied_destination != destination).then_some(requested_destination_a1);
568
569 let overwritten_cells = {
575 let read_a1 = grid_range_to_a1(&sheet_title, &applied_destination);
576 match api
577 .values_get(&opts.spreadsheet_id, &read_a1, ValueRenderOption::Formatted)
578 .await
579 {
580 Ok(values) => grid_range::non_blank_locations(
581 &values,
582 applied_destination.start_row_index.unwrap_or(0),
583 applied_destination.start_column_index.unwrap_or(0),
584 ),
585 Err(err) => {
586 return gated(AutoFillResult::Failed {
587 detail: format!("{err:#}"),
588 })
589 }
590 }
591 };
592
593 let summary = describe_effect(
594 &opts.form,
595 &source_a1,
596 &destination_a1,
597 applied_fill_length(&opts.form, &source_grid, &applied_destination),
598 opts.use_alternate_series,
599 );
600
601 if opts.dry_run {
602 return gated(AutoFillResult::WouldChange {
603 summary,
604 destination: destination_a1,
605 requested_destination,
606 overwritten_cells,
607 destination_is_upper_bound: is_upper_bound,
608 });
609 }
610
611 let request = build_request(&opts.form, source_grid, opts.use_alternate_series);
618
619 let files_api = FilesApi::new(drive);
620 let leased = LeasedWrite {
621 log_prefix: "drive sheets auto-fill",
622 operation: LOG_OPERATION,
623 ledger_path: &opts.ledger_path,
624 file_id: &opts.spreadsheet_id,
625 };
626 let lease_grant = match gate_optional_leased_write(
627 leased,
628 &files_api,
629 requires_lease,
630 opts.lease_token.as_deref(),
631 )
632 .await
633 {
634 Ok(grant) => grant,
635 Err(err) => return gated(err.into_result()),
636 };
637
638 let result = match conclude_native_leased_write(
639 leased,
640 &lease_grant,
641 &files_api,
642 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
643 |err| format!("{err:#}"),
644 )
645 .await
646 {
647 Ok(_response) => AutoFillResult::Changed {
648 summary,
649 destination: destination_a1,
650 requested_destination,
651 overwritten_cells,
652 destination_is_upper_bound: is_upper_bound,
653 },
654 Err(err) => AutoFillResult::Failed {
655 detail: format!("{err:#}"),
656 },
657 };
658 drop(lease_grant);
659 gated(result)
660}
661
662fn compute_destination(
672 source: GridRange,
673 dimension: Dimension,
674 fill_length: i64,
675) -> Result<GridRange, String> {
676 let (start, end) = match dimension {
677 Dimension::Rows => (source.start_row_index, source.end_row_index),
678 Dimension::Columns => (source.start_column_index, source.end_column_index),
679 };
680 let (Some(start), Some(end)) = (start, end) else {
684 return Err(format!(
685 "--source needs a fully bounded range on the {} axis to compute a destination from",
686 dimension.noun()
687 ));
688 };
689 let (new_start, new_end) = if fill_length > 0 {
690 let Some(new_end) = end.checked_add(fill_length) else {
691 return Err(format!("--fill-length {fill_length} is out of range"));
692 };
693 (end, new_end)
694 } else {
695 let Some(new_start) = start.checked_add(fill_length) else {
696 return Err(format!("--fill-length {fill_length} is out of range"));
697 };
698 if new_start < 0 {
699 return Err(format!(
700 "--fill-length {fill_length} would fill {} {}(s) before the start of the sheet",
701 new_start.unsigned_abs(),
702 dimension.noun()
703 ));
704 }
705 (new_start, start)
706 };
707 let mut destination = source;
708 match dimension {
709 Dimension::Rows => {
710 destination.start_row_index = Some(new_start);
711 destination.end_row_index = Some(new_end);
712 }
713 Dimension::Columns => {
714 destination.start_column_index = Some(new_start);
715 destination.end_column_index = Some(new_end);
716 }
717 }
718 Ok(destination)
719}
720
721fn clamp_to_grid(sheet: &Sheet, destination: &GridRange) -> Option<GridRange> {
730 let grid = sheet
731 .properties
732 .as_ref()
733 .and_then(|props| props.grid_properties.as_ref());
734 let mut clamped = *destination;
735 if let (Some(end), Some(count)) = (destination.end_row_index, grid.and_then(|g| g.row_count)) {
736 clamped.end_row_index = Some(end.min(count));
737 }
738 if let (Some(end), Some(count)) = (
739 destination.end_column_index,
740 grid.and_then(|g| g.column_count),
741 ) {
742 clamped.end_column_index = Some(end.min(count));
743 }
744 let empty = |start: Option<i64>, end: Option<i64>| match (start, end) {
745 (Some(start), Some(end)) => start >= end,
746 _ => false,
747 };
748 if empty(clamped.start_row_index, clamped.end_row_index)
749 || empty(clamped.start_column_index, clamped.end_column_index)
750 {
751 return None;
752 }
753 Some(clamped)
754}
755
756fn grid_range_to_a1(sheet_title: &str, grid: &GridRange) -> String {
760 let (Some(r0), Some(r1), Some(c0), Some(c1)) = (
761 grid.start_row_index,
762 grid.end_row_index,
763 grid.start_column_index,
764 grid.end_column_index,
765 ) else {
766 unreachable!("auto-fill ranges are refused earlier unless fully bounded")
767 };
769 let start = format!("{}{}", grid_range::column_index_to_letters(c0), r0 + 1);
770 let end = format!("{}{}", grid_range::column_index_to_letters(c1 - 1), r1);
771 match a1::compose(Some(sheet_title), Some(&format!("{start}:{end}"))) {
772 Ok(composed) => composed,
773 Err(err) => unreachable!("auto-fill composes only bounded numeric ranges: {err}"), }
782}
783
784const UNPREVIEWABLE_VALUES_CAVEAT: &str =
790 " the filled values are computed by Sheets' own series detection and are never reported, \
791 before or after the request";
792
793fn past_grid_extent_caveat_dry_run(applied: &str, requested: &str) -> String {
803 format!(
804 " the destination runs past the sheet's current extent; auto-fill clips to it, so only \
805 {applied} would be filled — {requested} was requested"
806 )
807}
808
809fn past_grid_extent_caveat(applied: &str, requested: &str) -> String {
812 format!(
813 " the destination ran past the sheet's current extent; auto-fill clipped to it, so \
814 only {applied} was filled — {requested} was requested"
815 )
816}
817
818fn applied_fill_length(
832 form: &AutoFillForm,
833 source_grid: &GridRange,
834 applied_destination: &GridRange,
835) -> i64 {
836 let AutoFillForm::SourceAndDestination {
837 dimension,
838 fill_length,
839 ..
840 } = form
841 else {
842 return 0;
843 };
844 let (source_start, source_end, applied_start, applied_end) = match dimension {
845 Dimension::Rows => (
846 source_grid.start_row_index,
847 source_grid.end_row_index,
848 applied_destination.start_row_index,
849 applied_destination.end_row_index,
850 ),
851 Dimension::Columns => (
852 source_grid.start_column_index,
853 source_grid.end_column_index,
854 applied_destination.start_column_index,
855 applied_destination.end_column_index,
856 ),
857 };
858 if *fill_length >= 0 {
859 applied_end.unwrap_or(0) - source_end.unwrap_or(0)
860 } else {
861 applied_start.unwrap_or(0) - source_start.unwrap_or(0)
862 }
863}
864
865fn describe_effect(
881 form: &AutoFillForm,
882 source_a1: &str,
883 destination_a1: &str,
884 applied_fill_length: i64,
885 use_alternate_series: bool,
886) -> String {
887 let mut summary = match form {
888 AutoFillForm::Range { .. } => format!(
889 "auto-fill within {destination_a1} (Sheets decides which cells are the source and \
890 which are filled)"
891 ),
892 AutoFillForm::SourceAndDestination { dimension, .. } => {
893 let direction = match (dimension, applied_fill_length >= 0) {
894 (Dimension::Rows, true) => "down",
895 (Dimension::Rows, false) => "up",
896 (Dimension::Columns, true) => "right",
897 (Dimension::Columns, false) => "left",
898 };
899 format!(
900 "auto-fill {destination_a1} from source {source_a1}, extending {} {}(s) \
901 {direction}",
902 applied_fill_length.abs(),
903 dimension.noun(),
904 )
905 }
906 };
907 if use_alternate_series {
908 summary.push_str(", using the alternate series");
909 }
910 summary
911}
912
913fn overwritten_line(
919 overwritten_cells: &[String],
920 destination_is_upper_bound: bool,
921 dry_run: bool,
922) -> String {
923 if overwritten_cells.is_empty() {
924 return " no non-blank cells in the destination".to_string();
925 }
926 let bound = if destination_is_upper_bound {
927 "up to "
928 } else {
929 ""
930 };
931 let tense = if dry_run {
932 "would be overwritten"
933 } else {
934 "were overwritten"
935 };
936 format!(
937 " {bound}{} non-blank cell(s) {tense}: {}",
938 overwritten_cells.len(),
939 grid_range::render_locations(overwritten_cells)
940 )
941}
942
943fn build_request(
948 form: &AutoFillForm,
949 source_grid: GridRange,
950 use_alternate_series: bool,
951) -> BatchUpdateRequestItem {
952 let request = match form {
953 AutoFillForm::Range { .. } => AutoFillRequest {
954 range: Some(source_grid),
955 source_and_destination: None,
956 use_alternate_series,
957 },
958 AutoFillForm::SourceAndDestination {
959 dimension,
960 fill_length,
961 ..
962 } => AutoFillRequest {
963 range: None,
964 source_and_destination: Some(SourceAndDestination {
965 source: source_grid,
966 dimension: *dimension,
967 fill_length: *fill_length,
968 }),
969 use_alternate_series,
970 },
971 };
972 BatchUpdateRequestItem::AutoFill(request)
973}
974
975fn record_attempt(outcome: &AutoFillOutcome, duration: Duration) {
976 let error = match &outcome.result {
977 AutoFillResult::Failed { detail } => Some(detail.clone()),
978 _ => None,
979 };
980 let decided_by = match &outcome.result {
981 AutoFillResult::Blocked { decided_by } => decided_by.as_ref(),
982 _ => None,
983 };
984 let decided_by = write_gate::decided_by_log_fields(decided_by);
985 let (range, fields_changed, overwritten_cells, overwritten_cells_upper_bound) =
986 match &outcome.result {
987 AutoFillResult::Changed {
988 summary,
989 destination,
990 overwritten_cells,
991 destination_is_upper_bound,
992 ..
993 } => (
994 Some(destination.clone()),
995 Some(summary.clone()),
996 overwritten_cells.clone(),
997 *destination_is_upper_bound,
998 ),
999 _ => (None, None, Vec::new(), false),
1000 };
1001
1002 request_log::record_drive_mutation(DriveMutationOutcome {
1003 operation: LOG_OPERATION,
1004 file_id: outcome.spreadsheet_id.clone(),
1005 file_name: outcome.file_name.clone().unwrap_or_default(),
1006 status: outcome.result.log_status().to_string(),
1007 resolved_folder_id: outcome.resolved_folder_id.clone(),
1008 decided_by_folder_id: decided_by.folder_id,
1009 decided_by_depth: decided_by.depth,
1010 decided_by_file_id: decided_by.file_id,
1011 sheet_id: outcome.sheet_id,
1012 range,
1013 fields_changed,
1014 overwritten_cells,
1015 overwritten_cells_upper_bound,
1016 error,
1017 duration,
1018 ..Default::default()
1019 });
1020}
1021
1022#[must_use]
1024pub fn describe(outcome: &AutoFillOutcome) -> String {
1025 describe_lines(outcome).join("\n")
1026}
1027
1028#[must_use]
1031pub fn describe_lines(outcome: &AutoFillOutcome) -> Vec<String> {
1032 let book = outcome.file_name.as_deref().map_or_else(
1033 || format!("'{}'", outcome.spreadsheet_id),
1034 |n| format!("'{n}'"),
1035 );
1036 match &outcome.result {
1037 AutoFillResult::WouldChange {
1038 summary,
1039 destination,
1040 requested_destination,
1041 overwritten_cells,
1042 destination_is_upper_bound,
1043 } => change_lines(
1044 &format!("Would {summary} in {book}"),
1045 overwritten_cells,
1046 *destination_is_upper_bound,
1047 destination,
1048 requested_destination.as_deref(),
1049 true,
1050 ),
1051 AutoFillResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1052 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
1053 `drive sheets auto-fill` only works on spreadsheets"
1054 )],
1055 AutoFillResult::RefusedShortcut => vec![format!(
1056 "Refused: {book} is a shortcut; `drive sheets auto-fill` doesn't follow shortcuts"
1057 )],
1058 AutoFillResult::RefusedNoVisibleParents => vec![format!(
1059 "Refused: {book} has no parent folder visible to this account, so no folder \
1060 rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
1061 id>\", \"allow\": [\"sheets-write\"]}} to write_permissions.rules."
1062 )],
1063 AutoFillResult::RefusedSheetNotFound { title, available } => {
1064 vec![grid_range::no_sheet_refusal(&book, title, available)]
1065 }
1066 AutoFillResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
1067 AutoFillResult::RefusedDestinationPastGrid {
1068 requested_destination,
1069 row_count,
1070 column_count,
1071 } => {
1072 let extent = match (row_count, column_count) {
1073 (Some(rows), Some(cols)) => format!(" ({rows} rows x {cols} columns)"),
1074 (Some(rows), None) => format!(" ({rows} rows)"),
1075 (None, Some(cols)) => format!(" ({cols} columns)"),
1076 (None, None) => String::new(),
1077 };
1078 vec![format!(
1079 "Refused: {requested_destination} lies wholly past {book}'s current grid{extent}; \
1080 auto-fill does not grow the sheet. Grow it first (e.g. `sheets append`, \
1081 `sheets insert-rows`/`insert-columns`), or choose a destination within the grid."
1082 )]
1083 }
1084 AutoFillResult::Blocked { decided_by } => vec![match decided_by {
1085 Some(rule) => format!(
1086 "Blocked: auto-fill on {book} refused by rule on {} {}{}",
1087 rule.kind_label(),
1088 rule.id(),
1089 rule.depth_suffix()
1090 ),
1091 None => format!(
1092 "Blocked: auto-fill on {book} refused by default policy (no matching rule for \
1093 sheets-write)"
1094 ),
1095 }],
1096 AutoFillResult::RefusedNoLease => LeaseGateRefusal::NoLease
1097 .describe_line(&outcome.spreadsheet_id, &book)
1098 .into_iter()
1099 .collect(),
1100 AutoFillResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1101 .describe_line(&outcome.spreadsheet_id, &book)
1102 .into_iter()
1103 .collect(),
1104 AutoFillResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1105 .describe_line(&outcome.spreadsheet_id, &book)
1106 .into_iter()
1107 .collect(),
1108 AutoFillResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1109 .describe_line(&outcome.spreadsheet_id, &book)
1110 .into_iter()
1111 .collect(),
1112 AutoFillResult::Changed {
1113 summary,
1114 destination,
1115 requested_destination,
1116 overwritten_cells,
1117 destination_is_upper_bound,
1118 } => change_lines(
1119 &format!("Applied: {summary} in {book}"),
1120 overwritten_cells,
1121 *destination_is_upper_bound,
1122 destination,
1123 requested_destination.as_deref(),
1124 false,
1125 ),
1126 AutoFillResult::Failed { detail } => vec![format!("Failed: {detail}")],
1127 }
1128}
1129
1130fn change_lines(
1135 head: &str,
1136 overwritten_cells: &[String],
1137 destination_is_upper_bound: bool,
1138 destination: &str,
1139 requested_destination: Option<&str>,
1140 dry_run: bool,
1141) -> Vec<String> {
1142 let mut lines = vec![
1143 head.to_string(),
1144 overwritten_line(overwritten_cells, destination_is_upper_bound, dry_run),
1145 ];
1146 if let Some(requested) = requested_destination {
1147 lines.push(if dry_run {
1148 past_grid_extent_caveat_dry_run(destination, requested)
1149 } else {
1150 past_grid_extent_caveat(destination, requested)
1151 });
1152 }
1153 lines.push(UNPREVIEWABLE_VALUES_CAVEAT.to_string());
1154 lines
1155}
1156
1157#[cfg(test)]
1158#[allow(clippy::unwrap_used, clippy::expect_used)]
1159mod tests {
1160 use super::*;
1161 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1162 use crate::drive::sheets::client::SHEETS_API_URL;
1163 use crate::drive::sheets::types::{GridProperties, SheetProperties};
1164 use crate::drive::test_support::seed_lease;
1165 use crate::test_support::env::MapEnv;
1166 use crate::utils::secret::Secret;
1167 use std::collections::HashSet;
1168
1169 fn bounded(sheet_id: i64, r0: i64, r1: i64, c0: i64, c1: i64) -> GridRange {
1172 GridRange {
1173 sheet_id,
1174 start_row_index: Some(r0),
1175 end_row_index: Some(r1),
1176 start_column_index: Some(c0),
1177 end_column_index: Some(c1),
1178 }
1179 }
1180
1181 #[test]
1182 fn compute_destination_extends_rows_forward() {
1183 let source = bounded(0, 0, 3, 0, 2); let dest = compute_destination(source, Dimension::Rows, 4).unwrap();
1185 assert_eq!(dest, bounded(0, 3, 7, 0, 2)); }
1187
1188 #[test]
1189 fn compute_destination_extends_rows_backward() {
1190 let source = bounded(0, 5, 8, 0, 2); let dest = compute_destination(source, Dimension::Rows, -3).unwrap();
1192 assert_eq!(dest, bounded(0, 2, 5, 0, 2)); }
1194
1195 #[test]
1196 fn compute_destination_extends_columns_forward() {
1197 let source = bounded(0, 0, 2, 0, 1); let dest = compute_destination(source, Dimension::Columns, 2).unwrap();
1199 assert_eq!(dest, bounded(0, 0, 2, 1, 3)); }
1201
1202 #[test]
1203 fn compute_destination_extends_columns_backward() {
1204 let source = bounded(0, 0, 2, 5, 6); let dest = compute_destination(source, Dimension::Columns, -5).unwrap();
1206 assert_eq!(dest, bounded(0, 0, 2, 0, 5)); }
1208
1209 #[test]
1210 fn compute_destination_refuses_going_before_the_start_of_the_sheet() {
1211 let source = bounded(0, 2, 5, 0, 2); let err = compute_destination(source, Dimension::Rows, -3).unwrap_err();
1213 assert!(err.contains("before the start"), "{err}");
1214 }
1215
1216 #[test]
1217 fn compute_destination_allows_landing_exactly_at_the_start() {
1218 let source = bounded(0, 2, 5, 0, 2); let dest = compute_destination(source, Dimension::Rows, -2).unwrap();
1220 assert_eq!(dest, bounded(0, 0, 2, 0, 2)); }
1222
1223 #[test]
1224 fn compute_destination_refuses_overflow() {
1225 let source = bounded(0, i64::MAX - 1, i64::MAX, 0, 1);
1226 let err = compute_destination(source, Dimension::Rows, i64::MAX).unwrap_err();
1227 assert!(err.contains("out of range"), "{err}");
1228 }
1229
1230 #[test]
1231 fn compute_destination_refuses_an_unbounded_axis_and_backward_overflow() {
1232 let unbounded = GridRange {
1233 sheet_id: 0,
1234 ..Default::default()
1235 };
1236 assert!(compute_destination(unbounded, Dimension::Columns, 1)
1237 .unwrap_err()
1238 .contains("fully bounded"));
1239 assert!(
1240 compute_destination(bounded(0, 0, 1, 0, 1), Dimension::Rows, i64::MIN)
1241 .unwrap_err()
1242 .contains("9223372036854775808 row(s) before the start")
1243 );
1244 assert!(
1245 compute_destination(bounded(0, -1, 1, 0, 1), Dimension::Rows, i64::MIN)
1246 .unwrap_err()
1247 .contains("out of range")
1248 );
1249 }
1250
1251 #[test]
1252 fn lease_refusals_map_to_their_auto_fill_results() {
1253 assert_eq!(
1254 AutoFillResult::from_lease_expired(),
1255 AutoFillResult::RefusedLeaseExpired
1256 );
1257 assert_eq!(
1258 AutoFillResult::from_lease_wrong_file(),
1259 AutoFillResult::RefusedLeaseWrongFile
1260 );
1261 assert_eq!(
1262 AutoFillResult::from_lease_stale(),
1263 AutoFillResult::RefusedLeaseStale
1264 );
1265 assert_eq!(
1266 AutoFillResult::from_lease_failed("ledger unavailable".into()),
1267 AutoFillResult::Failed {
1268 detail: "ledger unavailable".into()
1269 }
1270 );
1271 }
1272
1273 #[test]
1274 fn jsonl_outcome_serializes_one_record_without_the_input_form() {
1275 let outcome = AutoFillOutcome {
1276 spreadsheet_id: "sheet-1".into(),
1277 file_name: None,
1278 resolved_folder_id: None,
1279 sheet_id: None,
1280 form: range_form(),
1281 result: AutoFillResult::RefusedShortcut,
1282 };
1283 let mut out = Vec::new();
1284 outcome.write_jsonl(&mut out).unwrap();
1285 let value: serde_json::Value = serde_json::from_slice(&out).unwrap();
1286 assert_eq!(value["result"]["status"], "refused-shortcut");
1287 assert!(value.get("form").is_none());
1288 assert_eq!(std::str::from_utf8(&out).unwrap().lines().count(), 1);
1289 }
1290
1291 fn sheet_with_extent(row_count: i64, column_count: i64) -> Sheet {
1292 Sheet {
1293 properties: Some(SheetProperties {
1294 sheet_id: Some(0),
1295 title: "Q1".to_string(),
1296 index: Some(0),
1297 hidden: None,
1298 grid_properties: Some(GridProperties {
1299 row_count: Some(row_count),
1300 column_count: Some(column_count),
1301 frozen_row_count: None,
1302 frozen_column_count: None,
1303 hide_gridlines: None,
1304 }),
1305 right_to_left: None,
1306 }),
1307 ..Default::default()
1308 }
1309 }
1310
1311 #[test]
1312 fn clamp_to_grid_trims_a_destination_that_overhangs_the_extent() {
1313 let sheet = sheet_with_extent(10, 5);
1314 let clamped = clamp_to_grid(&sheet, &bounded(0, 8, 14, 0, 1)).unwrap();
1316 assert_eq!(clamped.end_row_index, Some(10));
1317 assert_eq!(clamped.start_row_index, Some(8));
1318 let clamped = clamp_to_grid(&sheet, &bounded(0, 0, 1, 3, 9)).unwrap();
1320 assert_eq!(clamped.end_column_index, Some(5));
1321 }
1322
1323 #[test]
1324 fn clamp_to_grid_leaves_a_destination_within_the_extent_untouched() {
1325 let sheet = sheet_with_extent(10, 5);
1326 let destination = bounded(0, 2, 5, 1, 3);
1327 assert_eq!(clamp_to_grid(&sheet, &destination), Some(destination));
1328 }
1329
1330 #[test]
1331 fn clamp_to_grid_is_none_when_the_destination_lies_wholly_past_the_extent() {
1332 let sheet = sheet_with_extent(10, 5);
1333 assert_eq!(clamp_to_grid(&sheet, &bounded(0, 10, 14, 0, 1)), None);
1335 assert_eq!(clamp_to_grid(&sheet, &bounded(0, 0, 1, 5, 9)), None);
1336 }
1337
1338 #[test]
1339 fn clamp_to_grid_leaves_an_axis_with_no_known_extent_alone() {
1340 let mut sheet = sheet_with_extent(10, 5);
1341 sheet
1342 .properties
1343 .as_mut()
1344 .expect("fixture always sets properties")
1345 .grid_properties = None;
1346 let destination = bounded(0, 0, 9_999, 0, 1);
1347 assert_eq!(clamp_to_grid(&sheet, &destination), Some(destination));
1348 }
1349
1350 #[test]
1351 fn grid_range_to_a1_round_trips_a_bounded_range() {
1352 let grid = bounded(0, 0, 2, 0, 2); assert_eq!(grid_range_to_a1("Q1", &grid), "'Q1'!A1:B2");
1354 }
1355
1356 #[test]
1357 #[should_panic(expected = "fully bounded")]
1358 fn grid_range_to_a1_panics_on_an_unbounded_range() {
1359 let grid = GridRange {
1360 sheet_id: 0,
1361 ..Default::default()
1362 };
1363 let _ = grid_range_to_a1("Q1", &grid);
1364 }
1365
1366 #[test]
1367 fn build_request_range_form_carries_the_range_and_no_source_and_destination() {
1368 let source = bounded(0, 0, 2, 0, 2);
1369 let form = AutoFillForm::Range {
1370 sheet: Some("Q1".to_string()),
1371 range: Some("A1:B2".to_string()),
1372 };
1373 let BatchUpdateRequestItem::AutoFill(request) = build_request(&form, source, true) else {
1374 panic!("expected AutoFill"); };
1376 assert_eq!(request.range, Some(source));
1377 assert_eq!(request.source_and_destination, None);
1378 assert!(request.use_alternate_series);
1379 }
1380
1381 #[test]
1382 fn build_request_source_and_destination_form_carries_dimension_and_fill_length() {
1383 let source = bounded(0, 0, 2, 0, 2);
1384 let form = AutoFillForm::SourceAndDestination {
1385 sheet: Some("Q1".to_string()),
1386 source: Some("A1:B2".to_string()),
1387 dimension: Dimension::Rows,
1388 fill_length: 5,
1389 };
1390 let BatchUpdateRequestItem::AutoFill(request) = build_request(&form, source, false) else {
1391 panic!("expected AutoFill"); };
1393 assert_eq!(request.range, None);
1394 let sad = request.source_and_destination.unwrap();
1395 assert_eq!(sad.source, source);
1396 assert_eq!(sad.dimension, Dimension::Rows);
1397 assert_eq!(sad.fill_length, 5);
1398 assert!(!request.use_alternate_series);
1399 }
1400
1401 #[test]
1402 fn describe_effect_names_the_direction_for_every_axis_and_sign() {
1403 let form = |dimension, fill_length| AutoFillForm::SourceAndDestination {
1404 sheet: None,
1405 source: None,
1406 dimension,
1407 fill_length,
1408 };
1409 assert!(
1410 describe_effect(&form(Dimension::Rows, 3), "A1:A3", "A4:A6", 3, false)
1411 .contains("3 row(s) down")
1412 );
1413 assert!(
1414 describe_effect(&form(Dimension::Rows, -3), "A4:A6", "A1:A3", -3, false)
1415 .contains("3 row(s) up")
1416 );
1417 assert!(
1418 describe_effect(&form(Dimension::Columns, 2), "A1:A1", "B1:C1", 2, false)
1419 .contains("2 column(s) right")
1420 );
1421 assert!(
1422 describe_effect(&form(Dimension::Columns, -2), "C1:C1", "A1:B1", -2, false)
1423 .contains("2 column(s) left")
1424 );
1425 }
1426
1427 #[test]
1428 fn describe_effect_names_the_source_it_extends() {
1429 let form = AutoFillForm::SourceAndDestination {
1430 sheet: None,
1431 source: None,
1432 dimension: Dimension::Rows,
1433 fill_length: 7,
1434 };
1435 let summary = describe_effect(&form, "'Q1'!A1:A3", "'Q1'!A4:A10", 7, false);
1438 assert!(summary.contains("from source 'Q1'!A1:A3"), "{summary}");
1439 }
1440
1441 #[test]
1446 fn describe_effect_uses_the_applied_fill_length_not_the_requested_one() {
1447 let form = AutoFillForm::SourceAndDestination {
1448 sheet: None,
1449 source: None,
1450 dimension: Dimension::Rows,
1451 fill_length: 1000,
1452 };
1453 let summary = describe_effect(&form, "'Q1'!A1:A3", "'Q1'!A4:A1000", 997, false);
1454 assert!(summary.contains("997 row(s) down"), "{summary}");
1455 assert!(!summary.contains("1000 row(s)"), "{summary}");
1458 }
1459
1460 #[test]
1461 fn describe_effect_names_the_range_form_as_server_decided() {
1462 let form = AutoFillForm::Range {
1463 sheet: None,
1464 range: None,
1465 };
1466 let summary = describe_effect(&form, "A1:A10", "A1:A10", 0, false);
1467 assert!(summary.contains("Sheets decides"), "{summary}");
1468 }
1469
1470 #[test]
1471 fn describe_effect_names_the_alternate_series_flag() {
1472 let form = AutoFillForm::SourceAndDestination {
1473 sheet: None,
1474 source: None,
1475 dimension: Dimension::Rows,
1476 fill_length: 1,
1477 };
1478 let summary = describe_effect(&form, "A1:A1", "A2:A2", 1, true);
1479 assert!(summary.contains("alternate series"), "{summary}");
1480 }
1481
1482 #[test]
1486 fn describe_effect_is_tense_neutral() {
1487 let form = AutoFillForm::SourceAndDestination {
1488 sheet: None,
1489 source: None,
1490 dimension: Dimension::Rows,
1491 fill_length: 1,
1492 };
1493 let summary = describe_effect(&form, "A1:A1", "A2:A2", 1, true);
1494 for conditional in ["would", "cannot", "may "] {
1495 assert!(!summary.contains(conditional), "{summary}");
1496 }
1497 }
1498
1499 #[test]
1500 fn applied_fill_length_shrinks_to_the_clipped_destination_forward() {
1501 let form = AutoFillForm::SourceAndDestination {
1502 sheet: None,
1503 source: None,
1504 dimension: Dimension::Rows,
1505 fill_length: 1000,
1506 };
1507 let source = bounded(0, 0, 3, 0, 1); let applied = bounded(0, 3, 1000, 0, 1); assert_eq!(applied_fill_length(&form, &source, &applied), 997);
1510 }
1511
1512 #[test]
1513 fn applied_fill_length_is_unaffected_by_a_backward_fill() {
1514 let form = AutoFillForm::SourceAndDestination {
1515 sheet: None,
1516 source: None,
1517 dimension: Dimension::Rows,
1518 fill_length: -3,
1519 };
1520 let source = bounded(0, 5, 8, 0, 1); let applied = bounded(0, 2, 5, 0, 1); assert_eq!(applied_fill_length(&form, &source, &applied), -3);
1524 }
1525
1526 #[test]
1527 fn applied_fill_length_is_zero_for_the_range_form() {
1528 let form = AutoFillForm::Range {
1529 sheet: None,
1530 range: None,
1531 };
1532 let grid = bounded(0, 0, 1, 0, 1);
1533 assert_eq!(applied_fill_length(&form, &grid, &grid), 0);
1534 }
1535
1536 #[test]
1537 fn overwritten_line_reports_cells_never_their_values() {
1538 let cells = vec!["A4".to_string(), "A5".to_string()];
1539 let line = overwritten_line(&cells, false, true);
1540 assert_eq!(line, " 2 non-blank cell(s) would be overwritten: A4, A5");
1541 }
1542
1543 #[test]
1544 fn overwritten_line_marks_an_upper_bound_count_as_up_to() {
1545 let cells = vec!["A1".to_string()];
1546 let line = overwritten_line(&cells, true, true);
1547 assert_eq!(line, " up to 1 non-blank cell(s) would be overwritten: A1");
1548 }
1549
1550 #[test]
1551 fn overwritten_line_uses_the_past_tense_after_a_real_run() {
1552 let cells = vec!["A4".to_string()];
1553 assert_eq!(
1554 overwritten_line(&cells, false, false),
1555 " 1 non-blank cell(s) were overwritten: A4"
1556 );
1557 assert_eq!(
1558 overwritten_line(&[], false, false),
1559 " no non-blank cells in the destination"
1560 );
1561 }
1562
1563 #[test]
1564 fn overwritten_line_elides_past_the_render_limit_but_keeps_the_true_count() {
1565 let cells: Vec<String> = (1..=60).map(|row| format!("A{row}")).collect();
1566 let line = overwritten_line(&cells, false, true);
1567 assert!(line.contains("… and 10 more"), "{line}");
1568 assert!(
1569 line.starts_with(" 60 non-blank cell(s) would be overwritten:"),
1570 "{line}"
1571 );
1572 assert_eq!(cells.len(), 60);
1573 }
1574
1575 #[test]
1576 fn change_lines_name_the_grid_extent_caveat_in_the_matching_tense() {
1577 let dry = change_lines(
1578 "Would x in 'B'",
1579 &[],
1580 false,
1581 "'Q1'!A1:A10",
1582 Some("'Q1'!A1:A20"),
1583 true,
1584 );
1585 assert!(
1586 dry.contains(&past_grid_extent_caveat_dry_run(
1587 "'Q1'!A1:A10",
1588 "'Q1'!A1:A20"
1589 )),
1590 "{dry:?}"
1591 );
1592 let real = change_lines(
1593 "Applied: x in 'B'",
1594 &[],
1595 false,
1596 "'Q1'!A1:A10",
1597 Some("'Q1'!A1:A20"),
1598 false,
1599 );
1600 assert!(
1601 real.contains(&past_grid_extent_caveat("'Q1'!A1:A10", "'Q1'!A1:A20")),
1602 "{real:?}"
1603 );
1604 let within = change_lines("Would x in 'B'", &[], false, "'Q1'!A1:A10", None, true);
1607 assert!(
1608 !within.iter().any(|l| l.contains("current extent")),
1609 "{within:?}"
1610 );
1611 }
1612
1613 #[test]
1614 fn change_lines_always_state_the_server_decides_the_values() {
1615 for dry_run in [true, false] {
1616 let lines = change_lines("head", &[], false, "'Q1'!A1:A10", None, dry_run);
1617 assert!(
1618 lines.contains(&UNPREVIEWABLE_VALUES_CAVEAT.to_string()),
1619 "{lines:?}"
1620 );
1621 }
1622 }
1623
1624 #[test]
1625 fn auto_fill_result_log_status_names_every_variant() {
1626 assert_eq!(
1627 AutoFillResult::WouldChange {
1628 summary: String::new(),
1629 destination: String::new(),
1630 requested_destination: None,
1631 overwritten_cells: Vec::new(),
1632 destination_is_upper_bound: false,
1633 }
1634 .log_status(),
1635 "would-change"
1636 );
1637 assert_eq!(
1638 AutoFillResult::RefusedNotASpreadsheet {
1639 mime_type: String::new()
1640 }
1641 .log_status(),
1642 "refused-not-a-spreadsheet"
1643 );
1644 assert_eq!(
1645 AutoFillResult::RefusedShortcut.log_status(),
1646 "refused-shortcut"
1647 );
1648 assert_eq!(
1649 AutoFillResult::RefusedNoVisibleParents.log_status(),
1650 "refused-no-visible-parents"
1651 );
1652 assert_eq!(
1653 AutoFillResult::RefusedSheetNotFound {
1654 title: String::new(),
1655 available: Vec::new(),
1656 }
1657 .log_status(),
1658 "refused-sheet-not-found"
1659 );
1660 assert_eq!(
1661 AutoFillResult::RefusedInvalidRange {
1662 detail: String::new()
1663 }
1664 .log_status(),
1665 "refused-invalid-range"
1666 );
1667 assert_eq!(
1668 AutoFillResult::RefusedDestinationPastGrid {
1669 requested_destination: String::new(),
1670 row_count: None,
1671 column_count: None,
1672 }
1673 .log_status(),
1674 "refused-destination-past-grid"
1675 );
1676 assert_eq!(
1677 AutoFillResult::Blocked { decided_by: None }.log_status(),
1678 "blocked"
1679 );
1680 assert_eq!(
1681 AutoFillResult::RefusedNoLease.log_status(),
1682 LeaseGateRefusal::NoLease.log_status()
1683 );
1684 assert_eq!(
1685 AutoFillResult::RefusedLeaseExpired.log_status(),
1686 LeaseGateRefusal::Expired.log_status()
1687 );
1688 assert_eq!(
1689 AutoFillResult::RefusedLeaseWrongFile.log_status(),
1690 LeaseGateRefusal::WrongFile.log_status()
1691 );
1692 assert_eq!(
1693 AutoFillResult::RefusedLeaseStale.log_status(),
1694 LeaseGateRefusal::Stale.log_status()
1695 );
1696 assert_eq!(
1697 AutoFillResult::Changed {
1698 summary: String::new(),
1699 destination: String::new(),
1700 requested_destination: None,
1701 overwritten_cells: Vec::new(),
1702 destination_is_upper_bound: false,
1703 }
1704 .log_status(),
1705 "changed"
1706 );
1707 assert_eq!(
1708 AutoFillResult::Failed {
1709 detail: String::new()
1710 }
1711 .log_status(),
1712 "failed"
1713 );
1714 }
1715
1716 #[test]
1717 fn sheet_and_range_reads_source_for_the_source_and_destination_form() {
1718 let form = AutoFillForm::SourceAndDestination {
1719 sheet: Some("Q1".to_string()),
1720 source: Some("A1:A3".to_string()),
1721 dimension: Dimension::Rows,
1722 fill_length: 1,
1723 };
1724 assert_eq!(form.sheet_and_range(), (Some("Q1"), Some("A1:A3")));
1725 }
1726
1727 #[test]
1735 fn no_describe_line_contains_an_embedded_newline() {
1736 let base = AutoFillOutcome {
1737 spreadsheet_id: "sheet-1".to_string(),
1738 file_name: Some("Budget".to_string()),
1739 resolved_folder_id: None,
1740 sheet_id: Some(0),
1741 form: range_form(),
1742 result: AutoFillResult::Failed {
1743 detail: String::new(),
1744 },
1745 };
1746 let results = vec![
1747 AutoFillResult::WouldChange {
1748 summary: "auto-fill within 'Q1'!A1:A10".to_string(),
1749 destination: "'Q1'!A1:A10".to_string(),
1750 requested_destination: Some("'Q1'!A1:A20".to_string()),
1751 overwritten_cells: vec!["A1".to_string()],
1752 destination_is_upper_bound: true,
1753 },
1754 AutoFillResult::RefusedNotASpreadsheet {
1755 mime_type: "application/pdf".to_string(),
1756 },
1757 AutoFillResult::RefusedShortcut,
1758 AutoFillResult::RefusedNoVisibleParents,
1759 AutoFillResult::RefusedSheetNotFound {
1760 title: "Q9".to_string(),
1761 available: vec!["Q1".to_string()],
1762 },
1763 AutoFillResult::RefusedInvalidRange {
1764 detail: "bad range".to_string(),
1765 },
1766 AutoFillResult::RefusedDestinationPastGrid {
1767 requested_destination: "'Q1'!A2001:A2010".to_string(),
1768 row_count: Some(1000),
1769 column_count: Some(26),
1770 },
1771 AutoFillResult::Blocked { decided_by: None },
1772 AutoFillResult::RefusedNoLease,
1773 AutoFillResult::RefusedLeaseExpired,
1774 AutoFillResult::RefusedLeaseWrongFile,
1775 AutoFillResult::RefusedLeaseStale,
1776 AutoFillResult::Changed {
1777 summary: "auto-fill within 'Q1'!A1:A10".to_string(),
1778 destination: "'Q1'!A1:A10".to_string(),
1779 requested_destination: None,
1780 overwritten_cells: Vec::new(),
1781 destination_is_upper_bound: true,
1782 },
1783 AutoFillResult::Failed {
1784 detail: "boom".to_string(),
1785 },
1786 ];
1787 for result in results {
1788 let outcome = AutoFillOutcome {
1789 result,
1790 ..base.clone()
1791 };
1792 let lines = describe_lines(&outcome);
1793 assert!(!lines.is_empty(), "{lines:?}");
1794 for line in &lines {
1795 assert!(!line.contains('\n') && !line.contains('\r'), "{line:?}");
1796 }
1797 }
1798 }
1799
1800 #[test]
1801 fn describe_handles_missing_metadata_and_identifies_a_deciding_rule() {
1802 let base = AutoFillOutcome {
1803 spreadsheet_id: "sheet-1".into(),
1804 file_name: None,
1805 resolved_folder_id: None,
1806 sheet_id: None,
1807 form: range_form(),
1808 result: AutoFillResult::RefusedSheetNotFound {
1809 title: "Missing".into(),
1810 available: Vec::new(),
1811 },
1812 };
1813 assert!(describe(&base).contains("Available: none"));
1814 assert!(describe(&base).contains("'sheet-1'"));
1815
1816 let blocked = AutoFillOutcome {
1817 result: AutoFillResult::Blocked {
1818 decided_by: Some(DecidingRule::Folder {
1819 folder_id: "folder-1".into(),
1820 depth: 1,
1821 }),
1822 },
1823 ..base
1824 };
1825 assert!(describe(&blocked).contains("folder-1"));
1826 }
1827
1828 #[test]
1833 fn refused_destination_past_grid_message_names_only_the_known_axes() {
1834 let base = AutoFillOutcome {
1835 spreadsheet_id: "sheet-1".into(),
1836 file_name: None,
1837 resolved_folder_id: None,
1838 sheet_id: None,
1839 form: range_form(),
1840 result: AutoFillResult::RefusedDestinationPastGrid {
1841 requested_destination: "'Q1'!A2001:A2010".into(),
1842 row_count: Some(1000),
1843 column_count: None,
1844 },
1845 };
1846 assert!(describe(&base).contains("(1000 rows)"));
1847
1848 let column_only = AutoFillOutcome {
1849 result: AutoFillResult::RefusedDestinationPastGrid {
1850 requested_destination: "'Q1'!AA1:AZ10".into(),
1851 row_count: None,
1852 column_count: Some(26),
1853 },
1854 ..base.clone()
1855 };
1856 assert!(describe(&column_only).contains("(26 columns)"));
1857
1858 let neither_known = AutoFillOutcome {
1859 result: AutoFillResult::RefusedDestinationPastGrid {
1860 requested_destination: "'Q1'!A2001:A2010".into(),
1861 row_count: None,
1862 column_count: None,
1863 },
1864 ..base
1865 };
1866 assert!(describe(&neither_known).contains("current grid;"));
1867 }
1868
1869 fn range_form() -> AutoFillForm {
1870 AutoFillForm::Range {
1871 sheet: Some("Q1".to_string()),
1872 range: Some("A1:A10".to_string()),
1873 }
1874 }
1875
1876 fn source_and_destination_form(dimension: Dimension, fill_length: i64) -> AutoFillForm {
1877 AutoFillForm::SourceAndDestination {
1878 sheet: Some("Q1".to_string()),
1879 source: Some("A1:A3".to_string()),
1880 dimension,
1881 fill_length,
1882 }
1883 }
1884
1885 fn test_credentials() -> DriveCredentials {
1888 DriveCredentials {
1889 client_id: "client-1".to_string(),
1890 client_secret: Secret::new("secret-1"),
1891 refresh_token: Secret::new("refresh-1"),
1892 scope: DriveGrantedScopes::READONLY,
1893 }
1894 }
1895
1896 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1897 wiremock::Mock::given(wiremock::matchers::method("POST"))
1898 .and(wiremock::matchers::path("/token"))
1899 .respond_with(
1900 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1901 "access_token": "test-token", "expires_in": 3600,
1902 })),
1903 )
1904 .mount(server)
1905 .await;
1906 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1907 crate::drive::client::test_support::replace_session(
1908 &mut drive,
1909 &test_credentials(),
1910 &format!("{}/token", server.uri()),
1911 );
1912 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1913 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1914 (drive, sheets)
1915 }
1916
1917 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1918 let parents: Vec<&str> = parents.to_vec();
1919 wiremock::Mock::given(wiremock::matchers::method("GET"))
1920 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1921 .respond_with(
1922 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1923 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1924 "version": "1",
1925 })),
1926 )
1927 }
1928
1929 fn mount_folder(id: &str) -> wiremock::Mock {
1930 mount_file(id, "application/vnd.google-apps.folder", &[])
1931 }
1932
1933 fn mount_workbook() -> wiremock::Mock {
1934 wiremock::Mock::given(wiremock::matchers::method("GET"))
1935 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1936 .respond_with(
1937 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1938 "spreadsheetId": "sheet-1",
1939 "properties": {"title": "Budget"},
1940 "sheets": [
1941 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1942 "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
1943 ],
1944 })),
1945 )
1946 }
1947
1948 fn allow_rule(folder: &str) -> FolderPermissionRule {
1949 FolderPermissionRule {
1950 folder_id: Some(folder.to_string()),
1951 file_id: None,
1952 recursive: true,
1953 allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
1954 deny: HashSet::default(),
1955 require_lease: true,
1956 }
1957 }
1958
1959 fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
1960 let ledger_path = tempfile::tempdir()
1961 .unwrap()
1962 .keep()
1963 .join("lease-ledger.jsonl");
1964 let token = seed_lease(&ledger_path, spreadsheet_id, "1");
1965 (Some(token), ledger_path)
1966 }
1967
1968 fn base_opts(form: AutoFillForm, dry_run: bool) -> AutoFillOptions {
1969 AutoFillOptions {
1970 spreadsheet_id: "sheet-1".to_string(),
1971 form,
1972 use_alternate_series: false,
1973 dry_run,
1974 lease_token: None,
1975 ledger_path: std::path::PathBuf::new(),
1976 }
1977 }
1978
1979 #[tokio::test]
1980 async fn a_denied_gate_blocks_before_any_read_or_batch_update_call() {
1981 let server = wiremock::MockServer::start().await;
1982 let (drive, sheets) = clients(&server).await;
1983 mount_file(
1984 "sheet-1",
1985 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1986 &["folder-1"],
1987 )
1988 .mount(&server)
1989 .await;
1990 mount_folder("folder-1").mount(&server).await;
1991 let rules: Vec<FolderPermissionRule> = Vec::new();
1992 let opts = base_opts(range_form(), false);
1993 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
1994 assert!(matches!(outcome.result, AutoFillResult::Blocked { .. }));
1995 }
1996
1997 #[tokio::test]
1998 async fn invalid_composed_range_is_refused_before_fetching_metadata() {
1999 let server = wiremock::MockServer::start().await;
2000 let (drive, sheets) = clients(&server).await;
2001 let form = AutoFillForm::Range {
2002 sheet: Some("Q1".into()),
2003 range: Some("Other!A1:A2".into()),
2004 };
2005 let outcome = auto_fill_inner(&drive, &sheets, &base_opts(form, true), &[]).await;
2006 assert!(matches!(
2007 outcome.result,
2008 AutoFillResult::RefusedInvalidRange { .. }
2009 ));
2010 assert!(server
2011 .received_requests()
2012 .await
2013 .unwrap()
2014 .iter()
2015 .all(|r| r.url.path() != "/drive/v3/files/sheet-1"));
2016 }
2017
2018 #[tokio::test]
2019 async fn target_resolution_refusals_keep_the_target_name() {
2020 for (mime, parents, expected) in [
2021 (
2022 crate::drive::types::GOOGLE_SHORTCUT_MIME_TYPE,
2023 vec!["folder-1"],
2024 "shortcut",
2025 ),
2026 ("application/pdf", vec!["folder-1"], "not-spreadsheet"),
2027 (
2028 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2029 vec![],
2030 "no-parents",
2031 ),
2032 ] {
2033 let server = wiremock::MockServer::start().await;
2034 let (drive, sheets) = clients(&server).await;
2035 mount_file("sheet-1", mime, &parents).mount(&server).await;
2036 let outcome =
2037 auto_fill_inner(&drive, &sheets, &base_opts(range_form(), true), &[]).await;
2038 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2039 assert!(outcome.sheet_id.is_none());
2040 assert!(
2041 match expected {
2042 "shortcut" => matches!(outcome.result, AutoFillResult::RefusedShortcut),
2043 "not-spreadsheet" => matches!(
2044 outcome.result,
2045 AutoFillResult::RefusedNotASpreadsheet { .. }
2046 ),
2047 _ => matches!(outcome.result, AutoFillResult::RefusedNoVisibleParents),
2048 },
2049 "{outcome:?}"
2050 );
2051 }
2052 }
2053
2054 #[tokio::test]
2055 async fn metadata_and_workbook_failures_are_reported_at_their_respective_stages() {
2056 let server = wiremock::MockServer::start().await;
2057 let (drive, sheets) = clients(&server).await;
2058 wiremock::Mock::given(wiremock::matchers::method("GET"))
2059 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2060 .respond_with(wiremock::ResponseTemplate::new(500))
2061 .mount(&server)
2062 .await;
2063 let outcome = auto_fill_inner(&drive, &sheets, &base_opts(range_form(), true), &[]).await;
2064 assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
2065 assert!(outcome.file_name.is_none());
2066
2067 let server = wiremock::MockServer::start().await;
2068 let (drive, sheets) = clients(&server).await;
2069 mount_file(
2070 "sheet-1",
2071 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2072 &["folder-1"],
2073 )
2074 .mount(&server)
2075 .await;
2076 mount_folder("folder-1").mount(&server).await;
2077 wiremock::Mock::given(wiremock::matchers::method("GET"))
2078 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2079 .respond_with(wiremock::ResponseTemplate::new(500))
2080 .mount(&server)
2081 .await;
2082 let outcome = auto_fill_inner(
2083 &drive,
2084 &sheets,
2085 &base_opts(range_form(), true),
2086 &[allow_rule("folder-1")],
2087 )
2088 .await;
2089 assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
2090 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2091 }
2092
2093 #[tokio::test]
2094 async fn a_folder_lookup_failure_preserves_the_target_name() {
2095 let server = wiremock::MockServer::start().await;
2096 let (drive, sheets) = clients(&server).await;
2097 mount_file(
2098 "sheet-1",
2099 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2100 &["folder-1"],
2101 )
2102 .mount(&server)
2103 .await;
2104 wiremock::Mock::given(wiremock::matchers::method("GET"))
2105 .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
2106 .respond_with(wiremock::ResponseTemplate::new(500))
2107 .mount(&server)
2108 .await;
2109 let outcome = auto_fill_inner(
2110 &drive,
2111 &sheets,
2112 &base_opts(range_form(), true),
2113 &[allow_rule("folder-1")],
2114 )
2115 .await;
2116 assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
2117 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2118 assert!(outcome.sheet_id.is_none());
2119 }
2120
2121 #[tokio::test]
2122 async fn backward_fill_before_the_sheet_start_is_refused_before_values_get() {
2123 let server = wiremock::MockServer::start().await;
2124 let (drive, sheets) = clients(&server).await;
2125 mount_file(
2126 "sheet-1",
2127 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2128 &["folder-1"],
2129 )
2130 .mount(&server)
2131 .await;
2132 mount_folder("folder-1").mount(&server).await;
2133 mount_workbook().mount(&server).await;
2134 let outcome = auto_fill_inner(
2135 &drive,
2136 &sheets,
2137 &base_opts(source_and_destination_form(Dimension::Rows, -1), true),
2138 &[allow_rule("folder-1")],
2139 )
2140 .await;
2141 assert!(matches!(
2142 outcome.result,
2143 AutoFillResult::RefusedInvalidRange { .. }
2144 ));
2145 assert_eq!(outcome.sheet_id, Some(0));
2146 assert!(server
2147 .received_requests()
2148 .await
2149 .unwrap()
2150 .iter()
2151 .all(|r| !r.url.path().contains("/values/")));
2152 }
2153
2154 #[tokio::test]
2155 async fn source_and_destination_dry_run_reports_would_change_and_makes_no_batch_update_call() {
2156 let server = wiremock::MockServer::start().await;
2157 let (drive, sheets) = clients(&server).await;
2158 mount_file(
2159 "sheet-1",
2160 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2161 &["folder-1"],
2162 )
2163 .mount(&server)
2164 .await;
2165 mount_folder("folder-1").mount(&server).await;
2166 mount_workbook().mount(&server).await;
2167 wiremock::Mock::given(wiremock::matchers::method("GET"))
2168 .and(wiremock::matchers::path(
2169 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2170 ))
2171 .respond_with(
2172 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2173 "values": [["existing"], [], ["also here"]]
2174 })),
2175 )
2176 .mount(&server)
2177 .await;
2178 let rules = vec![allow_rule("folder-1")];
2179 let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), true);
2180 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2181 match outcome.result {
2182 AutoFillResult::WouldChange {
2183 destination,
2184 overwritten_cells,
2185 destination_is_upper_bound,
2186 ..
2187 } => {
2188 assert_eq!(destination, "'Q1'!A4:A10");
2189 assert_eq!(overwritten_cells, vec!["A4".to_string(), "A6".to_string()]);
2190 assert!(!destination_is_upper_bound);
2191 }
2192 other => panic!("expected WouldChange, got {other:?}"),
2193 }
2194 }
2197
2198 #[tokio::test]
2199 async fn range_form_dry_run_reports_the_destination_as_an_upper_bound() {
2200 let server = wiremock::MockServer::start().await;
2201 let (drive, sheets) = clients(&server).await;
2202 mount_file(
2203 "sheet-1",
2204 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2205 &["folder-1"],
2206 )
2207 .mount(&server)
2208 .await;
2209 mount_folder("folder-1").mount(&server).await;
2210 mount_workbook().mount(&server).await;
2211 wiremock::Mock::given(wiremock::matchers::method("GET"))
2212 .and(wiremock::matchers::path(
2213 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:A10",
2214 ))
2215 .respond_with(
2216 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2217 "values": [["1"], ["2"]]
2218 })),
2219 )
2220 .mount(&server)
2221 .await;
2222 let rules = vec![allow_rule("folder-1")];
2223 let opts = base_opts(range_form(), true);
2224 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2225 match outcome.result {
2226 AutoFillResult::WouldChange {
2227 destination_is_upper_bound,
2228 overwritten_cells,
2229 ..
2230 } => {
2231 assert!(destination_is_upper_bound);
2232 assert_eq!(overwritten_cells, vec!["A1".to_string(), "A2".to_string()]);
2233 }
2234 other => panic!("expected WouldChange, got {other:?}"),
2235 }
2236 }
2237
2238 #[tokio::test]
2239 async fn an_open_ended_source_is_refused() {
2240 let server = wiremock::MockServer::start().await;
2241 let (drive, sheets) = clients(&server).await;
2242 mount_file(
2243 "sheet-1",
2244 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2245 &["folder-1"],
2246 )
2247 .mount(&server)
2248 .await;
2249 mount_folder("folder-1").mount(&server).await;
2250 mount_workbook().mount(&server).await;
2251 let rules = vec![allow_rule("folder-1")];
2252 let form = AutoFillForm::SourceAndDestination {
2253 sheet: Some("Q1".to_string()),
2254 source: Some("A:A".to_string()),
2255 dimension: Dimension::Rows,
2256 fill_length: 1,
2257 };
2258 let outcome = auto_fill(&drive, &sheets, &base_opts(form, true), &rules).await;
2259 match outcome.result {
2260 AutoFillResult::RefusedInvalidRange { detail } => {
2261 assert!(detail.contains("fully bounded"), "{detail}");
2262 assert!(detail.starts_with("'Q1'!A:A is open-ended"), "{detail}");
2263 }
2264 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2265 }
2266 }
2267
2268 #[tokio::test]
2269 async fn a_fill_length_of_zero_is_refused_before_any_request() {
2270 let server = wiremock::MockServer::start().await;
2271 let (drive, sheets) = clients(&server).await;
2272 let rules: Vec<FolderPermissionRule> = Vec::new();
2273 let outcome = auto_fill(
2274 &drive,
2275 &sheets,
2276 &base_opts(source_and_destination_form(Dimension::Rows, 0), true),
2277 &rules,
2278 )
2279 .await;
2280 match outcome.result {
2281 AutoFillResult::RefusedInvalidRange { detail } => {
2282 assert!(detail.contains("--fill-length"), "{detail}");
2283 }
2284 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2285 }
2286 }
2289
2290 #[tokio::test]
2291 async fn a_real_run_sends_a_source_and_destination_auto_fill_request_and_reports_changed() {
2292 let server = wiremock::MockServer::start().await;
2293 let (drive, sheets) = clients(&server).await;
2294 mount_file(
2295 "sheet-1",
2296 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2297 &["folder-1"],
2298 )
2299 .mount(&server)
2300 .await;
2301 mount_folder("folder-1").mount(&server).await;
2302 mount_workbook().mount(&server).await;
2303 wiremock::Mock::given(wiremock::matchers::method("GET"))
2304 .and(wiremock::matchers::path(
2305 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2306 ))
2307 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2308 .mount(&server)
2309 .await;
2310 wiremock::Mock::given(wiremock::matchers::method("POST"))
2311 .and(wiremock::matchers::path(
2312 "/v4/spreadsheets/sheet-1:batchUpdate",
2313 ))
2314 .and(wiremock::matchers::body_json(serde_json::json!({
2315 "requests": [{
2316 "autoFill": {
2317 "sourceAndDestination": {
2318 "source": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 3,
2319 "startColumnIndex": 0, "endColumnIndex": 1},
2320 "dimension": "ROWS",
2321 "fillLength": 7
2322 },
2323 "useAlternateSeries": false
2324 }
2325 }]
2326 })))
2327 .respond_with(
2328 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2329 "spreadsheetId": "sheet-1", "replies": [{}]
2330 })),
2331 )
2332 .mount(&server)
2333 .await;
2334 let rules = vec![allow_rule("folder-1")];
2335 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2336 let opts = AutoFillOptions {
2337 lease_token,
2338 ledger_path,
2339 ..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
2340 };
2341 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2342 assert!(matches!(outcome.result, AutoFillResult::Changed { .. }));
2343 }
2344
2345 #[tokio::test]
2357 async fn a_destination_wholly_past_the_grid_extent_is_refused_before_any_lease_or_mutation() {
2358 let server = wiremock::MockServer::start().await;
2359 let (drive, sheets) = clients(&server).await;
2360 mount_file(
2361 "sheet-1",
2362 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2363 &["folder-1"],
2364 )
2365 .mount(&server)
2366 .await;
2367 mount_folder("folder-1").mount(&server).await;
2368 mount_workbook().mount(&server).await;
2369 let rules = vec![allow_rule("folder-1")];
2370 let form = AutoFillForm::Range {
2373 sheet: Some("Q1".to_string()),
2374 range: Some("A2001:A2010".to_string()),
2375 };
2376 for dry_run in [true, false] {
2377 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2378 let opts = AutoFillOptions {
2379 lease_token,
2380 ledger_path,
2381 form: form.clone(),
2382 ..base_opts(range_form(), dry_run)
2383 };
2384 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2385 let AutoFillResult::RefusedDestinationPastGrid {
2386 requested_destination,
2387 row_count,
2388 column_count,
2389 } = &outcome.result
2390 else {
2391 panic!("dry_run={dry_run}: {:?}", outcome.result);
2392 };
2393 assert_eq!(requested_destination, "'Q1'!A2001:A2010");
2394 assert_eq!(*row_count, Some(1000));
2395 assert_eq!(*column_count, Some(26));
2396 let lines = describe_lines(&outcome);
2397 assert_eq!(lines.len(), 1, "{lines:?}");
2398 assert!(lines[0].contains("lies wholly past"), "{lines:?}");
2399 }
2400 assert!(
2401 !server
2402 .received_requests()
2403 .await
2404 .unwrap_or_default()
2405 .iter()
2406 .any(
2407 |r| r.url.path().contains("/values/") || r.url.path().ends_with(":batchUpdate")
2408 ),
2409 "neither the read nor the mutation must be attempted"
2410 );
2411 }
2412
2413 #[tokio::test]
2418 async fn a_destination_straddling_the_grid_edge_is_clipped_and_reports_both_ranges() {
2419 let server = wiremock::MockServer::start().await;
2420 let (drive, sheets) = clients(&server).await;
2421 mount_file(
2422 "sheet-1",
2423 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2424 &["folder-1"],
2425 )
2426 .mount(&server)
2427 .await;
2428 mount_folder("folder-1").mount(&server).await;
2429 mount_workbook().mount(&server).await;
2430 wiremock::Mock::given(wiremock::matchers::method("GET"))
2434 .and(wiremock::matchers::path(
2435 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A1000",
2436 ))
2437 .respond_with(
2438 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2439 "values": [["keep"]]
2440 })),
2441 )
2442 .mount(&server)
2443 .await;
2444 let rules = vec![allow_rule("folder-1")];
2445 let opts = base_opts(source_and_destination_form(Dimension::Rows, 1000), true);
2446 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2447 let AutoFillResult::WouldChange {
2448 destination,
2449 requested_destination,
2450 overwritten_cells,
2451 ..
2452 } = &outcome.result
2453 else {
2454 panic!("{:?}", outcome.result);
2455 };
2456 assert_eq!(destination, "'Q1'!A4:A1000");
2457 assert_eq!(requested_destination.as_deref(), Some("'Q1'!A4:A1003"));
2458 assert_eq!(overwritten_cells, &vec!["A4".to_string()]);
2459 let lines = describe_lines(&outcome);
2460 assert_eq!(
2466 lines[0],
2467 "Would auto-fill 'Q1'!A4:A1000 from source 'Q1'!A1:A3, extending 997 row(s) down in \
2468 'sheet-1'"
2469 );
2470 assert_eq!(
2471 lines[2],
2472 past_grid_extent_caveat_dry_run("'Q1'!A4:A1000", "'Q1'!A4:A1003")
2473 );
2474 }
2475
2476 #[tokio::test]
2480 async fn a_clipped_real_run_reports_the_applied_count_in_the_head_line() {
2481 let server = wiremock::MockServer::start().await;
2482 let (drive, sheets) = clients(&server).await;
2483 mount_file(
2484 "sheet-1",
2485 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2486 &["folder-1"],
2487 )
2488 .mount(&server)
2489 .await;
2490 mount_folder("folder-1").mount(&server).await;
2491 mount_workbook().mount(&server).await;
2492 wiremock::Mock::given(wiremock::matchers::method("GET"))
2493 .and(wiremock::matchers::path(
2494 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A1000",
2495 ))
2496 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2497 .mount(&server)
2498 .await;
2499 wiremock::Mock::given(wiremock::matchers::method("POST"))
2500 .and(wiremock::matchers::path(
2501 "/v4/spreadsheets/sheet-1:batchUpdate",
2502 ))
2503 .respond_with(
2504 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2505 "spreadsheetId": "sheet-1", "replies": [{}]
2506 })),
2507 )
2508 .mount(&server)
2509 .await;
2510 let rules = vec![allow_rule("folder-1")];
2511 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2512 let opts = AutoFillOptions {
2513 lease_token,
2514 ledger_path,
2515 ..base_opts(source_and_destination_form(Dimension::Rows, 1000), false)
2516 };
2517 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2518 assert!(matches!(outcome.result, AutoFillResult::Changed { .. }));
2519 let lines = describe_lines(&outcome);
2520 assert_eq!(
2521 lines[0],
2522 "Applied: auto-fill 'Q1'!A4:A1000 from source 'Q1'!A1:A3, extending 997 row(s) \
2523 down in 'sheet-1'"
2524 );
2525 assert_eq!(
2526 lines[2],
2527 past_grid_extent_caveat("'Q1'!A4:A1000", "'Q1'!A4:A1003")
2528 );
2529 }
2530
2531 #[tokio::test]
2535 async fn a_destination_straddling_the_grid_edge_clips_along_columns_too() {
2536 let server = wiremock::MockServer::start().await;
2537 let (drive, sheets) = clients(&server).await;
2538 mount_file(
2539 "sheet-1",
2540 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2541 &["folder-1"],
2542 )
2543 .mount(&server)
2544 .await;
2545 mount_folder("folder-1").mount(&server).await;
2546 mount_workbook().mount(&server).await;
2547 wiremock::Mock::given(wiremock::matchers::method("GET"))
2551 .and(wiremock::matchers::path(
2552 "/v4/spreadsheets/sheet-1/values/'Q1'!B1:Z3",
2553 ))
2554 .respond_with(
2555 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2556 "values": [["keep"]]
2557 })),
2558 )
2559 .mount(&server)
2560 .await;
2561 let rules = vec![allow_rule("folder-1")];
2562 let opts = base_opts(source_and_destination_form(Dimension::Columns, 30), true);
2563 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2564 let AutoFillResult::WouldChange {
2565 destination,
2566 requested_destination,
2567 overwritten_cells,
2568 ..
2569 } = &outcome.result
2570 else {
2571 panic!("{:?}", outcome.result); };
2573 assert_eq!(destination, "'Q1'!B1:Z3");
2574 assert!(requested_destination.is_some(), "{requested_destination:?}");
2575 assert_eq!(overwritten_cells, &vec!["B1".to_string()]);
2576 }
2577
2578 #[tokio::test]
2583 async fn a_range_form_destination_straddling_the_grid_edge_is_clipped_too() {
2584 let server = wiremock::MockServer::start().await;
2585 let (drive, sheets) = clients(&server).await;
2586 mount_file(
2587 "sheet-1",
2588 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2589 &["folder-1"],
2590 )
2591 .mount(&server)
2592 .await;
2593 mount_folder("folder-1").mount(&server).await;
2594 mount_workbook().mount(&server).await;
2595 wiremock::Mock::given(wiremock::matchers::method("GET"))
2596 .and(wiremock::matchers::path(
2597 "/v4/spreadsheets/sheet-1/values/'Q1'!A990:A1000",
2598 ))
2599 .respond_with(
2600 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2601 "values": [["keep"]]
2602 })),
2603 )
2604 .mount(&server)
2605 .await;
2606 let rules = vec![allow_rule("folder-1")];
2607 let form = AutoFillForm::Range {
2608 sheet: Some("Q1".to_string()),
2609 range: Some("A990:A1010".to_string()),
2610 };
2611 let outcome = auto_fill(&drive, &sheets, &base_opts(form, true), &rules).await;
2612 let AutoFillResult::WouldChange {
2613 destination,
2614 requested_destination,
2615 destination_is_upper_bound,
2616 ..
2617 } = &outcome.result
2618 else {
2619 panic!("{:?}", outcome.result); };
2621 assert_eq!(destination, "'Q1'!A990:A1000");
2622 assert_eq!(requested_destination.as_deref(), Some("'Q1'!A990:A1010"));
2623 assert!(destination_is_upper_bound);
2624 }
2625
2626 #[tokio::test]
2630 async fn a_destination_inside_the_grid_has_no_requested_destination_key_in_json() {
2631 let server = wiremock::MockServer::start().await;
2632 let (drive, sheets) = clients(&server).await;
2633 mount_file(
2634 "sheet-1",
2635 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2636 &["folder-1"],
2637 )
2638 .mount(&server)
2639 .await;
2640 mount_folder("folder-1").mount(&server).await;
2641 mount_workbook().mount(&server).await;
2642 wiremock::Mock::given(wiremock::matchers::method("GET"))
2643 .and(wiremock::matchers::path(
2644 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2645 ))
2646 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2647 .mount(&server)
2648 .await;
2649 let rules = vec![allow_rule("folder-1")];
2650 let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), true);
2651 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2652 let AutoFillResult::WouldChange {
2653 requested_destination,
2654 ..
2655 } = &outcome.result
2656 else {
2657 panic!("{:?}", outcome.result); };
2659 assert!(requested_destination.is_none());
2660 let mut out = Vec::new();
2661 outcome.write_jsonl(&mut out).unwrap();
2662 let value: serde_json::Value = serde_json::from_slice(&out).unwrap();
2663 assert!(value["result"].get("requested_destination").is_none());
2664 }
2665
2666 #[tokio::test]
2671 async fn unknown_grid_extent_leaves_the_destination_unclamped_and_never_refuses() {
2672 let server = wiremock::MockServer::start().await;
2673 let (drive, sheets) = clients(&server).await;
2674 mount_file(
2675 "sheet-1",
2676 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2677 &["folder-1"],
2678 )
2679 .mount(&server)
2680 .await;
2681 mount_folder("folder-1").mount(&server).await;
2682 wiremock::Mock::given(wiremock::matchers::method("GET"))
2683 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2684 .respond_with(
2685 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2686 "spreadsheetId": "sheet-1",
2687 "properties": {"title": "Budget"},
2688 "sheets": [
2689 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2690 ],
2691 })),
2692 )
2693 .mount(&server)
2694 .await;
2695 wiremock::Mock::given(wiremock::matchers::method("GET"))
2696 .and(wiremock::matchers::path(
2697 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A1003",
2698 ))
2699 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2700 .mount(&server)
2701 .await;
2702 let rules = vec![allow_rule("folder-1")];
2703 let opts = base_opts(source_and_destination_form(Dimension::Rows, 1000), true);
2704 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2705 let AutoFillResult::WouldChange {
2706 destination,
2707 requested_destination,
2708 ..
2709 } = &outcome.result
2710 else {
2711 panic!("{:?}", outcome.result); };
2713 assert_eq!(destination, "'Q1'!A4:A1003");
2714 assert!(requested_destination.is_none());
2715 }
2716
2717 #[tokio::test]
2718 async fn a_values_get_failure_is_reported_as_failed() {
2719 let server = wiremock::MockServer::start().await;
2720 let (drive, sheets) = clients(&server).await;
2721 mount_file(
2722 "sheet-1",
2723 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2724 &["folder-1"],
2725 )
2726 .mount(&server)
2727 .await;
2728 mount_folder("folder-1").mount(&server).await;
2729 mount_workbook().mount(&server).await;
2730 wiremock::Mock::given(wiremock::matchers::method("GET"))
2731 .and(wiremock::matchers::path(
2732 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2733 ))
2734 .respond_with(wiremock::ResponseTemplate::new(500))
2735 .mount(&server)
2736 .await;
2737 let rules = vec![allow_rule("folder-1")];
2738 let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), true);
2739 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2740 assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
2741 }
2742
2743 async fn mount_leasable_fixture(server: &wiremock::MockServer) {
2754 mount_file(
2755 "sheet-1",
2756 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2757 &["folder-1"],
2758 )
2759 .mount(server)
2760 .await;
2761 mount_folder("folder-1").mount(server).await;
2762 mount_workbook().mount(server).await;
2763 wiremock::Mock::given(wiremock::matchers::method("GET"))
2764 .and(wiremock::matchers::path(
2765 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2766 ))
2767 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2768 .mount(server)
2769 .await;
2770 }
2771
2772 async fn run_with_lease(
2776 server: &wiremock::MockServer,
2777 lease_token: Option<String>,
2778 ledger_path: std::path::PathBuf,
2779 ) -> AutoFillOutcome {
2780 let (drive, sheets) = clients(server).await;
2781 mount_leasable_fixture(server).await;
2782 let rules = vec![allow_rule("folder-1")];
2783 let opts = AutoFillOptions {
2784 lease_token,
2785 ledger_path,
2786 ..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
2787 };
2788 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2789 assert!(
2790 !server
2791 .received_requests()
2792 .await
2793 .unwrap_or_default()
2794 .iter()
2795 .any(|r| r.url.path().ends_with(":batchUpdate")),
2796 "a refused lease must not reach batchUpdate"
2797 );
2798 outcome
2799 }
2800
2801 fn ledger_in_a_tempdir() -> std::path::PathBuf {
2802 tempfile::tempdir()
2803 .unwrap()
2804 .keep()
2805 .join("lease-ledger.jsonl")
2806 }
2807
2808 #[tokio::test]
2809 async fn an_expired_or_unknown_lease_is_refused() {
2810 let server = wiremock::MockServer::start().await;
2811 let outcome = run_with_lease(
2814 &server,
2815 Some("never-issued".to_string()),
2816 ledger_in_a_tempdir(),
2817 )
2818 .await;
2819 assert!(matches!(
2820 outcome.result,
2821 AutoFillResult::RefusedLeaseExpired
2822 ));
2823 }
2824
2825 #[tokio::test]
2826 async fn a_lease_bound_to_another_file_is_refused() {
2827 let server = wiremock::MockServer::start().await;
2828 let ledger_path = ledger_in_a_tempdir();
2829 let token = seed_lease(&ledger_path, "some-other-sheet", "1");
2830 let outcome = run_with_lease(&server, Some(token), ledger_path).await;
2831 assert!(matches!(
2832 outcome.result,
2833 AutoFillResult::RefusedLeaseWrongFile
2834 ));
2835 }
2836
2837 #[tokio::test]
2838 async fn refuses_a_stale_lease_when_the_file_has_moved() {
2839 let server = wiremock::MockServer::start().await;
2840 let ledger_path = ledger_in_a_tempdir();
2841 let token = seed_lease(&ledger_path, "sheet-1", "0");
2844 let outcome = run_with_lease(&server, Some(token), ledger_path).await;
2845 assert!(matches!(outcome.result, AutoFillResult::RefusedLeaseStale));
2846 }
2847
2848 #[tokio::test]
2853 async fn a_real_run_renders_in_the_past_tense_and_names_the_source() {
2854 let server = wiremock::MockServer::start().await;
2855 let (drive, sheets) = clients(&server).await;
2856 mount_file(
2857 "sheet-1",
2858 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2859 &["folder-1"],
2860 )
2861 .mount(&server)
2862 .await;
2863 mount_folder("folder-1").mount(&server).await;
2864 mount_workbook().mount(&server).await;
2865 wiremock::Mock::given(wiremock::matchers::method("GET"))
2866 .and(wiremock::matchers::path(
2867 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2868 ))
2869 .respond_with(
2870 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2871 "values": [["keep"], [], ["also"]]
2872 })),
2873 )
2874 .mount(&server)
2875 .await;
2876 wiremock::Mock::given(wiremock::matchers::method("POST"))
2877 .and(wiremock::matchers::path(
2878 "/v4/spreadsheets/sheet-1:batchUpdate",
2879 ))
2880 .respond_with(
2881 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2882 "spreadsheetId": "sheet-1", "replies": [{}]
2883 })),
2884 )
2885 .mount(&server)
2886 .await;
2887 let rules = vec![allow_rule("folder-1")];
2888 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2889 let opts = AutoFillOptions {
2890 lease_token,
2891 ledger_path,
2892 ..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
2893 };
2894 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2895 let lines = describe_lines(&outcome);
2896 assert_eq!(
2897 lines[0],
2898 "Applied: auto-fill 'Q1'!A4:A10 from source 'Q1'!A1:A3, extending 7 row(s) down in \
2899 'sheet-1'"
2900 );
2901 assert_eq!(lines[1], " 2 non-blank cell(s) were overwritten: A4, A6");
2902 assert_eq!(lines[2], UNPREVIEWABLE_VALUES_CAVEAT);
2903 let AutoFillResult::Changed { summary, .. } = &outcome.result else {
2904 panic!("{:?}", outcome.result);
2905 };
2906 assert!(!summary.contains("would"), "{summary}");
2908 }
2909
2910 #[tokio::test]
2911 async fn a_batch_update_failure_is_reported_as_failed() {
2912 let server = wiremock::MockServer::start().await;
2913 let (drive, sheets) = clients(&server).await;
2914 mount_file(
2915 "sheet-1",
2916 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2917 &["folder-1"],
2918 )
2919 .mount(&server)
2920 .await;
2921 mount_folder("folder-1").mount(&server).await;
2922 mount_workbook().mount(&server).await;
2923 wiremock::Mock::given(wiremock::matchers::method("GET"))
2924 .and(wiremock::matchers::path(
2925 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2926 ))
2927 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2928 .mount(&server)
2929 .await;
2930 wiremock::Mock::given(wiremock::matchers::method("POST"))
2931 .and(wiremock::matchers::path(
2932 "/v4/spreadsheets/sheet-1:batchUpdate",
2933 ))
2934 .respond_with(wiremock::ResponseTemplate::new(500))
2935 .mount(&server)
2936 .await;
2937 let rules = vec![allow_rule("folder-1")];
2938 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2939 let opts = AutoFillOptions {
2940 lease_token,
2941 ledger_path,
2942 ..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
2943 };
2944 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2945 assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
2946 }
2947
2948 #[tokio::test]
2949 async fn a_missing_lease_is_refused_when_the_deciding_rule_requires_one() {
2950 let server = wiremock::MockServer::start().await;
2951 let (drive, sheets) = clients(&server).await;
2952 mount_file(
2953 "sheet-1",
2954 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2955 &["folder-1"],
2956 )
2957 .mount(&server)
2958 .await;
2959 mount_folder("folder-1").mount(&server).await;
2960 mount_workbook().mount(&server).await;
2961 wiremock::Mock::given(wiremock::matchers::method("GET"))
2962 .and(wiremock::matchers::path(
2963 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2964 ))
2965 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2966 .mount(&server)
2967 .await;
2968 let rules = vec![allow_rule("folder-1")];
2969 let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), false);
2970 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2971 assert!(matches!(outcome.result, AutoFillResult::RefusedNoLease));
2972 }
2973
2974 #[tokio::test]
2975 async fn the_sheet_not_found_refusal_names_the_available_sheets() {
2976 let server = wiremock::MockServer::start().await;
2977 let (drive, sheets) = clients(&server).await;
2978 mount_file(
2979 "sheet-1",
2980 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2981 &["folder-1"],
2982 )
2983 .mount(&server)
2984 .await;
2985 mount_folder("folder-1").mount(&server).await;
2986 mount_workbook().mount(&server).await;
2987 let rules = vec![allow_rule("folder-1")];
2988 let form = AutoFillForm::Range {
2989 sheet: Some("Missing".to_string()),
2990 range: Some("A1:A2".to_string()),
2991 };
2992 let outcome = auto_fill(&drive, &sheets, &base_opts(form, true), &rules).await;
2993 match outcome.result {
2994 AutoFillResult::RefusedSheetNotFound { title, available } => {
2995 assert_eq!(title, "Missing");
2996 assert_eq!(available, vec!["Q1".to_string()]);
2997 }
2998 other => panic!("expected RefusedSheetNotFound, got {other:?}"),
2999 }
3000 }
3001}