1#![allow(missing_docs)] use std::path::PathBuf;
45use std::time::{Duration, Instant};
46
47use serde::Serialize;
48
49use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
50use crate::drive::client::DriveClient;
51use crate::drive::files_api::FilesApi;
52use crate::drive::lease::check::{
53 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
54 LeasedWrite,
55};
56use crate::drive::sheets::api::{SheetsApi, ValueRenderOption};
57use crate::drive::sheets::client::SheetsClient;
58use crate::drive::sheets::types::{
59 BatchUpdateRequestItem, GridRange, Sheet, Spreadsheet, TrimWhitespaceRequest,
60};
61use crate::drive::sheets::{a1, grid_range, target_gate};
62use crate::drive::types::SheetTargetRefusal;
63use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
64use crate::request_log::{self, DriveMutationOutcome};
65
66const LOG_OPERATION: &str = "sheets-trim-whitespace";
67
68#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum TrimScope {
75 Range,
79 WholeSheet,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct TrimWhitespaceOptions {
85 pub spreadsheet_id: String,
86 pub sheet: Option<String>,
87 pub range: Option<String>,
88 pub scope: TrimScope,
89 pub dry_run: bool,
90 pub lease_token: Option<String>,
91 pub ledger_path: PathBuf,
92}
93
94#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
95#[serde(tag = "status", rename_all = "kebab-case")]
96pub enum TrimWhitespaceResult {
97 WouldChange {
98 range: String,
100 candidate_cells: Vec<String>,
104 candidate_cells_upper_bound: bool,
107 read_clamped_to_sheet: bool,
110 },
111 Changed {
112 range: String,
113 cells_changed_count: Option<i64>,
117 },
118 RefusedInvalidRequest {
119 detail: String,
120 },
121 RefusedNotASpreadsheet {
122 mime_type: String,
123 },
124 RefusedShortcut,
125 RefusedNoVisibleParents,
126 RefusedSheetNotFound {
127 title: String,
128 available: Vec<String>,
129 },
130 Blocked {
131 decided_by: Option<DecidingRule>,
132 },
133 RefusedNoLease,
134 RefusedLeaseExpired,
135 RefusedLeaseWrongFile,
136 RefusedLeaseStale,
137 Failed {
138 detail: String,
139 },
140}
141
142impl FromLeaseRefusal for TrimWhitespaceResult {
143 fn from_no_lease() -> Self {
144 Self::RefusedNoLease
145 }
146 fn from_lease_expired() -> Self {
147 Self::RefusedLeaseExpired
148 }
149 fn from_lease_wrong_file() -> Self {
150 Self::RefusedLeaseWrongFile
151 }
152 fn from_lease_stale() -> Self {
153 Self::RefusedLeaseStale
154 }
155 fn from_lease_failed(detail: String) -> Self {
156 Self::Failed { detail }
157 }
158}
159
160impl TrimWhitespaceResult {
161 fn log_status(&self) -> &'static str {
162 match self {
163 Self::WouldChange { .. } => "would-change",
164 Self::Changed { .. } => "changed",
165 Self::RefusedInvalidRequest { .. } => "refused-invalid-request",
166 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
167 Self::RefusedShortcut => "refused-shortcut",
168 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
169 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
170 Self::Blocked { .. } => "blocked",
171 Self::RefusedNoLease => "refused-no-lease",
172 Self::RefusedLeaseExpired => "refused-lease-expired",
173 Self::RefusedLeaseWrongFile => "refused-lease-wrong-file",
174 Self::RefusedLeaseStale => "refused-lease-stale",
175 Self::Failed { .. } => "failed",
176 }
177 }
178}
179
180#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
181pub struct TrimWhitespaceOutcome {
182 pub spreadsheet_id: String,
183 pub file_name: Option<String>,
184 pub resolved_folder_id: Option<String>,
185 pub result: TrimWhitespaceResult,
186}
187
188impl JsonlSerialize for TrimWhitespaceOutcome {
189 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
190 write_scalar_jsonl(self, out)
191 }
192}
193
194pub async fn trim_whitespace(
195 drive: &DriveClient,
196 sheets: &SheetsClient,
197 opts: &TrimWhitespaceOptions,
198 rules: &[FolderPermissionRule],
199) -> TrimWhitespaceOutcome {
200 let started = Instant::now();
201 let outcome = trim_whitespace_inner(drive, sheets, opts, rules).await;
202 if !opts.dry_run {
203 record_attempt(&outcome, started.elapsed());
204 }
205 outcome
206}
207
208async fn trim_whitespace_inner(
209 drive: &DriveClient,
210 sheets: &SheetsClient,
211 opts: &TrimWhitespaceOptions,
212 rules: &[FolderPermissionRule],
213) -> TrimWhitespaceOutcome {
214 let bare = |result| TrimWhitespaceOutcome {
215 spreadsheet_id: opts.spreadsheet_id.clone(),
216 file_name: None,
217 resolved_folder_id: None,
218 result,
219 };
220 if let Err(detail) = validate_scope_syntax(opts) {
221 return bare(TrimWhitespaceResult::RefusedInvalidRequest { detail });
222 }
223 let composed = match opts.scope {
226 TrimScope::WholeSheet => None,
227 TrimScope::Range => match a1::compose(opts.sheet.as_deref(), opts.range.as_deref()) {
228 Ok(range) => Some(range),
229 Err(err) => {
230 return bare(TrimWhitespaceResult::RefusedInvalidRequest {
231 detail: err.to_string(),
232 })
233 }
234 },
235 };
236 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
237 drive,
238 &opts.spreadsheet_id,
239 DriveOperation::SheetsWrite,
240 rules,
241 )
242 .await
243 {
244 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
245 return bare(TrimWhitespaceResult::Failed { detail })
246 }
247 target_gate::TargetGateOutcome::Refused { target, refusal } => {
248 let result = match refusal {
249 SheetTargetRefusal::Shortcut => TrimWhitespaceResult::RefusedShortcut,
250 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
251 TrimWhitespaceResult::RefusedNotASpreadsheet { mime_type }
252 }
253 SheetTargetRefusal::NoVisibleParents => {
254 TrimWhitespaceResult::RefusedNoVisibleParents
255 }
256 };
257 return TrimWhitespaceOutcome {
258 spreadsheet_id: opts.spreadsheet_id.clone(),
259 file_name: Some(target.name),
260 resolved_folder_id: None,
261 result,
262 };
263 }
264 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
265 return TrimWhitespaceOutcome {
266 spreadsheet_id: opts.spreadsheet_id.clone(),
267 file_name: Some(target.name),
268 resolved_folder_id: None,
269 result: TrimWhitespaceResult::Failed { detail },
270 }
271 }
272 target_gate::TargetGateOutcome::Gated {
273 target,
274 decision,
275 resolved_folder_id,
276 requires_lease,
277 } => (target, decision, resolved_folder_id, requires_lease),
278 };
279 let gated = |result| TrimWhitespaceOutcome {
280 spreadsheet_id: opts.spreadsheet_id.clone(),
281 file_name: Some(target.name.clone()),
282 resolved_folder_id: resolved_folder_id.clone(),
283 result,
284 };
285 if decision.verdict == write_gate::Verdict::Deny {
286 return gated(TrimWhitespaceResult::Blocked {
287 decided_by: decision.decided_by,
288 });
289 }
290 let api = SheetsApi::new(sheets);
291 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
292 Ok(book) => book,
293 Err(err) => {
294 return gated(TrimWhitespaceResult::Failed {
295 detail: format!("{err:#}"),
296 })
297 }
298 };
299 let (sheet_title, grid) = match resolve_scope(opts, composed.as_deref(), &workbook) {
300 Ok(value) => value,
301 Err(result) => return gated(result),
302 };
303 let grid = materialise_bounds(
309 &grid,
310 grid_range::find_sheet_by_id(&workbook, grid.sheet_id),
311 );
312 let Some(range_a1) = grid_range::bounded_range_to_a1(&sheet_title, &grid) else {
313 return gated(TrimWhitespaceResult::RefusedInvalidRequest {
314 detail: format!(
315 "'{}' resolves to no cells in the sheet's current grid",
316 composed.as_deref().unwrap_or(&sheet_title)
317 ),
318 });
319 };
320
321 let request = BatchUpdateRequestItem::TrimWhitespace(TrimWhitespaceRequest { range: grid });
324
325 if opts.dry_run {
326 let Some(read_grid) = grid_range::clamp_to_sheet(&workbook, &grid) else {
330 return gated(TrimWhitespaceResult::RefusedInvalidRequest {
331 detail: format!("{range_a1} has no cells inside the sheet's current grid"),
332 });
333 };
334 let Some(read_a1) = grid_range::bounded_range_to_a1(&sheet_title, &read_grid) else {
335 return gated(TrimWhitespaceResult::RefusedInvalidRequest {
336 detail: format!("{range_a1} has no cells inside the sheet's current grid"),
337 });
338 };
339 let candidate_cells =
340 match read_non_blank(&api, &opts.spreadsheet_id, &read_a1, &read_grid).await {
341 Ok(cells) => cells,
342 Err(detail) => return gated(TrimWhitespaceResult::Failed { detail }),
343 };
344 return gated(TrimWhitespaceResult::WouldChange {
345 range: range_a1,
346 candidate_cells,
347 candidate_cells_upper_bound: true,
348 read_clamped_to_sheet: read_grid != grid,
349 });
350 }
351
352 let leased = LeasedWrite {
353 log_prefix: "drive sheets trim-whitespace",
354 operation: LOG_OPERATION,
355 ledger_path: &opts.ledger_path,
356 file_id: &opts.spreadsheet_id,
357 };
358 let files_api = FilesApi::new(drive);
359 let lease_grant = match gate_optional_leased_write(
360 leased,
361 &files_api,
362 requires_lease,
363 opts.lease_token.as_deref(),
364 )
365 .await
366 {
367 Ok(grant) => grant,
368 Err(err) => return gated(err.into_result()),
369 };
370 let result = match conclude_native_leased_write(
371 leased,
372 &lease_grant,
373 &files_api,
374 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
375 |err| format!("{err:#}"),
376 )
377 .await
378 {
379 Ok(response) => TrimWhitespaceResult::Changed {
380 range: range_a1,
381 cells_changed_count: response
382 .replies
383 .into_iter()
384 .next()
385 .and_then(|reply| reply.trim_whitespace)
386 .map(|trim| trim.cells_changed_count),
387 },
388 Err(err) => TrimWhitespaceResult::Failed {
389 detail: format!("{err:#}"),
390 },
391 };
392 drop(lease_grant);
393 gated(result)
394}
395
396fn validate_scope_syntax(opts: &TrimWhitespaceOptions) -> Result<(), String> {
399 match opts.scope {
400 TrimScope::WholeSheet => {
401 if opts.range.is_some() {
402 return Err("--whole-sheet cannot be combined with --range".to_string());
403 }
404 if opts.sheet.as_ref().is_none_or(|s| s.trim().is_empty()) {
405 return Err("--whole-sheet needs --sheet to name the sheet to trim".to_string());
406 }
407 Ok(())
408 }
409 TrimScope::Range => {
410 if opts.range.as_ref().is_none_or(|r| r.trim().is_empty()) {
411 return Err("pass --range, or --whole-sheet to trim an entire sheet".to_string());
412 }
413 Ok(())
414 }
415 }
416}
417
418fn resolve_scope(
420 opts: &TrimWhitespaceOptions,
421 composed: Option<&str>,
422 workbook: &Spreadsheet,
423) -> Result<(String, GridRange), TrimWhitespaceResult> {
424 match opts.scope {
425 TrimScope::WholeSheet => {
426 let title = opts.sheet.clone().unwrap_or_default();
427 let sheet_id = grid_range::find_sheet_id(workbook, &title, |title, available| {
428 TrimWhitespaceResult::RefusedSheetNotFound { title, available }
429 })?;
430 Ok((
431 title,
432 GridRange {
433 sheet_id,
434 start_row_index: None,
435 end_row_index: None,
436 start_column_index: None,
437 end_column_index: None,
438 },
439 ))
440 }
441 TrimScope::Range => {
442 let composed = composed.unwrap_or_default();
443 grid_range::resolve_grid_range(
444 workbook,
445 composed,
446 |detail| TrimWhitespaceResult::RefusedInvalidRequest { detail },
447 |title, available| TrimWhitespaceResult::RefusedSheetNotFound { title, available },
448 )
449 }
450 }
451}
452
453fn materialise_bounds(grid: &GridRange, sheet: Option<&Sheet>) -> GridRange {
462 let props = sheet
463 .and_then(|sheet| sheet.properties.as_ref())
464 .and_then(|p| p.grid_properties.as_ref());
465 GridRange {
466 sheet_id: grid.sheet_id,
467 start_row_index: grid.start_row_index.or(Some(0)),
468 end_row_index: grid
469 .end_row_index
470 .or_else(|| props.and_then(|g| g.row_count)),
471 start_column_index: grid.start_column_index.or(Some(0)),
472 end_column_index: grid
473 .end_column_index
474 .or_else(|| props.and_then(|g| g.column_count)),
475 }
476}
477
478async fn read_non_blank(
483 api: &SheetsApi<'_>,
484 spreadsheet_id: &str,
485 range_a1: &str,
486 grid: &GridRange,
487) -> Result<Vec<String>, String> {
488 let values = api
489 .values_get(spreadsheet_id, range_a1, ValueRenderOption::Formatted)
490 .await
491 .map_err(|err| format!("{err:#}"))?;
492 Ok(grid_range::non_blank_locations(
493 &values,
494 grid.start_row_index.unwrap_or(0),
495 grid.start_column_index.unwrap_or(0),
496 ))
497}
498
499fn record_attempt(outcome: &TrimWhitespaceOutcome, duration: Duration) {
500 let decided_by = match &outcome.result {
501 TrimWhitespaceResult::Blocked { decided_by } => decided_by.as_ref(),
502 _ => None,
503 };
504 let decided_by = write_gate::decided_by_log_fields(decided_by);
505 let fields_changed = match &outcome.result {
506 TrimWhitespaceResult::Changed {
507 range,
508 cells_changed_count,
509 } => Some(match cells_changed_count {
510 Some(count) => format!("trimmed {count} cell(s) in {range}"),
511 None => format!("trimmed {range}; the API reported no count"),
512 }),
513 _ => None,
514 };
515 let error = match &outcome.result {
516 TrimWhitespaceResult::RefusedInvalidRequest { detail }
517 | TrimWhitespaceResult::Failed { detail } => Some(detail.clone()),
518 _ => None,
519 };
520 request_log::record_drive_mutation(DriveMutationOutcome {
521 operation: LOG_OPERATION,
522 file_id: outcome.spreadsheet_id.clone(),
523 file_name: outcome.file_name.clone().unwrap_or_default(),
524 status: outcome.result.log_status().to_string(),
525 resolved_folder_id: outcome.resolved_folder_id.clone(),
526 decided_by_folder_id: decided_by.folder_id,
527 decided_by_depth: decided_by.depth,
528 decided_by_file_id: decided_by.file_id,
529 fields_changed,
530 error,
531 duration,
532 ..Default::default()
533 });
534}
535
536pub fn describe_lines(outcome: &TrimWhitespaceOutcome) -> Vec<String> {
537 let book = outcome.file_name.as_deref().map_or_else(
538 || format!("'{}'", outcome.spreadsheet_id),
539 |name| format!("'{name}'"),
540 );
541 match &outcome.result {
542 TrimWhitespaceResult::WouldChange {
543 range,
544 candidate_cells,
545 read_clamped_to_sheet,
546 ..
547 } => {
548 let mut lines = vec![
549 format!("Would trim whitespace in {range} of {book}"),
550 candidate_line(candidate_cells),
551 ];
552 if *read_clamped_to_sheet {
553 lines.push(
554 " the preview read was clipped to the sheet's current grid; the real run sends the requested range to Sheets".to_string(),
555 );
556 }
557 lines
558 }
559 TrimWhitespaceResult::Changed {
560 range,
561 cells_changed_count,
562 } => vec![match cells_changed_count {
563 Some(count) => format!("Trimmed whitespace in {count} cell(s) of {range} in {book}"),
564 None => {
565 format!("Trimmed whitespace in {range} of {book}; the API reported no cell count")
566 }
567 }],
568 TrimWhitespaceResult::RefusedInvalidRequest { detail } => {
569 vec![format!("Refused: {detail}")]
570 }
571 TrimWhitespaceResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
572 "Refused: {book} is not a Google Sheet (mimeType: {mime_type})"
573 )],
574 TrimWhitespaceResult::RefusedShortcut => vec![format!(
575 "Refused: {book} is a shortcut; trim-whitespace doesn't follow shortcuts"
576 )],
577 TrimWhitespaceResult::RefusedNoVisibleParents => {
578 vec![format!("Refused: {book} has no visible parent folder")]
579 }
580 TrimWhitespaceResult::RefusedSheetNotFound { title, available } => {
581 vec![grid_range::no_sheet_refusal(&book, title, available)]
582 }
583 TrimWhitespaceResult::Blocked { .. } => vec![format!(
584 "Blocked: trim-whitespace on {book} requires an allowing sheets-write rule"
585 )],
586 TrimWhitespaceResult::RefusedNoLease => LeaseGateRefusal::NoLease
587 .describe_line(&outcome.spreadsheet_id, &book)
588 .into_iter()
589 .collect(),
590 TrimWhitespaceResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
591 .describe_line(&outcome.spreadsheet_id, &book)
592 .into_iter()
593 .collect(),
594 TrimWhitespaceResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
595 .describe_line(&outcome.spreadsheet_id, &book)
596 .into_iter()
597 .collect(),
598 TrimWhitespaceResult::RefusedLeaseStale => LeaseGateRefusal::Stale
599 .describe_line(&outcome.spreadsheet_id, &book)
600 .into_iter()
601 .collect(),
602 TrimWhitespaceResult::Failed { detail } => vec![format!("Failed: {detail}")],
603 }
604}
605
606fn candidate_line(candidate_cells: &[String]) -> String {
610 if candidate_cells.is_empty() {
611 return " no non-blank cells in the range".to_string();
612 }
613 format!(
614 " {} non-blank cell(s) may be trimmed: {}",
615 candidate_cells.len(),
616 grid_range::render_locations(candidate_cells)
617 )
618}
619
620#[cfg(test)]
621#[allow(clippy::unwrap_used)]
622mod tests {
623 use super::*;
624 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
625 use crate::drive::sheets::client::SHEETS_API_URL;
626 use crate::drive::test_support::seed_lease;
627 use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
628 use crate::test_support::env::MapEnv;
629 use crate::utils::secret::Secret;
630 use std::collections::HashSet;
631
632 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
633 let credentials = DriveCredentials {
634 client_id: "client-1".into(),
635 client_secret: Secret::new("secret-1"),
636 refresh_token: Secret::new("refresh-1"),
637 scope: DriveGrantedScopes::READONLY,
638 };
639 wiremock::Mock::given(wiremock::matchers::method("POST"))
640 .and(wiremock::matchers::path("/token"))
641 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
642 serde_json::json!({"access_token": "test-token", "expires_in": 3600}),
643 ))
644 .mount(server)
645 .await;
646 let mut drive = DriveClient::new(&server.uri(), &credentials).unwrap();
647 crate::drive::client::test_support::replace_session(
648 &mut drive,
649 &credentials,
650 &format!("{}/token", server.uri()),
651 );
652 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
653 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
654 (drive, sheets)
655 }
656
657 async fn mount_metadata(server: &wiremock::MockServer) {
658 wiremock::Mock::given(wiremock::matchers::method("GET"))
659 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
660 .respond_with(
661 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
662 "id": "sheet-1", "name": "Budget", "mimeType": GOOGLE_SHEET_MIME_TYPE,
663 "parents": ["parent-1"], "version": "1"
664 })),
665 )
666 .mount(server)
667 .await;
668 wiremock::Mock::given(wiremock::matchers::method("GET"))
669 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
670 .respond_with(
671 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
672 "id": "parent-1", "name": "parent-1",
673 "mimeType": "application/vnd.google-apps.folder", "parents": []
674 })),
675 )
676 .mount(server)
677 .await;
678 wiremock::Mock::given(wiremock::matchers::method("GET"))
679 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
680 .respond_with(
681 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
682 "spreadsheetId": "sheet-1",
683 "sheets": [{"properties": {
684 "sheetId": 0, "title": "Q1",
685 "gridProperties": {"rowCount": 100, "columnCount": 6}
686 }}]
687 })),
688 )
689 .mount(server)
690 .await;
691 }
692
693 fn mount_values(values: serde_json::Value) -> wiremock::Mock {
695 wiremock::Mock::given(wiremock::matchers::method("GET"))
696 .and(wiremock::matchers::path_regex(
697 r"^/v4/spreadsheets/sheet-1/values/.*$",
698 ))
699 .respond_with(
700 wiremock::ResponseTemplate::new(200)
701 .set_body_json(serde_json::json!({"values": values})),
702 )
703 }
704
705 fn mount_batch_update(body: serde_json::Value) -> wiremock::Mock {
706 wiremock::Mock::given(wiremock::matchers::method("POST"))
707 .and(wiremock::matchers::path(
708 "/v4/spreadsheets/sheet-1:batchUpdate",
709 ))
710 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
711 }
712
713 fn options(dry_run: bool) -> TrimWhitespaceOptions {
714 TrimWhitespaceOptions {
715 spreadsheet_id: "sheet-1".into(),
716 sheet: Some("Q1".into()),
717 range: Some("A2:C10".into()),
718 scope: TrimScope::Range,
719 dry_run,
720 lease_token: None,
721 ledger_path: PathBuf::from("/tmp/unused-trim-whitespace-ledger"),
722 }
723 }
724
725 fn rule() -> FolderPermissionRule {
726 FolderPermissionRule {
727 folder_id: Some("parent-1".into()),
728 file_id: None,
729 recursive: true,
730 allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
731 deny: HashSet::default(),
732 require_lease: false,
733 }
734 }
735
736 fn rule_allowing(operation: DriveOperation) -> FolderPermissionRule {
737 FolderPermissionRule {
738 allow: std::iter::once(operation).collect(),
739 ..rule()
740 }
741 }
742
743 fn rule_requiring_lease() -> FolderPermissionRule {
744 FolderPermissionRule {
745 require_lease: true,
746 ..rule()
747 }
748 }
749
750 #[test]
753 fn whole_sheet_refuses_a_range_and_demands_a_sheet() {
754 let mut opts = options(true);
755 opts.scope = TrimScope::WholeSheet;
756 assert!(validate_scope_syntax(&opts)
757 .unwrap_err()
758 .contains("cannot be combined with --range"));
759
760 opts.range = None;
761 opts.sheet = None;
762 assert!(validate_scope_syntax(&opts)
763 .unwrap_err()
764 .contains("needs --sheet"));
765
766 opts.sheet = Some(" ".into());
768 assert!(validate_scope_syntax(&opts).is_err());
769
770 opts.sheet = Some("Q1".into());
771 assert!(validate_scope_syntax(&opts).is_ok());
772 }
773
774 #[test]
775 fn range_scope_demands_a_non_empty_range_and_names_the_alternative() {
776 let mut opts = options(true);
777 opts.range = None;
778 let err = validate_scope_syntax(&opts).unwrap_err();
779 assert!(err.contains("--range"), "{err}");
780 assert!(err.contains("--whole-sheet"), "{err}");
781
782 opts.range = Some(" ".into());
783 assert!(validate_scope_syntax(&opts).is_err());
784 }
785
786 fn sheet_with(row_count: Option<i64>, column_count: Option<i64>) -> Sheet {
789 let json = serde_json::json!({"properties": {
790 "sheetId": 0, "title": "Q1",
791 "gridProperties": {"rowCount": row_count, "columnCount": column_count}
792 }});
793 serde_json::from_value(json).unwrap()
794 }
795
796 #[test]
797 fn materialise_bounds_fills_open_axes_from_the_sheet_extent() {
798 let open = GridRange {
799 sheet_id: 0,
800 start_row_index: None,
801 end_row_index: None,
802 start_column_index: None,
803 end_column_index: None,
804 };
805 let filled = materialise_bounds(&open, Some(&sheet_with(Some(100), Some(6))));
806 assert_eq!(filled.start_row_index, Some(0));
807 assert_eq!(filled.end_row_index, Some(100));
808 assert_eq!(filled.start_column_index, Some(0));
809 assert_eq!(filled.end_column_index, Some(6));
810 }
811
812 #[test]
813 fn materialise_bounds_leaves_a_bound_the_sheet_reports_no_count_for() {
814 let open = GridRange {
815 sheet_id: 0,
816 start_row_index: None,
817 end_row_index: None,
818 start_column_index: Some(0),
819 end_column_index: Some(3),
820 };
821 let filled = materialise_bounds(&open, Some(&sheet_with(None, Some(6))));
822 assert_eq!(filled.start_row_index, Some(0));
825 assert_eq!(filled.end_row_index, None);
826 }
827
828 #[test]
832 fn materialise_bounds_without_a_sheet_leaves_the_ends_open() {
833 let open = GridRange {
834 sheet_id: 0,
835 start_row_index: None,
836 end_row_index: None,
837 start_column_index: None,
838 end_column_index: None,
839 };
840 let filled = materialise_bounds(&open, None);
841 assert_eq!(filled.end_row_index, None);
842 assert_eq!(filled.end_column_index, None);
843 assert!(!grid_range::is_bounded(&filled));
844 }
845
846 #[test]
847 fn materialise_bounds_never_widens_an_already_bounded_range() {
848 let bounded = GridRange {
849 sheet_id: 0,
850 start_row_index: Some(1),
851 end_row_index: Some(10),
852 start_column_index: Some(0),
853 end_column_index: Some(3),
854 };
855 assert_eq!(
856 materialise_bounds(&bounded, Some(&sheet_with(Some(100), Some(6)))),
857 bounded
858 );
859 }
860
861 #[test]
864 fn the_candidate_line_says_may_be_trimmed_never_would_be() {
865 let line = candidate_line(&["A1".to_string(), "B2".to_string()]);
866 assert!(
867 line.contains("2 non-blank cell(s) may be trimmed"),
868 "{line}"
869 );
870 assert!(!line.contains("would be"), "{line}");
871 assert_eq!(candidate_line(&[]), " no non-blank cells in the range");
872 }
873
874 #[test]
875 fn the_candidate_line_elides_past_the_render_limit_but_keeps_the_true_count() {
876 let cells: Vec<String> = (1..=120).map(|row| format!("A{row}")).collect();
877 let line = candidate_line(&cells);
878 assert!(line.contains("120 non-blank cell(s)"), "{line}");
879 assert!(line.contains("… and 70 more"), "{line}");
880 assert!(!line.contains("A120"), "{line}");
881 }
882
883 #[test]
884 fn lease_refusals_map_to_their_trim_whitespace_results() {
885 assert_eq!(
886 TrimWhitespaceResult::from_no_lease(),
887 TrimWhitespaceResult::RefusedNoLease
888 );
889 assert_eq!(
890 TrimWhitespaceResult::from_lease_expired(),
891 TrimWhitespaceResult::RefusedLeaseExpired
892 );
893 assert_eq!(
894 TrimWhitespaceResult::from_lease_wrong_file(),
895 TrimWhitespaceResult::RefusedLeaseWrongFile
896 );
897 assert_eq!(
898 TrimWhitespaceResult::from_lease_stale(),
899 TrimWhitespaceResult::RefusedLeaseStale
900 );
901 assert_eq!(
902 TrimWhitespaceResult::from_lease_failed("boom".into()),
903 TrimWhitespaceResult::Failed {
904 detail: "boom".into()
905 }
906 );
907 }
908
909 fn every_result() -> Vec<TrimWhitespaceResult> {
910 vec![
911 TrimWhitespaceResult::WouldChange {
912 range: "Q1!A2:C10".into(),
913 candidate_cells: vec!["A2".into()],
914 candidate_cells_upper_bound: true,
915 read_clamped_to_sheet: false,
916 },
917 TrimWhitespaceResult::WouldChange {
918 range: "Q1!A2:C10".into(),
919 candidate_cells: Vec::new(),
920 candidate_cells_upper_bound: true,
921 read_clamped_to_sheet: false,
922 },
923 TrimWhitespaceResult::Changed {
924 range: "Q1!A2:C10".into(),
925 cells_changed_count: Some(3),
926 },
927 TrimWhitespaceResult::Changed {
928 range: "Q1!A2:C10".into(),
929 cells_changed_count: None,
930 },
931 TrimWhitespaceResult::RefusedInvalidRequest {
932 detail: "bad".into(),
933 },
934 TrimWhitespaceResult::RefusedNotASpreadsheet {
935 mime_type: "application/pdf".into(),
936 },
937 TrimWhitespaceResult::RefusedShortcut,
938 TrimWhitespaceResult::RefusedNoVisibleParents,
939 TrimWhitespaceResult::RefusedSheetNotFound {
940 title: "Nope".into(),
941 available: vec!["Q1".into()],
942 },
943 TrimWhitespaceResult::Blocked { decided_by: None },
944 TrimWhitespaceResult::RefusedNoLease,
945 TrimWhitespaceResult::RefusedLeaseExpired,
946 TrimWhitespaceResult::RefusedLeaseWrongFile,
947 TrimWhitespaceResult::RefusedLeaseStale,
948 TrimWhitespaceResult::Failed {
949 detail: "boom".into(),
950 },
951 ]
952 }
953
954 #[test]
955 fn every_result_renders_and_carries_a_distinct_log_status() {
956 let mut statuses = HashSet::new();
957 for result in every_result() {
958 let outcome = TrimWhitespaceOutcome {
959 spreadsheet_id: "sheet-1".into(),
960 file_name: Some("Budget".into()),
961 resolved_folder_id: None,
962 result,
963 };
964 assert!(
965 !describe_lines(&outcome).is_empty(),
966 "{:?} rendered nothing",
967 outcome.result
968 );
969 statuses.insert(outcome.result.log_status());
970 }
971 assert_eq!(statuses.len(), 13);
974 }
975
976 #[test]
977 fn describe_lines_falls_back_to_the_spreadsheet_id_with_no_file_name() {
978 let outcome = TrimWhitespaceOutcome {
979 spreadsheet_id: "sheet-1".into(),
980 file_name: None,
981 resolved_folder_id: None,
982 result: TrimWhitespaceResult::RefusedShortcut,
983 };
984 assert!(describe_lines(&outcome).join("\n").contains("'sheet-1'"));
985 }
986
987 #[test]
988 fn write_jsonl_emits_one_line_of_json() {
989 let outcome = TrimWhitespaceOutcome {
990 spreadsheet_id: "sheet-1".into(),
991 file_name: Some("Budget".into()),
992 resolved_folder_id: None,
993 result: TrimWhitespaceResult::Changed {
994 range: "Q1!A2:C10".into(),
995 cells_changed_count: Some(3),
996 },
997 };
998 let mut buf = Vec::new();
999 outcome.write_jsonl(&mut buf).unwrap();
1000 let text = String::from_utf8(buf).unwrap();
1001 assert_eq!(text.lines().count(), 1);
1002 assert!(text.contains("\"cells_changed_count\":3"), "{text}");
1003 }
1004
1005 #[tokio::test]
1008 async fn gate_denies_sheets_write_by_default() {
1009 let server = wiremock::MockServer::start().await;
1010 let (drive, sheets) = clients(&server).await;
1011 mount_metadata(&server).await;
1012 let outcome = trim_whitespace(&drive, &sheets, &options(true), &[]).await;
1013 assert!(
1014 matches!(outcome.result, TrimWhitespaceResult::Blocked { .. }),
1015 "{outcome:?}"
1016 );
1017 }
1018
1019 #[tokio::test]
1023 async fn a_sheets_delete_grant_alone_does_not_permit_trim_whitespace() {
1024 let server = wiremock::MockServer::start().await;
1025 let (drive, sheets) = clients(&server).await;
1026 mount_metadata(&server).await;
1027 let outcome = trim_whitespace(
1028 &drive,
1029 &sheets,
1030 &options(true),
1031 &[rule_allowing(DriveOperation::SheetsDelete)],
1032 )
1033 .await;
1034 assert!(
1035 matches!(outcome.result, TrimWhitespaceResult::Blocked { .. }),
1036 "{outcome:?}"
1037 );
1038 }
1039
1040 #[tokio::test]
1041 async fn a_denied_target_makes_no_sheets_calls_at_all() {
1042 let server = wiremock::MockServer::start().await;
1043 let (drive, sheets) = clients(&server).await;
1044 mount_metadata(&server).await;
1045 let _ = trim_whitespace(&drive, &sheets, &options(true), &[]).await;
1046 let requests = server.received_requests().await.unwrap();
1047 assert!(
1048 !requests
1049 .iter()
1050 .any(|request| request.url.path().starts_with("/v4/spreadsheets")),
1051 "a blocked run reached the Sheets API"
1052 );
1053 }
1054
1055 #[tokio::test]
1058 async fn a_metadata_fetch_failure_surfaces_as_failed_without_a_file_name() {
1059 let server = wiremock::MockServer::start().await;
1060 let (drive, sheets) = clients(&server).await;
1061 wiremock::Mock::given(wiremock::matchers::method("GET"))
1062 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1063 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1064 .mount(&server)
1065 .await;
1066
1067 let outcome = trim_whitespace(&drive, &sheets, &options(true), &[]).await;
1068
1069 assert!(matches!(
1070 outcome.result,
1071 TrimWhitespaceResult::Failed { .. }
1072 ));
1073 assert!(outcome.file_name.is_none());
1074 }
1075
1076 #[tokio::test]
1077 async fn a_shortcut_target_is_refused_without_following_it() {
1078 let server = wiremock::MockServer::start().await;
1079 let (drive, sheets) = clients(&server).await;
1080 wiremock::Mock::given(wiremock::matchers::method("GET"))
1081 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1082 .respond_with(
1083 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1084 "id": "sheet-1", "name": "Budget",
1085 "mimeType": "application/vnd.google-apps.shortcut",
1086 "parents": ["parent-1"], "version": "1"
1087 })),
1088 )
1089 .mount(&server)
1090 .await;
1091
1092 let outcome = trim_whitespace(&drive, &sheets, &options(true), &[]).await;
1093
1094 assert_eq!(outcome.result, TrimWhitespaceResult::RefusedShortcut);
1095 assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
1096 }
1097
1098 #[tokio::test]
1099 async fn a_non_spreadsheet_target_is_refused_with_its_mime_type() {
1100 let server = wiremock::MockServer::start().await;
1101 let (drive, sheets) = clients(&server).await;
1102 wiremock::Mock::given(wiremock::matchers::method("GET"))
1103 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1104 .respond_with(
1105 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1106 "id": "sheet-1", "name": "Budget",
1107 "mimeType": "application/vnd.google-apps.document",
1108 "parents": ["parent-1"], "version": "1"
1109 })),
1110 )
1111 .mount(&server)
1112 .await;
1113
1114 let outcome = trim_whitespace(&drive, &sheets, &options(true), &[]).await;
1115
1116 assert_eq!(
1117 outcome.result,
1118 TrimWhitespaceResult::RefusedNotASpreadsheet {
1119 mime_type: "application/vnd.google-apps.document".into(),
1120 }
1121 );
1122 assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
1123 }
1124
1125 #[tokio::test]
1126 async fn a_target_with_no_visible_parents_is_refused() {
1127 let server = wiremock::MockServer::start().await;
1128 let (drive, sheets) = clients(&server).await;
1129 wiremock::Mock::given(wiremock::matchers::method("GET"))
1130 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1131 .respond_with(
1132 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1133 "id": "sheet-1", "name": "Budget", "mimeType": GOOGLE_SHEET_MIME_TYPE,
1134 "parents": [], "version": "1"
1135 })),
1136 )
1137 .mount(&server)
1138 .await;
1139
1140 let outcome = trim_whitespace(&drive, &sheets, &options(true), &[]).await;
1141
1142 assert_eq!(
1143 outcome.result,
1144 TrimWhitespaceResult::RefusedNoVisibleParents
1145 );
1146 assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
1147 }
1148
1149 #[tokio::test]
1150 async fn a_gate_ancestor_fetch_failure_surfaces_as_failed_with_the_file_name() {
1151 let server = wiremock::MockServer::start().await;
1152 let (drive, sheets) = clients(&server).await;
1153 wiremock::Mock::given(wiremock::matchers::method("GET"))
1154 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1155 .respond_with(
1156 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1157 "id": "sheet-1", "name": "Budget", "mimeType": GOOGLE_SHEET_MIME_TYPE,
1158 "parents": ["parent-1"], "version": "1"
1159 })),
1160 )
1161 .mount(&server)
1162 .await;
1163 wiremock::Mock::given(wiremock::matchers::method("GET"))
1164 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
1165 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1166 .mount(&server)
1167 .await;
1168
1169 let outcome = trim_whitespace(&drive, &sheets, &options(true), &[]).await;
1170
1171 assert!(matches!(
1172 outcome.result,
1173 TrimWhitespaceResult::Failed { .. }
1174 ));
1175 assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
1176 assert!(outcome.resolved_folder_id.is_none());
1177 }
1178
1179 #[tokio::test]
1180 async fn a_workbook_fetch_failure_after_a_granted_gate_surfaces_as_failed() {
1181 let server = wiremock::MockServer::start().await;
1182 let (drive, sheets) = clients(&server).await;
1183 mount_metadata(&server).await;
1184 wiremock::Mock::given(wiremock::matchers::method("GET"))
1185 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1186 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1187 .mount(&server)
1188 .await;
1189
1190 let outcome = trim_whitespace(&drive, &sheets, &options(true), &[rule()]).await;
1191
1192 assert!(matches!(
1193 outcome.result,
1194 TrimWhitespaceResult::Failed { .. }
1195 ));
1196 assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
1197 }
1198
1199 #[tokio::test]
1202 async fn dry_run_reads_values_once_and_never_mutates() {
1203 let server = wiremock::MockServer::start().await;
1204 let (drive, sheets) = clients(&server).await;
1205 mount_metadata(&server).await;
1206 mount_values(serde_json::json!([
1207 [" padded ", "", "x"],
1208 [null, "y", null]
1209 ]))
1210 .mount(&server)
1211 .await;
1212 let outcome = trim_whitespace(&drive, &sheets, &options(true), &[rule()]).await;
1213 let json = serde_json::to_value(&outcome).unwrap();
1214 assert_eq!(json["result"]["candidate_cells_upper_bound"], true);
1215 let TrimWhitespaceResult::WouldChange {
1216 range,
1217 candidate_cells,
1218 candidate_cells_upper_bound,
1219 read_clamped_to_sheet,
1220 } = outcome.result
1221 else {
1222 panic!("expected would-change"); };
1224 assert_eq!(range, "'Q1'!A2:C10");
1225 assert_eq!(candidate_cells, vec!["A2", "C2", "B3"]);
1227 assert!(candidate_cells_upper_bound);
1228 assert!(!read_clamped_to_sheet);
1229
1230 let requests = server.received_requests().await.unwrap();
1231 assert_eq!(
1232 requests
1233 .iter()
1234 .filter(|r| r.url.path().contains("/values/"))
1235 .count(),
1236 1
1237 );
1238 assert!(
1239 !requests
1240 .iter()
1241 .any(|r| r.url.path().ends_with(":batchUpdate")),
1242 "a dry run mutated"
1243 );
1244 }
1245
1246 #[tokio::test]
1247 async fn dry_run_clamps_an_explicit_range_to_the_sheet_grid_for_its_read() {
1248 let server = wiremock::MockServer::start().await;
1249 let (drive, sheets) = clients(&server).await;
1250 mount_metadata(&server).await;
1251 mount_values(serde_json::json!([[" padded "]]))
1252 .mount(&server)
1253 .await;
1254 let mut opts = options(true);
1255 opts.range = Some("E90:J120".into());
1256
1257 let outcome = trim_whitespace(&drive, &sheets, &opts, &[rule()]).await;
1258 assert_eq!(
1259 outcome.result,
1260 TrimWhitespaceResult::WouldChange {
1261 range: "'Q1'!E90:J120".into(),
1262 candidate_cells: vec!["E90".into()],
1263 candidate_cells_upper_bound: true,
1264 read_clamped_to_sheet: true,
1265 }
1266 );
1267 assert_eq!(
1268 serde_json::to_value(&outcome).unwrap()["result"]["read_clamped_to_sheet"],
1269 true
1270 );
1271 assert!(describe_lines(&outcome)
1272 .join("\n")
1273 .contains("preview read was clipped"));
1274 let requests = server.received_requests().await.unwrap();
1275 let reads: Vec<_> = requests
1276 .iter()
1277 .filter(|request| request.url.path().contains("/values/"))
1278 .collect();
1279 assert_eq!(reads.len(), 1);
1280 let read_path = reads[0].url.path();
1281 assert!(
1282 read_path.contains("E90") && read_path.contains("F100"),
1283 "{read_path}"
1284 );
1285 assert!(!read_path.contains("J120"), "{read_path}");
1286 }
1287
1288 #[tokio::test]
1292 async fn real_run_sends_one_trim_request_and_reads_no_values() {
1293 let server = wiremock::MockServer::start().await;
1294 let (drive, sheets) = clients(&server).await;
1295 mount_metadata(&server).await;
1296 mount_batch_update(serde_json::json!({
1297 "replies": [{"trimWhitespace": {"cellsChangedCount": 7}}]
1298 }))
1299 .mount(&server)
1300 .await;
1301 let outcome = trim_whitespace(&drive, &sheets, &options(false), &[rule()]).await;
1302 assert_eq!(
1303 outcome.result,
1304 TrimWhitespaceResult::Changed {
1305 range: "'Q1'!A2:C10".into(),
1306 cells_changed_count: Some(7),
1307 }
1308 );
1309
1310 let requests = server.received_requests().await.unwrap();
1311 assert!(
1312 !requests.iter().any(|r| r.url.path().contains("/values/")),
1313 "the real run read values it did not need"
1314 );
1315 let batch = requests
1316 .iter()
1317 .find(|r| r.url.path().ends_with(":batchUpdate"))
1318 .unwrap();
1319 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
1320 assert_eq!(
1321 body["requests"][0]["trimWhitespace"]["range"],
1322 serde_json::json!({
1323 "sheetId": 0, "startRowIndex": 1, "endRowIndex": 10,
1324 "startColumnIndex": 0, "endColumnIndex": 3
1325 })
1326 );
1327 assert_eq!(body["requests"].as_array().unwrap().len(), 1);
1328 }
1329
1330 #[tokio::test]
1331 async fn whole_sheet_sends_the_materialised_grid_extent() {
1332 let server = wiremock::MockServer::start().await;
1333 let (drive, sheets) = clients(&server).await;
1334 mount_metadata(&server).await;
1335 mount_batch_update(serde_json::json!({"replies": [{}]}))
1336 .mount(&server)
1337 .await;
1338 let mut opts = options(false);
1339 opts.scope = TrimScope::WholeSheet;
1340 opts.range = None;
1341 let outcome = trim_whitespace(&drive, &sheets, &opts, &[rule()]).await;
1342 let TrimWhitespaceResult::Changed { range, .. } = &outcome.result else {
1343 panic!("expected changed"); };
1345 assert_eq!(range, "'Q1'!A1:F100");
1346
1347 let requests = server.received_requests().await.unwrap();
1348 let batch = requests
1349 .iter()
1350 .find(|r| r.url.path().ends_with(":batchUpdate"))
1351 .unwrap();
1352 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
1353 assert_eq!(
1354 body["requests"][0]["trimWhitespace"]["range"]["endColumnIndex"],
1355 6
1356 );
1357 }
1358
1359 #[tokio::test]
1360 async fn a_reply_without_a_trim_object_reports_changed_with_no_count() {
1361 let server = wiremock::MockServer::start().await;
1362 let (drive, sheets) = clients(&server).await;
1363 mount_metadata(&server).await;
1364 mount_batch_update(serde_json::json!({"replies": [{}]}))
1365 .mount(&server)
1366 .await;
1367 let outcome = trim_whitespace(&drive, &sheets, &options(false), &[rule()]).await;
1368 assert_eq!(
1369 outcome.result,
1370 TrimWhitespaceResult::Changed {
1371 range: "'Q1'!A2:C10".into(),
1372 cells_changed_count: None,
1373 }
1374 );
1375 assert!(describe_lines(&outcome)
1376 .join("\n")
1377 .contains("reported no cell count"));
1378 }
1379
1380 #[tokio::test]
1383 async fn no_cell_value_ever_reaches_the_outcome_or_its_rendering() {
1384 const SECRET: &str = " zzsecretzz ";
1385 let server = wiremock::MockServer::start().await;
1386 let (drive, sheets) = clients(&server).await;
1387 mount_metadata(&server).await;
1388 mount_values(serde_json::json!([[SECRET]]))
1389 .mount(&server)
1390 .await;
1391 let outcome = trim_whitespace(&drive, &sheets, &options(true), &[rule()]).await;
1392
1393 let json = serde_json::to_string(&outcome).unwrap();
1394 assert!(!json.contains("zzsecretzz"), "{json}");
1395 assert!(!describe_lines(&outcome).join("\n").contains("zzsecretzz"));
1396 }
1397
1398 #[tokio::test]
1401 async fn a_values_read_failure_surfaces_as_failed() {
1402 let server = wiremock::MockServer::start().await;
1403 let (drive, sheets) = clients(&server).await;
1404 mount_metadata(&server).await;
1405 wiremock::Mock::given(wiremock::matchers::method("GET"))
1406 .and(wiremock::matchers::path_regex(
1407 r"^/v4/spreadsheets/sheet-1/values/.*$",
1408 ))
1409 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1410 .mount(&server)
1411 .await;
1412 let outcome = trim_whitespace(&drive, &sheets, &options(true), &[rule()]).await;
1413 assert!(
1414 matches!(outcome.result, TrimWhitespaceResult::Failed { .. }),
1415 "{outcome:?}"
1416 );
1417 }
1418
1419 #[tokio::test]
1420 async fn a_batch_update_failure_surfaces_as_failed() {
1421 let server = wiremock::MockServer::start().await;
1422 let (drive, sheets) = clients(&server).await;
1423 mount_metadata(&server).await;
1424 wiremock::Mock::given(wiremock::matchers::method("POST"))
1425 .and(wiremock::matchers::path(
1426 "/v4/spreadsheets/sheet-1:batchUpdate",
1427 ))
1428 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1429 .mount(&server)
1430 .await;
1431 let outcome = trim_whitespace(&drive, &sheets, &options(false), &[rule()]).await;
1432 assert!(
1433 matches!(outcome.result, TrimWhitespaceResult::Failed { .. }),
1434 "{outcome:?}"
1435 );
1436 }
1437
1438 #[tokio::test]
1439 async fn an_unknown_sheet_is_refused_with_the_available_titles() {
1440 let server = wiremock::MockServer::start().await;
1441 let (drive, sheets) = clients(&server).await;
1442 mount_metadata(&server).await;
1443 let mut opts = options(true);
1444 opts.scope = TrimScope::WholeSheet;
1445 opts.sheet = Some("Nope".into());
1446 opts.range = None;
1447 let outcome = trim_whitespace(&drive, &sheets, &opts, &[rule()]).await;
1448 assert_eq!(
1449 outcome.result,
1450 TrimWhitespaceResult::RefusedSheetNotFound {
1451 title: "Nope".into(),
1452 available: vec!["Q1".into()],
1453 }
1454 );
1455 }
1456
1457 #[tokio::test]
1458 async fn a_scope_refusal_happens_before_any_network_call() {
1459 let server = wiremock::MockServer::start().await;
1460 let (drive, sheets) = clients(&server).await;
1461 let mut opts = options(true);
1462 opts.range = None;
1463 let outcome = trim_whitespace(&drive, &sheets, &opts, &[rule()]).await;
1464 assert!(matches!(
1465 outcome.result,
1466 TrimWhitespaceResult::RefusedInvalidRequest { .. }
1467 ));
1468 assert!(server.received_requests().await.unwrap().is_empty());
1469 }
1470
1471 #[tokio::test]
1474 async fn refuses_without_a_lease_when_the_rule_requires_one() {
1475 let server = wiremock::MockServer::start().await;
1476 let (drive, sheets) = clients(&server).await;
1477 mount_metadata(&server).await;
1478 let outcome =
1480 trim_whitespace(&drive, &sheets, &options(false), &[rule_requiring_lease()]).await;
1481 assert_eq!(outcome.result, TrimWhitespaceResult::RefusedNoLease);
1482 }
1483
1484 #[tokio::test]
1485 async fn refuses_a_lease_bound_to_a_different_file() {
1486 let server = wiremock::MockServer::start().await;
1487 let (drive, sheets) = clients(&server).await;
1488 mount_metadata(&server).await;
1489 let dir = tempfile::tempdir().unwrap();
1490 let ledger_path = dir.path().join("leases.json");
1491 let token = seed_lease(&ledger_path, "other-file", "1");
1492 let mut opts = options(false);
1493 opts.lease_token = Some(token);
1494 opts.ledger_path = ledger_path;
1495 let outcome = trim_whitespace(&drive, &sheets, &opts, &[rule_requiring_lease()]).await;
1496 assert_eq!(outcome.result, TrimWhitespaceResult::RefusedLeaseWrongFile);
1497 }
1498
1499 #[tokio::test]
1500 async fn a_valid_lease_lets_the_write_through() {
1501 let server = wiremock::MockServer::start().await;
1502 let (drive, sheets) = clients(&server).await;
1503 mount_metadata(&server).await;
1504 mount_batch_update(serde_json::json!({
1505 "replies": [{"trimWhitespace": {"cellsChangedCount": 1}}]
1506 }))
1507 .mount(&server)
1508 .await;
1509 let dir = tempfile::tempdir().unwrap();
1510 let ledger_path = dir.path().join("leases.json");
1511 let token = seed_lease(&ledger_path, "sheet-1", "1");
1512 let mut opts = options(false);
1513 opts.lease_token = Some(token);
1514 opts.ledger_path = ledger_path;
1515 let outcome = trim_whitespace(&drive, &sheets, &opts, &[rule_requiring_lease()]).await;
1516 assert!(
1517 matches!(
1518 outcome.result,
1519 TrimWhitespaceResult::Changed {
1520 cells_changed_count: Some(1),
1521 ..
1522 }
1523 ),
1524 "{outcome:?}"
1525 );
1526 }
1527}