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