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::{
100 applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi, ValueRenderOption,
101};
102use crate::drive::sheets::client::SheetsClient;
103use crate::drive::sheets::grid_range;
104use crate::drive::sheets::target_gate;
105use crate::drive::sheets::types::{
106 AutoFillRequest, BatchUpdateRequestItem, Dimension, GridRange, Sheet, SourceAndDestination,
107};
108use crate::drive::types::SheetTargetRefusal;
109use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
110use crate::request_log::{self, DriveMutationOutcome};
111
112const LOG_OPERATION: &str = "sheets-auto-fill";
117
118#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum AutoFillForm {
124 Range {
127 sheet: Option<String>,
129 range: Option<String>,
131 },
132 SourceAndDestination {
135 sheet: Option<String>,
137 source: Option<String>,
140 dimension: Dimension,
142 fill_length: i64,
145 },
146}
147
148impl AutoFillForm {
149 fn sheet_and_range(&self) -> (Option<&str>, Option<&str>) {
152 match self {
153 Self::Range { sheet, range } => (sheet.as_deref(), range.as_deref()),
154 Self::SourceAndDestination { sheet, source, .. } => {
155 (sheet.as_deref(), source.as_deref())
156 }
157 }
158 }
159}
160
161#[derive(Debug, Clone)]
163pub struct AutoFillOptions {
164 pub spreadsheet_id: String,
166 pub form: AutoFillForm,
168 pub use_alternate_series: bool,
171 pub dry_run: bool,
173 pub lease_token: Option<String>,
178 pub ledger_path: PathBuf,
182}
183
184#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
186#[serde(tag = "status", rename_all = "kebab-case")]
187pub enum AutoFillResult {
188 WouldChange {
190 summary: String,
198 destination: String,
204 #[serde(skip_serializing_if = "Option::is_none")]
209 requested_destination: Option<String>,
210 #[serde(skip_serializing_if = "Vec::is_empty")]
214 overwritten_cells: Vec<String>,
215 destination_is_upper_bound: bool,
221 },
222 RefusedNotASpreadsheet {
224 mime_type: String,
226 },
227 RefusedShortcut,
229 RefusedNoVisibleParents,
231 RefusedSheetNotFound {
233 title: String,
235 available: Vec<String>,
237 },
238 RefusedInvalidRange {
241 detail: String,
243 },
244 RefusedDestinationPastGrid {
251 requested_destination: String,
253 row_count: Option<i64>,
255 column_count: Option<i64>,
257 },
258 Blocked {
260 decided_by: Option<DecidingRule>,
262 },
263 RefusedNoLease,
266 RefusedLeaseExpired,
269 RefusedLeaseWrongFile,
271 RefusedLeaseStale,
274 Changed {
276 summary: String,
280 destination: String,
282 #[serde(skip_serializing_if = "Option::is_none")]
284 requested_destination: Option<String>,
285 #[serde(skip_serializing_if = "Vec::is_empty")]
289 overwritten_cells: Vec<String>,
290 destination_is_upper_bound: bool,
292 },
293 AppliedReplyUnreadable {
297 summary: String,
299 destination: String,
302 #[serde(skip_serializing_if = "Option::is_none")]
304 requested_destination: Option<String>,
305 #[serde(skip_serializing_if = "Vec::is_empty")]
307 overwritten_cells: Vec<String>,
308 destination_is_upper_bound: bool,
310 detail: String,
312 },
313 Failed {
315 detail: String,
317 },
318}
319
320impl FromLeaseRefusal for AutoFillResult {
321 fn from_no_lease() -> Self {
322 Self::RefusedNoLease
323 }
324 fn from_lease_expired() -> Self {
325 Self::RefusedLeaseExpired
326 }
327 fn from_lease_wrong_file() -> Self {
328 Self::RefusedLeaseWrongFile
329 }
330 fn from_lease_stale() -> Self {
331 Self::RefusedLeaseStale
332 }
333 fn from_lease_failed(detail: String) -> Self {
334 Self::Failed { detail }
335 }
336}
337
338impl AutoFillResult {
339 fn log_status(&self) -> &'static str {
340 match self {
341 Self::WouldChange { .. } => "would-change",
342 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
343 Self::RefusedShortcut => "refused-shortcut",
344 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
345 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
346 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
347 Self::RefusedDestinationPastGrid { .. } => "refused-destination-past-grid",
348 Self::Blocked { .. } => "blocked",
349 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
350 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
351 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
352 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
353 Self::Changed { .. } => "changed",
354 Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
355 Self::Failed { .. } => "failed",
356 }
357 }
358}
359
360#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
362pub struct AutoFillOutcome {
363 pub spreadsheet_id: String,
365 #[serde(skip_serializing_if = "Option::is_none")]
367 pub file_name: Option<String>,
368 #[serde(skip_serializing_if = "Option::is_none")]
370 pub resolved_folder_id: Option<String>,
371 #[serde(skip_serializing_if = "Option::is_none")]
373 pub sheet_id: Option<i64>,
374 #[serde(skip)]
376 pub form: AutoFillForm,
377 pub result: AutoFillResult,
379}
380
381impl JsonlSerialize for AutoFillOutcome {
382 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
383 write_scalar_jsonl(self, out)
384 }
385}
386
387pub async fn auto_fill(
389 drive: &DriveClient,
390 sheets: &SheetsClient,
391 opts: &AutoFillOptions,
392 rules: &[FolderPermissionRule],
393) -> AutoFillOutcome {
394 let started = Instant::now();
395 let outcome = auto_fill_inner(drive, sheets, opts, rules).await;
396 if !opts.dry_run {
397 record_attempt(&outcome, started.elapsed());
398 }
399 outcome
400}
401
402async fn auto_fill_inner(
403 drive: &DriveClient,
404 sheets: &SheetsClient,
405 opts: &AutoFillOptions,
406 rules: &[FolderPermissionRule],
407) -> AutoFillOutcome {
408 let bare = |result| AutoFillOutcome {
409 spreadsheet_id: opts.spreadsheet_id.clone(),
410 file_name: None,
411 resolved_folder_id: None,
412 sheet_id: None,
413 form: opts.form.clone(),
414 result,
415 };
416
417 let (sheet, range) = opts.form.sheet_and_range();
421 let composed = match a1::compose(sheet, range) {
422 Ok(composed) => composed,
423 Err(err) => {
424 return bare(AutoFillResult::RefusedInvalidRange {
425 detail: err.to_string(),
426 })
427 }
428 };
429 if let AutoFillForm::SourceAndDestination { fill_length, .. } = &opts.form {
430 if *fill_length == 0 {
431 return bare(AutoFillResult::RefusedInvalidRange {
432 detail: "--fill-length must not be 0".to_string(),
433 });
434 }
435 }
436
437 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
439 drive,
440 &opts.spreadsheet_id,
441 DriveOperation::SheetsWrite,
442 rules,
443 )
444 .await
445 {
446 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
447 return bare(AutoFillResult::Failed { detail })
448 }
449 target_gate::TargetGateOutcome::Refused { target, refusal } => {
450 let result = match refusal {
451 SheetTargetRefusal::Shortcut => AutoFillResult::RefusedShortcut,
452 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
453 AutoFillResult::RefusedNotASpreadsheet { mime_type }
454 }
455 SheetTargetRefusal::NoVisibleParents => AutoFillResult::RefusedNoVisibleParents,
456 };
457 return AutoFillOutcome {
458 spreadsheet_id: opts.spreadsheet_id.clone(),
459 file_name: Some(target.name),
460 resolved_folder_id: None,
461 sheet_id: None,
462 form: opts.form.clone(),
463 result,
464 };
465 }
466 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
467 return AutoFillOutcome {
468 spreadsheet_id: opts.spreadsheet_id.clone(),
469 file_name: Some(target.name),
470 resolved_folder_id: None,
471 sheet_id: None,
472 form: opts.form.clone(),
473 result: AutoFillResult::Failed { detail },
474 };
475 }
476 target_gate::TargetGateOutcome::Gated {
477 target,
478 decision,
479 resolved_folder_id,
480 requires_lease,
481 } => (target, decision, resolved_folder_id, requires_lease),
482 };
483
484 let pre_gated = |result| AutoFillOutcome {
485 spreadsheet_id: opts.spreadsheet_id.clone(),
486 file_name: Some(target.name.clone()),
487 resolved_folder_id: resolved_folder_id.clone(),
488 sheet_id: None,
489 form: opts.form.clone(),
490 result,
491 };
492
493 if decision.verdict == write_gate::Verdict::Deny {
494 return pre_gated(AutoFillResult::Blocked {
495 decided_by: decision.decided_by,
496 });
497 }
498
499 let api = SheetsApi::new(sheets);
500 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
501 Ok(workbook) => workbook,
502 Err(err) => {
503 return pre_gated(AutoFillResult::Failed {
504 detail: format!("{err:#}"),
505 })
506 }
507 };
508
509 let (sheet_title, source_grid) = match grid_range::resolve_grid_range(
510 &workbook,
511 &composed,
512 |detail| AutoFillResult::RefusedInvalidRange { detail },
513 |title, available| AutoFillResult::RefusedSheetNotFound { title, available },
514 ) {
515 Ok(resolved) => resolved,
516 Err(result) => return pre_gated(result),
517 };
518
519 let gated = |result| AutoFillOutcome {
520 spreadsheet_id: opts.spreadsheet_id.clone(),
521 file_name: Some(target.name.clone()),
522 resolved_folder_id: resolved_folder_id.clone(),
523 sheet_id: Some(source_grid.sheet_id),
524 form: opts.form.clone(),
525 result,
526 };
527
528 if !grid_range::is_bounded(&source_grid) {
529 return gated(AutoFillResult::RefusedInvalidRange {
530 detail: format!(
531 "{composed} is open-ended; auto-fill needs a fully bounded range (e.g. A1:D10)"
532 ),
533 });
534 }
535
536 let (destination, is_upper_bound) = match &opts.form {
537 AutoFillForm::Range { .. } => (source_grid, true),
538 AutoFillForm::SourceAndDestination {
539 dimension,
540 fill_length,
541 ..
542 } => match compute_destination(source_grid, *dimension, *fill_length) {
543 Ok(destination) => (destination, false),
544 Err(detail) => return gated(AutoFillResult::RefusedInvalidRange { detail }),
545 },
546 };
547 let requested_destination_a1 = grid_range_to_a1(&sheet_title, &destination);
548 let source_a1 = grid_range_to_a1(&sheet_title, &source_grid);
553
554 let sheet = grid_range::find_sheet_by_id(&workbook, source_grid.sheet_id);
564 let applied_destination = if let Some(sheet) = sheet {
565 if let Some(applied) = clamp_to_grid(sheet, &destination) {
566 applied
567 } else {
568 let grid = sheet
574 .properties
575 .as_ref()
576 .and_then(|props| props.grid_properties.as_ref());
577 return gated(AutoFillResult::RefusedDestinationPastGrid {
578 requested_destination: requested_destination_a1,
579 row_count: grid.and_then(|g| g.row_count),
580 column_count: grid.and_then(|g| g.column_count),
581 });
582 }
583 } else {
584 destination };
586 let destination_a1 = grid_range_to_a1(&sheet_title, &applied_destination);
587 let requested_destination =
590 (applied_destination != destination).then_some(requested_destination_a1);
591
592 let overwritten_cells = {
598 let read_a1 = grid_range_to_a1(&sheet_title, &applied_destination);
599 match api
600 .values_get(&opts.spreadsheet_id, &read_a1, ValueRenderOption::Formatted)
601 .await
602 {
603 Ok(values) => grid_range::non_blank_locations(
604 &values,
605 applied_destination.start_row_index.unwrap_or(0),
606 applied_destination.start_column_index.unwrap_or(0),
607 ),
608 Err(err) => {
609 return gated(AutoFillResult::Failed {
610 detail: format!("{err:#}"),
611 })
612 }
613 }
614 };
615
616 let summary = describe_effect(
617 &opts.form,
618 &source_a1,
619 &destination_a1,
620 applied_fill_length(&opts.form, &source_grid, &applied_destination),
621 opts.use_alternate_series,
622 );
623
624 if opts.dry_run {
625 return gated(AutoFillResult::WouldChange {
626 summary,
627 destination: destination_a1,
628 requested_destination,
629 overwritten_cells,
630 destination_is_upper_bound: is_upper_bound,
631 });
632 }
633
634 let request = build_request(&opts.form, source_grid, opts.use_alternate_series);
641
642 let files_api = FilesApi::new(drive);
643 let leased = LeasedWrite {
644 log_prefix: "drive sheets auto-fill",
645 operation: LOG_OPERATION,
646 ledger_path: &opts.ledger_path,
647 file_id: &opts.spreadsheet_id,
648 };
649 let lease_grant = match gate_optional_leased_write(
650 leased,
651 &files_api,
652 requires_lease,
653 opts.lease_token.as_deref(),
654 )
655 .await
656 {
657 Ok(grant) => grant,
658 Err(err) => return gated(err.into_result()),
659 };
660
661 let result = match conclude_native_leased_write(
662 leased,
663 &lease_grant,
664 &files_api,
665 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
666 |err| format!("{err:#}"),
667 )
668 .await
669 {
670 Ok(BatchUpdateOutcome::Applied(_response)) => AutoFillResult::Changed {
671 summary,
672 destination: destination_a1,
673 requested_destination,
674 overwritten_cells,
675 destination_is_upper_bound: is_upper_bound,
676 },
677 Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
680 AutoFillResult::AppliedReplyUnreadable {
681 summary,
682 destination: destination_a1,
683 requested_destination,
684 overwritten_cells,
685 destination_is_upper_bound: is_upper_bound,
686 detail,
687 }
688 }
689 Err(err) => AutoFillResult::Failed {
690 detail: format!("{err:#}"),
691 },
692 };
693 drop(lease_grant);
694 gated(result)
695}
696
697fn compute_destination(
707 source: GridRange,
708 dimension: Dimension,
709 fill_length: i64,
710) -> Result<GridRange, String> {
711 let (start, end) = match dimension {
712 Dimension::Rows => (source.start_row_index, source.end_row_index),
713 Dimension::Columns => (source.start_column_index, source.end_column_index),
714 };
715 let (Some(start), Some(end)) = (start, end) else {
719 return Err(format!(
720 "--source needs a fully bounded range on the {} axis to compute a destination from",
721 dimension.noun()
722 ));
723 };
724 let (new_start, new_end) = if fill_length > 0 {
725 let Some(new_end) = end.checked_add(fill_length) else {
726 return Err(format!("--fill-length {fill_length} is out of range"));
727 };
728 (end, new_end)
729 } else {
730 let Some(new_start) = start.checked_add(fill_length) else {
731 return Err(format!("--fill-length {fill_length} is out of range"));
732 };
733 if new_start < 0 {
734 return Err(format!(
735 "--fill-length {fill_length} would fill {} {}(s) before the start of the sheet",
736 new_start.unsigned_abs(),
737 dimension.noun()
738 ));
739 }
740 (new_start, start)
741 };
742 let mut destination = source;
743 match dimension {
744 Dimension::Rows => {
745 destination.start_row_index = Some(new_start);
746 destination.end_row_index = Some(new_end);
747 }
748 Dimension::Columns => {
749 destination.start_column_index = Some(new_start);
750 destination.end_column_index = Some(new_end);
751 }
752 }
753 Ok(destination)
754}
755
756fn clamp_to_grid(sheet: &Sheet, destination: &GridRange) -> Option<GridRange> {
765 let grid = sheet
766 .properties
767 .as_ref()
768 .and_then(|props| props.grid_properties.as_ref());
769 let mut clamped = *destination;
770 if let (Some(end), Some(count)) = (destination.end_row_index, grid.and_then(|g| g.row_count)) {
771 clamped.end_row_index = Some(end.min(count));
772 }
773 if let (Some(end), Some(count)) = (
774 destination.end_column_index,
775 grid.and_then(|g| g.column_count),
776 ) {
777 clamped.end_column_index = Some(end.min(count));
778 }
779 let empty = |start: Option<i64>, end: Option<i64>| match (start, end) {
780 (Some(start), Some(end)) => start >= end,
781 _ => false,
782 };
783 if empty(clamped.start_row_index, clamped.end_row_index)
784 || empty(clamped.start_column_index, clamped.end_column_index)
785 {
786 return None;
787 }
788 Some(clamped)
789}
790
791fn grid_range_to_a1(sheet_title: &str, grid: &GridRange) -> String {
795 let (Some(r0), Some(r1), Some(c0), Some(c1)) = (
796 grid.start_row_index,
797 grid.end_row_index,
798 grid.start_column_index,
799 grid.end_column_index,
800 ) else {
801 unreachable!("auto-fill ranges are refused earlier unless fully bounded")
802 };
804 let start = format!("{}{}", grid_range::column_index_to_letters(c0), r0 + 1);
805 let end = format!("{}{}", grid_range::column_index_to_letters(c1 - 1), r1);
806 match a1::compose(Some(sheet_title), Some(&format!("{start}:{end}"))) {
807 Ok(composed) => composed,
808 Err(err) => unreachable!("auto-fill composes only bounded numeric ranges: {err}"), }
817}
818
819const UNPREVIEWABLE_VALUES_CAVEAT: &str =
825 " the filled values are computed by Sheets' own series detection and are never reported, \
826 before or after the request";
827
828fn past_grid_extent_caveat_dry_run(applied: &str, requested: &str) -> String {
838 format!(
839 " the destination runs past the sheet's current extent; auto-fill clips to it, so only \
840 {applied} would be filled — {requested} was requested"
841 )
842}
843
844fn past_grid_extent_caveat(applied: &str, requested: &str) -> String {
847 format!(
848 " the destination ran past the sheet's current extent; auto-fill clipped to it, so \
849 only {applied} was filled — {requested} was requested"
850 )
851}
852
853fn applied_fill_length(
867 form: &AutoFillForm,
868 source_grid: &GridRange,
869 applied_destination: &GridRange,
870) -> i64 {
871 let AutoFillForm::SourceAndDestination {
872 dimension,
873 fill_length,
874 ..
875 } = form
876 else {
877 return 0;
878 };
879 let (source_start, source_end, applied_start, applied_end) = match dimension {
880 Dimension::Rows => (
881 source_grid.start_row_index,
882 source_grid.end_row_index,
883 applied_destination.start_row_index,
884 applied_destination.end_row_index,
885 ),
886 Dimension::Columns => (
887 source_grid.start_column_index,
888 source_grid.end_column_index,
889 applied_destination.start_column_index,
890 applied_destination.end_column_index,
891 ),
892 };
893 if *fill_length >= 0 {
894 applied_end.unwrap_or(0) - source_end.unwrap_or(0)
895 } else {
896 applied_start.unwrap_or(0) - source_start.unwrap_or(0)
897 }
898}
899
900fn describe_effect(
916 form: &AutoFillForm,
917 source_a1: &str,
918 destination_a1: &str,
919 applied_fill_length: i64,
920 use_alternate_series: bool,
921) -> String {
922 let mut summary = match form {
923 AutoFillForm::Range { .. } => format!(
924 "auto-fill within {destination_a1} (Sheets decides which cells are the source and \
925 which are filled)"
926 ),
927 AutoFillForm::SourceAndDestination { dimension, .. } => {
928 let direction = match (dimension, applied_fill_length >= 0) {
929 (Dimension::Rows, true) => "down",
930 (Dimension::Rows, false) => "up",
931 (Dimension::Columns, true) => "right",
932 (Dimension::Columns, false) => "left",
933 };
934 format!(
935 "auto-fill {destination_a1} from source {source_a1}, extending {} {}(s) \
936 {direction}",
937 applied_fill_length.abs(),
938 dimension.noun(),
939 )
940 }
941 };
942 if use_alternate_series {
943 summary.push_str(", using the alternate series");
944 }
945 summary
946}
947
948fn overwritten_line(
954 overwritten_cells: &[String],
955 destination_is_upper_bound: bool,
956 dry_run: bool,
957) -> String {
958 if overwritten_cells.is_empty() {
959 return " no non-blank cells in the destination".to_string();
960 }
961 let bound = if destination_is_upper_bound {
962 "up to "
963 } else {
964 ""
965 };
966 let tense = if dry_run {
967 "would be overwritten"
968 } else {
969 "were overwritten"
970 };
971 format!(
972 " {bound}{} non-blank cell(s) {tense}: {}",
973 overwritten_cells.len(),
974 grid_range::render_locations(overwritten_cells)
975 )
976}
977
978fn build_request(
983 form: &AutoFillForm,
984 source_grid: GridRange,
985 use_alternate_series: bool,
986) -> BatchUpdateRequestItem {
987 let request = match form {
988 AutoFillForm::Range { .. } => AutoFillRequest {
989 range: Some(source_grid),
990 source_and_destination: None,
991 use_alternate_series,
992 },
993 AutoFillForm::SourceAndDestination {
994 dimension,
995 fill_length,
996 ..
997 } => AutoFillRequest {
998 range: None,
999 source_and_destination: Some(SourceAndDestination {
1000 source: source_grid,
1001 dimension: *dimension,
1002 fill_length: *fill_length,
1003 }),
1004 use_alternate_series,
1005 },
1006 };
1007 BatchUpdateRequestItem::AutoFill(request)
1008}
1009
1010fn record_attempt(outcome: &AutoFillOutcome, duration: Duration) {
1011 request_log::record_drive_mutation(mutation_record(outcome, duration));
1012}
1013
1014fn mutation_record(outcome: &AutoFillOutcome, duration: Duration) -> DriveMutationOutcome {
1017 let error = match &outcome.result {
1018 AutoFillResult::Failed { detail }
1019 | AutoFillResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
1020 _ => None,
1021 };
1022 let decided_by = match &outcome.result {
1023 AutoFillResult::Blocked { decided_by } => decided_by.as_ref(),
1024 _ => None,
1025 };
1026 let decided_by = write_gate::decided_by_log_fields(decided_by);
1027 let (range, fields_changed, overwritten_cells, overwritten_cells_upper_bound) =
1028 match &outcome.result {
1029 AutoFillResult::Changed {
1030 summary,
1031 destination,
1032 overwritten_cells,
1033 destination_is_upper_bound,
1034 ..
1035 }
1036 | AutoFillResult::AppliedReplyUnreadable {
1037 summary,
1038 destination,
1039 overwritten_cells,
1040 destination_is_upper_bound,
1041 ..
1042 } => (
1043 Some(destination.clone()),
1044 Some(summary.clone()),
1045 overwritten_cells.clone(),
1046 *destination_is_upper_bound,
1047 ),
1048 _ => (None, None, Vec::new(), false),
1049 };
1050
1051 DriveMutationOutcome {
1052 operation: LOG_OPERATION,
1053 file_id: outcome.spreadsheet_id.clone(),
1054 file_name: outcome.file_name.clone().unwrap_or_default(),
1055 status: outcome.result.log_status().to_string(),
1056 resolved_folder_id: outcome.resolved_folder_id.clone(),
1057 decided_by_folder_id: decided_by.folder_id,
1058 decided_by_depth: decided_by.depth,
1059 decided_by_file_id: decided_by.file_id,
1060 sheet_id: outcome.sheet_id,
1061 range,
1062 fields_changed,
1063 overwritten_cells,
1064 overwritten_cells_upper_bound,
1065 error,
1066 duration,
1067 ..Default::default()
1068 }
1069}
1070
1071#[must_use]
1073pub fn describe(outcome: &AutoFillOutcome) -> String {
1074 describe_lines(outcome).join("\n")
1075}
1076
1077#[must_use]
1080pub fn describe_lines(outcome: &AutoFillOutcome) -> Vec<String> {
1081 let book = outcome.file_name.as_deref().map_or_else(
1082 || format!("'{}'", outcome.spreadsheet_id),
1083 |n| format!("'{n}'"),
1084 );
1085 match &outcome.result {
1086 AutoFillResult::WouldChange {
1087 summary,
1088 destination,
1089 requested_destination,
1090 overwritten_cells,
1091 destination_is_upper_bound,
1092 } => change_lines(
1093 &format!("Would {summary} in {book}"),
1094 overwritten_cells,
1095 *destination_is_upper_bound,
1096 destination,
1097 requested_destination.as_deref(),
1098 true,
1099 ),
1100 AutoFillResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1101 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
1102 `drive sheets auto-fill` only works on spreadsheets"
1103 )],
1104 AutoFillResult::RefusedShortcut => vec![format!(
1105 "Refused: {book} is a shortcut; `drive sheets auto-fill` doesn't follow shortcuts"
1106 )],
1107 AutoFillResult::RefusedNoVisibleParents => vec![format!(
1108 "Refused: {book} has no parent folder visible to this account, so no folder \
1109 rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
1110 id>\", \"allow\": [\"sheets-write\"]}} to write_permissions.rules."
1111 )],
1112 AutoFillResult::RefusedSheetNotFound { title, available } => {
1113 vec![grid_range::no_sheet_refusal(&book, title, available)]
1114 }
1115 AutoFillResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
1116 AutoFillResult::RefusedDestinationPastGrid {
1117 requested_destination,
1118 row_count,
1119 column_count,
1120 } => {
1121 let extent = match (row_count, column_count) {
1122 (Some(rows), Some(cols)) => format!(" ({rows} rows x {cols} columns)"),
1123 (Some(rows), None) => format!(" ({rows} rows)"),
1124 (None, Some(cols)) => format!(" ({cols} columns)"),
1125 (None, None) => String::new(),
1126 };
1127 vec![format!(
1128 "Refused: {requested_destination} lies wholly past {book}'s current grid{extent}; \
1129 auto-fill does not grow the sheet. Grow it first (e.g. `sheets append`, \
1130 `sheets insert-rows`/`insert-columns`), or choose a destination within the grid."
1131 )]
1132 }
1133 AutoFillResult::Blocked { decided_by } => vec![match decided_by {
1134 Some(rule) => format!(
1135 "Blocked: auto-fill on {book} refused by rule on {} {}{}",
1136 rule.kind_label(),
1137 rule.id(),
1138 rule.depth_suffix()
1139 ),
1140 None => format!(
1141 "Blocked: auto-fill on {book} refused by default policy (no matching rule for \
1142 sheets-write)"
1143 ),
1144 }],
1145 AutoFillResult::RefusedNoLease => LeaseGateRefusal::NoLease
1146 .describe_line(&outcome.spreadsheet_id, &book)
1147 .into_iter()
1148 .collect(),
1149 AutoFillResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1150 .describe_line(&outcome.spreadsheet_id, &book)
1151 .into_iter()
1152 .collect(),
1153 AutoFillResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1154 .describe_line(&outcome.spreadsheet_id, &book)
1155 .into_iter()
1156 .collect(),
1157 AutoFillResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1158 .describe_line(&outcome.spreadsheet_id, &book)
1159 .into_iter()
1160 .collect(),
1161 AutoFillResult::Changed {
1162 summary,
1163 destination,
1164 requested_destination,
1165 overwritten_cells,
1166 destination_is_upper_bound,
1167 } => change_lines(
1168 &format!("Applied: {summary} in {book}"),
1169 overwritten_cells,
1170 *destination_is_upper_bound,
1171 destination,
1172 requested_destination.as_deref(),
1173 false,
1174 ),
1175 AutoFillResult::AppliedReplyUnreadable {
1178 summary,
1179 destination,
1180 requested_destination,
1181 overwritten_cells,
1182 destination_is_upper_bound,
1183 detail,
1184 } => {
1185 let mut lines = vec![applied_reply_unreadable_line(
1186 summary,
1187 &book,
1188 RetryHint::Idempotent,
1189 detail,
1190 )];
1191 lines.extend(change_detail_lines(
1193 overwritten_cells,
1194 *destination_is_upper_bound,
1195 destination,
1196 requested_destination.as_deref(),
1197 false,
1198 ));
1199 lines
1200 }
1201 AutoFillResult::Failed { detail } => vec![format!("Failed: {detail}")],
1202 }
1203}
1204
1205fn change_lines(
1210 head: &str,
1211 overwritten_cells: &[String],
1212 destination_is_upper_bound: bool,
1213 destination: &str,
1214 requested_destination: Option<&str>,
1215 dry_run: bool,
1216) -> Vec<String> {
1217 let mut lines = vec![head.to_string()];
1218 lines.extend(change_detail_lines(
1219 overwritten_cells,
1220 destination_is_upper_bound,
1221 destination,
1222 requested_destination,
1223 dry_run,
1224 ));
1225 lines
1226}
1227
1228fn change_detail_lines(
1232 overwritten_cells: &[String],
1233 destination_is_upper_bound: bool,
1234 destination: &str,
1235 requested_destination: Option<&str>,
1236 dry_run: bool,
1237) -> Vec<String> {
1238 let mut lines = vec![overwritten_line(
1239 overwritten_cells,
1240 destination_is_upper_bound,
1241 dry_run,
1242 )];
1243 if let Some(requested) = requested_destination {
1244 lines.push(if dry_run {
1245 past_grid_extent_caveat_dry_run(destination, requested)
1246 } else {
1247 past_grid_extent_caveat(destination, requested)
1248 });
1249 }
1250 lines.push(UNPREVIEWABLE_VALUES_CAVEAT.to_string());
1251 lines
1252}
1253
1254#[cfg(test)]
1255#[allow(clippy::unwrap_used, clippy::expect_used)]
1256mod tests {
1257 use super::*;
1258 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1259 use crate::drive::sheets::client::SHEETS_API_URL;
1260 use crate::drive::sheets::types::{GridProperties, SheetProperties};
1261 use crate::drive::test_support::seed_lease;
1262 use crate::test_support::env::MapEnv;
1263 use crate::utils::secret::Secret;
1264 use std::collections::HashSet;
1265
1266 fn bounded(sheet_id: i64, r0: i64, r1: i64, c0: i64, c1: i64) -> GridRange {
1269 GridRange {
1270 sheet_id,
1271 start_row_index: Some(r0),
1272 end_row_index: Some(r1),
1273 start_column_index: Some(c0),
1274 end_column_index: Some(c1),
1275 }
1276 }
1277
1278 #[test]
1279 fn compute_destination_extends_rows_forward() {
1280 let source = bounded(0, 0, 3, 0, 2); let dest = compute_destination(source, Dimension::Rows, 4).unwrap();
1282 assert_eq!(dest, bounded(0, 3, 7, 0, 2)); }
1284
1285 #[test]
1286 fn compute_destination_extends_rows_backward() {
1287 let source = bounded(0, 5, 8, 0, 2); let dest = compute_destination(source, Dimension::Rows, -3).unwrap();
1289 assert_eq!(dest, bounded(0, 2, 5, 0, 2)); }
1291
1292 #[test]
1293 fn compute_destination_extends_columns_forward() {
1294 let source = bounded(0, 0, 2, 0, 1); let dest = compute_destination(source, Dimension::Columns, 2).unwrap();
1296 assert_eq!(dest, bounded(0, 0, 2, 1, 3)); }
1298
1299 #[test]
1300 fn compute_destination_extends_columns_backward() {
1301 let source = bounded(0, 0, 2, 5, 6); let dest = compute_destination(source, Dimension::Columns, -5).unwrap();
1303 assert_eq!(dest, bounded(0, 0, 2, 0, 5)); }
1305
1306 #[test]
1307 fn compute_destination_refuses_going_before_the_start_of_the_sheet() {
1308 let source = bounded(0, 2, 5, 0, 2); let err = compute_destination(source, Dimension::Rows, -3).unwrap_err();
1310 assert!(err.contains("before the start"), "{err}");
1311 }
1312
1313 #[test]
1314 fn compute_destination_allows_landing_exactly_at_the_start() {
1315 let source = bounded(0, 2, 5, 0, 2); let dest = compute_destination(source, Dimension::Rows, -2).unwrap();
1317 assert_eq!(dest, bounded(0, 0, 2, 0, 2)); }
1319
1320 #[test]
1321 fn compute_destination_refuses_overflow() {
1322 let source = bounded(0, i64::MAX - 1, i64::MAX, 0, 1);
1323 let err = compute_destination(source, Dimension::Rows, i64::MAX).unwrap_err();
1324 assert!(err.contains("out of range"), "{err}");
1325 }
1326
1327 #[test]
1328 fn compute_destination_refuses_an_unbounded_axis_and_backward_overflow() {
1329 let unbounded = GridRange {
1330 sheet_id: 0,
1331 ..Default::default()
1332 };
1333 assert!(compute_destination(unbounded, Dimension::Columns, 1)
1334 .unwrap_err()
1335 .contains("fully bounded"));
1336 assert!(
1337 compute_destination(bounded(0, 0, 1, 0, 1), Dimension::Rows, i64::MIN)
1338 .unwrap_err()
1339 .contains("9223372036854775808 row(s) before the start")
1340 );
1341 assert!(
1342 compute_destination(bounded(0, -1, 1, 0, 1), Dimension::Rows, i64::MIN)
1343 .unwrap_err()
1344 .contains("out of range")
1345 );
1346 }
1347
1348 #[test]
1349 fn lease_refusals_map_to_their_auto_fill_results() {
1350 assert_eq!(
1351 AutoFillResult::from_lease_expired(),
1352 AutoFillResult::RefusedLeaseExpired
1353 );
1354 assert_eq!(
1355 AutoFillResult::from_lease_wrong_file(),
1356 AutoFillResult::RefusedLeaseWrongFile
1357 );
1358 assert_eq!(
1359 AutoFillResult::from_lease_stale(),
1360 AutoFillResult::RefusedLeaseStale
1361 );
1362 assert_eq!(
1363 AutoFillResult::from_lease_failed("ledger unavailable".into()),
1364 AutoFillResult::Failed {
1365 detail: "ledger unavailable".into()
1366 }
1367 );
1368 }
1369
1370 #[test]
1371 fn jsonl_outcome_serializes_one_record_without_the_input_form() {
1372 let outcome = AutoFillOutcome {
1373 spreadsheet_id: "sheet-1".into(),
1374 file_name: None,
1375 resolved_folder_id: None,
1376 sheet_id: None,
1377 form: range_form(),
1378 result: AutoFillResult::RefusedShortcut,
1379 };
1380 let mut out = Vec::new();
1381 outcome.write_jsonl(&mut out).unwrap();
1382 let value: serde_json::Value = serde_json::from_slice(&out).unwrap();
1383 assert_eq!(value["result"]["status"], "refused-shortcut");
1384 assert!(value.get("form").is_none());
1385 assert_eq!(std::str::from_utf8(&out).unwrap().lines().count(), 1);
1386 }
1387
1388 fn sheet_with_extent(row_count: i64, column_count: i64) -> Sheet {
1389 Sheet {
1390 properties: Some(SheetProperties {
1391 sheet_id: Some(0),
1392 title: "Q1".to_string(),
1393 index: Some(0),
1394 hidden: None,
1395 grid_properties: Some(GridProperties {
1396 row_count: Some(row_count),
1397 column_count: Some(column_count),
1398 frozen_row_count: None,
1399 frozen_column_count: None,
1400 hide_gridlines: None,
1401 }),
1402 right_to_left: None,
1403 }),
1404 ..Default::default()
1405 }
1406 }
1407
1408 #[test]
1409 fn clamp_to_grid_trims_a_destination_that_overhangs_the_extent() {
1410 let sheet = sheet_with_extent(10, 5);
1411 let clamped = clamp_to_grid(&sheet, &bounded(0, 8, 14, 0, 1)).unwrap();
1413 assert_eq!(clamped.end_row_index, Some(10));
1414 assert_eq!(clamped.start_row_index, Some(8));
1415 let clamped = clamp_to_grid(&sheet, &bounded(0, 0, 1, 3, 9)).unwrap();
1417 assert_eq!(clamped.end_column_index, Some(5));
1418 }
1419
1420 #[test]
1421 fn clamp_to_grid_leaves_a_destination_within_the_extent_untouched() {
1422 let sheet = sheet_with_extent(10, 5);
1423 let destination = bounded(0, 2, 5, 1, 3);
1424 assert_eq!(clamp_to_grid(&sheet, &destination), Some(destination));
1425 }
1426
1427 #[test]
1428 fn clamp_to_grid_is_none_when_the_destination_lies_wholly_past_the_extent() {
1429 let sheet = sheet_with_extent(10, 5);
1430 assert_eq!(clamp_to_grid(&sheet, &bounded(0, 10, 14, 0, 1)), None);
1432 assert_eq!(clamp_to_grid(&sheet, &bounded(0, 0, 1, 5, 9)), None);
1433 }
1434
1435 #[test]
1436 fn clamp_to_grid_leaves_an_axis_with_no_known_extent_alone() {
1437 let mut sheet = sheet_with_extent(10, 5);
1438 sheet
1439 .properties
1440 .as_mut()
1441 .expect("fixture always sets properties")
1442 .grid_properties = None;
1443 let destination = bounded(0, 0, 9_999, 0, 1);
1444 assert_eq!(clamp_to_grid(&sheet, &destination), Some(destination));
1445 }
1446
1447 #[test]
1448 fn grid_range_to_a1_round_trips_a_bounded_range() {
1449 let grid = bounded(0, 0, 2, 0, 2); assert_eq!(grid_range_to_a1("Q1", &grid), "'Q1'!A1:B2");
1451 }
1452
1453 #[test]
1454 #[should_panic(expected = "fully bounded")]
1455 fn grid_range_to_a1_panics_on_an_unbounded_range() {
1456 let grid = GridRange {
1457 sheet_id: 0,
1458 ..Default::default()
1459 };
1460 let _ = grid_range_to_a1("Q1", &grid);
1461 }
1462
1463 #[test]
1464 fn build_request_range_form_carries_the_range_and_no_source_and_destination() {
1465 let source = bounded(0, 0, 2, 0, 2);
1466 let form = AutoFillForm::Range {
1467 sheet: Some("Q1".to_string()),
1468 range: Some("A1:B2".to_string()),
1469 };
1470 let BatchUpdateRequestItem::AutoFill(request) = build_request(&form, source, true) else {
1471 panic!("expected AutoFill"); };
1473 assert_eq!(request.range, Some(source));
1474 assert_eq!(request.source_and_destination, None);
1475 assert!(request.use_alternate_series);
1476 }
1477
1478 #[test]
1479 fn build_request_source_and_destination_form_carries_dimension_and_fill_length() {
1480 let source = bounded(0, 0, 2, 0, 2);
1481 let form = AutoFillForm::SourceAndDestination {
1482 sheet: Some("Q1".to_string()),
1483 source: Some("A1:B2".to_string()),
1484 dimension: Dimension::Rows,
1485 fill_length: 5,
1486 };
1487 let BatchUpdateRequestItem::AutoFill(request) = build_request(&form, source, false) else {
1488 panic!("expected AutoFill"); };
1490 assert_eq!(request.range, None);
1491 let sad = request.source_and_destination.unwrap();
1492 assert_eq!(sad.source, source);
1493 assert_eq!(sad.dimension, Dimension::Rows);
1494 assert_eq!(sad.fill_length, 5);
1495 assert!(!request.use_alternate_series);
1496 }
1497
1498 #[test]
1499 fn describe_effect_names_the_direction_for_every_axis_and_sign() {
1500 let form = |dimension, fill_length| AutoFillForm::SourceAndDestination {
1501 sheet: None,
1502 source: None,
1503 dimension,
1504 fill_length,
1505 };
1506 assert!(
1507 describe_effect(&form(Dimension::Rows, 3), "A1:A3", "A4:A6", 3, false)
1508 .contains("3 row(s) down")
1509 );
1510 assert!(
1511 describe_effect(&form(Dimension::Rows, -3), "A4:A6", "A1:A3", -3, false)
1512 .contains("3 row(s) up")
1513 );
1514 assert!(
1515 describe_effect(&form(Dimension::Columns, 2), "A1:A1", "B1:C1", 2, false)
1516 .contains("2 column(s) right")
1517 );
1518 assert!(
1519 describe_effect(&form(Dimension::Columns, -2), "C1:C1", "A1:B1", -2, false)
1520 .contains("2 column(s) left")
1521 );
1522 }
1523
1524 #[test]
1525 fn describe_effect_names_the_source_it_extends() {
1526 let form = AutoFillForm::SourceAndDestination {
1527 sheet: None,
1528 source: None,
1529 dimension: Dimension::Rows,
1530 fill_length: 7,
1531 };
1532 let summary = describe_effect(&form, "'Q1'!A1:A3", "'Q1'!A4:A10", 7, false);
1535 assert!(summary.contains("from source 'Q1'!A1:A3"), "{summary}");
1536 }
1537
1538 #[test]
1543 fn describe_effect_uses_the_applied_fill_length_not_the_requested_one() {
1544 let form = AutoFillForm::SourceAndDestination {
1545 sheet: None,
1546 source: None,
1547 dimension: Dimension::Rows,
1548 fill_length: 1000,
1549 };
1550 let summary = describe_effect(&form, "'Q1'!A1:A3", "'Q1'!A4:A1000", 997, false);
1551 assert!(summary.contains("997 row(s) down"), "{summary}");
1552 assert!(!summary.contains("1000 row(s)"), "{summary}");
1555 }
1556
1557 #[test]
1558 fn describe_effect_names_the_range_form_as_server_decided() {
1559 let form = AutoFillForm::Range {
1560 sheet: None,
1561 range: None,
1562 };
1563 let summary = describe_effect(&form, "A1:A10", "A1:A10", 0, false);
1564 assert!(summary.contains("Sheets decides"), "{summary}");
1565 }
1566
1567 #[test]
1568 fn describe_effect_names_the_alternate_series_flag() {
1569 let form = AutoFillForm::SourceAndDestination {
1570 sheet: None,
1571 source: None,
1572 dimension: Dimension::Rows,
1573 fill_length: 1,
1574 };
1575 let summary = describe_effect(&form, "A1:A1", "A2:A2", 1, true);
1576 assert!(summary.contains("alternate series"), "{summary}");
1577 }
1578
1579 #[test]
1583 fn describe_effect_is_tense_neutral() {
1584 let form = AutoFillForm::SourceAndDestination {
1585 sheet: None,
1586 source: None,
1587 dimension: Dimension::Rows,
1588 fill_length: 1,
1589 };
1590 let summary = describe_effect(&form, "A1:A1", "A2:A2", 1, true);
1591 for conditional in ["would", "cannot", "may "] {
1592 assert!(!summary.contains(conditional), "{summary}");
1593 }
1594 }
1595
1596 #[test]
1597 fn applied_fill_length_shrinks_to_the_clipped_destination_forward() {
1598 let form = AutoFillForm::SourceAndDestination {
1599 sheet: None,
1600 source: None,
1601 dimension: Dimension::Rows,
1602 fill_length: 1000,
1603 };
1604 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);
1607 }
1608
1609 #[test]
1610 fn applied_fill_length_is_unaffected_by_a_backward_fill() {
1611 let form = AutoFillForm::SourceAndDestination {
1612 sheet: None,
1613 source: None,
1614 dimension: Dimension::Rows,
1615 fill_length: -3,
1616 };
1617 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);
1621 }
1622
1623 #[test]
1624 fn applied_fill_length_is_zero_for_the_range_form() {
1625 let form = AutoFillForm::Range {
1626 sheet: None,
1627 range: None,
1628 };
1629 let grid = bounded(0, 0, 1, 0, 1);
1630 assert_eq!(applied_fill_length(&form, &grid, &grid), 0);
1631 }
1632
1633 #[test]
1634 fn overwritten_line_reports_cells_never_their_values() {
1635 let cells = vec!["A4".to_string(), "A5".to_string()];
1636 let line = overwritten_line(&cells, false, true);
1637 assert_eq!(line, " 2 non-blank cell(s) would be overwritten: A4, A5");
1638 }
1639
1640 #[test]
1641 fn overwritten_line_marks_an_upper_bound_count_as_up_to() {
1642 let cells = vec!["A1".to_string()];
1643 let line = overwritten_line(&cells, true, true);
1644 assert_eq!(line, " up to 1 non-blank cell(s) would be overwritten: A1");
1645 }
1646
1647 #[test]
1648 fn overwritten_line_uses_the_past_tense_after_a_real_run() {
1649 let cells = vec!["A4".to_string()];
1650 assert_eq!(
1651 overwritten_line(&cells, false, false),
1652 " 1 non-blank cell(s) were overwritten: A4"
1653 );
1654 assert_eq!(
1655 overwritten_line(&[], false, false),
1656 " no non-blank cells in the destination"
1657 );
1658 }
1659
1660 #[test]
1661 fn overwritten_line_elides_past_the_render_limit_but_keeps_the_true_count() {
1662 let cells: Vec<String> = (1..=60).map(|row| format!("A{row}")).collect();
1663 let line = overwritten_line(&cells, false, true);
1664 assert!(line.contains("… and 10 more"), "{line}");
1665 assert!(
1666 line.starts_with(" 60 non-blank cell(s) would be overwritten:"),
1667 "{line}"
1668 );
1669 assert_eq!(cells.len(), 60);
1670 }
1671
1672 #[test]
1673 fn change_lines_name_the_grid_extent_caveat_in_the_matching_tense() {
1674 let dry = change_lines(
1675 "Would x in 'B'",
1676 &[],
1677 false,
1678 "'Q1'!A1:A10",
1679 Some("'Q1'!A1:A20"),
1680 true,
1681 );
1682 assert!(
1683 dry.contains(&past_grid_extent_caveat_dry_run(
1684 "'Q1'!A1:A10",
1685 "'Q1'!A1:A20"
1686 )),
1687 "{dry:?}"
1688 );
1689 let real = change_lines(
1690 "Applied: x in 'B'",
1691 &[],
1692 false,
1693 "'Q1'!A1:A10",
1694 Some("'Q1'!A1:A20"),
1695 false,
1696 );
1697 assert!(
1698 real.contains(&past_grid_extent_caveat("'Q1'!A1:A10", "'Q1'!A1:A20")),
1699 "{real:?}"
1700 );
1701 let within = change_lines("Would x in 'B'", &[], false, "'Q1'!A1:A10", None, true);
1704 assert!(
1705 !within.iter().any(|l| l.contains("current extent")),
1706 "{within:?}"
1707 );
1708 }
1709
1710 #[test]
1711 fn change_lines_always_state_the_server_decides_the_values() {
1712 for dry_run in [true, false] {
1713 let lines = change_lines("head", &[], false, "'Q1'!A1:A10", None, dry_run);
1714 assert!(
1715 lines.contains(&UNPREVIEWABLE_VALUES_CAVEAT.to_string()),
1716 "{lines:?}"
1717 );
1718 }
1719 }
1720
1721 #[test]
1722 fn auto_fill_result_log_status_names_every_variant() {
1723 assert_eq!(
1724 AutoFillResult::WouldChange {
1725 summary: String::new(),
1726 destination: String::new(),
1727 requested_destination: None,
1728 overwritten_cells: Vec::new(),
1729 destination_is_upper_bound: false,
1730 }
1731 .log_status(),
1732 "would-change"
1733 );
1734 assert_eq!(
1735 AutoFillResult::RefusedNotASpreadsheet {
1736 mime_type: String::new()
1737 }
1738 .log_status(),
1739 "refused-not-a-spreadsheet"
1740 );
1741 assert_eq!(
1742 AutoFillResult::RefusedShortcut.log_status(),
1743 "refused-shortcut"
1744 );
1745 assert_eq!(
1746 AutoFillResult::RefusedNoVisibleParents.log_status(),
1747 "refused-no-visible-parents"
1748 );
1749 assert_eq!(
1750 AutoFillResult::RefusedSheetNotFound {
1751 title: String::new(),
1752 available: Vec::new(),
1753 }
1754 .log_status(),
1755 "refused-sheet-not-found"
1756 );
1757 assert_eq!(
1758 AutoFillResult::RefusedInvalidRange {
1759 detail: String::new()
1760 }
1761 .log_status(),
1762 "refused-invalid-range"
1763 );
1764 assert_eq!(
1765 AutoFillResult::RefusedDestinationPastGrid {
1766 requested_destination: String::new(),
1767 row_count: None,
1768 column_count: None,
1769 }
1770 .log_status(),
1771 "refused-destination-past-grid"
1772 );
1773 assert_eq!(
1774 AutoFillResult::Blocked { decided_by: None }.log_status(),
1775 "blocked"
1776 );
1777 assert_eq!(
1778 AutoFillResult::RefusedNoLease.log_status(),
1779 LeaseGateRefusal::NoLease.log_status()
1780 );
1781 assert_eq!(
1782 AutoFillResult::RefusedLeaseExpired.log_status(),
1783 LeaseGateRefusal::Expired.log_status()
1784 );
1785 assert_eq!(
1786 AutoFillResult::RefusedLeaseWrongFile.log_status(),
1787 LeaseGateRefusal::WrongFile.log_status()
1788 );
1789 assert_eq!(
1790 AutoFillResult::RefusedLeaseStale.log_status(),
1791 LeaseGateRefusal::Stale.log_status()
1792 );
1793 assert_eq!(
1794 AutoFillResult::Changed {
1795 summary: String::new(),
1796 destination: String::new(),
1797 requested_destination: None,
1798 overwritten_cells: Vec::new(),
1799 destination_is_upper_bound: false,
1800 }
1801 .log_status(),
1802 "changed"
1803 );
1804 assert_eq!(
1805 AutoFillResult::AppliedReplyUnreadable {
1806 summary: String::new(),
1807 destination: String::new(),
1808 requested_destination: None,
1809 overwritten_cells: Vec::new(),
1810 destination_is_upper_bound: false,
1811 detail: String::new(),
1812 }
1813 .log_status(),
1814 "applied-reply-unreadable"
1815 );
1816 assert_eq!(
1817 AutoFillResult::Failed {
1818 detail: String::new()
1819 }
1820 .log_status(),
1821 "failed"
1822 );
1823 }
1824
1825 #[test]
1826 fn sheet_and_range_reads_source_for_the_source_and_destination_form() {
1827 let form = AutoFillForm::SourceAndDestination {
1828 sheet: Some("Q1".to_string()),
1829 source: Some("A1:A3".to_string()),
1830 dimension: Dimension::Rows,
1831 fill_length: 1,
1832 };
1833 assert_eq!(form.sheet_and_range(), (Some("Q1"), Some("A1:A3")));
1834 }
1835
1836 #[test]
1844 fn no_describe_line_contains_an_embedded_newline() {
1845 let base = AutoFillOutcome {
1846 spreadsheet_id: "sheet-1".to_string(),
1847 file_name: Some("Budget".to_string()),
1848 resolved_folder_id: None,
1849 sheet_id: Some(0),
1850 form: range_form(),
1851 result: AutoFillResult::Failed {
1852 detail: String::new(),
1853 },
1854 };
1855 let results = vec![
1856 AutoFillResult::WouldChange {
1857 summary: "auto-fill within 'Q1'!A1:A10".to_string(),
1858 destination: "'Q1'!A1:A10".to_string(),
1859 requested_destination: Some("'Q1'!A1:A20".to_string()),
1860 overwritten_cells: vec!["A1".to_string()],
1861 destination_is_upper_bound: true,
1862 },
1863 AutoFillResult::RefusedNotASpreadsheet {
1864 mime_type: "application/pdf".to_string(),
1865 },
1866 AutoFillResult::RefusedShortcut,
1867 AutoFillResult::RefusedNoVisibleParents,
1868 AutoFillResult::RefusedSheetNotFound {
1869 title: "Q9".to_string(),
1870 available: vec!["Q1".to_string()],
1871 },
1872 AutoFillResult::RefusedInvalidRange {
1873 detail: "bad range".to_string(),
1874 },
1875 AutoFillResult::RefusedDestinationPastGrid {
1876 requested_destination: "'Q1'!A2001:A2010".to_string(),
1877 row_count: Some(1000),
1878 column_count: Some(26),
1879 },
1880 AutoFillResult::Blocked { decided_by: None },
1881 AutoFillResult::RefusedNoLease,
1882 AutoFillResult::RefusedLeaseExpired,
1883 AutoFillResult::RefusedLeaseWrongFile,
1884 AutoFillResult::RefusedLeaseStale,
1885 AutoFillResult::Changed {
1886 summary: "auto-fill within 'Q1'!A1:A10".to_string(),
1887 destination: "'Q1'!A1:A10".to_string(),
1888 requested_destination: None,
1889 overwritten_cells: Vec::new(),
1890 destination_is_upper_bound: true,
1891 },
1892 AutoFillResult::AppliedReplyUnreadable {
1893 summary: "auto-fill within 'Q1'!A1:A10".to_string(),
1894 destination: "'Q1'!A1:A10".to_string(),
1895 requested_destination: Some("'Q1'!A1:A20".to_string()),
1896 overwritten_cells: vec!["A1".to_string()],
1897 destination_is_upper_bound: true,
1898 detail: "bad reply".to_string(),
1899 },
1900 AutoFillResult::Failed {
1901 detail: "boom".to_string(),
1902 },
1903 ];
1904 for result in results {
1905 let outcome = AutoFillOutcome {
1906 result,
1907 ..base.clone()
1908 };
1909 let lines = describe_lines(&outcome);
1910 assert!(!lines.is_empty(), "{lines:?}");
1911 for line in &lines {
1912 assert!(!line.contains('\n') && !line.contains('\r'), "{line:?}");
1913 }
1914 }
1915 }
1916
1917 #[test]
1918 fn describe_handles_missing_metadata_and_identifies_a_deciding_rule() {
1919 let base = AutoFillOutcome {
1920 spreadsheet_id: "sheet-1".into(),
1921 file_name: None,
1922 resolved_folder_id: None,
1923 sheet_id: None,
1924 form: range_form(),
1925 result: AutoFillResult::RefusedSheetNotFound {
1926 title: "Missing".into(),
1927 available: Vec::new(),
1928 },
1929 };
1930 assert!(describe(&base).contains("Available: none"));
1931 assert!(describe(&base).contains("'sheet-1'"));
1932
1933 let blocked = AutoFillOutcome {
1934 result: AutoFillResult::Blocked {
1935 decided_by: Some(DecidingRule::Folder {
1936 folder_id: "folder-1".into(),
1937 depth: 1,
1938 }),
1939 },
1940 ..base
1941 };
1942 assert!(describe(&blocked).contains("folder-1"));
1943 }
1944
1945 #[test]
1950 fn refused_destination_past_grid_message_names_only_the_known_axes() {
1951 let base = AutoFillOutcome {
1952 spreadsheet_id: "sheet-1".into(),
1953 file_name: None,
1954 resolved_folder_id: None,
1955 sheet_id: None,
1956 form: range_form(),
1957 result: AutoFillResult::RefusedDestinationPastGrid {
1958 requested_destination: "'Q1'!A2001:A2010".into(),
1959 row_count: Some(1000),
1960 column_count: None,
1961 },
1962 };
1963 assert!(describe(&base).contains("(1000 rows)"));
1964
1965 let column_only = AutoFillOutcome {
1966 result: AutoFillResult::RefusedDestinationPastGrid {
1967 requested_destination: "'Q1'!AA1:AZ10".into(),
1968 row_count: None,
1969 column_count: Some(26),
1970 },
1971 ..base.clone()
1972 };
1973 assert!(describe(&column_only).contains("(26 columns)"));
1974
1975 let neither_known = AutoFillOutcome {
1976 result: AutoFillResult::RefusedDestinationPastGrid {
1977 requested_destination: "'Q1'!A2001:A2010".into(),
1978 row_count: None,
1979 column_count: None,
1980 },
1981 ..base
1982 };
1983 assert!(describe(&neither_known).contains("current grid;"));
1984 }
1985
1986 fn range_form() -> AutoFillForm {
1987 AutoFillForm::Range {
1988 sheet: Some("Q1".to_string()),
1989 range: Some("A1:A10".to_string()),
1990 }
1991 }
1992
1993 fn source_and_destination_form(dimension: Dimension, fill_length: i64) -> AutoFillForm {
1994 AutoFillForm::SourceAndDestination {
1995 sheet: Some("Q1".to_string()),
1996 source: Some("A1:A3".to_string()),
1997 dimension,
1998 fill_length,
1999 }
2000 }
2001
2002 fn test_credentials() -> DriveCredentials {
2005 DriveCredentials {
2006 client_id: "client-1".to_string(),
2007 client_secret: Secret::new("secret-1"),
2008 refresh_token: Secret::new("refresh-1"),
2009 scope: DriveGrantedScopes::READONLY,
2010 }
2011 }
2012
2013 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
2014 wiremock::Mock::given(wiremock::matchers::method("POST"))
2015 .and(wiremock::matchers::path("/token"))
2016 .respond_with(
2017 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2018 "access_token": "test-token", "expires_in": 3600,
2019 })),
2020 )
2021 .mount(server)
2022 .await;
2023 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
2024 crate::drive::client::test_support::replace_session(
2025 &mut drive,
2026 &test_credentials(),
2027 &format!("{}/token", server.uri()),
2028 );
2029 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
2030 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
2031 (drive, sheets)
2032 }
2033
2034 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
2035 let parents: Vec<&str> = parents.to_vec();
2036 wiremock::Mock::given(wiremock::matchers::method("GET"))
2037 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
2038 .respond_with(
2039 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2040 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
2041 "version": "1",
2042 })),
2043 )
2044 }
2045
2046 fn mount_folder(id: &str) -> wiremock::Mock {
2047 mount_file(id, "application/vnd.google-apps.folder", &[])
2048 }
2049
2050 fn mount_workbook() -> wiremock::Mock {
2051 wiremock::Mock::given(wiremock::matchers::method("GET"))
2052 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2053 .respond_with(
2054 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2055 "spreadsheetId": "sheet-1",
2056 "properties": {"title": "Budget"},
2057 "sheets": [
2058 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2059 "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
2060 ],
2061 })),
2062 )
2063 }
2064
2065 fn allow_rule(folder: &str) -> FolderPermissionRule {
2066 FolderPermissionRule {
2067 folder_id: Some(folder.to_string()),
2068 file_id: None,
2069 recursive: true,
2070 allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
2071 deny: HashSet::default(),
2072 require_lease: true,
2073 }
2074 }
2075
2076 fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
2077 let ledger_path = tempfile::tempdir()
2078 .unwrap()
2079 .keep()
2080 .join("lease-ledger.jsonl");
2081 let token = seed_lease(&ledger_path, spreadsheet_id, "1");
2082 (Some(token), ledger_path)
2083 }
2084
2085 fn base_opts(form: AutoFillForm, dry_run: bool) -> AutoFillOptions {
2086 AutoFillOptions {
2087 spreadsheet_id: "sheet-1".to_string(),
2088 form,
2089 use_alternate_series: false,
2090 dry_run,
2091 lease_token: None,
2092 ledger_path: std::path::PathBuf::new(),
2093 }
2094 }
2095
2096 #[tokio::test]
2097 async fn a_denied_gate_blocks_before_any_read_or_batch_update_call() {
2098 let server = wiremock::MockServer::start().await;
2099 let (drive, sheets) = clients(&server).await;
2100 mount_file(
2101 "sheet-1",
2102 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2103 &["folder-1"],
2104 )
2105 .mount(&server)
2106 .await;
2107 mount_folder("folder-1").mount(&server).await;
2108 let rules: Vec<FolderPermissionRule> = Vec::new();
2109 let opts = base_opts(range_form(), false);
2110 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2111 assert!(matches!(outcome.result, AutoFillResult::Blocked { .. }));
2112 }
2113
2114 #[tokio::test]
2115 async fn invalid_composed_range_is_refused_before_fetching_metadata() {
2116 let server = wiremock::MockServer::start().await;
2117 let (drive, sheets) = clients(&server).await;
2118 let form = AutoFillForm::Range {
2119 sheet: Some("Q1".into()),
2120 range: Some("Other!A1:A2".into()),
2121 };
2122 let outcome = auto_fill_inner(&drive, &sheets, &base_opts(form, true), &[]).await;
2123 assert!(matches!(
2124 outcome.result,
2125 AutoFillResult::RefusedInvalidRange { .. }
2126 ));
2127 assert!(server
2128 .received_requests()
2129 .await
2130 .unwrap()
2131 .iter()
2132 .all(|r| r.url.path() != "/drive/v3/files/sheet-1"));
2133 }
2134
2135 #[tokio::test]
2136 async fn target_resolution_refusals_keep_the_target_name() {
2137 for (mime, parents, expected) in [
2138 (
2139 crate::drive::types::GOOGLE_SHORTCUT_MIME_TYPE,
2140 vec!["folder-1"],
2141 "shortcut",
2142 ),
2143 ("application/pdf", vec!["folder-1"], "not-spreadsheet"),
2144 (
2145 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2146 vec![],
2147 "no-parents",
2148 ),
2149 ] {
2150 let server = wiremock::MockServer::start().await;
2151 let (drive, sheets) = clients(&server).await;
2152 mount_file("sheet-1", mime, &parents).mount(&server).await;
2153 let outcome =
2154 auto_fill_inner(&drive, &sheets, &base_opts(range_form(), true), &[]).await;
2155 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2156 assert!(outcome.sheet_id.is_none());
2157 assert!(
2158 match expected {
2159 "shortcut" => matches!(outcome.result, AutoFillResult::RefusedShortcut),
2160 "not-spreadsheet" => matches!(
2161 outcome.result,
2162 AutoFillResult::RefusedNotASpreadsheet { .. }
2163 ),
2164 _ => matches!(outcome.result, AutoFillResult::RefusedNoVisibleParents),
2165 },
2166 "{outcome:?}"
2167 );
2168 }
2169 }
2170
2171 #[tokio::test]
2172 async fn metadata_and_workbook_failures_are_reported_at_their_respective_stages() {
2173 let server = wiremock::MockServer::start().await;
2174 let (drive, sheets) = clients(&server).await;
2175 wiremock::Mock::given(wiremock::matchers::method("GET"))
2176 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2177 .respond_with(wiremock::ResponseTemplate::new(500))
2178 .mount(&server)
2179 .await;
2180 let outcome = auto_fill_inner(&drive, &sheets, &base_opts(range_form(), true), &[]).await;
2181 assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
2182 assert!(outcome.file_name.is_none());
2183
2184 let server = wiremock::MockServer::start().await;
2185 let (drive, sheets) = clients(&server).await;
2186 mount_file(
2187 "sheet-1",
2188 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2189 &["folder-1"],
2190 )
2191 .mount(&server)
2192 .await;
2193 mount_folder("folder-1").mount(&server).await;
2194 wiremock::Mock::given(wiremock::matchers::method("GET"))
2195 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2196 .respond_with(wiremock::ResponseTemplate::new(500))
2197 .mount(&server)
2198 .await;
2199 let outcome = auto_fill_inner(
2200 &drive,
2201 &sheets,
2202 &base_opts(range_form(), true),
2203 &[allow_rule("folder-1")],
2204 )
2205 .await;
2206 assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
2207 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2208 }
2209
2210 #[tokio::test]
2211 async fn a_folder_lookup_failure_preserves_the_target_name() {
2212 let server = wiremock::MockServer::start().await;
2213 let (drive, sheets) = clients(&server).await;
2214 mount_file(
2215 "sheet-1",
2216 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2217 &["folder-1"],
2218 )
2219 .mount(&server)
2220 .await;
2221 wiremock::Mock::given(wiremock::matchers::method("GET"))
2222 .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
2223 .respond_with(wiremock::ResponseTemplate::new(500))
2224 .mount(&server)
2225 .await;
2226 let outcome = auto_fill_inner(
2227 &drive,
2228 &sheets,
2229 &base_opts(range_form(), true),
2230 &[allow_rule("folder-1")],
2231 )
2232 .await;
2233 assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
2234 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2235 assert!(outcome.sheet_id.is_none());
2236 }
2237
2238 #[tokio::test]
2239 async fn backward_fill_before_the_sheet_start_is_refused_before_values_get() {
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 outcome = auto_fill_inner(
2252 &drive,
2253 &sheets,
2254 &base_opts(source_and_destination_form(Dimension::Rows, -1), true),
2255 &[allow_rule("folder-1")],
2256 )
2257 .await;
2258 assert!(matches!(
2259 outcome.result,
2260 AutoFillResult::RefusedInvalidRange { .. }
2261 ));
2262 assert_eq!(outcome.sheet_id, Some(0));
2263 assert!(server
2264 .received_requests()
2265 .await
2266 .unwrap()
2267 .iter()
2268 .all(|r| !r.url.path().contains("/values/")));
2269 }
2270
2271 #[tokio::test]
2272 async fn source_and_destination_dry_run_reports_would_change_and_makes_no_batch_update_call() {
2273 let server = wiremock::MockServer::start().await;
2274 let (drive, sheets) = clients(&server).await;
2275 mount_file(
2276 "sheet-1",
2277 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2278 &["folder-1"],
2279 )
2280 .mount(&server)
2281 .await;
2282 mount_folder("folder-1").mount(&server).await;
2283 mount_workbook().mount(&server).await;
2284 wiremock::Mock::given(wiremock::matchers::method("GET"))
2285 .and(wiremock::matchers::path(
2286 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2287 ))
2288 .respond_with(
2289 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2290 "values": [["existing"], [], ["also here"]]
2291 })),
2292 )
2293 .mount(&server)
2294 .await;
2295 let rules = vec![allow_rule("folder-1")];
2296 let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), true);
2297 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2298 match outcome.result {
2299 AutoFillResult::WouldChange {
2300 destination,
2301 overwritten_cells,
2302 destination_is_upper_bound,
2303 ..
2304 } => {
2305 assert_eq!(destination, "'Q1'!A4:A10");
2306 assert_eq!(overwritten_cells, vec!["A4".to_string(), "A6".to_string()]);
2307 assert!(!destination_is_upper_bound);
2308 }
2309 other => panic!("expected WouldChange, got {other:?}"),
2310 }
2311 }
2314
2315 #[tokio::test]
2316 async fn range_form_dry_run_reports_the_destination_as_an_upper_bound() {
2317 let server = wiremock::MockServer::start().await;
2318 let (drive, sheets) = clients(&server).await;
2319 mount_file(
2320 "sheet-1",
2321 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2322 &["folder-1"],
2323 )
2324 .mount(&server)
2325 .await;
2326 mount_folder("folder-1").mount(&server).await;
2327 mount_workbook().mount(&server).await;
2328 wiremock::Mock::given(wiremock::matchers::method("GET"))
2329 .and(wiremock::matchers::path(
2330 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:A10",
2331 ))
2332 .respond_with(
2333 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2334 "values": [["1"], ["2"]]
2335 })),
2336 )
2337 .mount(&server)
2338 .await;
2339 let rules = vec![allow_rule("folder-1")];
2340 let opts = base_opts(range_form(), true);
2341 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2342 match outcome.result {
2343 AutoFillResult::WouldChange {
2344 destination_is_upper_bound,
2345 overwritten_cells,
2346 ..
2347 } => {
2348 assert!(destination_is_upper_bound);
2349 assert_eq!(overwritten_cells, vec!["A1".to_string(), "A2".to_string()]);
2350 }
2351 other => panic!("expected WouldChange, got {other:?}"),
2352 }
2353 }
2354
2355 #[tokio::test]
2356 async fn an_open_ended_source_is_refused() {
2357 let server = wiremock::MockServer::start().await;
2358 let (drive, sheets) = clients(&server).await;
2359 mount_file(
2360 "sheet-1",
2361 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2362 &["folder-1"],
2363 )
2364 .mount(&server)
2365 .await;
2366 mount_folder("folder-1").mount(&server).await;
2367 mount_workbook().mount(&server).await;
2368 let rules = vec![allow_rule("folder-1")];
2369 let form = AutoFillForm::SourceAndDestination {
2370 sheet: Some("Q1".to_string()),
2371 source: Some("A:A".to_string()),
2372 dimension: Dimension::Rows,
2373 fill_length: 1,
2374 };
2375 let outcome = auto_fill(&drive, &sheets, &base_opts(form, true), &rules).await;
2376 match outcome.result {
2377 AutoFillResult::RefusedInvalidRange { detail } => {
2378 assert!(detail.contains("fully bounded"), "{detail}");
2379 assert!(detail.starts_with("'Q1'!A:A is open-ended"), "{detail}");
2380 }
2381 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2382 }
2383 }
2384
2385 #[tokio::test]
2386 async fn a_fill_length_of_zero_is_refused_before_any_request() {
2387 let server = wiremock::MockServer::start().await;
2388 let (drive, sheets) = clients(&server).await;
2389 let rules: Vec<FolderPermissionRule> = Vec::new();
2390 let outcome = auto_fill(
2391 &drive,
2392 &sheets,
2393 &base_opts(source_and_destination_form(Dimension::Rows, 0), true),
2394 &rules,
2395 )
2396 .await;
2397 match outcome.result {
2398 AutoFillResult::RefusedInvalidRange { detail } => {
2399 assert!(detail.contains("--fill-length"), "{detail}");
2400 }
2401 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2402 }
2403 }
2406
2407 #[tokio::test]
2408 async fn a_real_run_sends_a_source_and_destination_auto_fill_request_and_reports_changed() {
2409 let server = wiremock::MockServer::start().await;
2410 let (drive, sheets) = clients(&server).await;
2411 mount_file(
2412 "sheet-1",
2413 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2414 &["folder-1"],
2415 )
2416 .mount(&server)
2417 .await;
2418 mount_folder("folder-1").mount(&server).await;
2419 mount_workbook().mount(&server).await;
2420 wiremock::Mock::given(wiremock::matchers::method("GET"))
2421 .and(wiremock::matchers::path(
2422 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2423 ))
2424 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2425 .mount(&server)
2426 .await;
2427 wiremock::Mock::given(wiremock::matchers::method("POST"))
2428 .and(wiremock::matchers::path(
2429 "/v4/spreadsheets/sheet-1:batchUpdate",
2430 ))
2431 .and(wiremock::matchers::body_json(serde_json::json!({
2432 "requests": [{
2433 "autoFill": {
2434 "sourceAndDestination": {
2435 "source": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 3,
2436 "startColumnIndex": 0, "endColumnIndex": 1},
2437 "dimension": "ROWS",
2438 "fillLength": 7
2439 },
2440 "useAlternateSeries": false
2441 }
2442 }]
2443 })))
2444 .respond_with(
2445 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2446 "spreadsheetId": "sheet-1", "replies": [{}]
2447 })),
2448 )
2449 .mount(&server)
2450 .await;
2451 let rules = vec![allow_rule("folder-1")];
2452 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2453 let opts = AutoFillOptions {
2454 lease_token,
2455 ledger_path,
2456 ..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
2457 };
2458 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2459 assert!(matches!(outcome.result, AutoFillResult::Changed { .. }));
2460 }
2461
2462 #[tokio::test]
2474 async fn a_destination_wholly_past_the_grid_extent_is_refused_before_any_lease_or_mutation() {
2475 let server = wiremock::MockServer::start().await;
2476 let (drive, sheets) = clients(&server).await;
2477 mount_file(
2478 "sheet-1",
2479 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2480 &["folder-1"],
2481 )
2482 .mount(&server)
2483 .await;
2484 mount_folder("folder-1").mount(&server).await;
2485 mount_workbook().mount(&server).await;
2486 let rules = vec![allow_rule("folder-1")];
2487 let form = AutoFillForm::Range {
2490 sheet: Some("Q1".to_string()),
2491 range: Some("A2001:A2010".to_string()),
2492 };
2493 for dry_run in [true, false] {
2494 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2495 let opts = AutoFillOptions {
2496 lease_token,
2497 ledger_path,
2498 form: form.clone(),
2499 ..base_opts(range_form(), dry_run)
2500 };
2501 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2502 let AutoFillResult::RefusedDestinationPastGrid {
2503 requested_destination,
2504 row_count,
2505 column_count,
2506 } = &outcome.result
2507 else {
2508 panic!("dry_run={dry_run}: {:?}", outcome.result);
2509 };
2510 assert_eq!(requested_destination, "'Q1'!A2001:A2010");
2511 assert_eq!(*row_count, Some(1000));
2512 assert_eq!(*column_count, Some(26));
2513 let lines = describe_lines(&outcome);
2514 assert_eq!(lines.len(), 1, "{lines:?}");
2515 assert!(lines[0].contains("lies wholly past"), "{lines:?}");
2516 }
2517 assert!(
2518 !server
2519 .received_requests()
2520 .await
2521 .unwrap_or_default()
2522 .iter()
2523 .any(
2524 |r| r.url.path().contains("/values/") || r.url.path().ends_with(":batchUpdate")
2525 ),
2526 "neither the read nor the mutation must be attempted"
2527 );
2528 }
2529
2530 #[tokio::test]
2535 async fn a_destination_straddling_the_grid_edge_is_clipped_and_reports_both_ranges() {
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'!A4:A1000",
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::Rows, 1000), 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);
2572 };
2573 assert_eq!(destination, "'Q1'!A4:A1000");
2574 assert_eq!(requested_destination.as_deref(), Some("'Q1'!A4:A1003"));
2575 assert_eq!(overwritten_cells, &vec!["A4".to_string()]);
2576 let lines = describe_lines(&outcome);
2577 assert_eq!(
2583 lines[0],
2584 "Would auto-fill 'Q1'!A4:A1000 from source 'Q1'!A1:A3, extending 997 row(s) down in \
2585 'sheet-1'"
2586 );
2587 assert_eq!(
2588 lines[2],
2589 past_grid_extent_caveat_dry_run("'Q1'!A4:A1000", "'Q1'!A4:A1003")
2590 );
2591 }
2592
2593 #[tokio::test]
2597 async fn a_clipped_real_run_reports_the_applied_count_in_the_head_line() {
2598 let server = wiremock::MockServer::start().await;
2599 let (drive, sheets) = clients(&server).await;
2600 mount_file(
2601 "sheet-1",
2602 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2603 &["folder-1"],
2604 )
2605 .mount(&server)
2606 .await;
2607 mount_folder("folder-1").mount(&server).await;
2608 mount_workbook().mount(&server).await;
2609 wiremock::Mock::given(wiremock::matchers::method("GET"))
2610 .and(wiremock::matchers::path(
2611 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A1000",
2612 ))
2613 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2614 .mount(&server)
2615 .await;
2616 wiremock::Mock::given(wiremock::matchers::method("POST"))
2617 .and(wiremock::matchers::path(
2618 "/v4/spreadsheets/sheet-1:batchUpdate",
2619 ))
2620 .respond_with(
2621 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2622 "spreadsheetId": "sheet-1", "replies": [{}]
2623 })),
2624 )
2625 .mount(&server)
2626 .await;
2627 let rules = vec![allow_rule("folder-1")];
2628 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2629 let opts = AutoFillOptions {
2630 lease_token,
2631 ledger_path,
2632 ..base_opts(source_and_destination_form(Dimension::Rows, 1000), false)
2633 };
2634 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2635 assert!(matches!(outcome.result, AutoFillResult::Changed { .. }));
2636 let lines = describe_lines(&outcome);
2637 assert_eq!(
2638 lines[0],
2639 "Applied: auto-fill 'Q1'!A4:A1000 from source 'Q1'!A1:A3, extending 997 row(s) \
2640 down in 'sheet-1'"
2641 );
2642 assert_eq!(
2643 lines[2],
2644 past_grid_extent_caveat("'Q1'!A4:A1000", "'Q1'!A4:A1003")
2645 );
2646 }
2647
2648 #[tokio::test]
2652 async fn a_destination_straddling_the_grid_edge_clips_along_columns_too() {
2653 let server = wiremock::MockServer::start().await;
2654 let (drive, sheets) = clients(&server).await;
2655 mount_file(
2656 "sheet-1",
2657 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2658 &["folder-1"],
2659 )
2660 .mount(&server)
2661 .await;
2662 mount_folder("folder-1").mount(&server).await;
2663 mount_workbook().mount(&server).await;
2664 wiremock::Mock::given(wiremock::matchers::method("GET"))
2668 .and(wiremock::matchers::path(
2669 "/v4/spreadsheets/sheet-1/values/'Q1'!B1:Z3",
2670 ))
2671 .respond_with(
2672 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2673 "values": [["keep"]]
2674 })),
2675 )
2676 .mount(&server)
2677 .await;
2678 let rules = vec![allow_rule("folder-1")];
2679 let opts = base_opts(source_and_destination_form(Dimension::Columns, 30), true);
2680 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2681 let AutoFillResult::WouldChange {
2682 destination,
2683 requested_destination,
2684 overwritten_cells,
2685 ..
2686 } = &outcome.result
2687 else {
2688 panic!("{:?}", outcome.result); };
2690 assert_eq!(destination, "'Q1'!B1:Z3");
2691 assert!(requested_destination.is_some(), "{requested_destination:?}");
2692 assert_eq!(overwritten_cells, &vec!["B1".to_string()]);
2693 }
2694
2695 #[tokio::test]
2700 async fn a_range_form_destination_straddling_the_grid_edge_is_clipped_too() {
2701 let server = wiremock::MockServer::start().await;
2702 let (drive, sheets) = clients(&server).await;
2703 mount_file(
2704 "sheet-1",
2705 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2706 &["folder-1"],
2707 )
2708 .mount(&server)
2709 .await;
2710 mount_folder("folder-1").mount(&server).await;
2711 mount_workbook().mount(&server).await;
2712 wiremock::Mock::given(wiremock::matchers::method("GET"))
2713 .and(wiremock::matchers::path(
2714 "/v4/spreadsheets/sheet-1/values/'Q1'!A990:A1000",
2715 ))
2716 .respond_with(
2717 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2718 "values": [["keep"]]
2719 })),
2720 )
2721 .mount(&server)
2722 .await;
2723 let rules = vec![allow_rule("folder-1")];
2724 let form = AutoFillForm::Range {
2725 sheet: Some("Q1".to_string()),
2726 range: Some("A990:A1010".to_string()),
2727 };
2728 let outcome = auto_fill(&drive, &sheets, &base_opts(form, true), &rules).await;
2729 let AutoFillResult::WouldChange {
2730 destination,
2731 requested_destination,
2732 destination_is_upper_bound,
2733 ..
2734 } = &outcome.result
2735 else {
2736 panic!("{:?}", outcome.result); };
2738 assert_eq!(destination, "'Q1'!A990:A1000");
2739 assert_eq!(requested_destination.as_deref(), Some("'Q1'!A990:A1010"));
2740 assert!(destination_is_upper_bound);
2741 }
2742
2743 #[tokio::test]
2747 async fn a_destination_inside_the_grid_has_no_requested_destination_key_in_json() {
2748 let server = wiremock::MockServer::start().await;
2749 let (drive, sheets) = clients(&server).await;
2750 mount_file(
2751 "sheet-1",
2752 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2753 &["folder-1"],
2754 )
2755 .mount(&server)
2756 .await;
2757 mount_folder("folder-1").mount(&server).await;
2758 mount_workbook().mount(&server).await;
2759 wiremock::Mock::given(wiremock::matchers::method("GET"))
2760 .and(wiremock::matchers::path(
2761 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2762 ))
2763 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2764 .mount(&server)
2765 .await;
2766 let rules = vec![allow_rule("folder-1")];
2767 let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), true);
2768 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2769 let AutoFillResult::WouldChange {
2770 requested_destination,
2771 ..
2772 } = &outcome.result
2773 else {
2774 panic!("{:?}", outcome.result); };
2776 assert!(requested_destination.is_none());
2777 let mut out = Vec::new();
2778 outcome.write_jsonl(&mut out).unwrap();
2779 let value: serde_json::Value = serde_json::from_slice(&out).unwrap();
2780 assert!(value["result"].get("requested_destination").is_none());
2781 }
2782
2783 #[tokio::test]
2788 async fn unknown_grid_extent_leaves_the_destination_unclamped_and_never_refuses() {
2789 let server = wiremock::MockServer::start().await;
2790 let (drive, sheets) = clients(&server).await;
2791 mount_file(
2792 "sheet-1",
2793 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2794 &["folder-1"],
2795 )
2796 .mount(&server)
2797 .await;
2798 mount_folder("folder-1").mount(&server).await;
2799 wiremock::Mock::given(wiremock::matchers::method("GET"))
2800 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2801 .respond_with(
2802 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2803 "spreadsheetId": "sheet-1",
2804 "properties": {"title": "Budget"},
2805 "sheets": [
2806 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2807 ],
2808 })),
2809 )
2810 .mount(&server)
2811 .await;
2812 wiremock::Mock::given(wiremock::matchers::method("GET"))
2813 .and(wiremock::matchers::path(
2814 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A1003",
2815 ))
2816 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2817 .mount(&server)
2818 .await;
2819 let rules = vec![allow_rule("folder-1")];
2820 let opts = base_opts(source_and_destination_form(Dimension::Rows, 1000), true);
2821 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2822 let AutoFillResult::WouldChange {
2823 destination,
2824 requested_destination,
2825 ..
2826 } = &outcome.result
2827 else {
2828 panic!("{:?}", outcome.result); };
2830 assert_eq!(destination, "'Q1'!A4:A1003");
2831 assert!(requested_destination.is_none());
2832 }
2833
2834 #[tokio::test]
2835 async fn a_values_get_failure_is_reported_as_failed() {
2836 let server = wiremock::MockServer::start().await;
2837 let (drive, sheets) = clients(&server).await;
2838 mount_file(
2839 "sheet-1",
2840 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2841 &["folder-1"],
2842 )
2843 .mount(&server)
2844 .await;
2845 mount_folder("folder-1").mount(&server).await;
2846 mount_workbook().mount(&server).await;
2847 wiremock::Mock::given(wiremock::matchers::method("GET"))
2848 .and(wiremock::matchers::path(
2849 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2850 ))
2851 .respond_with(wiremock::ResponseTemplate::new(500))
2852 .mount(&server)
2853 .await;
2854 let rules = vec![allow_rule("folder-1")];
2855 let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), true);
2856 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2857 assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
2858 }
2859
2860 async fn mount_leasable_fixture(server: &wiremock::MockServer) {
2871 mount_file(
2872 "sheet-1",
2873 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2874 &["folder-1"],
2875 )
2876 .mount(server)
2877 .await;
2878 mount_folder("folder-1").mount(server).await;
2879 mount_workbook().mount(server).await;
2880 wiremock::Mock::given(wiremock::matchers::method("GET"))
2881 .and(wiremock::matchers::path(
2882 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2883 ))
2884 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2885 .mount(server)
2886 .await;
2887 }
2888
2889 async fn run_with_lease(
2893 server: &wiremock::MockServer,
2894 lease_token: Option<String>,
2895 ledger_path: std::path::PathBuf,
2896 ) -> AutoFillOutcome {
2897 let (drive, sheets) = clients(server).await;
2898 mount_leasable_fixture(server).await;
2899 let rules = vec![allow_rule("folder-1")];
2900 let opts = AutoFillOptions {
2901 lease_token,
2902 ledger_path,
2903 ..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
2904 };
2905 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2906 assert!(
2907 !server
2908 .received_requests()
2909 .await
2910 .unwrap_or_default()
2911 .iter()
2912 .any(|r| r.url.path().ends_with(":batchUpdate")),
2913 "a refused lease must not reach batchUpdate"
2914 );
2915 outcome
2916 }
2917
2918 fn ledger_in_a_tempdir() -> std::path::PathBuf {
2919 tempfile::tempdir()
2920 .unwrap()
2921 .keep()
2922 .join("lease-ledger.jsonl")
2923 }
2924
2925 #[tokio::test]
2926 async fn an_expired_or_unknown_lease_is_refused() {
2927 let server = wiremock::MockServer::start().await;
2928 let outcome = run_with_lease(
2931 &server,
2932 Some("never-issued".to_string()),
2933 ledger_in_a_tempdir(),
2934 )
2935 .await;
2936 assert!(matches!(
2937 outcome.result,
2938 AutoFillResult::RefusedLeaseExpired
2939 ));
2940 }
2941
2942 #[tokio::test]
2943 async fn a_lease_bound_to_another_file_is_refused() {
2944 let server = wiremock::MockServer::start().await;
2945 let ledger_path = ledger_in_a_tempdir();
2946 let token = seed_lease(&ledger_path, "some-other-sheet", "1");
2947 let outcome = run_with_lease(&server, Some(token), ledger_path).await;
2948 assert!(matches!(
2949 outcome.result,
2950 AutoFillResult::RefusedLeaseWrongFile
2951 ));
2952 }
2953
2954 #[tokio::test]
2955 async fn refuses_a_stale_lease_when_the_file_has_moved() {
2956 let server = wiremock::MockServer::start().await;
2957 let ledger_path = ledger_in_a_tempdir();
2958 let token = seed_lease(&ledger_path, "sheet-1", "0");
2961 let outcome = run_with_lease(&server, Some(token), ledger_path).await;
2962 assert!(matches!(outcome.result, AutoFillResult::RefusedLeaseStale));
2963 }
2964
2965 #[tokio::test]
2970 async fn a_real_run_renders_in_the_past_tense_and_names_the_source() {
2971 let server = wiremock::MockServer::start().await;
2972 let (drive, sheets) = clients(&server).await;
2973 mount_file(
2974 "sheet-1",
2975 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2976 &["folder-1"],
2977 )
2978 .mount(&server)
2979 .await;
2980 mount_folder("folder-1").mount(&server).await;
2981 mount_workbook().mount(&server).await;
2982 wiremock::Mock::given(wiremock::matchers::method("GET"))
2983 .and(wiremock::matchers::path(
2984 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2985 ))
2986 .respond_with(
2987 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2988 "values": [["keep"], [], ["also"]]
2989 })),
2990 )
2991 .mount(&server)
2992 .await;
2993 wiremock::Mock::given(wiremock::matchers::method("POST"))
2994 .and(wiremock::matchers::path(
2995 "/v4/spreadsheets/sheet-1:batchUpdate",
2996 ))
2997 .respond_with(
2998 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2999 "spreadsheetId": "sheet-1", "replies": [{}]
3000 })),
3001 )
3002 .mount(&server)
3003 .await;
3004 let rules = vec![allow_rule("folder-1")];
3005 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3006 let opts = AutoFillOptions {
3007 lease_token,
3008 ledger_path,
3009 ..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
3010 };
3011 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
3012 let lines = describe_lines(&outcome);
3013 assert_eq!(
3014 lines[0],
3015 "Applied: auto-fill 'Q1'!A4:A10 from source 'Q1'!A1:A3, extending 7 row(s) down in \
3016 'sheet-1'"
3017 );
3018 assert_eq!(lines[1], " 2 non-blank cell(s) were overwritten: A4, A6");
3019 assert_eq!(lines[2], UNPREVIEWABLE_VALUES_CAVEAT);
3020 let AutoFillResult::Changed { summary, .. } = &outcome.result else {
3021 panic!("{:?}", outcome.result);
3022 };
3023 assert!(!summary.contains("would"), "{summary}");
3025 }
3026
3027 #[tokio::test]
3028 async fn auto_fill_with_an_unreadable_reply_reports_applied_reply_unreadable() {
3029 let server = wiremock::MockServer::start().await;
3030 let (drive, sheets) = clients(&server).await;
3031 mount_file(
3032 "sheet-1",
3033 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3034 &["folder-1"],
3035 )
3036 .mount(&server)
3037 .await;
3038 mount_folder("folder-1").mount(&server).await;
3039 mount_workbook().mount(&server).await;
3040 wiremock::Mock::given(wiremock::matchers::method("GET"))
3041 .and(wiremock::matchers::path(
3042 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
3043 ))
3044 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
3045 .mount(&server)
3046 .await;
3047 wiremock::Mock::given(wiremock::matchers::method("POST"))
3049 .and(wiremock::matchers::path(
3050 "/v4/spreadsheets/sheet-1:batchUpdate",
3051 ))
3052 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
3053 .expect(1)
3054 .mount(&server)
3055 .await;
3056 let rules = vec![allow_rule("folder-1")];
3057 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3058 let opts = AutoFillOptions {
3059 lease_token,
3060 ledger_path,
3061 ..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
3062 };
3063 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
3064 assert_eq!(
3065 outcome.result.log_status(),
3066 "applied-reply-unreadable",
3067 "{:?}",
3068 outcome.result
3069 );
3070 assert!(
3071 describe(&outcome).starts_with("Applied, but the reply could not be read"),
3072 "{}",
3073 describe(&outcome)
3074 );
3075 }
3076
3077 #[test]
3078 fn describe_lines_renders_applied_reply_unreadable_with_the_same_detail_lines_as_changed() {
3079 let outcome = AutoFillOutcome {
3080 spreadsheet_id: "sheet-1".to_string(),
3081 file_name: Some("Budget".to_string()),
3082 resolved_folder_id: None,
3083 sheet_id: Some(0),
3084 form: range_form(),
3085 result: AutoFillResult::AppliedReplyUnreadable {
3086 summary: "auto-fill within 'Q1'!A1:A10".to_string(),
3087 destination: "'Q1'!A1:A10".to_string(),
3088 requested_destination: Some("'Q1'!A1:A20".to_string()),
3089 overwritten_cells: vec!["A2".to_string()],
3090 destination_is_upper_bound: true,
3091 detail: "bad reply".to_string(),
3092 },
3093 };
3094 let lines = describe_lines(&outcome);
3095 assert_eq!(
3096 lines[0],
3097 "Applied, but the reply could not be read: auto-fill within 'Q1'!A1:A10 in \
3098 'Budget' — check the spreadsheet to confirm (bad reply)"
3099 );
3100 let changed = describe_lines(&AutoFillOutcome {
3102 result: AutoFillResult::Changed {
3103 summary: "auto-fill within 'Q1'!A1:A10".to_string(),
3104 destination: "'Q1'!A1:A10".to_string(),
3105 requested_destination: Some("'Q1'!A1:A20".to_string()),
3106 overwritten_cells: vec!["A2".to_string()],
3107 destination_is_upper_bound: true,
3108 },
3109 ..outcome
3110 });
3111 assert!(lines.len() > 1, "{lines:?}");
3112 assert_eq!(lines[1..], changed[1..]);
3113 assert!(lines[1].contains("A2"), "{lines:?}");
3114 }
3115
3116 #[test]
3119 fn an_unreadable_reply_logs_the_same_fields_as_changed() {
3120 let outcome = |result| AutoFillOutcome {
3121 spreadsheet_id: "sheet-1".to_string(),
3122 file_name: Some("Budget".to_string()),
3123 resolved_folder_id: None,
3124 sheet_id: Some(7),
3125 form: range_form(),
3126 result,
3127 };
3128 let changed = mutation_record(
3129 &outcome(AutoFillResult::Changed {
3130 summary: "auto-fill within 'Q1'!A1:A10".to_string(),
3131 destination: "'Q1'!A1:A10".to_string(),
3132 requested_destination: None,
3133 overwritten_cells: vec!["A2".to_string()],
3134 destination_is_upper_bound: true,
3135 }),
3136 Duration::from_millis(1),
3137 );
3138 let unreadable = mutation_record(
3139 &outcome(AutoFillResult::AppliedReplyUnreadable {
3140 summary: "auto-fill within 'Q1'!A1:A10".to_string(),
3141 destination: "'Q1'!A1:A10".to_string(),
3142 requested_destination: None,
3143 overwritten_cells: vec!["A2".to_string()],
3144 destination_is_upper_bound: true,
3145 detail: "bad reply".to_string(),
3146 }),
3147 Duration::from_millis(1),
3148 );
3149 assert_eq!(unreadable.range.as_deref(), Some("'Q1'!A1:A10"));
3150 assert_eq!(unreadable.range, changed.range);
3151 assert_eq!(
3152 unreadable.fields_changed.as_deref(),
3153 Some("auto-fill within 'Q1'!A1:A10")
3154 );
3155 assert_eq!(unreadable.overwritten_cells, vec!["A2".to_string()]);
3156 assert!(unreadable.overwritten_cells_upper_bound);
3157 assert_eq!(unreadable.sheet_id, Some(7));
3158 assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
3159 }
3160
3161 #[tokio::test]
3162 async fn a_batch_update_failure_is_reported_as_failed() {
3163 let server = wiremock::MockServer::start().await;
3164 let (drive, sheets) = clients(&server).await;
3165 mount_file(
3166 "sheet-1",
3167 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3168 &["folder-1"],
3169 )
3170 .mount(&server)
3171 .await;
3172 mount_folder("folder-1").mount(&server).await;
3173 mount_workbook().mount(&server).await;
3174 wiremock::Mock::given(wiremock::matchers::method("GET"))
3175 .and(wiremock::matchers::path(
3176 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
3177 ))
3178 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
3179 .mount(&server)
3180 .await;
3181 wiremock::Mock::given(wiremock::matchers::method("POST"))
3182 .and(wiremock::matchers::path(
3183 "/v4/spreadsheets/sheet-1:batchUpdate",
3184 ))
3185 .respond_with(wiremock::ResponseTemplate::new(500))
3186 .mount(&server)
3187 .await;
3188 let rules = vec![allow_rule("folder-1")];
3189 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3190 let opts = AutoFillOptions {
3191 lease_token,
3192 ledger_path,
3193 ..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
3194 };
3195 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
3196 assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
3197 }
3198
3199 #[tokio::test]
3200 async fn a_missing_lease_is_refused_when_the_deciding_rule_requires_one() {
3201 let server = wiremock::MockServer::start().await;
3202 let (drive, sheets) = clients(&server).await;
3203 mount_file(
3204 "sheet-1",
3205 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3206 &["folder-1"],
3207 )
3208 .mount(&server)
3209 .await;
3210 mount_folder("folder-1").mount(&server).await;
3211 mount_workbook().mount(&server).await;
3212 wiremock::Mock::given(wiremock::matchers::method("GET"))
3213 .and(wiremock::matchers::path(
3214 "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
3215 ))
3216 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
3217 .mount(&server)
3218 .await;
3219 let rules = vec![allow_rule("folder-1")];
3220 let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), false);
3221 let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
3222 assert!(matches!(outcome.result, AutoFillResult::RefusedNoLease));
3223 }
3224
3225 #[tokio::test]
3226 async fn the_sheet_not_found_refusal_names_the_available_sheets() {
3227 let server = wiremock::MockServer::start().await;
3228 let (drive, sheets) = clients(&server).await;
3229 mount_file(
3230 "sheet-1",
3231 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3232 &["folder-1"],
3233 )
3234 .mount(&server)
3235 .await;
3236 mount_folder("folder-1").mount(&server).await;
3237 mount_workbook().mount(&server).await;
3238 let rules = vec![allow_rule("folder-1")];
3239 let form = AutoFillForm::Range {
3240 sheet: Some("Missing".to_string()),
3241 range: Some("A1:A2".to_string()),
3242 };
3243 let outcome = auto_fill(&drive, &sheets, &base_opts(form, true), &rules).await;
3244 match outcome.result {
3245 AutoFillResult::RefusedSheetNotFound { title, available } => {
3246 assert_eq!(title, "Missing");
3247 assert_eq!(available, vec!["Q1".to_string()]);
3248 }
3249 other => panic!("expected RefusedSheetNotFound, got {other:?}"),
3250 }
3251 }
3252}